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

Our player type for this game.

Player()
Inherited Members
ba._player.Player
actor
on_expire
team
customdata
sessionplayer
node
position
exists
getname
is_alive
get_icon
assigninput
resetinput
class Team(ba._team.Team[bastd.game.deathmatch.Player]):
25class Team(ba.Team[Player]):
26    """Our team type for this game."""
27
28    def __init__(self) -> None:
29        self.score = 0

Our team type for this game.

Team()
28    def __init__(self) -> None:
29        self.score = 0
Inherited Members
ba._team.Team
manual_init
customdata
on_expire
sessionteam
class DeathMatchGame(ba._teamgame.TeamGameActivity[bastd.game.deathmatch.Player, bastd.game.deathmatch.Team]):
 33class DeathMatchGame(ba.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    @classmethod
 43    def get_available_settings(
 44        cls, sessiontype: type[ba.Session]
 45    ) -> list[ba.Setting]:
 46        settings = [
 47            ba.IntSetting(
 48                'Kills to Win Per Player',
 49                min_value=1,
 50                default=5,
 51                increment=1,
 52            ),
 53            ba.IntChoiceSetting(
 54                'Time Limit',
 55                choices=[
 56                    ('None', 0),
 57                    ('1 Minute', 60),
 58                    ('2 Minutes', 120),
 59                    ('5 Minutes', 300),
 60                    ('10 Minutes', 600),
 61                    ('20 Minutes', 1200),
 62                ],
 63                default=0,
 64            ),
 65            ba.FloatChoiceSetting(
 66                'Respawn Times',
 67                choices=[
 68                    ('Shorter', 0.25),
 69                    ('Short', 0.5),
 70                    ('Normal', 1.0),
 71                    ('Long', 2.0),
 72                    ('Longer', 4.0),
 73                ],
 74                default=1.0,
 75            ),
 76            ba.BoolSetting('Epic Mode', default=False),
 77        ]
 78
 79        # In teams mode, a suicide gives a point to the other team, but in
 80        # free-for-all it subtracts from your own score. By default we clamp
 81        # this at zero to benefit new players, but pro players might like to
 82        # be able to go negative. (to avoid a strategy of just
 83        # suiciding until you get a good drop)
 84        if issubclass(sessiontype, ba.FreeForAllSession):
 85            settings.append(
 86                ba.BoolSetting('Allow Negative Scores', default=False)
 87            )
 88
 89        return settings
 90
 91    @classmethod
 92    def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
 93        return issubclass(sessiontype, ba.DualTeamSession) or issubclass(
 94            sessiontype, ba.FreeForAllSession
 95        )
 96
 97    @classmethod
 98    def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]:
 99        return ba.getmaps('melee')
100
101    def __init__(self, settings: dict):
102        super().__init__(settings)
103        self._scoreboard = Scoreboard()
104        self._score_to_win: int | None = None
105        self._dingsound = ba.getsound('dingSmall')
106        self._epic_mode = bool(settings['Epic Mode'])
107        self._kills_to_win_per_player = int(settings['Kills to Win Per Player'])
108        self._time_limit = float(settings['Time Limit'])
109        self._allow_negative_scores = bool(
110            settings.get('Allow Negative Scores', False)
111        )
112
113        # Base class overrides.
114        self.slow_motion = self._epic_mode
115        self.default_music = (
116            ba.MusicType.EPIC if self._epic_mode else ba.MusicType.TO_THE_DEATH
117        )
118
119    def get_instance_description(self) -> str | Sequence:
120        return 'Crush ${ARG1} of your enemies.', self._score_to_win
121
122    def get_instance_description_short(self) -> str | Sequence:
123        return 'kill ${ARG1} enemies', self._score_to_win
124
125    def on_team_join(self, team: Team) -> None:
126        if self.has_begun():
127            self._update_scoreboard()
128
129    def on_begin(self) -> None:
130        super().on_begin()
131        self.setup_standard_time_limit(self._time_limit)
132        self.setup_standard_powerup_drops()
133
134        # Base kills needed to win on the size of the largest team.
135        self._score_to_win = self._kills_to_win_per_player * max(
136            1, max(len(t.players) for t in self.teams)
137        )
138        self._update_scoreboard()
139
140    def handlemessage(self, msg: Any) -> Any:
141
142        if isinstance(msg, ba.PlayerDiedMessage):
143
144            # Augment standard behavior.
145            super().handlemessage(msg)
146
147            player = msg.getplayer(Player)
148            self.respawn_player(player)
149
150            killer = msg.getkillerplayer(Player)
151            if killer is None:
152                return None
153
154            # Handle team-kills.
155            if killer.team is player.team:
156
157                # In free-for-all, killing yourself loses you a point.
158                if isinstance(self.session, ba.FreeForAllSession):
159                    new_score = player.team.score - 1
160                    if not self._allow_negative_scores:
161                        new_score = max(0, new_score)
162                    player.team.score = new_score
163
164                # In teams-mode it gives a point to the other team.
165                else:
166                    ba.playsound(self._dingsound)
167                    for team in self.teams:
168                        if team is not killer.team:
169                            team.score += 1
170
171            # Killing someone on another team nets a kill.
172            else:
173                killer.team.score += 1
174                ba.playsound(self._dingsound)
175
176                # In FFA show scores since its hard to find on the scoreboard.
177                if isinstance(killer.actor, PlayerSpaz) and killer.actor:
178                    killer.actor.set_score_text(
179                        str(killer.team.score) + '/' + str(self._score_to_win),
180                        color=killer.team.color,
181                        flash=True,
182                    )
183
184            self._update_scoreboard()
185
186            # If someone has won, set a timer to end shortly.
187            # (allows the dust to clear and draws to occur if deaths are
188            # close enough)
189            assert self._score_to_win is not None
190            if any(team.score >= self._score_to_win for team in self.teams):
191                ba.timer(0.5, self.end_game)
192
193        else:
194            return super().handlemessage(msg)
195        return None
196
197    def _update_scoreboard(self) -> None:
198        for team in self.teams:
199            self._scoreboard.set_team_value(
200                team, team.score, self._score_to_win
201            )
202
203    def end_game(self) -> None:
204        results = ba.GameResults()
205        for team in self.teams:
206            results.set_team_score(team, team.score)
207        self.end(results=results)

