# 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]:
# (Pylint Bug?) pylint: disable=missing-function-docstring
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:
# (Pylint Bug?) pylint: disable=missing-function-docstring
return 'Secure all ${ARG1} flags.', len(self.map.flag_points)
[docs]
@override
def get_instance_description_short(self) -> str | Sequence:
# (Pylint Bug?) pylint: disable=missing-function-docstring
return 'secure all ${ARG1} flags', len(self.map.flag_points)
[docs]
@override
def on_team_join(self, team: Team) -> None:
# (Pylint Bug?) pylint: disable=missing-function-docstring
if self.has_begun():
self._update_scores()
[docs]
@override
def on_player_join(self, player: Player) -> None:
# (Pylint Bug?) pylint: disable=missing-function-docstring
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:
# (Pylint Bug?) pylint: disable=missing-function-docstring
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:
# (Pylint Bug?) pylint: disable=missing-function-docstring
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:
# (Pylint Bug?) pylint: disable=missing-function-docstring
# 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
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum