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 8
  6# (see https://ballistica.net/wiki/meta-tag-system)
  7
  8from __future__ import annotations
  9
 10from typing import TYPE_CHECKING
 11
 12from typing_extensions import override
 13import bascenev1 as bs
 14
 15from bascenev1lib.actor.playerspaz import PlayerSpaz
 16from bascenev1lib.actor.scoreboard import Scoreboard
 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
 26class Team(bs.Team[Player]):
 27    """Our team type for this game."""
 28
 29    def __init__(self) -> None:
 30        self.score = 0
 31
 32
 33# ba_meta export bascenev1.GameActivity
 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)
class Player(bascenev1._player.Player[ForwardRef('Team')]):
23class Player(bs.Player['Team']):
24    """Our player type for this game."""

Our player type for this game.

Inherited Members
bascenev1._player.Player
character
actor
color
highlight
on_expire
team
customdata
sessionplayer
node
position
exists
getname
is_alive
get_icon
assigninput
resetinput
class Team(bascenev1._team.Team[bascenev1lib.game.deathmatch.Player]):
27class Team(bs.Team[Player]):
28    """Our team type for this game."""
29
30    def __init__(self) -> None:
31        self.score = 0

Our team type for this game.

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

A game type based on acquiring kills.

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

Instantiate the Activity.

name = 'Death Match'
description = '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.

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

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

@override
@classmethod
def supports_session_type(cls, sessiontype: type[bascenev1._session.Session]) -> bool:
94    @override
95    @classmethod
96    def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
97        return issubclass(sessiontype, bs.DualTeamSession) or issubclass(
98            sessiontype, bs.FreeForAllSession
99        )

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

@override
@classmethod
def get_supported_maps(cls, sessiontype: type[bascenev1._session.Session]) -> list[str]:
101    @override
102    @classmethod
103    def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]:
104        assert bs.app.classic is not None
105        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.

slow_motion = False

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

default_music = None
@override
def get_instance_description(self) -> Union[str, Sequence]:
125    @override
126    def get_instance_description(self) -> str | Sequence:
127        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.

@override
def get_instance_description_short(self) -> Union[str, Sequence]:
129    @override
130    def get_instance_description_short(self) -> str | Sequence:
131        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.

@override
def on_team_join(self, team: Team) -> None:
133    @override
134    def on_team_join(self, team: Team) -> None:
135        if self.has_begun():
136            self._update_scoreboard()

Called when a new bascenev1.Team joins the Activity.

(including the initial set of Teams)

@override
def on_begin(self) -> None:
138    @override
139    def on_begin(self) -> None:
140        super().on_begin()
141        self.setup_standard_time_limit(self._time_limit)
142        self.setup_standard_powerup_drops()
143
144        # Base kills needed to win on the size of the largest team.
145        self._score_to_win = self._kills_to_win_per_player * max(
146            1, max((len(t.players) for t in self.teams), default=0)
147        )
148        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.

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

General message handling; can be passed any message object.

@override
def end_game(self) -> None:
211    @override
212    def end_game(self) -> None:
213        results = bs.GameResults()
214        for team in self.teams:
215            results.set_team_score(team, team.score)
216        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.

Inherited Members
bascenev1._teamgame.TeamGameActivity
on_transition_in
spawn_player_spaz
end
bascenev1._gameactivity.GameActivity
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
initialplayerinfos
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
bascenev1._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
paused_text
preloads
lobby
context
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
bascenev1._dependency.DependencyComponent
dep_is_present
get_dynamic_deps