# Released under the MIT License. See LICENSE for details.
#
"""Defines the King of the Hill game."""
# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations
import weakref
from enum import Enum
from typing import TYPE_CHECKING, override
import bascenev1 as bs
from bascenev1lib.actor.flag import Flag
from bascenev1lib.actor.playerspaz import PlayerSpaz
from bascenev1lib.actor.scoreboard import Scoreboard
from bascenev1lib.gameutils import SharedObjects
if TYPE_CHECKING:
from typing import Any, Sequence
[docs]
class FlagState(Enum):
"""States our single flag can be in."""
NEW = 0
UNCONTESTED = 1
CONTESTED = 2
HELD = 3
[docs]
class Player(bs.Player['Team']):
"""Our player type for this game."""
def __init__(self) -> None:
self.time_at_flag = 0
[docs]
class Team(bs.Team[Player]):
"""Our team type for this game."""
def __init__(self, time_remaining: int) -> None:
self.time_remaining = time_remaining
# ba_meta export bascenev1.GameActivity
[docs]
class KingOfTheHillGame(bs.TeamGameActivity[Player, Team]):
"""Game where a team wins by holding a 'hill' for a set amount of time."""
name = 'King of the Hill'
description = 'Secure the flag for a set length of time.'
available_settings = [
bs.IntSetting(
'Hold Time',
min_value=10,
default=30,
increment=10,
),
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),
]
scoreconfig = bs.ScoreConfig(label='Time Held')
[docs]
@override
@classmethod
def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
return issubclass(sessiontype, bs.MultiTeamSession)
[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('king_of_the_hill')
def __init__(self, settings: dict):
super().__init__(settings)
shared = SharedObjects.get()
self._scoreboard = Scoreboard()
self._swipsound = bs.getsound('swip')
self._tick_sound = bs.getsound('tick')
self._countdownsounds = {
10: bs.getsound('announceTen'),
9: bs.getsound('announceNine'),
8: bs.getsound('announceEight'),
7: bs.getsound('announceSeven'),
6: bs.getsound('announceSix'),
5: bs.getsound('announceFive'),
4: bs.getsound('announceFour'),
3: bs.getsound('announceThree'),
2: bs.getsound('announceTwo'),
1: bs.getsound('announceOne'),
}
self._flag_pos: Sequence[float] | None = None
self._flag_state: FlagState | None = None
self._flag: Flag | None = None
self._flag_light: bs.Node | None = None
self._scoring_team: weakref.ref[Team] | None = None
self._hold_time = int(settings['Hold Time'])
self._time_limit = float(settings['Time Limit'])
self._epic_mode = bool(settings['Epic Mode'])
self._flag_region_material = bs.Material()
self._flag_region_material.add_actions(
conditions=('they_have_material', shared.player_material),
actions=(
('modify_part_collision', 'collide', True),
('modify_part_collision', 'physical', False),
(
'call',
'at_connect',
bs.Call(self._handle_player_flag_region_collide, True),
),
(
'call',
'at_disconnect',
bs.Call(self._handle_player_flag_region_collide, False),
),
),
)
# Base class overrides.
self.slow_motion = self._epic_mode
self.default_music = (
bs.MusicType.EPIC if self._epic_mode else bs.MusicType.SCARY
)
[docs]
@override
def get_instance_description(self) -> str | Sequence:
return 'Secure the flag for ${ARG1} seconds.', self._hold_time
[docs]
@override
def get_instance_description_short(self) -> str | Sequence:
return 'secure the flag for ${ARG1} seconds', self._hold_time
[docs]
@override
def create_team(self, sessionteam: bs.SessionTeam) -> Team:
return Team(time_remaining=self._hold_time)
[docs]
@override
def on_begin(self) -> None:
super().on_begin()
shared = SharedObjects.get()
self.setup_standard_time_limit(self._time_limit)
self.setup_standard_powerup_drops()
self._flag_pos = self.map.get_flag_position(None)
bs.timer(1.0, self._tick, repeat=True)
self._flag_state = FlagState.NEW
Flag.project_stand(self._flag_pos)
self._flag = Flag(
position=self._flag_pos, touchable=False, color=(1, 1, 1)
)
self._flag_light = bs.newnode(
'light',
attrs={
'position': self._flag_pos,
'intensity': 0.2,
'height_attenuated': False,
'radius': 0.4,
'color': (0.2, 0.2, 0.2),
},
)
# Flag region.
flagmats = [self._flag_region_material, shared.region_material]
bs.newnode(
'region',
attrs={
'position': self._flag_pos,
'scale': (1.8, 1.8, 1.8),
'type': 'sphere',
'materials': flagmats,
},
)
self._update_scoreboard()
self._update_flag_state()
def _tick(self) -> None:
self._update_flag_state()
# Give holding players points.
for player in self.players:
if player.time_at_flag > 0:
self.stats.player_scored(
player, 3, screenmessage=False, display=False
)
if self._scoring_team is None:
scoring_team = None
else:
scoring_team = self._scoring_team()
if scoring_team:
if scoring_team.time_remaining > 0:
self._tick_sound.play()
scoring_team.time_remaining = max(
0, scoring_team.time_remaining - 1
)
self._update_scoreboard()
if scoring_team.time_remaining > 0:
assert self._flag is not None
self._flag.set_score_text(str(scoring_team.time_remaining))
# Announce numbers we have sounds for.
numsound = self._countdownsounds.get(scoring_team.time_remaining)
if numsound is not None:
numsound.play()
# winner
if scoring_team.time_remaining <= 0:
self.end_game()
[docs]
@override
def end_game(self) -> None:
results = bs.GameResults()
for team in self.teams:
results.set_team_score(team, self._hold_time - team.time_remaining)
self.end(results=results, announce_delay=0)
def _update_flag_state(self) -> None:
holding_teams = set(
player.team for player in self.players if player.time_at_flag
)
prev_state = self._flag_state
assert self._flag_light
assert self._flag is not None
assert self._flag.node
if len(holding_teams) > 1:
self._flag_state = FlagState.CONTESTED
self._scoring_team = None
self._flag_light.color = (0.6, 0.6, 0.1)
self._flag.node.color = (1.0, 1.0, 0.4)
elif len(holding_teams) == 1:
holding_team = list(holding_teams)[0]
self._flag_state = FlagState.HELD
self._scoring_team = weakref.ref(holding_team)
self._flag_light.color = bs.normalized_color(holding_team.color)
self._flag.node.color = holding_team.color
else:
self._flag_state = FlagState.UNCONTESTED
self._scoring_team = None
self._flag_light.color = (0.2, 0.2, 0.2)
self._flag.node.color = (1, 1, 1)
if self._flag_state != prev_state:
self._swipsound.play()
def _handle_player_flag_region_collide(self, colliding: bool) -> None:
try:
spaz = bs.getcollision().opposingnode.getdelegate(PlayerSpaz, True)
except bs.NotFoundError:
return
if not spaz.is_alive():
return
player = spaz.getplayer(Player, True)
# Different parts of us can collide so a single value isn't enough
# also don't count it if we're dead (flying heads shouldn't be able to
# win the game :-)
if colliding and player.is_alive():
player.time_at_flag += 1
else:
player.time_at_flag = max(0, player.time_at_flag - 1)
self._update_flag_state()
def _update_scoreboard(self) -> None:
for team in self.teams:
self._scoreboard.set_team_value(
team, team.time_remaining, self._hold_time, countdown=True
)
[docs]
@override
def handlemessage(self, msg: Any) -> Any:
if isinstance(msg, bs.PlayerDiedMessage):
super().handlemessage(msg) # Augment default.
# No longer can count as time_at_flag once dead.
player = msg.getplayer(Player)
player.time_at_flag = 0
self._update_flag_state()
self.respawn_player(player)