A game type based on acquiring kills.

DeathMatchGame(settings: dict)
101    def __init__(self, settings: dict):
102        super().__init__(settings)
103        self._scoreboard = Scoreboard()
104        self._score_to_win: int | None = None
105        self._dingsound = ba.getsound('dingSmall')
106        self._epic_mode = bool(settings['Epic Mode'])
107        self._kills_to_win_per_player = int(settings['Kills to Win Per Player'])
108        self._time_limit = float(settings['Time Limit'])
109        self._allow_negative_scores = bool(
110            settings.get('Allow Negative Scores', False)
111        )
112
113        # Base class overrides.
114        self.slow_motion = self._epic_mode
115        self.default_music = (
116            ba.MusicType.EPIC if self._epic_mode else ba.MusicType.TO_THE_DEATH
117        )

Instantiate the Activity.

announce_player_deaths = True

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.

@classmethod
def get_available_settings( cls, sessiontype: type[ba._session.Session]) -> list[ba._settings.Setting]:
42    @classmethod
43    def get_available_settings(
44        cls, sessiontype: type[ba.Session]
45    ) -> list[ba.Setting]:
46        settings = [
47            ba.IntSetting(
48                'Kills to Win Per Player',
49                min_value=1,
50                default=5,
51                increment=1,
52            ),
53            ba.IntChoiceSetting(
54                'Time Limit',
55                choices=[
56                    ('None', 0),
57                    ('1 Minute', 60),
58                    ('2 Minutes', 120),
59                    ('5 Minutes', 300),
60                    ('10 Minutes', 600),
61                    ('20 Minutes', 1200),
62                ],
63                default=0,
64            ),
65            ba.FloatChoiceSetting(
66                'Respawn Times',
67                choices=[
68                    ('Shorter', 0.25),
69                    ('Short', 0.5),
70                    ('Normal', 1.0),
71                    ('Long', 2.0),
72                    ('Longer', 4.0),
73                ],
74                default=1.0,
75            ),
76            ba.BoolSetting('Epic Mode', default=False),
77        ]
78
79        # In teams mode, a suicide gives a point to the other team, but in
80        # free-for-all it subtracts from your own score. By default we clamp
81        # this at zero to benefit new players, but pro players might like to
82        # be able to go negative. (to avoid a strategy of just
83        # suiciding until you get a good drop)
84        if issubclass(sessiontype, ba.FreeForAllSession):
85            settings.append(
86                ba.BoolSetting('Allow Negative Scores', default=False)
87            )
88
89        return settings

Return a list of settings relevant to this game type when running under the provided session type.

@classmethod
def supports_session_type(cls, sessiontype: type[ba._session.Session]) -> bool:
91    @classmethod
92    def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
93        return issubclass(sessiontype, ba.DualTeamSession) or issubclass(
94            sessiontype, ba.FreeForAllSession
95        )

Class method override; returns True for ba.DualTeamSessions and ba.FreeForAllSessions; False otherwise.

@classmethod
def get_supported_maps(cls, sessiontype: type[ba._session.Session]) -> list[str]:
97    @classmethod
98    def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]:
99        return ba.getmaps('melee')

