bascenev1lib.game.assault
Defines assault minigame.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Defines assault minigame.""" 4 5# ba_meta require api 9 6# (see https://ballistica.net/wiki/meta-tag-system) 7 8from __future__ import annotations 9 10import random 11from typing import TYPE_CHECKING, override 12 13import bascenev1 as bs 14 15from bascenev1lib.actor.playerspaz import PlayerSpaz 16from bascenev1lib.actor.flag import Flag 17from bascenev1lib.actor.scoreboard import Scoreboard 18from bascenev1lib.gameutils import SharedObjects 19 20if TYPE_CHECKING: 21 from typing import Any, Sequence 22 23 24class Player(bs.Player['Team']): 25 """Our player type for this game.""" 26 27 28class Team(bs.Team[Player]): 29 """Our team type for this game.""" 30 31 def __init__(self, base_pos: Sequence[float], flag: Flag) -> None: 32 33 #: Where our base is. 34 self.base_pos = base_pos 35 36 #: Flag for this team. 37 self.flag = flag 38 39 #: Current score. 40 self.score = 0 41 42 43# ba_meta export bascenev1.GameActivity 44class AssaultGame(bs.TeamGameActivity[Player, Team]): 45 """Game where you score by touching the other team's flag.""" 46 47 name = 'Assault' 48 description = 'Reach the enemy flag to score.' 49 available_settings = [ 50 bs.IntSetting( 51 'Score to Win', 52 min_value=1, 53 default=3, 54 ), 55 bs.IntChoiceSetting( 56 'Time Limit', 57 choices=[ 58 ('None', 0), 59 ('1 Minute', 60), 60 ('2 Minutes', 120), 61 ('5 Minutes', 300), 62 ('10 Minutes', 600), 63 ('20 Minutes', 1200), 64 ], 65 default=0, 66 ), 67 bs.FloatChoiceSetting( 68 'Respawn Times', 69 choices=[ 70 ('Shorter', 0.25), 71 ('Short', 0.5), 72 ('Normal', 1.0), 73 ('Long', 2.0), 74 ('Longer', 4.0), 75 ], 76 default=1.0, 77 ), 78 bs.BoolSetting('Epic Mode', default=False), 79 ] 80 81 @override 82 @classmethod 83 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 84 return issubclass(sessiontype, bs.DualTeamSession) 85 86 @override 87 @classmethod 88 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 89 assert bs.app.classic is not None 90 return bs.app.classic.getmaps('team_flag') 91 92 def __init__(self, settings: dict): 93 super().__init__(settings) 94 self._scoreboard = Scoreboard() 95 self._last_score_time = 0.0 96 self._score_sound = bs.getsound('score') 97 self._base_region_materials: dict[int, bs.Material] = {} 98 self._epic_mode = bool(settings['Epic Mode']) 99 self._score_to_win = int(settings['Score to Win']) 100 self._time_limit = float(settings['Time Limit']) 101 102 # Base class overrides 103 self.slow_motion = self._epic_mode 104 self.default_music = ( 105 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FORWARD_MARCH 106 ) 107 108 @override 109 def get_instance_description(self) -> str | Sequence: 110 if self._score_to_win == 1: 111 return 'Touch the enemy flag.' 112 return 'Touch the enemy flag ${ARG1} times.', self._score_to_win 113 114 @override 115 def get_instance_description_short(self) -> str | Sequence: 116 if self._score_to_win == 1: 117 return 'touch 1 flag' 118 return 'touch ${ARG1} flags', self._score_to_win 119 120 @override 121 def create_team(self, sessionteam: bs.SessionTeam) -> Team: 122 shared = SharedObjects.get() 123 base_pos = self.map.get_flag_position(sessionteam.id) 124 bs.newnode( 125 'light', 126 attrs={ 127 'position': base_pos, 128 'intensity': 0.6, 129 'height_attenuated': False, 130 'volume_intensity_scale': 0.1, 131 'radius': 0.1, 132 'color': sessionteam.color, 133 }, 134 ) 135 Flag.project_stand(base_pos) 136 flag = Flag(touchable=False, position=base_pos, color=sessionteam.color) 137 team = Team(base_pos=base_pos, flag=flag) 138 139 mat = self._base_region_materials[sessionteam.id] = bs.Material() 140 mat.add_actions( 141 conditions=('they_have_material', shared.player_material), 142 actions=( 143 ('modify_part_collision', 'collide', True), 144 ('modify_part_collision', 'physical', False), 145 ( 146 'call', 147 'at_connect', 148 bs.Call(self._handle_base_collide, team), 149 ), 150 ), 151 ) 152 153 bs.newnode( 154 'region', 155 owner=flag.node, 156 attrs={ 157 'position': (base_pos[0], base_pos[1] + 0.75, base_pos[2]), 158 'scale': (0.5, 0.5, 0.5), 159 'type': 'sphere', 160 'materials': [self._base_region_materials[sessionteam.id]], 161 }, 162 ) 163 164 return team 165 166 @override 167 def on_team_join(self, team: Team) -> None: 168 # Can't do this in create_team because the team's color/etc. have 169 # not been wired up yet at that point. 170 self._update_scoreboard() 171 172 @override 173 def on_begin(self) -> None: 174 super().on_begin() 175 self.setup_standard_time_limit(self._time_limit) 176 self.setup_standard_powerup_drops() 177 178 @override 179 def handlemessage(self, msg: Any) -> Any: 180 if isinstance(msg, bs.PlayerDiedMessage): 181 super().handlemessage(msg) # Augment standard. 182 self.respawn_player(msg.getplayer(Player)) 183 else: 184 super().handlemessage(msg) 185 186 def _flash_base(self, team: Team, length: float = 2.0) -> None: 187 light = bs.newnode( 188 'light', 189 attrs={ 190 'position': team.base_pos, 191 'height_attenuated': False, 192 'radius': 0.3, 193 'color': team.color, 194 }, 195 ) 196 bs.animate(light, 'intensity', {0: 0, 0.25: 2.0, 0.5: 0}, loop=True) 197 bs.timer(length, light.delete) 198 199 def _handle_base_collide(self, team: Team) -> None: 200 try: 201 spaz = bs.getcollision().opposingnode.getdelegate(PlayerSpaz, True) 202 except bs.NotFoundError: 203 return 204 205 if not spaz.is_alive(): 206 return 207 208 try: 209 player = spaz.getplayer(Player, True) 210 except bs.NotFoundError: 211 return 212 213 # If its another team's player, they scored. 214 player_team = player.team 215 if player_team is not team: 216 # Prevent multiple simultaneous scores. 217 if bs.time() != self._last_score_time: 218 self._last_score_time = bs.time() 219 self.stats.player_scored(player, 50, big_message=True) 220 self._score_sound.play() 221 self._flash_base(team) 222 223 # Move all players on the scoring team back to their start 224 # and add flashes of light so its noticeable. 225 for player in player_team.players: 226 if player.is_alive(): 227 pos = player.node.position 228 light = bs.newnode( 229 'light', 230 attrs={ 231 'position': pos, 232 'color': player_team.color, 233 'height_attenuated': False, 234 'radius': 0.4, 235 }, 236 ) 237 bs.timer(0.5, light.delete) 238 bs.animate(light, 'intensity', {0: 0, 0.1: 1.0, 0.5: 0}) 239 240 new_pos = self.map.get_start_position(player_team.id) 241 light = bs.newnode( 242 'light', 243 attrs={ 244 'position': new_pos, 245 'color': player_team.color, 246 'radius': 0.4, 247 'height_attenuated': False, 248 }, 249 ) 250 bs.timer(0.5, light.delete) 251 bs.animate(light, 'intensity', {0: 0, 0.1: 1.0, 0.5: 0}) 252 if player.actor: 253 random_num = random.uniform(0, 360) 254 255 # Slightly hacky workaround: normally, 256 # teleporting back to base with a sticky 257 # bomb stuck to you gives a crazy whiplash 258 # rubber-band effect. Running the teleport 259 # twice in a row seems to suppress that 260 # though. Would be better to fix this at a 261 # lower level, but this works for now. 262 self._teleport(player, new_pos, random_num) 263 bs.timer( 264 0.01, 265 bs.Call( 266 self._teleport, player, new_pos, random_num 267 ), 268 ) 269 270 # Have teammates celebrate. 271 for player in player_team.players: 272 if player.actor: 273 player.actor.handlemessage(bs.CelebrateMessage(2.0)) 274 275 player_team.score += 1 276 self._update_scoreboard() 277 if player_team.score >= self._score_to_win: 278 self.end_game() 279 280 def _teleport( 281 self, client: Player, pos: Sequence[float], num: float 282 ) -> None: 283 if client.actor: 284 client.actor.handlemessage(bs.StandMessage(pos, num)) 285 286 @override 287 def end_game(self) -> None: 288 results = bs.GameResults() 289 for team in self.teams: 290 results.set_team_score(team, team.score) 291 self.end(results=results) 292 293 def _update_scoreboard(self) -> None: 294 for team in self.teams: 295 self._scoreboard.set_team_value( 296 team, team.score, self._score_to_win 297 )
Our player type for this game.
29class Team(bs.Team[Player]): 30 """Our team type for this game.""" 31 32 def __init__(self, base_pos: Sequence[float], flag: Flag) -> None: 33 34 #: Where our base is. 35 self.base_pos = base_pos 36 37 #: Flag for this team. 38 self.flag = flag 39 40 #: Current score. 41 self.score = 0
Our team type for this game.
45class AssaultGame(bs.TeamGameActivity[Player, Team]): 46 """Game where you score by touching the other team's flag.""" 47 48 name = 'Assault' 49 description = 'Reach the enemy flag to score.' 50 available_settings = [ 51 bs.IntSetting( 52 'Score to Win', 53 min_value=1, 54 default=3, 55 ), 56 bs.IntChoiceSetting( 57 'Time Limit', 58 choices=[ 59 ('None', 0), 60 ('1 Minute', 60), 61 ('2 Minutes', 120), 62 ('5 Minutes', 300), 63 ('10 Minutes', 600), 64 ('20 Minutes', 1200), 65 ], 66 default=0, 67 ), 68 bs.FloatChoiceSetting( 69 'Respawn Times', 70 choices=[ 71 ('Shorter', 0.25), 72 ('Short', 0.5), 73 ('Normal', 1.0), 74 ('Long', 2.0), 75 ('Longer', 4.0), 76 ], 77 default=1.0, 78 ), 79 bs.BoolSetting('Epic Mode', default=False), 80 ] 81 82 @override 83 @classmethod 84 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 85 return issubclass(sessiontype, bs.DualTeamSession) 86 87 @override 88 @classmethod 89 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 90 assert bs.app.classic is not None 91 return bs.app.classic.getmaps('team_flag') 92 93 def __init__(self, settings: dict): 94 super().__init__(settings) 95 self._scoreboard = Scoreboard() 96 self._last_score_time = 0.0 97 self._score_sound = bs.getsound('score') 98 self._base_region_materials: dict[int, bs.Material] = {} 99 self._epic_mode = bool(settings['Epic Mode']) 100 self._score_to_win = int(settings['Score to Win']) 101 self._time_limit = float(settings['Time Limit']) 102 103 # Base class overrides 104 self.slow_motion = self._epic_mode 105 self.default_music = ( 106 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FORWARD_MARCH 107 ) 108 109 @override 110 def get_instance_description(self) -> str | Sequence: 111 if self._score_to_win == 1: 112 return 'Touch the enemy flag.' 113 return 'Touch the enemy flag ${ARG1} times.', self._score_to_win 114 115 @override 116 def get_instance_description_short(self) -> str | Sequence: 117 if self._score_to_win == 1: 118 return 'touch 1 flag' 119 return 'touch ${ARG1} flags', self._score_to_win 120 121 @override 122 def create_team(self, sessionteam: bs.SessionTeam) -> Team: 123 shared = SharedObjects.get() 124 base_pos = self.map.get_flag_position(sessionteam.id) 125 bs.newnode( 126 'light', 127 attrs={ 128 'position': base_pos, 129 'intensity': 0.6, 130 'height_attenuated': False, 131 'volume_intensity_scale': 0.1, 132 'radius': 0.1, 133 'color': sessionteam.color, 134 }, 135 ) 136 Flag.project_stand(base_pos) 137 flag = Flag(touchable=False, position=base_pos, color=sessionteam.color) 138 team = Team(base_pos=base_pos, flag=flag) 139 140 mat = self._base_region_materials[sessionteam.id] = bs.Material() 141 mat.add_actions( 142 conditions=('they_have_material', shared.player_material), 143 actions=( 144 ('modify_part_collision', 'collide', True), 145 ('modify_part_collision', 'physical', False), 146 ( 147 'call', 148 'at_connect', 149 bs.Call(self._handle_base_collide, team), 150 ), 151 ), 152 ) 153 154 bs.newnode( 155 'region', 156 owner=flag.node, 157 attrs={ 158 'position': (base_pos[0], base_pos[1] + 0.75, base_pos[2]), 159 'scale': (0.5, 0.5, 0.5), 160 'type': 'sphere', 161 'materials': [self._base_region_materials[sessionteam.id]], 162 }, 163 ) 164 165 return team 166 167 @override 168 def on_team_join(self, team: Team) -> None: 169 # Can't do this in create_team because the team's color/etc. have 170 # not been wired up yet at that point. 171 self._update_scoreboard() 172 173 @override 174 def on_begin(self) -> None: 175 super().on_begin() 176 self.setup_standard_time_limit(self._time_limit) 177 self.setup_standard_powerup_drops() 178 179 @override 180 def handlemessage(self, msg: Any) -> Any: 181 if isinstance(msg, bs.PlayerDiedMessage): 182 super().handlemessage(msg) # Augment standard. 183 self.respawn_player(msg.getplayer(Player)) 184 else: 185 super().handlemessage(msg) 186 187 def _flash_base(self, team: Team, length: float = 2.0) -> None: 188 light = bs.newnode( 189 'light', 190 attrs={ 191 'position': team.base_pos, 192 'height_attenuated': False, 193 'radius': 0.3, 194 'color': team.color, 195 }, 196 ) 197 bs.animate(light, 'intensity', {0: 0, 0.25: 2.0, 0.5: 0}, loop=True) 198 bs.timer(length, light.delete) 199 200 def _handle_base_collide(self, team: Team) -> None: 201 try: 202 spaz = bs.getcollision().opposingnode.getdelegate(PlayerSpaz, True) 203 except bs.NotFoundError: 204 return 205 206 if not spaz.is_alive(): 207 return 208 209 try: 210 player = spaz.getplayer(Player, True) 211 except bs.NotFoundError: 212 return 213 214 # If its another team's player, they scored. 215 player_team = player.team 216 if player_team is not team: 217 # Prevent multiple simultaneous scores. 218 if bs.time() != self._last_score_time: 219 self._last_score_time = bs.time() 220 self.stats.player_scored(player, 50, big_message=True) 221 self._score_sound.play() 222 self._flash_base(team) 223 224 # Move all players on the scoring team back to their start 225 # and add flashes of light so its noticeable. 226 for player in player_team.players: 227 if player.is_alive(): 228 pos = player.node.position 229 light = bs.newnode( 230 'light', 231 attrs={ 232 'position': pos, 233 'color': player_team.color, 234 'height_attenuated': False, 235 'radius': 0.4, 236 }, 237 ) 238 bs.timer(0.5, light.delete) 239 bs.animate(light, 'intensity', {0: 0, 0.1: 1.0, 0.5: 0}) 240 241 new_pos = self.map.get_start_position(player_team.id) 242 light = bs.newnode( 243 'light', 244 attrs={ 245 'position': new_pos, 246 'color': player_team.color, 247 'radius': 0.4, 248 'height_attenuated': False, 249 }, 250 ) 251 bs.timer(0.5, light.delete) 252 bs.animate(light, 'intensity', {0: 0, 0.1: 1.0, 0.5: 0}) 253 if player.actor: 254 random_num = random.uniform(0, 360) 255 256 # Slightly hacky workaround: normally, 257 # teleporting back to base with a sticky 258 # bomb stuck to you gives a crazy whiplash 259 # rubber-band effect. Running the teleport 260 # twice in a row seems to suppress that 261 # though. Would be better to fix this at a 262 # lower level, but this works for now. 263 self._teleport(player, new_pos, random_num) 264 bs.timer( 265 0.01, 266 bs.Call( 267 self._teleport, player, new_pos, random_num 268 ), 269 ) 270 271 # Have teammates celebrate. 272 for player in player_team.players: 273 if player.actor: 274 player.actor.handlemessage(bs.CelebrateMessage(2.0)) 275 276 player_team.score += 1 277 self._update_scoreboard() 278 if player_team.score >= self._score_to_win: 279 self.end_game() 280 281 def _teleport( 282 self, client: Player, pos: Sequence[float], num: float 283 ) -> None: 284 if client.actor: 285 client.actor.handlemessage(bs.StandMessage(pos, num)) 286 287 @override 288 def end_game(self) -> None: 289 results = bs.GameResults() 290 for team in self.teams: 291 results.set_team_score(team, team.score) 292 self.end(results=results) 293 294 def _update_scoreboard(self) -> None: 295 for team in self.teams: 296 self._scoreboard.set_team_value( 297 team, team.score, self._score_to_win 298 )
Game where you score by touching the other team's flag.
93 def __init__(self, settings: dict): 94 super().__init__(settings) 95 self._scoreboard = Scoreboard() 96 self._last_score_time = 0.0 97 self._score_sound = bs.getsound('score') 98 self._base_region_materials: dict[int, bs.Material] = {} 99 self._epic_mode = bool(settings['Epic Mode']) 100 self._score_to_win = int(settings['Score to Win']) 101 self._time_limit = float(settings['Time Limit']) 102 103 # Base class overrides 104 self.slow_motion = self._epic_mode 105 self.default_music = ( 106 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.FORWARD_MARCH 107 )
Instantiate the Activity.
82 @override 83 @classmethod 84 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 85 return issubclass(sessiontype, bs.DualTeamSession)
Class method override; returns True for ba.DualTeamSessions and ba.FreeForAllSessions; False otherwise.
87 @override 88 @classmethod 89 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 90 assert bs.app.classic is not None 91 return bs.app.classic.getmaps('team_flag')
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.
109 @override 110 def get_instance_description(self) -> str | Sequence: 111 if self._score_to_win == 1: 112 return 'Touch the enemy flag.' 113 return 'Touch the enemy flag ${ARG1} times.', self._score_to_win
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.
115 @override 116 def get_instance_description_short(self) -> str | Sequence: 117 if self._score_to_win == 1: 118 return 'touch 1 flag' 119 return 'touch ${ARG1} flags', self._score_to_win
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.
121 @override 122 def create_team(self, sessionteam: bs.SessionTeam) -> Team: 123 shared = SharedObjects.get() 124 base_pos = self.map.get_flag_position(sessionteam.id) 125 bs.newnode( 126 'light', 127 attrs={ 128 'position': base_pos, 129 'intensity': 0.6, 130 'height_attenuated': False, 131 'volume_intensity_scale': 0.1, 132 'radius': 0.1, 133 'color': sessionteam.color, 134 }, 135 ) 136 Flag.project_stand(base_pos) 137 flag = Flag(touchable=False, position=base_pos, color=sessionteam.color) 138 team = Team(base_pos=base_pos, flag=flag) 139 140 mat = self._base_region_materials[sessionteam.id] = bs.Material() 141 mat.add_actions( 142 conditions=('they_have_material', shared.player_material), 143 actions=( 144 ('modify_part_collision', 'collide', True), 145 ('modify_part_collision', 'physical', False), 146 ( 147 'call', 148 'at_connect', 149 bs.Call(self._handle_base_collide, team), 150 ), 151 ), 152 ) 153 154 bs.newnode( 155 'region', 156 owner=flag.node, 157 attrs={ 158 'position': (base_pos[0], base_pos[1] + 0.75, base_pos[2]), 159 'scale': (0.5, 0.5, 0.5), 160 'type': 'sphere', 161 'materials': [self._base_region_materials[sessionteam.id]], 162 }, 163 ) 164 165 return team
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.
167 @override 168 def on_team_join(self, team: Team) -> None: 169 # Can't do this in create_team because the team's color/etc. have 170 # not been wired up yet at that point. 171 self._update_scoreboard()
Called when a new bascenev1.Team joins the Activity.
(including the initial set of Teams)
173 @override 174 def on_begin(self) -> None: 175 super().on_begin() 176 self.setup_standard_time_limit(self._time_limit) 177 self.setup_standard_powerup_drops()
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.
179 @override 180 def handlemessage(self, msg: Any) -> Any: 181 if isinstance(msg, bs.PlayerDiedMessage): 182 super().handlemessage(msg) # Augment standard. 183 self.respawn_player(msg.getplayer(Player)) 184 else: 185 super().handlemessage(msg)
General message handling; can be passed any message object.
287 @override 288 def end_game(self) -> None: 289 results = bs.GameResults() 290 for team in self.teams: 291 results.set_team_score(team, team.score) 292 self.end(results=results)
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.