# ba_meta require api 7 # (see https://ballistica.net/wiki/meta-tag-system) from __future__ import annotations import random from typing import TYPE_CHECKING import ba from bastd.actor.spazbot import SpazBotSet, ChargerBot, SpazBotDiedMessage from bastd.actor.bomb import Bomb from bastd.actor.onscreentimer import OnScreenTimer if TYPE_CHECKING: from typing import Any class Player(ba.Player['Team']): """Our player type for this game.""" def __init__(self) -> None: super().__init__() self.death_time: float | None = None class Team(ba.Team[Player]): """Our team type for this game.""" # ba_meta export game class FlyingMeteorShower(ba.TeamGameActivity[Player, Team]): """ A minigame where you try to dodge bombs for as long as possible, while flying in the Happy Thoughts map. """ name = "It's Raining Bombs!" description = 'Dodge the falling bombs.' available_settings = [ ba.BoolSetting('Epic Mode', default=False), ba.BoolSetting('Sticky Bombs', default=False) ] scoreconfig = ba.ScoreConfig( label='Survived', scoretype=ba.ScoreType.MILLISECONDS, version='B' ) default_music = ba.MusicType.FLYING # Print messages when players die (since it's meaningful in this game). announce_player_deaths = True # Don't allow joining after we start # (would enable leave/rejoin tomfoolery). allow_mid_activity_joins = False @classmethod def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]: # This mode is hard-coded, so it's only supported # in Happy Thoughts for now. return ['Happy Thoughts'] @classmethod def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: # Supported everywhere (even in co-op!). return ( issubclass(sessiontype, ba.DualTeamSession) or issubclass(sessiontype, ba.FreeForAllSession) or issubclass(sessiontype, ba.CoopSession) ) # In the constructor we should load any media we need/etc. # ...but not actually create anything yet. def __init__(self, settings: dict): super().__init__(settings) self._winsound = ba.getsound('score') self._won = False self._timer: OnScreenTimer | None = None self._last_player_death_time: float | None = None self._epic_mode = settings.get('Epic Mode', False) self.slow_motion = self._epic_mode self._sticky_bombs = settings.get('Sticky Bombs', False) # Called when our game actually begins. def on_begin(self) -> None: super().on_begin() # No powerups in this mode. # Make our on-screen timer and start it roughly when bombs start appearing. self._timer = OnScreenTimer() ba.timer(4.0, self._timer.start) # Spawn some bombs. ba.timer( self._real_time(0), lambda: ba.timer( 1.0, lambda: self._drop_bombs(3), repeat=True ) ) ba.timer( self._real_time(20), lambda: ba.timer( 1.5, lambda: self._drop_bombs(3), repeat=True ) ) ba.timer( self._real_time(40), lambda: ba.timer( 2.0, lambda: self._drop_bombs(4), repeat=True ) ) ba.timer( self._real_time(60), lambda: ba.timer( 2.5, lambda: self._drop_bombs(6), repeat=True ) ) ba.timer( self._real_time(80), lambda: ba.timer( 3.0, lambda: self._drop_bombs(8), repeat=True ) ) ba.timer( self._real_time(100), lambda: ba.timer( 3.5, lambda: self._drop_bombs(10), repeat=True ) ) ba.timer( self._real_time(120), lambda: ba.timer( 0.1, lambda: self._drop_bombs(3), repeat=True ) ) # Spawn a falling bomb. def _drop_bomb( self, position: Sequence[float], velocity: Sequence[float] ) -> None: _type = 'sticky' if self._sticky_bombs else 'normal' Bomb(position=position, velocity=velocity, bomb_type=_type).autoretain() # Spawn multiple falling bombs. def _drop_bombs( self, amount: int ) -> None: for i in range(amount): position = (random.uniform(-20, 20), 22, 0) velocity = (random.uniform(-15, 15), random.uniform(-5, 0), 0) self._drop_bomb(position, velocity) # If this game is in epic mode, divides the time by 4, # then adds 3 seconds. def _real_time(self, secs: int) -> int: if self.slow_motion: return int(secs / 4 + 3) else: return secs + 3 # Called for each spawning player. def spawn_player(self, player: Player) -> ba.Actor: spaz = self.spawn_player_spaz(player) # Let's reconnect this player's controls to this # spaz but *without* the ability to punch or pick stuff up. # Bomb throw enabled for more chaos. spaz.connect_controls_to_player( enable_punch=False, enable_pickup=False ) # Also lets have them make some noise when they die. spaz.play_big_death_sound = True return spaz def on_player_leave(self, player: Player) -> None: # Augment default behavior. super().on_player_leave(player) # A departing player may trigger game-over. self._check_end_game() def _check_end_game(self) -> None: # Count currently alive teams. living_team_count = 0 for team in self.teams: for player in team.players: if player.is_alive(): living_team_count += 1 break # In co-op, we go till everyone is dead... otherwise we go # until one team remains. if isinstance(self.session, ba.CoopSession): if living_team_count <= 0: self.end_game() else: if living_team_count <= 1: self.end_game() # Various high-level game events come through this method. def handlemessage(self, msg: Any) -> Any: if isinstance(msg, ba.PlayerDiedMessage): # Augment standard behavior. super().handlemessage(msg) curtime = ba.time() # Record the player's moment of death. # assert isinstance(msg.spaz.player msg.getplayer(Player).death_time = curtime # In co-op mode, end the game the instant everyone dies # (more accurate looking). # In teams/ffa, allow a one-second fudge-factor, so we can # get more draws if players die basically at the same time. if isinstance(self.session, ba.CoopSession): # Teams will still show up if we check now... check in # the next cycle. ba.pushcall(self._check_end_game) # Also record this for a final setting of the clock. self._last_player_death_time = curtime else: ba.timer(1.0, self._check_end_game) else: # Default handler: return super().handlemessage(msg) return None # When this is called, we should fill out results and end the game # *regardless* of whether is has been won. (this may be called due # to a tournament ending or other external reason). def end_game(self) -> None: cur_time = ba.time() assert self._timer is not None start_time = self._timer.getstarttime() # Mark death-time as now for any still-living players # and award players points for how long they lasted. # (these per-player scores are only meaningful in team-games) for team in self.teams: for player in team.players: survived = False # Throw an extra fudge factor in so teams that # didn't die come out ahead of teams that did. if player.death_time is None: survived = True player.death_time = cur_time + 0.01 # Award a per-player score depending on how many seconds # they lasted (per-player scores only affect teams mode; # everywhere else just looks at the per-team score). score = int(player.death_time - self._timer.getstarttime()) if survived: score += 50 # A bit extra for survivors. self.stats.player_scored(player, score, screenmessage=False) # Stop updating our time text, and set the final time to match # exactly when our last guy died. self._timer.stop(endtime=self._last_player_death_time) # Ok now calc game results: set a score for each team and then tell # the game to end. results = ba.GameResults() # Remember that 'free-for-all' mode is simply a special form # of 'teams' mode where each player gets their own team, so we can # just always deal in teams and have all cases covered. for team in self.teams: # Set the team score to the max time survived by any player on # that team. longest_life = 0.0 for player in team.players: assert player.death_time is not None longest_life = max(longest_life, player.death_time - start_time) # Submit the score value in milliseconds. results.set_team_score(team, int(1000.0 * longest_life)) self.end(results=results) # ba_meta export plugin class FlyingMeteorShowerPlugin(ba.Plugin): """Plugin which adds the "It's Falling Bombs!" game mode for FFA, Teams and Co-op.""" def on_app_running(self) -> None: # Add this game to the practice tab so players can compete in the leaderboards. ba.app.add_coop_practice_level( ba.Level( name="It's Raining Bombs!", displayname='${GAME}', gametype=FlyingMeteorShower, settings={'preset': 'regular'}, preview_texture_name='alwaysLandPreview', ) ) # Add the epic variation of this game as well. ba.app.add_coop_practice_level( ba.Level( name="Epic Raining Bombs!", displayname='${GAME}', gametype=FlyingMeteorShower, settings={'Epic Mode': True}, preview_texture_name='alwaysLandPreview', ) )