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