bascenev1lib.game.deathmatch
DeathMatch game and support classes.
1# Released under the MIT License. See LICENSE for details. 2# 3"""DeathMatch game and support classes.""" 4 5# ba_meta require api 9 6# (see https://ballistica.net/wiki/meta-tag-system) 7 8from __future__ import annotations 9 10from typing import TYPE_CHECKING, override 11 12import bascenev1 as bs 13 14from bascenev1lib.actor.playerspaz import PlayerSpaz 15from bascenev1lib.actor.scoreboard import Scoreboard 16 17if TYPE_CHECKING: 18 from typing import Any, Sequence 19 20 21class Player(bs.Player['Team']): 22 """Our player type for this game.""" 23 24 25class Team(bs.Team[Player]): 26 """Our team type for this game.""" 27 28 def __init__(self) -> None: 29 self.score = 0 30 31 32# ba_meta export bascenev1.GameActivity 33class DeathMatchGame(bs.TeamGameActivity[Player, Team]): 34 """A game type based on acquiring kills.""" 35 36 name = 'Death Match' 37 description = 'Kill a set number of enemies to win.' 38 39 # Print messages when players die since it matters here. 40 announce_player_deaths = True 41 42 @override 43 @classmethod 44 def get_available_settings( 45 cls, sessiontype: type[bs.Session] 46 ) -> list[bs.Setting]: 47 settings = [ 48 bs.IntSetting( 49 'Kills to Win Per Player', 50 min_value=1, 51 default=5, 52 increment=1, 53 ), 54 bs.IntChoiceSetting( 55 'Time Limit', 56 choices=[ 57 ('None', 0), 58 ('1 Minute', 60), 59 ('2 Minutes', 120), 60 ('5 Minutes', 300), 61 ('10 Minutes', 600), 62 ('20 Minutes', 1200), 63 ], 64 default=0, 65 ), 66 bs.FloatChoiceSetting( 67 'Respawn Times', 68 choices=[ 69 ('Shorter', 0.25), 70 ('Short', 0.5), 71 ('Normal', 1.0), 72 ('Long', 2.0), 73 ('Longer', 4.0), 74 ], 75 default=1.0, 76 ), 77 bs.BoolSetting('Epic Mode', default=False), 78 ] 79 80 # In teams mode, a suicide gives a point to the other team, but in 81 # free-for-all it subtracts from your own score. By default we clamp 82 # this at zero to benefit new players, but pro players might like to 83 # be able to go negative. (to avoid a strategy of just 84 # suiciding until you get a good drop) 85 if issubclass(sessiontype, bs.FreeForAllSession): 86 settings.append( 87 bs.BoolSetting('Allow Negative Scores', default=False) 88 ) 89 90 return settings 91 92 @override 93 @classmethod 94 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 95 return issubclass(sessiontype, bs.DualTeamSession) or issubclass( 96 sessiontype, bs.FreeForAllSession 97 ) 98 99 @override 100 @classmethod 101 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 102 assert bs.app.classic is not None 103 return bs.app.classic.getmaps('melee') 104 105 def __init__(self, settings: dict): 106 super().__init__(settings) 107 self._scoreboard = Scoreboard() 108 self._score_to_win: int | None = None 109 self._dingsound = bs.getsound('dingSmall') 110 self._epic_mode = bool(settings['Epic Mode']) 111 self._kills_to_win_per_player = int(settings['Kills to Win Per Player']) 112 self._time_limit = float(settings['Time Limit']) 113 self._allow_negative_scores = bool( 114 settings.get('Allow Negative Scores', False) 115 ) 116 117 # Base class overrides. 118 self.slow_motion = self._epic_mode 119 self.default_music = ( 120 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.TO_THE_DEATH 121 ) 122 123 @override 124 def get_instance_description(self) -> str | Sequence: 125 return 'Crush ${ARG1} of your enemies.', self._score_to_win 126 127 @override 128 def get_instance_description_short(self) -> str | Sequence: 129 return 'kill ${ARG1} enemies', self._score_to_win 130 131 @override 132 def on_team_join(self, team: Team) -> None: 133 if self.has_begun(): 134 self._update_scoreboard() 135 136 @override 137 def on_begin(self) -> None: 138 super().on_begin() 139 self.setup_standard_time_limit(self._time_limit) 140 self.setup_standard_powerup_drops() 141 142 # Base kills needed to win on the size of the largest team. 143 self._score_to_win = self._kills_to_win_per_player * max( 144 1, max((len(t.players) for t in self.teams), default=0) 145 ) 146 self._update_scoreboard() 147 148 @override 149 def handlemessage(self, msg: Any) -> Any: 150 if isinstance(msg, bs.PlayerDiedMessage): 151 # Augment standard behavior. 152 super().handlemessage(msg) 153 154 player = msg.getplayer(Player) 155 self.respawn_player(player) 156 157 killer = msg.getkillerplayer(Player) 158 if killer is None: 159 return None 160 161 # Handle team-kills. 162 if killer.team is player.team: 163 # In free-for-all, killing yourself loses you a point. 164 if isinstance(self.session, bs.FreeForAllSession): 165 new_score = player.team.score - 1 166 if not self._allow_negative_scores: 167 new_score = max(0, new_score) 168 player.team.score = new_score 169 170 # In teams-mode it gives a point to the other team. 171 else: 172 self._dingsound.play() 173 for team in self.teams: 174 if team is not killer.team: 175 team.score += 1 176 177 # Killing someone on another team nets a kill. 178 else: 179 killer.team.score += 1 180 self._dingsound.play() 181 182 # In FFA show scores since its hard to find on the scoreboard. 183 if isinstance(killer.actor, PlayerSpaz) and killer.actor: 184 killer.actor.set_score_text( 185 str(killer.team.score) + '/' + str(self._score_to_win), 186 color=killer.team.color, 187 flash=True, 188 ) 189 190 self._update_scoreboard() 191 192 # If someone has won, set a timer to end shortly. 193 # (allows the dust to clear and draws to occur if deaths are 194 # close enough) 195 assert self._score_to_win is not None 196 if any(team.score >= self._score_to_win for team in self.teams): 197 bs.timer(0.5, self.end_game) 198 199 else: 200 return super().handlemessage(msg) 201 return None 202 203 def _update_scoreboard(self) -> None: 204 for team in self.teams: 205 self._scoreboard.set_team_value( 206 team, team.score, self._score_to_win 207 ) 208 209 @override 210 def end_game(self) -> None: 211 results = bs.GameResults() 212 for team in self.teams: 213 results.set_team_score(team, team.score) 214 self.end(results=results)
Our player type for this game.
26class Team(bs.Team[Player]): 27 """Our team type for this game.""" 28 29 def __init__(self) -> None: 30 self.score = 0
Our team type for this game.
34class DeathMatchGame(bs.TeamGameActivity[Player, Team]): 35 """A game type based on acquiring kills.""" 36 37 name = 'Death Match' 38 description = 'Kill a set number of enemies to win.' 39 40 # Print messages when players die since it matters here. 41 announce_player_deaths = True 42 43 @override 44 @classmethod 45 def get_available_settings( 46 cls, sessiontype: type[bs.Session] 47 ) -> list[bs.Setting]: 48 settings = [ 49 bs.IntSetting( 50 'Kills to Win Per Player', 51 min_value=1, 52 default=5, 53 increment=1, 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 # In teams mode, a suicide gives a point to the other team, but in 82 # free-for-all it subtracts from your own score. By default we clamp 83 # this at zero to benefit new players, but pro players might like to 84 # be able to go negative. (to avoid a strategy of just 85 # suiciding until you get a good drop) 86 if issubclass(sessiontype, bs.FreeForAllSession): 87 settings.append( 88 bs.BoolSetting('Allow Negative Scores', default=False) 89 ) 90 91 return settings 92 93 @override 94 @classmethod 95 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 96 return issubclass(sessiontype, bs.DualTeamSession) or issubclass( 97 sessiontype, bs.FreeForAllSession 98 ) 99 100 @override 101 @classmethod 102 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 103 assert bs.app.classic is not None 104 return bs.app.classic.getmaps('melee') 105 106 def __init__(self, settings: dict): 107 super().__init__(settings) 108 self._scoreboard = Scoreboard() 109 self._score_to_win: int | None = None 110 self._dingsound = bs.getsound('dingSmall') 111 self._epic_mode = bool(settings['Epic Mode']) 112 self._kills_to_win_per_player = int(settings['Kills to Win Per Player']) 113 self._time_limit = float(settings['Time Limit']) 114 self._allow_negative_scores = bool( 115 settings.get('Allow Negative Scores', False) 116 ) 117 118 # Base class overrides. 119 self.slow_motion = self._epic_mode 120 self.default_music = ( 121 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.TO_THE_DEATH 122 ) 123 124 @override 125 def get_instance_description(self) -> str | Sequence: 126 return 'Crush ${ARG1} of your enemies.', self._score_to_win 127 128 @override 129 def get_instance_description_short(self) -> str | Sequence: 130 return 'kill ${ARG1} enemies', self._score_to_win 131 132 @override 133 def on_team_join(self, team: Team) -> None: 134 if self.has_begun(): 135 self._update_scoreboard() 136 137 @override 138 def on_begin(self) -> None: 139 super().on_begin() 140 self.setup_standard_time_limit(self._time_limit) 141 self.setup_standard_powerup_drops() 142 143 # Base kills needed to win on the size of the largest team. 144 self._score_to_win = self._kills_to_win_per_player * max( 145 1, max((len(t.players) for t in self.teams), default=0) 146 ) 147 self._update_scoreboard() 148 149 @override 150 def handlemessage(self, msg: Any) -> Any: 151 if isinstance(msg, bs.PlayerDiedMessage): 152 # Augment standard behavior. 153 super().handlemessage(msg) 154 155 player = msg.getplayer(Player) 156 self.respawn_player(player) 157 158 killer = msg.getkillerplayer(Player) 159 if killer is None: 160 return None 161 162 # Handle team-kills. 163 if killer.team is player.team: 164 # In free-for-all, killing yourself loses you a point. 165 if isinstance(self.session, bs.FreeForAllSession): 166 new_score = player.team.score - 1 167 if not self._allow_negative_scores: 168 new_score = max(0, new_score) 169 player.team.score = new_score 170 171 # In teams-mode it gives a point to the other team. 172 else: 173 self._dingsound.play() 174 for team in self.teams: 175 if team is not killer.team: 176 team.score += 1 177 178 # Killing someone on another team nets a kill. 179 else: 180 killer.team.score += 1 181 self._dingsound.play() 182 183 # In FFA show scores since its hard to find on the scoreboard. 184 if isinstance(killer.actor, PlayerSpaz) and killer.actor: 185 killer.actor.set_score_text( 186 str(killer.team.score) + '/' + str(self._score_to_win), 187 color=killer.team.color, 188 flash=True, 189 ) 190 191 self._update_scoreboard() 192 193 # If someone has won, set a timer to end shortly. 194 # (allows the dust to clear and draws to occur if deaths are 195 # close enough) 196 assert self._score_to_win is not None 197 if any(team.score >= self._score_to_win for team in self.teams): 198 bs.timer(0.5, self.end_game) 199 200 else: 201 return super().handlemessage(msg) 202 return None 203 204 def _update_scoreboard(self) -> None: 205 for team in self.teams: 206 self._scoreboard.set_team_value( 207 team, team.score, self._score_to_win 208 ) 209 210 @override 211 def end_game(self) -> None: 212 results = bs.GameResults() 213 for team in self.teams: 214 results.set_team_score(team, team.score) 215 self.end(results=results)
A game type based on acquiring kills.
106 def __init__(self, settings: dict): 107 super().__init__(settings) 108 self._scoreboard = Scoreboard() 109 self._score_to_win: int | None = None 110 self._dingsound = bs.getsound('dingSmall') 111 self._epic_mode = bool(settings['Epic Mode']) 112 self._kills_to_win_per_player = int(settings['Kills to Win Per Player']) 113 self._time_limit = float(settings['Time Limit']) 114 self._allow_negative_scores = bool( 115 settings.get('Allow Negative Scores', False) 116 ) 117 118 # Base class overrides. 119 self.slow_motion = self._epic_mode 120 self.default_music = ( 121 bs.MusicType.EPIC if self._epic_mode else bs.MusicType.TO_THE_DEATH 122 )
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.
43 @override 44 @classmethod 45 def get_available_settings( 46 cls, sessiontype: type[bs.Session] 47 ) -> list[bs.Setting]: 48 settings = [ 49 bs.IntSetting( 50 'Kills to Win Per Player', 51 min_value=1, 52 default=5, 53 increment=1, 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 # In teams mode, a suicide gives a point to the other team, but in 82 # free-for-all it subtracts from your own score. By default we clamp 83 # this at zero to benefit new players, but pro players might like to 84 # be able to go negative. (to avoid a strategy of just 85 # suiciding until you get a good drop) 86 if issubclass(sessiontype, bs.FreeForAllSession): 87 settings.append( 88 bs.BoolSetting('Allow Negative Scores', default=False) 89 ) 90 91 return settings
Return a list of settings relevant to this game type when running under the provided session type.
93 @override 94 @classmethod 95 def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: 96 return issubclass(sessiontype, bs.DualTeamSession) or issubclass( 97 sessiontype, bs.FreeForAllSession 98 )
Class method override; returns True for ba.DualTeamSessions and ba.FreeForAllSessions; False otherwise.
100 @override 101 @classmethod 102 def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: 103 assert bs.app.classic is not None 104 return bs.app.classic.getmaps('melee')
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.
124 @override 125 def get_instance_description(self) -> str | Sequence: 126 return 'Crush ${ARG1} of your enemies.', 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.
128 @override 129 def get_instance_description_short(self) -> str | Sequence: 130 return 'kill ${ARG1} enemies', 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.
132 @override 133 def on_team_join(self, team: Team) -> None: 134 if self.has_begun(): 135 self._update_scoreboard()
Called when a new bascenev1.Team joins the Activity.
(including the initial set of Teams)
137 @override 138 def on_begin(self) -> None: 139 super().on_begin() 140 self.setup_standard_time_limit(self._time_limit) 141 self.setup_standard_powerup_drops() 142 143 # Base kills needed to win on the size of the largest team. 144 self._score_to_win = self._kills_to_win_per_player * max( 145 1, max((len(t.players) for t in self.teams), default=0) 146 ) 147 self._update_scoreboard()
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.
149 @override 150 def handlemessage(self, msg: Any) -> Any: 151 if isinstance(msg, bs.PlayerDiedMessage): 152 # Augment standard behavior. 153 super().handlemessage(msg) 154 155 player = msg.getplayer(Player) 156 self.respawn_player(player) 157 158 killer = msg.getkillerplayer(Player) 159 if killer is None: 160 return None 161 162 # Handle team-kills. 163 if killer.team is player.team: 164 # In free-for-all, killing yourself loses you a point. 165 if isinstance(self.session, bs.FreeForAllSession): 166 new_score = player.team.score - 1 167 if not self._allow_negative_scores: 168 new_score = max(0, new_score) 169 player.team.score = new_score 170 171 # In teams-mode it gives a point to the other team. 172 else: 173 self._dingsound.play() 174 for team in self.teams: 175 if team is not killer.team: 176 team.score += 1 177 178 # Killing someone on another team nets a kill. 179 else: 180 killer.team.score += 1 181 self._dingsound.play() 182 183 # In FFA show scores since its hard to find on the scoreboard. 184 if isinstance(killer.actor, PlayerSpaz) and killer.actor: 185 killer.actor.set_score_text( 186 str(killer.team.score) + '/' + str(self._score_to_win), 187 color=killer.team.color, 188 flash=True, 189 ) 190 191 self._update_scoreboard() 192 193 # If someone has won, set a timer to end shortly. 194 # (allows the dust to clear and draws to occur if deaths are 195 # close enough) 196 assert self._score_to_win is not None 197 if any(team.score >= self._score_to_win for team in self.teams): 198 bs.timer(0.5, self.end_game) 199 200 else: 201 return super().handlemessage(msg) 202 return None
General message handling; can be passed any message object.
210 @override 211 def end_game(self) -> None: 212 results = bs.GameResults() 213 for team in self.teams: 214 results.set_team_score(team, team.score) 215 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.