# Released under the MIT License. See LICENSE for details.
#
"""Provides the Conquest game."""
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations
import random
from typing import TYPE_CHECKING, override
import bascenev1 as bs
from bascenev1lib.actor.flag import Flag
from bascenev1lib.actor.scoreboard import Scoreboard
from bascenev1lib.actor.playerspaz import PlayerSpaz
from bascenev1lib.gameutils import SharedObjects
from bascenev1lib.actor.respawnicon import RespawnIcon
if TYPE_CHECKING:
from typing import Any, Sequence
[docs]
class ConquestFlag(Flag):
"""A custom flag for use with Conquest games."""
def __init__(self, *args: Any, **keywds: Any):
super().__init__(*args, **keywds)
self._team: Team | None = None
self.light: bs.Node | None = None
@property
def team(self) -> Team | None:
"""The team that owns this flag."""
return self._team
@team.setter
def team(self, team: Team) -> None:
"""Set the team that owns this flag."""
self._team = team
[docs]
class Player(bs.Player['Team']):
"""Our player type for this game."""
# FIXME: We shouldn't be using customdata here
# (but need to update respawn funcs accordingly first).
@property
def respawn_timer(self) -> bs.Timer | None:
"""Type safe access to standard respawn timer."""
val = self.customdata.get('respawn_timer', None)
assert isinstance(val, (bs.Timer, type(None)))
return val
@respawn_timer.setter
def respawn_timer(self, value: bs.Timer | None) -> None:
self.customdata['respawn_timer'] = value
@property
def respawn_icon(self) -> RespawnIcon | None:
"""Type safe access to standard respawn icon."""
val = self.customdata.get('respawn_icon', None)
assert isinstance(val, (RespawnIcon, type(None)))
return val
@respawn_icon.setter
def respawn_icon(self, value: RespawnIcon | None) -> None:
self.customdata['respawn_icon'] = value
[docs]
class Team(bs.Team[Player]):
"""Our team type for this game."""
def __init__(self) -> None:
self.flags_held = 0
# ba_meta export bascenev1.GameActivity
[docs]
class ConquestGame(bs.TeamGameActivity[Player, Team]):
"""A game where teams try to claim all flags on the map."""
name = 'Conquest'
description = 'Secure all flags on the map to win.'
available_settings = [
bs.IntChoiceSetting(
'Time Limit',
choices=[
('None', 0),
('1 Minute', 60),
('2 Minutes', 120),
('5 Minutes', 300),
('10 Minutes', 600),
('20 Minutes', 1200),
],
default=0,
),
bs.FloatChoiceSetting(
'Respawn Times',
choices=[
('Shorter', 0.25),
('Short', 0.5),
('Normal', 1.0),
('Long', 2.0),
('Longer', 4.0),
],
default=1.0,
),
bs.BoolSetting('Epic Mode', default=False),
]
[docs]
@override
@classmethod
def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
return issubclass(sessiontype, bs.DualTeamSession)
[docs]
@override
@classmethod
def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]:
assert bs.app.classic is not None
return bs.app.classic.getmaps('conquest')
def __init__(self, settings: dict):
super().__init__(settings)
shared = SharedObjects.get()
self._scoreboard = Scoreboard()
self._score_sound = bs.getsound('score')
self._swipsound = bs.getsound('swip')
self._extraflagmat = bs.Material()
self._flags: list[ConquestFlag] = []
self._epic_mode = bool(settings['Epic Mode'])
self._time_limit = float(settings['Time Limit'])
# Base class overrides.
self.slow_motion = self._epic_mode
self.default_music = (
bs.MusicType.EPIC if self._epic_mode else bs.MusicType.GRAND_ROMP
)
# We want flags to tell us they've been hit but not react physically.
self._extraflagmat.add_actions(
conditions=('they_have_material', shared.player_material),
actions=(
('modify_part_collision', 'collide', True),
('call', 'at_connect', self._handle_flag_player_collide),
),
)
[docs]
@override
def get_instance_description(self) -> str | Sequence:
return 'Secure all ${ARG1} flags.', len(self.map.flag_points)
[docs]
@override
def get_instance_description_short(self) -> str | Sequence:
return 'secure all ${ARG1} flags', len(self.map.flag_points)
[docs]
@override
def on_team_join(self, team: Team) -> None:
if self.has_begun():
self._update_scores()
[docs]
@override
def on_player_join(self, player: Player) -> None:
player.respawn_timer = None
# Only spawn if this player's team has a flag currently.
if player.team.flags_held > 0:
self.spawn_player(player)
[docs]
@override
def on_begin(self) -> None:
super().on_begin()
self.setup_standard_time_limit(self._time_limit)
self.setup_standard_powerup_drops()
# Set up flags with marker lights.
for i, flag_point in enumerate(self.map.flag_points):
point = flag_point
flag = ConquestFlag(
position=point, touchable=False, materials=[self._extraflagmat]
)
self._flags.append(flag)
Flag.project_stand(point)
flag.light = bs.newnode(
'light',
owner=flag.node,
attrs={
'position': point,
'intensity': 0.25,
'height_attenuated': False,
'radius': 0.3,
'color': (1, 1, 1),
},
)
# Give teams a flag to start with.
for i, team in enumerate(self.teams):
self._flags[i].team = team
light = self._flags[i].light
assert light
node = self._flags[i].node
assert node
light.color = team.color
node.color = team.color
self._update_scores()
# Initial joiners didn't spawn due to no flags being owned yet;
# spawn them now.
for player in self.players:
self.spawn_player(player)
def _update_scores(self) -> None:
for team in self.teams:
team.flags_held = 0
for flag in self._flags:
if flag.team is not None:
flag.team.flags_held += 1
for team in self.teams:
# If a team finds themselves with no flags, cancel all
# outstanding spawn-timers.
if team.flags_held == 0:
for player in team.players:
player.respawn_timer = None
player.respawn_icon = None
if team.flags_held == len(self._flags):
self.end_game()
self._scoreboard.set_team_value(
team, team.flags_held, len(self._flags)
)
[docs]
@override
def end_game(self) -> None:
results = bs.GameResults()
for team in self.teams:
results.set_team_score(team, team.flags_held)
self.end(results=results)
def _flash_flag(self, flag: ConquestFlag, length: float = 1.0) -> None:
assert flag.node
assert flag.light
light = bs.newnode(
'light',
attrs={
'position': flag.node.position,
'height_attenuated': False,
'color': flag.light.color,
},
)
bs.animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0}, loop=True)
bs.timer(length, light.delete)
def _handle_flag_player_collide(self) -> None:
collision = bs.getcollision()
try:
flag = collision.sourcenode.getdelegate(ConquestFlag, True)
player = collision.opposingnode.getdelegate(
PlayerSpaz, True
).getplayer(Player, True)
except bs.NotFoundError:
return
assert flag.light
if flag.team is not player.team:
flag.team = player.team
flag.light.color = player.team.color
flag.node.color = player.team.color
self.stats.player_scored(player, 10, screenmessage=False)
self._swipsound.play()
self._flash_flag(flag)
self._update_scores()
# Respawn any players on this team that were in limbo due to the
# lack of a flag for their team.
for otherplayer in self.players:
if (
otherplayer.team is flag.team
and otherplayer.actor is not None
and not otherplayer.is_alive()
and otherplayer.respawn_timer is None
):
self.spawn_player(otherplayer)
[docs]
@override
def handlemessage(self, msg: Any) -> Any:
if isinstance(msg, bs.PlayerDiedMessage):
# Augment standard behavior.
super().handlemessage(msg)
# Respawn only if this team has a flag.
player = msg.getplayer(Player)
if player.team.flags_held > 0:
self.respawn_player(player)
else:
player.respawn_timer = None
else:
super().handlemessage(msg)
[docs]
@override
def spawn_player(self, player: Player) -> bs.Actor:
# We spawn players at different places based on what flags are held.
return self.spawn_player_spaz(
player, self._get_player_spawn_position(player)
)
def _get_player_spawn_position(self, player: Player) -> Sequence[float]:
# Iterate until we find a spawn owned by this team.
spawn_count = len(self.map.spawn_by_flag_points)
# Get all spawns owned by this team.
spawns = [
i for i in range(spawn_count) if self._flags[i].team is player.team
]
closest_spawn = 0
closest_distance = 9999.0
# Now find the spawn that's closest to a spawn not owned by us;
# we'll use that one.
for spawn in spawns:
spt = self.map.spawn_by_flag_points[spawn]
our_pt = bs.Vec3(spt[0], spt[1], spt[2])
for otherspawn in [
i
for i in range(spawn_count)
if self._flags[i].team is not player.team
]:
spt = self.map.spawn_by_flag_points[otherspawn]
their_pt = bs.Vec3(spt[0], spt[1], spt[2])
dist = (their_pt - our_pt).length()
if dist < closest_distance:
closest_distance = dist
closest_spawn = spawn
pos = self.map.spawn_by_flag_points[closest_spawn]
x_range = (-0.5, 0.5) if pos[3] == 0.0 else (-pos[3], pos[3])
z_range = (-0.5, 0.5) if pos[5] == 0.0 else (-pos[5], pos[5])
pos = (
pos[0] + random.uniform(*x_range),
pos[1],
pos[2] + random.uniform(*z_range),
)
return pos