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

A game type based on acquiring kills.

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

Instantiate the Activity.

name: str | None = 'Death Match'
description: str | None = 'Kill a set number of enemies to win.'
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]) -> 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        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:
89    @classmethod
90    def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
91        return (issubclass(sessiontype, ba.DualTeamSession)
92                or issubclass(sessiontype, ba.FreeForAllSession))

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]:
94    @classmethod
95    def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]:
96        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.

def get_instance_description(self) -> Union[str, Sequence]:
115    def get_instance_description(self) -> str | Sequence:
116        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]:
118    def get_instance_description_short(self) -> str | Sequence:
119        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:
121    def on_team_join(self, team: Team) -> None:
122        if self.has_begun():
123            self._update_scoreboard()

Called when a new ba.Team joins the Activity.

(including the initial set of Teams)

def on_begin(self) -> None:
125    def on_begin(self) -> None:
126        super().on_begin()
127        self.setup_standard_time_limit(self._time_limit)
128        self.setup_standard_powerup_drops()
129
130        # Base kills needed to win on the size of the largest team.
131        self._score_to_win = (self._kills_to_win_per_player *
132                              max(1, max(len(t.players) for t in self.teams)))
133        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:
135    def handlemessage(self, msg: Any) -> Any:
136
137        if isinstance(msg, ba.PlayerDiedMessage):
138
139            # Augment standard behavior.
140            super().handlemessage(msg)
141
142            player = msg.getplayer(Player)
143            self.respawn_player(player)
144
145            killer = msg.getkillerplayer(Player)
146            if killer is None:
147                return None
148
149            # Handle team-kills.
150            if killer.team is player.team:
151
152                # In free-for-all, killing yourself loses you a point.
153                if isinstance(self.session, ba.FreeForAllSession):
154                    new_score = player.team.score - 1
155                    if not self._allow_negative_scores:
156                        new_score = max(0, new_score)
157                    player.team.score = new_score
158
159                # In teams-mode it gives a point to the other team.
160                else:
161                    ba.playsound(self._dingsound)
162                    for team in self.teams:
163                        if team is not killer.team:
164                            team.score += 1
165
166            # Killing someone on another team nets a kill.
167            else:
168                killer.team.score += 1
169                ba.playsound(self._dingsound)
170
171                # In FFA show scores since its hard to find on the scoreboard.
172                if isinstance(killer.actor, PlayerSpaz) and killer.actor:
173                    killer.actor.set_score_text(str(killer.team.score) + '/' +
174                                                str(self._score_to_win),
175                                                color=killer.team.color,
176                                                flash=True)
177
178            self._update_scoreboard()
179
180            # If someone has won, set a timer to end shortly.
181            # (allows the dust to clear and draws to occur if deaths are
182            # close enough)
183            assert self._score_to_win is not None
184            if any(team.score >= self._score_to_win for team in self.teams):
185                ba.timer(0.5, self.end_game)
186
187        else:
188            return super().handlemessage(msg)
189        return None

General message handling; can be passed any message object.

def end_game(self) -> None:
196    def end_game(self) -> None:
197        results = ba.GameResults()
198        for team in self.teams:
199            results.set_team_score(team, team.score)
200        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._activity.Activity
slow_motion
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._gameactivity.GameActivity
default_music
tips
available_settings
scoreconfig
allow_pausing
allow_kick_idle_players
show_kill_points
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._teamgame.TeamGameActivity
on_transition_in
spawn_player_spaz
end
ba._dependency.DependencyComponent
dep_is_present
get_dynamic_deps