bascenev1lib.game.meteorshower
Defines a bomb-dodging mini-game.
1# Released under the MIT License. See LICENSE for details. 2# 3"""Defines a bomb-dodging mini-game.""" 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.bomb import Bomb 16from bascenev1lib.actor.onscreentimer import OnScreenTimer 17 18if TYPE_CHECKING: 19 from typing import Any, Sequence 20 21 22class Player(bs.Player['Team']): 23 """Our player type for this game.""" 24 25 def __init__(self) -> None: 26 super().__init__() 27 self.death_time: float | None = None 28 29 30class Team(bs.Team[Player]): 31 """Our team type for this game.""" 32 33 34# ba_meta export bascenev1.GameActivity 35class MeteorShowerGame(bs.TeamGameActivity[Player, Team]): 36 """Minigame involving dodging falling bombs.""" 37 38 name = 'Meteor Shower' 39 description = 'Dodge the falling bombs.' 40 available_settings = [bs.BoolSetting('Epic Mode', default=False)] 41 scoreconfig = bs.ScoreConfig( 42 label='Survived', scoretype=bs.ScoreType.MILLISECONDS, version='B' 43 ) 44 45 # Print messages when players die (since its meaningful in this game). 46 announce_player_deaths = True 47 48 # Don't allow joining after we start 49 # (would enable leave/rejoin tomfoolery). 50 allow_mid_activity_joins = False 51 52 # We're currently hard-coded for one map. 53 @override 54 @classmethod 55 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 56 return ['Rampage'] 57 58 # We support teams, free-for-all, and co-op sessions. 59 @override 60 @classmethod 61 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 62 return ( 63 issubclass(sessiontype, bs.DualTeamSession) 64 or issubclass(sessiontype, bs.FreeForAllSession) 65 or issubclass(sessiontype, bs.CoopSession) 66 ) 67 68 def __init__(self, settings: dict): 69 super().__init__(settings) 70 71 self._epic_mode = settings.get('Epic Mode', False) 72 self._last_player_death_time: float | None = None 73 self._meteor_time = 2.0 74 self._timer: OnScreenTimer | None = None 75 self._ended: bool = False 76 77 # Some base class overrides: 78 self.default_music = ( 79 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.SURVIVAL 80 ) 81 if self._epic_mode: 82 self.slow_motion = True 83 84 @override 85 def on_begin(self) -> None: 86 super().on_begin() 87 88 # Drop a wave every few seconds.. and every so often drop the time 89 # between waves ..lets have things increase faster if we have fewer 90 # players. 91 delay = 5.0 if len(self.players) > 2 else 2.5 92 if self._epic_mode: 93 delay *= 0.25 94 bs.timer(delay, self._decrement_meteor_time, repeat=True) 95 96 # Kick off the first wave in a few seconds. 97 delay = 3.0 98 if self._epic_mode: 99 delay *= 0.25 100 bs.timer(delay, self._set_meteor_timer) 101 102 self._timer = OnScreenTimer() 103 self._timer.start() 104 105 # Check for immediate end (if we've only got 1 player, etc). 106 bs.timer(5.0, self._check_end_game) 107 108 @override 109 def on_player_leave(self, player: Player) -> None: 110 # Augment default behavior. 111 super().on_player_leave(player) 112 113 # A departing player may trigger game-over. 114 self._check_end_game() 115 116 # overriding the default character spawning.. 117 @override 118 def spawn_player(self, player: Player) -> bs.Actor: 119 spaz = self.spawn_player_spaz(player) 120 121 # Let's reconnect this player's controls to this 122 # spaz but *without* the ability to attack or pick stuff up. 123 spaz.connect_controls_to_player( 124 enable_punch=False, enable_bomb=False, enable_pickup=False 125 ) 126 127 # Also lets have them make some noise when they die. 128 spaz.play_big_death_sound = True 129 return spaz 130 131 # Various high-level game events come through this method. 132 @override 133 def handlemessage(self, msg: Any) -> Any: 134 if isinstance(msg, bs.PlayerDiedMessage): 135 # Augment standard behavior. 136 super().handlemessage(msg) 137 138 curtime = bs.time() 139 140 # Record the player's moment of death. 141 # assert isinstance(msg.spaz.player 142 msg.getplayer(Player).death_time = curtime 143 144 # In co-op mode, end the game the instant everyone dies 145 # (more accurate looking). 146 # In teams/ffa, allow a one-second fudge-factor so we can 147 # get more draws if players die basically at the same time. 148 if isinstance(self.session, bs.CoopSession): 149 # Teams will still show up if we check now.. check in 150 # the next cycle. 151 bs.pushcall(self._check_end_game) 152 153 # Also record this for a final setting of the clock. 154 self._last_player_death_time = curtime 155 else: 156 bs.timer(1.0, self._check_end_game) 157 158 else: 159 # Default handler: 160 return super().handlemessage(msg) 161 return None 162 163 def _check_end_game(self) -> None: 164 # We don't want to end this activity more than once. 165 if self._ended: 166 return 167 168 living_team_count = 0 169 for team in self.teams: 170 for player in team.players: 171 if player.is_alive(): 172 living_team_count += 1 173 break 174 175 # In co-op, we go till everyone is dead.. otherwise we go 176 # until one team remains. 177 if isinstance(self.session, bs.CoopSession): 178 if living_team_count <= 0: 179 self.end_game() 180 else: 181 if living_team_count <= 1: 182 self.end_game() 183 184 def _set_meteor_timer(self) -> None: 185 bs.timer( 186 (1.0 + 0.2 * random.random()) * self._meteor_time, 187 self._drop_bomb_cluster, 188 ) 189 190 def _drop_bomb_cluster(self) -> None: 191 # Random note: code like this is a handy way to plot out extents 192 # and debug things. 193 loc_test = False 194 if loc_test: 195 bs.newnode('locator', attrs={'position': (8, 6, -5.5)}) 196 bs.newnode('locator', attrs={'position': (8, 6, -2.3)}) 197 bs.newnode('locator', attrs={'position': (-7.3, 6, -5.5)}) 198 bs.newnode('locator', attrs={'position': (-7.3, 6, -2.3)}) 199 200 # Drop several bombs in series. 201 delay = 0.0 202 for _i in range(random.randrange(1, 3)): 203 # Drop them somewhere within our bounds with velocity pointing 204 # toward the opposite side. 205 pos = ( 206 -7.3 + 15.3 * random.random(), 207 11, 208 -5.57 + 2.1 * random.random(), 209 ) 210 dropdir = -1.0 if pos[0] > 0 else 1.0 211 vel = ( 212 (-5.0 + random.random() * 30.0) * dropdir, 213 random.uniform(-3.066, -4.12), 214 0, 215 ) 216 bs.timer(delay, bs.Call(self._drop_bomb, pos, vel)) 217 delay += 0.1 218 self._set_meteor_timer() 219 220 def _drop_bomb( 221 self, position: Sequence[float], velocity: Sequence[float] 222 ) -> None: 223 Bomb(position=position, velocity=velocity).autoretain() 224 225 def _decrement_meteor_time(self) -> None: 226 self._meteor_time = max(0.01, self._meteor_time * 0.9) 227 228 @override 229 def end_game(self) -> None: 230 cur_time = bs.time() 231 assert self._timer is not None 232 start_time = self._timer.getstarttime() 233 234 # Mark death-time as now for any still-living players 235 # and award players points for how long they lasted. 236 # (these per-player scores are only meaningful in team-games) 237 for team in self.teams: 238 for player in team.players: 239 survived = False 240 241 # Throw an extra fudge factor in so teams that 242 # didn't die come out ahead of teams that did. 243 if player.death_time is None: 244 survived = True 245 player.death_time = cur_time + 1 246 247 # Award a per-player score depending on how many seconds 248 # they lasted (per-player scores only affect teams mode; 249 # everywhere else just looks at the per-team score). 250 score = int(player.death_time - self._timer.getstarttime()) 251 if survived: 252 score += 50 # A bit extra for survivors. 253 self.stats.player_scored(player, score, screenmessage=False) 254 255 # Stop updating our time text, and set the final time to match 256 # exactly when our last guy died. 257 self._timer.stop(endtime=self._last_player_death_time) 258 259 # Ok now calc game results: set a score for each team and then tell 260 # the game to end. 261 results = bs.GameResults() 262 263 # Remember that 'free-for-all' mode is simply a special form 264 # of 'teams' mode where each player gets their own team, so we can 265 # just always deal in teams and have all cases covered. 266 for team in self.teams: 267 # Set the team score to the max time survived by any player on 268 # that team. 269 longest_life = 0.0 270 for player in team.players: 271 assert player.death_time is not None 272 longest_life = max(longest_life, player.death_time - start_time) 273 274 # Submit the score value in milliseconds. 275 results.set_team_score(team, int(1000.0 * longest_life)) 276 277 self._ended = True 278 self.end(results=results)
23class Player(bs.Player['Team']): 24 """Our player type for this game.""" 25 26 def __init__(self) -> None: 27 super().__init__() 28 self.death_time: float | None = None
Our player type for this game.
Our team type for this game.
36class MeteorShowerGame(bs.TeamGameActivity[Player, Team]): 37 """Minigame involving dodging falling bombs.""" 38 39 name = 'Meteor Shower' 40 description = 'Dodge the falling bombs.' 41 available_settings = [bs.BoolSetting('Epic Mode', default=False)] 42 scoreconfig = bs.ScoreConfig( 43 label='Survived', scoretype=bs.ScoreType.MILLISECONDS, version='B' 44 ) 45 46 # Print messages when players die (since its meaningful in this game). 47 announce_player_deaths = True 48 49 # Don't allow joining after we start 50 # (would enable leave/rejoin tomfoolery). 51 allow_mid_activity_joins = False 52 53 # We're currently hard-coded for one map. 54 @override 55 @classmethod 56 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 57 return ['Rampage'] 58 59 # We support teams, free-for-all, and co-op sessions. 60 @override 61 @classmethod 62 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 63 return ( 64 issubclass(sessiontype, bs.DualTeamSession) 65 or issubclass(sessiontype, bs.FreeForAllSession) 66 or issubclass(sessiontype, bs.CoopSession) 67 ) 68 69 def __init__(self, settings: dict): 70 super().__init__(settings) 71 72 self._epic_mode = settings.get('Epic Mode', False) 73 self._last_player_death_time: float | None = None 74 self._meteor_time = 2.0 75 self._timer: OnScreenTimer | None = None 76 self._ended: bool = False 77 78 # Some base class overrides: 79 self.default_music = ( 80 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.SURVIVAL 81 ) 82 if self._epic_mode: 83 self.slow_motion = True 84 85 @override 86 def on_begin(self) -> None: 87 super().on_begin() 88 89 # Drop a wave every few seconds.. and every so often drop the time 90 # between waves ..lets have things increase faster if we have fewer 91 # players. 92 delay = 5.0 if len(self.players) > 2 else 2.5 93 if self._epic_mode: 94 delay *= 0.25 95 bs.timer(delay, self._decrement_meteor_time, repeat=True) 96 97 # Kick off the first wave in a few seconds. 98 delay = 3.0 99 if self._epic_mode: 100 delay *= 0.25 101 bs.timer(delay, self._set_meteor_timer) 102 103 self._timer = OnScreenTimer() 104 self._timer.start() 105 106 # Check for immediate end (if we've only got 1 player, etc). 107 bs.timer(5.0, self._check_end_game) 108 109 @override 110 def on_player_leave(self, player: Player) -> None: 111 # Augment default behavior. 112 super().on_player_leave(player) 113 114 # A departing player may trigger game-over. 115 self._check_end_game() 116 117 # overriding the default character spawning.. 118 @override 119 def spawn_player(self, player: Player) -> bs.Actor: 120 spaz = self.spawn_player_spaz(player) 121 122 # Let's reconnect this player's controls to this 123 # spaz but *without* the ability to attack or pick stuff up. 124 spaz.connect_controls_to_player( 125 enable_punch=False, enable_bomb=False, enable_pickup=False 126 ) 127 128 # Also lets have them make some noise when they die. 129 spaz.play_big_death_sound = True 130 return spaz 131 132 # Various high-level game events come through this method. 133 @override 134 def handlemessage(self, msg: Any) -> Any: 135 if isinstance(msg, bs.PlayerDiedMessage): 136 # Augment standard behavior. 137 super().handlemessage(msg) 138 139 curtime = bs.time() 140 141 # Record the player's moment of death. 142 # assert isinstance(msg.spaz.player 143 msg.getplayer(Player).death_time = curtime 144 145 # In co-op mode, end the game the instant everyone dies 146 # (more accurate looking). 147 # In teams/ffa, allow a one-second fudge-factor so we can 148 # get more draws if players die basically at the same time. 149 if isinstance(self.session, bs.CoopSession): 150 # Teams will still show up if we check now.. check in 151 # the next cycle. 152 bs.pushcall(self._check_end_game) 153 154 # Also record this for a final setting of the clock. 155 self._last_player_death_time = curtime 156 else: 157 bs.timer(1.0, self._check_end_game) 158 159 else: 160 # Default handler: 161 return super().handlemessage(msg) 162 return None 163 164 def _check_end_game(self) -> None: 165 # We don't want to end this activity more than once. 166 if self._ended: 167 return 168 169 living_team_count = 0 170 for team in self.teams: 171 for player in team.players: 172 if player.is_alive(): 173 living_team_count += 1 174 break 175 176 # In co-op, we go till everyone is dead.. otherwise we go 177 # until one team remains. 178 if isinstance(self.session, bs.CoopSession): 179 if living_team_count <= 0: 180 self.end_game() 181 else: 182 if living_team_count <= 1: 183 self.end_game() 184 185 def _set_meteor_timer(self) -> None: 186 bs.timer( 187 (1.0 + 0.2 * random.random()) * self._meteor_time, 188 self._drop_bomb_cluster, 189 ) 190 191 def _drop_bomb_cluster(self) -> None: 192 # Random note: code like this is a handy way to plot out extents 193 # and debug things. 194 loc_test = False 195 if loc_test: 196 bs.newnode('locator', attrs={'position': (8, 6, -5.5)}) 197 bs.newnode('locator', attrs={'position': (8, 6, -2.3)}) 198 bs.newnode('locator', attrs={'position': (-7.3, 6, -5.5)}) 199 bs.newnode('locator', attrs={'position': (-7.3, 6, -2.3)}) 200 201 # Drop several bombs in series. 202 delay = 0.0 203 for _i in range(random.randrange(1, 3)): 204 # Drop them somewhere within our bounds with velocity pointing 205 # toward the opposite side. 206 pos = ( 207 -7.3 + 15.3 * random.random(), 208 11, 209 -5.57 + 2.1 * random.random(), 210 ) 211 dropdir = -1.0 if pos[0] > 0 else 1.0 212 vel = ( 213 (-5.0 + random.random() * 30.0) * dropdir, 214 random.uniform(-3.066, -4.12), 215 0, 216 ) 217 bs.timer(delay, bs.Call(self._drop_bomb, pos, vel)) 218 delay += 0.1 219 self._set_meteor_timer() 220 221 def _drop_bomb( 222 self, position: Sequence[float], velocity: Sequence[float] 223 ) -> None: 224 Bomb(position=position, velocity=velocity).autoretain() 225 226 def _decrement_meteor_time(self) -> None: 227 self._meteor_time = max(0.01, self._meteor_time * 0.9) 228 229 @override 230 def end_game(self) -> None: 231 cur_time = bs.time() 232 assert self._timer is not None 233 start_time = self._timer.getstarttime() 234 235 # Mark death-time as now for any still-living players 236 # and award players points for how long they lasted. 237 # (these per-player scores are only meaningful in team-games) 238 for team in self.teams: 239 for player in team.players: 240 survived = False 241 242 # Throw an extra fudge factor in so teams that 243 # didn't die come out ahead of teams that did. 244 if player.death_time is None: 245 survived = True 246 player.death_time = cur_time + 1 247 248 # Award a per-player score depending on how many seconds 249 # they lasted (per-player scores only affect teams mode; 250 # everywhere else just looks at the per-team score). 251 score = int(player.death_time - self._timer.getstarttime()) 252 if survived: 253 score += 50 # A bit extra for survivors. 254 self.stats.player_scored(player, score, screenmessage=False) 255 256 # Stop updating our time text, and set the final time to match 257 # exactly when our last guy died. 258 self._timer.stop(endtime=self._last_player_death_time) 259 260 # Ok now calc game results: set a score for each team and then tell 261 # the game to end. 262 results = bs.GameResults() 263 264 # Remember that 'free-for-all' mode is simply a special form 265 # of 'teams' mode where each player gets their own team, so we can 266 # just always deal in teams and have all cases covered. 267 for team in self.teams: 268 # Set the team score to the max time survived by any player on 269 # that team. 270 longest_life = 0.0 271 for player in team.players: 272 assert player.death_time is not None 273 longest_life = max(longest_life, player.death_time - start_time) 274 275 # Submit the score value in milliseconds. 276 results.set_team_score(team, int(1000.0 * longest_life)) 277 278 self._ended = True 279 self.end(results=results)
Minigame involving dodging falling bombs.
69 def __init__(self, settings: dict): 70 super().__init__(settings) 71 72 self._epic_mode = settings.get('Epic Mode', False) 73 self._last_player_death_time: float | None = None 74 self._meteor_time = 2.0 75 self._timer: OnScreenTimer | None = None 76 self._ended: bool = False 77 78 # Some base class overrides: 79 self.default_music = ( 80 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.SURVIVAL 81 ) 82 if self._epic_mode: 83 self.slow_motion = True
Instantiate the Activity.
Whether to print every time a player dies. This can be pertinent in games such as Death-Match but can be annoying in games where it doesn't matter.
Whether players should be allowed to join in the middle of this activity. Note that Sessions may not allow mid-activity-joins even if the activity says its ok.
54 @override 55 @classmethod 56 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 57 return ['Rampage']
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.
60 @override 61 @classmethod 62 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 63 return ( 64 issubclass(sessiontype, bs.DualTeamSession) 65 or issubclass(sessiontype, bs.FreeForAllSession) 66 or issubclass(sessiontype, bs.CoopSession) 67 )
Class method override; returns True for ba.DualTeamSessions and ba.FreeForAllSessions; False otherwise.
85 @override 86 def on_begin(self) -> None: 87 super().on_begin() 88 89 # Drop a wave every few seconds.. and every so often drop the time 90 # between waves ..lets have things increase faster if we have fewer 91 # players. 92 delay = 5.0 if len(self.players) > 2 else 2.5 93 if self._epic_mode: 94 delay *= 0.25 95 bs.timer(delay, self._decrement_meteor_time, repeat=True) 96 97 # Kick off the first wave in a few seconds. 98 delay = 3.0 99 if self._epic_mode: 100 delay *= 0.25 101 bs.timer(delay, self._set_meteor_timer) 102 103 self._timer = OnScreenTimer() 104 self._timer.start() 105 106 # Check for immediate end (if we've only got 1 player, etc). 107 bs.timer(5.0, self._check_end_game)
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.
109 @override 110 def on_player_leave(self, player: Player) -> None: 111 # Augment default behavior. 112 super().on_player_leave(player) 113 114 # A departing player may trigger game-over. 115 self._check_end_game()
Called when a bascenev1.Player is leaving the Activity.
118 @override 119 def spawn_player(self, player: Player) -> bs.Actor: 120 spaz = self.spawn_player_spaz(player) 121 122 # Let's reconnect this player's controls to this 123 # spaz but *without* the ability to attack or pick stuff up. 124 spaz.connect_controls_to_player( 125 enable_punch=False, enable_bomb=False, enable_pickup=False 126 ) 127 128 # Also lets have them make some noise when they die. 129 spaz.play_big_death_sound = True 130 return spaz
Spawn something for the provided bascenev1.Player.
The default implementation simply calls spawn_player_spaz().
133 @override 134 def handlemessage(self, msg: Any) -> Any: 135 if isinstance(msg, bs.PlayerDiedMessage): 136 # Augment standard behavior. 137 super().handlemessage(msg) 138 139 curtime = bs.time() 140 141 # Record the player's moment of death. 142 # assert isinstance(msg.spaz.player 143 msg.getplayer(Player).death_time = curtime 144 145 # In co-op mode, end the game the instant everyone dies 146 # (more accurate looking). 147 # In teams/ffa, allow a one-second fudge-factor so we can 148 # get more draws if players die basically at the same time. 149 if isinstance(self.session, bs.CoopSession): 150 # Teams will still show up if we check now.. check in 151 # the next cycle. 152 bs.pushcall(self._check_end_game) 153 154 # Also record this for a final setting of the clock. 155 self._last_player_death_time = curtime 156 else: 157 bs.timer(1.0, self._check_end_game) 158 159 else: 160 # Default handler: 161 return super().handlemessage(msg) 162 return None
General message handling; can be passed any message object.
229 @override 230 def end_game(self) -> None: 231 cur_time = bs.time() 232 assert self._timer is not None 233 start_time = self._timer.getstarttime() 234 235 # Mark death-time as now for any still-living players 236 # and award players points for how long they lasted. 237 # (these per-player scores are only meaningful in team-games) 238 for team in self.teams: 239 for player in team.players: 240 survived = False 241 242 # Throw an extra fudge factor in so teams that 243 # didn't die come out ahead of teams that did. 244 if player.death_time is None: 245 survived = True 246 player.death_time = cur_time + 1 247 248 # Award a per-player score depending on how many seconds 249 # they lasted (per-player scores only affect teams mode; 250 # everywhere else just looks at the per-team score). 251 score = int(player.death_time - self._timer.getstarttime()) 252 if survived: 253 score += 50 # A bit extra for survivors. 254 self.stats.player_scored(player, score, screenmessage=False) 255 256 # Stop updating our time text, and set the final time to match 257 # exactly when our last guy died. 258 self._timer.stop(endtime=self._last_player_death_time) 259 260 # Ok now calc game results: set a score for each team and then tell 261 # the game to end. 262 results = bs.GameResults() 263 264 # Remember that 'free-for-all' mode is simply a special form 265 # of 'teams' mode where each player gets their own team, so we can 266 # just always deal in teams and have all cases covered. 267 for team in self.teams: 268 # Set the team score to the max time survived by any player on 269 # that team. 270 longest_life = 0.0 271 for player in team.players: 272 assert player.death_time is not None 273 longest_life = max(longest_life, player.death_time - start_time) 274 275 # Submit the score value in milliseconds. 276 results.set_team_score(team, int(1000.0 * longest_life)) 277 278 self._ended = True 279 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.