bascenev1lib.game.keepaway
Defines a keep-away game type.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Defines a keep-away game type.""" 4 5# ba_meta require api 9 6# (see https://ballistica.net/wiki/meta-tag-system) 7 8from __future__ import annotations 9 10import logging 11from enum import Enum 12from typing import TYPE_CHECKING, override 13 14import bascenev1 as bs 15 16from bascenev1lib.actor.playerspaz import PlayerSpaz 17from bascenev1lib.actor.scoreboard import Scoreboard 18from bascenev1lib.actor.flag import ( 19 Flag, 20 FlagDroppedMessage, 21 FlagDiedMessage, 22 FlagPickedUpMessage, 23) 24 25if TYPE_CHECKING: 26 from typing import Any, Sequence 27 28 29class FlagState(Enum): 30 """States our single flag can be in.""" 31 32 NEW = 0 33 UNCONTESTED = 1 34 CONTESTED = 2 35 HELD = 3 36 37 38class Player(bs.Player['Team']): 39 """Our player type for this game.""" 40 41 42class Team(bs.Team[Player]): 43 """Our team type for this game.""" 44 45 def __init__(self, timeremaining: int) -> None: 46 self.timeremaining = timeremaining 47 self.holdingflag = False 48 49 50# ba_meta export bascenev1.GameActivity 51class KeepAwayGame(bs.TeamGameActivity[Player, Team]): 52 """Game where you try to keep the flag away from your enemies.""" 53 54 name = 'Keep Away' 55 description = 'Carry the flag for a set length of time.' 56 available_settings = [ 57 bs.IntSetting( 58 'Hold Time', 59 min_value=10, 60 default=30, 61 increment=10, 62 ), 63 bs.IntChoiceSetting( 64 'Time Limit', 65 choices=[ 66 ('None', 0), 67 ('1 Minute', 60), 68 ('2 Minutes', 120), 69 ('5 Minutes', 300), 70 ('10 Minutes', 600), 71 ('20 Minutes', 1200), 72 ], 73 default=0, 74 ), 75 bs.FloatChoiceSetting( 76 'Respawn Times', 77 choices=[ 78 ('Shorter', 0.25), 79 ('Short', 0.5), 80 ('Normal', 1.0), 81 ('Long', 2.0), 82 ('Longer', 4.0), 83 ], 84 default=1.0, 85 ), 86 bs.BoolSetting('Epic Mode', default=False), 87 ] 88 scoreconfig = bs.ScoreConfig(label='Time Held') 89 90 @override 91 @classmethod 92 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 93 return issubclass(sessiontype, bs.DualTeamSession) or issubclass( 94 sessiontype, bs.FreeForAllSession 95 ) 96 97 @override 98 @classmethod 99 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 100 assert bs.app.classic is not None 101 return bs.app.classic.getmaps('keep_away') 102 103 def __init__(self, settings: dict): 104 super().__init__(settings) 105 self._scoreboard = Scoreboard() 106 self._swipsound = bs.getsound('swip') 107 self._tick_sound = bs.getsound('tick') 108 self._countdownsounds = { 109 10: bs.getsound('announceTen'), 110 9: bs.getsound('announceNine'), 111 8: bs.getsound('announceEight'), 112 7: bs.getsound('announceSeven'), 113 6: bs.getsound('announceSix'), 114 5: bs.getsound('announceFive'), 115 4: bs.getsound('announceFour'), 116 3: bs.getsound('announceThree'), 117 2: bs.getsound('announceTwo'), 118 1: bs.getsound('announceOne'), 119 } 120 self._flag_spawn_pos: Sequence[float] | None = None 121 self._update_timer: bs.Timer | None = None 122 self._holding_players: list[Player] = [] 123 self._flag_state: FlagState | None = None 124 self._flag_light: bs.Node | None = None 125 self._scoring_team: Team | None = None 126 self._flag: Flag | None = None 127 self._hold_time = int(settings['Hold Time']) 128 self._time_limit = float(settings['Time Limit']) 129 self._epic_mode = bool(settings['Epic Mode']) 130 self.slow_motion = self._epic_mode 131 self.default_music = ( 132 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.KEEP_AWAY 133 ) 134 135 @override 136 def get_instance_description(self) -> str | Sequence: 137 return 'Carry the flag for ${ARG1} seconds.', self._hold_time 138 139 @override 140 def get_instance_description_short(self) -> str | Sequence: 141 return 'carry the flag for ${ARG1} seconds', self._hold_time 142 143 @override 144 def create_team(self, sessionteam: bs.SessionTeam) -> Team: 145 return Team(timeremaining=self._hold_time) 146 147 @override 148 def on_team_join(self, team: Team) -> None: 149 self._update_scoreboard() 150 151 @override 152 def on_begin(self) -> None: 153 super().on_begin() 154 self.setup_standard_time_limit(self._time_limit) 155 self.setup_standard_powerup_drops() 156 self._flag_spawn_pos = self.map.get_flag_position(None) 157 self._spawn_flag() 158 self._update_timer = bs.Timer(1.0, call=self._tick, repeat=True) 159 self._update_flag_state() 160 Flag.project_stand(self._flag_spawn_pos) 161 162 def _tick(self) -> None: 163 self._update_flag_state() 164 165 # Award points to all living players holding the flag. 166 for player in self._holding_players: 167 if player: 168 self.stats.player_scored( 169 player, 3, screenmessage=False, display=False 170 ) 171 172 scoreteam = self._scoring_team 173 174 if scoreteam is not None: 175 if scoreteam.timeremaining > 0: 176 self._tick_sound.play() 177 178 scoreteam.timeremaining = max(0, scoreteam.timeremaining - 1) 179 self._update_scoreboard() 180 if scoreteam.timeremaining > 0: 181 assert self._flag is not None 182 self._flag.set_score_text(str(scoreteam.timeremaining)) 183 184 # Announce numbers we have sounds for. 185 if scoreteam.timeremaining in self._countdownsounds: 186 self._countdownsounds[scoreteam.timeremaining].play() 187 188 # Winner. 189 if scoreteam.timeremaining <= 0: 190 self.end_game() 191 192 @override 193 def end_game(self) -> None: 194 results = bs.GameResults() 195 for team in self.teams: 196 results.set_team_score(team, self._hold_time - team.timeremaining) 197 self.end(results=results, announce_delay=0) 198 199 def _update_flag_state(self) -> None: 200 for team in self.teams: 201 team.holdingflag = False 202 self._holding_players = [] 203 for player in self.players: 204 holdingflag = False 205 try: 206 assert isinstance(player.actor, (PlayerSpaz, type(None))) 207 if ( 208 player.actor 209 and player.actor.node 210 and player.actor.node.hold_node 211 ): 212 holdingflag = ( 213 player.actor.node.hold_node.getnodetype() == 'flag' 214 ) 215 except Exception: 216 logging.exception('Error checking hold flag.') 217 if holdingflag: 218 self._holding_players.append(player) 219 player.team.holdingflag = True 220 221 holdingteams = set(t for t in self.teams if t.holdingflag) 222 prevstate = self._flag_state 223 assert self._flag is not None 224 assert self._flag_light 225 assert self._flag.node 226 if len(holdingteams) > 1: 227 self._flag_state = FlagState.CONTESTED 228 self._scoring_team = None 229 self._flag_light.color = (0.6, 0.6, 0.1) 230 self._flag.node.color = (1.0, 1.0, 0.4) 231 elif len(holdingteams) == 1: 232 holdingteam = list(holdingteams)[0] 233 self._flag_state = FlagState.HELD 234 self._scoring_team = holdingteam 235 self._flag_light.color = bs.normalized_color(holdingteam.color) 236 self._flag.node.color = holdingteam.color 237 else: 238 self._flag_state = FlagState.UNCONTESTED 239 self._scoring_team = None 240 self._flag_light.color = (0.2, 0.2, 0.2) 241 self._flag.node.color = (1, 1, 1) 242 243 if self._flag_state != prevstate: 244 self._swipsound.play() 245 246 def _spawn_flag(self) -> None: 247 self._swipsound.play() 248 self._flash_flag_spawn() 249 assert self._flag_spawn_pos is not None 250 self._flag = Flag(dropped_timeout=20, position=self._flag_spawn_pos) 251 self._flag_state = FlagState.NEW 252 self._flag_light = bs.newnode( 253 'light', 254 owner=self._flag.node, 255 attrs={'intensity': 0.2, 'radius': 0.3, 'color': (0.2, 0.2, 0.2)}, 256 ) 257 assert self._flag.node 258 self._flag.node.connectattr('position', self._flag_light, 'position') 259 self._update_flag_state() 260 261 def _flash_flag_spawn(self) -> None: 262 light = bs.newnode( 263 'light', 264 attrs={ 265 'position': self._flag_spawn_pos, 266 'color': (1, 1, 1), 267 'radius': 0.3, 268 'height_attenuated': False, 269 }, 270 ) 271 bs.animate(light, 'intensity', {0.0: 0, 0.25: 0.5, 0.5: 0}, loop=True) 272 bs.timer(1.0, light.delete) 273 274 def _update_scoreboard(self) -> None: 275 for team in self.teams: 276 self._scoreboard.set_team_value( 277 team, team.timeremaining, self._hold_time, countdown=True 278 ) 279 280 @override 281 def handlemessage(self, msg: Any) -> Any: 282 if isinstance(msg, bs.PlayerDiedMessage): 283 # Augment standard behavior. 284 super().handlemessage(msg) 285 self.respawn_player(msg.getplayer(Player)) 286 elif isinstance(msg, FlagDiedMessage): 287 self._spawn_flag() 288 elif isinstance(msg, (FlagDroppedMessage, FlagPickedUpMessage)): 289 self._update_flag_state() 290 else: 291 super().handlemessage(msg)
30class FlagState(Enum): 31 """States our single flag can be in.""" 32 33 NEW = 0 34 UNCONTESTED = 1 35 CONTESTED = 2 36 HELD = 3
States our single flag can be in.
Our player type for this game.
43class Team(bs.Team[Player]): 44 """Our team type for this game.""" 45 46 def __init__(self, timeremaining: int) -> None: 47 self.timeremaining = timeremaining 48 self.holdingflag = False
Our team type for this game.
52class KeepAwayGame(bs.TeamGameActivity[Player, Team]): 53 """Game where you try to keep the flag away from your enemies.""" 54 55 name = 'Keep Away' 56 description = 'Carry the flag for a set length of time.' 57 available_settings = [ 58 bs.IntSetting( 59 'Hold Time', 60 min_value=10, 61 default=30, 62 increment=10, 63 ), 64 bs.IntChoiceSetting( 65 'Time Limit', 66 choices=[ 67 ('None', 0), 68 ('1 Minute', 60), 69 ('2 Minutes', 120), 70 ('5 Minutes', 300), 71 ('10 Minutes', 600), 72 ('20 Minutes', 1200), 73 ], 74 default=0, 75 ), 76 bs.FloatChoiceSetting( 77 'Respawn Times', 78 choices=[ 79 ('Shorter', 0.25), 80 ('Short', 0.5), 81 ('Normal', 1.0), 82 ('Long', 2.0), 83 ('Longer', 4.0), 84 ], 85 default=1.0, 86 ), 87 bs.BoolSetting('Epic Mode', default=False), 88 ] 89 scoreconfig = bs.ScoreConfig(label='Time Held') 90 91 @override 92 @classmethod 93 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 94 return issubclass(sessiontype, bs.DualTeamSession) or issubclass( 95 sessiontype, bs.FreeForAllSession 96 ) 97 98 @override 99 @classmethod 100 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 101 assert bs.app.classic is not None 102 return bs.app.classic.getmaps('keep_away') 103 104 def __init__(self, settings: dict): 105 super().__init__(settings) 106 self._scoreboard = Scoreboard() 107 self._swipsound = bs.getsound('swip') 108 self._tick_sound = bs.getsound('tick') 109 self._countdownsounds = { 110 10: bs.getsound('announceTen'), 111 9: bs.getsound('announceNine'), 112 8: bs.getsound('announceEight'), 113 7: bs.getsound('announceSeven'), 114 6: bs.getsound('announceSix'), 115 5: bs.getsound('announceFive'), 116 4: bs.getsound('announceFour'), 117 3: bs.getsound('announceThree'), 118 2: bs.getsound('announceTwo'), 119 1: bs.getsound('announceOne'), 120 } 121 self._flag_spawn_pos: Sequence[float] | None = None 122 self._update_timer: bs.Timer | None = None 123 self._holding_players: list[Player] = [] 124 self._flag_state: FlagState | None = None 125 self._flag_light: bs.Node | None = None 126 self._scoring_team: Team | None = None 127 self._flag: Flag | None = None 128 self._hold_time = int(settings['Hold Time']) 129 self._time_limit = float(settings['Time Limit']) 130 self._epic_mode = bool(settings['Epic Mode']) 131 self.slow_motion = self._epic_mode 132 self.default_music = ( 133 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.KEEP_AWAY 134 ) 135 136 @override 137 def get_instance_description(self) -> str | Sequence: 138 return 'Carry the flag for ${ARG1} seconds.', self._hold_time 139 140 @override 141 def get_instance_description_short(self) -> str | Sequence: 142 return 'carry the flag for ${ARG1} seconds', self._hold_time 143 144 @override 145 def create_team(self, sessionteam: bs.SessionTeam) -> Team: 146 return Team(timeremaining=self._hold_time) 147 148 @override 149 def on_team_join(self, team: Team) -> None: 150 self._update_scoreboard() 151 152 @override 153 def on_begin(self) -> None: 154 super().on_begin() 155 self.setup_standard_time_limit(self._time_limit) 156 self.setup_standard_powerup_drops() 157 self._flag_spawn_pos = self.map.get_flag_position(None) 158 self._spawn_flag() 159 self._update_timer = bs.Timer(1.0, call=self._tick, repeat=True) 160 self._update_flag_state() 161 Flag.project_stand(self._flag_spawn_pos) 162 163 def _tick(self) -> None: 164 self._update_flag_state() 165 166 # Award points to all living players holding the flag. 167 for player in self._holding_players: 168 if player: 169 self.stats.player_scored( 170 player, 3, screenmessage=False, display=False 171 ) 172 173 scoreteam = self._scoring_team 174 175 if scoreteam is not None: 176 if scoreteam.timeremaining > 0: 177 self._tick_sound.play() 178 179 scoreteam.timeremaining = max(0, scoreteam.timeremaining - 1) 180 self._update_scoreboard() 181 if scoreteam.timeremaining > 0: 182 assert self._flag is not None 183 self._flag.set_score_text(str(scoreteam.timeremaining)) 184 185 # Announce numbers we have sounds for. 186 if scoreteam.timeremaining in self._countdownsounds: 187 self._countdownsounds[scoreteam.timeremaining].play() 188 189 # Winner. 190 if scoreteam.timeremaining <= 0: 191 self.end_game() 192 193 @override 194 def end_game(self) -> None: 195 results = bs.GameResults() 196 for team in self.teams: 197 results.set_team_score(team, self._hold_time - team.timeremaining) 198 self.end(results=results, announce_delay=0) 199 200 def _update_flag_state(self) -> None: 201 for team in self.teams: 202 team.holdingflag = False 203 self._holding_players = [] 204 for player in self.players: 205 holdingflag = False 206 try: 207 assert isinstance(player.actor, (PlayerSpaz, type(None))) 208 if ( 209 player.actor 210 and player.actor.node 211 and player.actor.node.hold_node 212 ): 213 holdingflag = ( 214 player.actor.node.hold_node.getnodetype() == 'flag' 215 ) 216 except Exception: 217 logging.exception('Error checking hold flag.') 218 if holdingflag: 219 self._holding_players.append(player) 220 player.team.holdingflag = True 221 222 holdingteams = set(t for t in self.teams if t.holdingflag) 223 prevstate = self._flag_state 224 assert self._flag is not None 225 assert self._flag_light 226 assert self._flag.node 227 if len(holdingteams) > 1: 228 self._flag_state = FlagState.CONTESTED 229 self._scoring_team = None 230 self._flag_light.color = (0.6, 0.6, 0.1) 231 self._flag.node.color = (1.0, 1.0, 0.4) 232 elif len(holdingteams) == 1: 233 holdingteam = list(holdingteams)[0] 234 self._flag_state = FlagState.HELD 235 self._scoring_team = holdingteam 236 self._flag_light.color = bs.normalized_color(holdingteam.color) 237 self._flag.node.color = holdingteam.color 238 else: 239 self._flag_state = FlagState.UNCONTESTED 240 self._scoring_team = None 241 self._flag_light.color = (0.2, 0.2, 0.2) 242 self._flag.node.color = (1, 1, 1) 243 244 if self._flag_state != prevstate: 245 self._swipsound.play() 246 247 def _spawn_flag(self) -> None: 248 self._swipsound.play() 249 self._flash_flag_spawn() 250 assert self._flag_spawn_pos is not None 251 self._flag = Flag(dropped_timeout=20, position=self._flag_spawn_pos) 252 self._flag_state = FlagState.NEW 253 self._flag_light = bs.newnode( 254 'light', 255 owner=self._flag.node, 256 attrs={'intensity': 0.2, 'radius': 0.3, 'color': (0.2, 0.2, 0.2)}, 257 ) 258 assert self._flag.node 259 self._flag.node.connectattr('position', self._flag_light, 'position') 260 self._update_flag_state() 261 262 def _flash_flag_spawn(self) -> None: 263 light = bs.newnode( 264 'light', 265 attrs={ 266 'position': self._flag_spawn_pos, 267 'color': (1, 1, 1), 268 'radius': 0.3, 269 'height_attenuated': False, 270 }, 271 ) 272 bs.animate(light, 'intensity', {0.0: 0, 0.25: 0.5, 0.5: 0}, loop=True) 273 bs.timer(1.0, light.delete) 274 275 def _update_scoreboard(self) -> None: 276 for team in self.teams: 277 self._scoreboard.set_team_value( 278 team, team.timeremaining, self._hold_time, countdown=True 279 ) 280 281 @override 282 def handlemessage(self, msg: Any) -> Any: 283 if isinstance(msg, bs.PlayerDiedMessage): 284 # Augment standard behavior. 285 super().handlemessage(msg) 286 self.respawn_player(msg.getplayer(Player)) 287 elif isinstance(msg, FlagDiedMessage): 288 self._spawn_flag() 289 elif isinstance(msg, (FlagDroppedMessage, FlagPickedUpMessage)): 290 self._update_flag_state() 291 else: 292 super().handlemessage(msg)
Game where you try to keep the flag away from your enemies.
104 def __init__(self, settings: dict): 105 super().__init__(settings) 106 self._scoreboard = Scoreboard() 107 self._swipsound = bs.getsound('swip') 108 self._tick_sound = bs.getsound('tick') 109 self._countdownsounds = { 110 10: bs.getsound('announceTen'), 111 9: bs.getsound('announceNine'), 112 8: bs.getsound('announceEight'), 113 7: bs.getsound('announceSeven'), 114 6: bs.getsound('announceSix'), 115 5: bs.getsound('announceFive'), 116 4: bs.getsound('announceFour'), 117 3: bs.getsound('announceThree'), 118 2: bs.getsound('announceTwo'), 119 1: bs.getsound('announceOne'), 120 } 121 self._flag_spawn_pos: Sequence[float] | None = None 122 self._update_timer: bs.Timer | None = None 123 self._holding_players: list[Player] = [] 124 self._flag_state: FlagState | None = None 125 self._flag_light: bs.Node | None = None 126 self._scoring_team: Team | None = None 127 self._flag: Flag | None = None 128 self._hold_time = int(settings['Hold Time']) 129 self._time_limit = float(settings['Time Limit']) 130 self._epic_mode = bool(settings['Epic Mode']) 131 self.slow_motion = self._epic_mode 132 self.default_music = ( 133 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.KEEP_AWAY 134 )
Instantiate the Activity.
91 @override 92 @classmethod 93 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 94 return issubclass(sessiontype, bs.DualTeamSession) or issubclass( 95 sessiontype, bs.FreeForAllSession 96 )
Class method override; returns True for ba.DualTeamSessions and ba.FreeForAllSessions; False otherwise.
98 @override 99 @classmethod 100 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 101 assert bs.app.classic is not None 102 return bs.app.classic.getmaps('keep_away')
Called by the default bascenev1.GameActivity.create_settings_ui() implementation; should return a list of map names valid for this game-type for the given bascenev1.Session type.
136 @override 137 def get_instance_description(self) -> str | Sequence: 138 return 'Carry the flag for ${ARG1} seconds.', self._hold_time
Return a description for this game instance, in English.
This is shown in the center of the screen below the game name at the start of a game. It should start with a capital letter and end with a period, and can be a bit more verbose than the version returned by get_instance_description_short().
Note that translation is applied by looking up the specific returned value as a key, so the number of returned variations should be limited; ideally just one or two. To include arbitrary values in the description, you can return a sequence of values in the following form instead of just a string:
This will give us something like 'Score 3 goals.' in English
and can properly translate to 'Anota 3 goles.' in Spanish.
If we just returned the string 'Score 3 Goals' here, there would
have to be a translation entry for each specific number. ew.
return ['Score ${ARG1} goals.', self.settings_raw['Score to Win']]
This way the first string can be consistently translated, with any arg values then substituted into the result. ${ARG1} will be replaced with the first value, ${ARG2} with the second, etc.
140 @override 141 def get_instance_description_short(self) -> str | Sequence: 142 return 'carry the flag for ${ARG1} seconds', self._hold_time
Return a short description for this game instance in English.
This description is used above the game scoreboard in the corner of the screen, so it should be as concise as possible. It should be lowercase and should not contain periods or other punctuation.
Note that translation is applied by looking up the specific returned value as a key, so the number of returned variations should be limited; ideally just one or two. To include arbitrary values in the description, you can return a sequence of values in the following form instead of just a string:
This will give us something like 'score 3 goals' in English
and can properly translate to 'anota 3 goles' in Spanish.
If we just returned the string 'score 3 goals' here, there would
have to be a translation entry for each specific number. ew.
return ['score ${ARG1} goals', self.settings_raw['Score to Win']]
This way the first string can be consistently translated, with any arg values then substituted into the result. ${ARG1} will be replaced with the first value, ${ARG2} with the second, etc.
144 @override 145 def create_team(self, sessionteam: bs.SessionTeam) -> Team: 146 return Team(timeremaining=self._hold_time)
Create the Team instance for this Activity.
Subclasses can override this if the activity's team class requires a custom constructor; otherwise it will be called with no args. Note that the team object should not be used at this point as it is not yet fully wired up; wait for on_team_join() for that.
Called when a new bascenev1.Team joins the Activity.
(including the initial set of Teams)
152 @override 153 def on_begin(self) -> None: 154 super().on_begin() 155 self.setup_standard_time_limit(self._time_limit) 156 self.setup_standard_powerup_drops() 157 self._flag_spawn_pos = self.map.get_flag_position(None) 158 self._spawn_flag() 159 self._update_timer = bs.Timer(1.0, call=self._tick, repeat=True) 160 self._update_flag_state() 161 Flag.project_stand(self._flag_spawn_pos)
Called once the previous Activity has finished transitioning out.
At this point the activity's initial players and teams are filled in and it should begin its actual game logic.
193 @override 194 def end_game(self) -> None: 195 results = bs.GameResults() 196 for team in self.teams: 197 results.set_team_score(team, self._hold_time - team.timeremaining) 198 self.end(results=results, announce_delay=0)
Tell the game to wrap up and call bascenev1.Activity.end().
This method should be overridden by subclasses. A game should always be prepared to end and deliver results, even if there is no 'winner' yet; this way things like the standard time-limit (bascenev1.GameActivity.setup_standard_time_limit()) will work with the game.
281 @override 282 def handlemessage(self, msg: Any) -> Any: 283 if isinstance(msg, bs.PlayerDiedMessage): 284 # Augment standard behavior. 285 super().handlemessage(msg) 286 self.respawn_player(msg.getplayer(Player)) 287 elif isinstance(msg, FlagDiedMessage): 288 self._spawn_flag() 289 elif isinstance(msg, (FlagDroppedMessage, FlagPickedUpMessage)): 290 self._update_flag_state() 291 else: 292 super().handlemessage(msg)
General message handling; can be passed any message object.