Called by the default ba.GameActivity.create_settings_ui() implementation; should return a list of map names valid for this game-type for the given ba.Session type.

slow_motion = False

If True, runs in slow motion and turns down sound pitch.

def get_instance_description(self) -> Union[str, Sequence]:
119    def get_instance_description(self) -> str | Sequence:
120        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.

def get_instance_description_short(self) -> Union[str, Sequence]:
122    def get_instance_description_short(self) -> str | Sequence:
123        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.

def on_team_join(self, team: bastd.game.deathmatch.Team) -> None:
125    def on_team_join(self, team: Team) -> None:
126        if self.has_begun():
127            self._update_scoreboard()

Called when a new ba.Team joins the Activity.

(including the initial set of Teams)

def on_begin(self) -> None:
129    def on_begin(self) -> None:
130        super().on_begin()
131        self.setup_standard_time_limit(self._time_limit)
132        self.setup_standard_powerup_drops()
133
134        # Base kills needed to win on the size of the largest team.
135        self._score_to_win = self._kills_to_win_per_player * max(
136            1, max(len(t.players) for t in self.teams)
137        )
138        self._update_scoreboard()

Called once the previous ba.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.

def handlemessage(self, msg: Any) -> Any:
140    def handlemessage(self, msg: Any) -> Any:
141
142        if isinstance(msg, ba.PlayerDiedMessage):
143
144            # Augment standard behavior.
145            super().handlemessage(msg)
146
147            player = msg.getplayer(Player)
148            self.respawn_player(player)
149
150            killer = msg.getkillerplayer(Player)
151            if killer is None:
152                return None
153
154            # Handle team-kills.
155            if killer.team is player.team:
156
157                # In free-for-all, killing yourself loses you a point.
158                if isinstance(self.session, ba.FreeForAllSession):
159                    new_score = player.team.score - 1
160                    if not self._allow_negative_scores:
161                        new_score = max(0, new_score)
162                    player.team.score = new_score
163
164                # In teams-mode it gives a point to the other team.
165                else:
166                    ba.playsound(self._dingsound)
167                    for team in self.teams:
168                        if team is not killer.team:
169                            team.score += 1
170
171            # Killing someone on another team nets a kill.
172            else:
173                killer.team.score += 1
174                ba.playsound(self._dingsound)
175
176                # In FFA show scores since its hard to find on the scoreboard.
177                if isinstance(killer.actor, PlayerSpaz) and killer.actor:
178                    killer.actor.set_score_text(
179                        str(killer.team.score) + '/' + str(self._score_to_win),
180                        color=killer.team.color,
181                        flash=True,
182                    )
183
184            self._update_scoreboard()
185
186            # If someone has won, set a timer to end shortly.
187            # (allows the dust to clear and draws to occur if deaths are
188            # close enough)
189            assert self._score_to_win is not None
190            if any(team.score >= self._score_to_win for team in self.teams):
191                ba.timer(0.5, self.end_game)
192
193        else:
194            return super().handlemessage(msg)
195        return None

General message handling; can be passed any message object.

def end_game(self) -> None:
203    def end_game(self) -> None:
204        results = ba.GameResults()
205        for team in self.teams:
206            results.set_team_score(team, team.score)
207        self.end(results=results)

Tell the game to wrap up and call ba.Activity.end() immediately.

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 (ba.GameActivity.setup_standard_time_limit()) will work with the game.

Inherited Members
ba._teamgame.TeamGameActivity
on_transition_in
spawn_player_spaz
end
ba._gameactivity.GameActivity
allow_pausing
allow_kick_idle_players
create_settings_ui
getscoreconfig
getname
get_display_string
get_team_display_string
get_description
get_description_display_string
get_settings_display_string
map
get_instance_display_string
get_instance_scoreboard_display_string
on_continue
is_waiting_for_continue
continue_or_end_game
on_player_join
respawn_player
spawn_player_if_exists
spawn_player
setup_standard_powerup_drops
setup_standard_time_limit
show_zoom_message
ba._activity.Activity
settings_raw
teams
players
is_joining_activity
use_fixed_vr_overlay
inherits_slow_motion
inherits_music
inherits_vr_camera_offset
inherits_vr_overlay_center
inherits_tint
allow_mid_activity_joins
transition_time
can_show_ad_on_death
globalsnode
stats
on_expire
customdata
expired
playertype
teamtype
retain_actor
add_actor_weak_ref
session
on_player_leave
on_team_leave
on_transition_out
has_transitioned_in
has_begun
has_ended
is_transitioning_out
transition_out
create_player
create_team
ba._dependency.DependencyComponent
dep_is_present
get_dynamic_deps