Source code for bascenev1lib.game.elimination

# Released under the MIT License. See LICENSE for details.
#
"""Elimination mini-game."""

# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)

from __future__ import annotations

import logging
from typing import TYPE_CHECKING, override

import bascenev1 as bs

from bascenev1lib.actor.spazfactory import SpazFactory
from bascenev1lib.actor.scoreboard import Scoreboard

if TYPE_CHECKING:
    from typing import Any, Sequence


[docs] class Icon(bs.Actor): """Creates in in-game icon on screen.""" def __init__( self, player: Player, position: tuple[float, float], scale: float, *, show_lives: bool = True, show_death: bool = True, name_scale: float = 1.0, name_maxwidth: float = 115.0, flatness: float = 1.0, shadow: float = 1.0, ): super().__init__() self._player = player self._show_lives = show_lives self._show_death = show_death self._name_scale = name_scale self._outline_tex = bs.gettexture('characterIconMask') icon = player.get_icon() self.node = bs.newnode( 'image', delegate=self, attrs={ 'texture': icon['texture'], 'tint_texture': icon['tint_texture'], 'tint_color': icon['tint_color'], 'vr_depth': 400, 'tint2_color': icon['tint2_color'], 'mask_texture': self._outline_tex, 'opacity': 1.0, 'absolute_scale': True, 'attach': 'bottomCenter', }, ) self._name_text = bs.newnode( 'text', owner=self.node, attrs={ 'text': bs.Lstr(value=player.getname()), 'color': bs.safecolor(player.team.color), 'h_align': 'center', 'v_align': 'center', 'vr_depth': 410, 'maxwidth': name_maxwidth, 'shadow': shadow, 'flatness': flatness, 'h_attach': 'center', 'v_attach': 'bottom', }, ) if self._show_lives: self._lives_text = bs.newnode( 'text', owner=self.node, attrs={ 'text': 'x0', 'color': (1, 1, 0.5), 'h_align': 'left', 'vr_depth': 430, 'shadow': 1.0, 'flatness': 1.0, 'h_attach': 'center', 'v_attach': 'bottom', }, ) self.set_position_and_scale(position, scale)
[docs] def set_position_and_scale( self, position: tuple[float, float], scale: float ) -> None: """(Re)position the icon.""" assert self.node self.node.position = position self.node.scale = [70.0 * scale] self._name_text.position = (position[0], position[1] + scale * 52.0) self._name_text.scale = 1.0 * scale * self._name_scale if self._show_lives: self._lives_text.position = ( position[0] + scale * 10.0, position[1] - scale * 43.0, ) self._lives_text.scale = 1.0 * scale
[docs] def update_for_lives(self) -> None: """Update for the target player's current lives.""" if self._player: lives = self._player.lives else: lives = 0 if self._show_lives: if lives > 0: self._lives_text.text = 'x' + str(lives - 1) else: self._lives_text.text = '' if lives == 0: self._name_text.opacity = 0.2 assert self.node self.node.color = (0.7, 0.3, 0.3) self.node.opacity = 0.2
[docs] def handle_player_spawned(self) -> None: """Our player spawned; hooray!""" if not self.node: return self.node.opacity = 1.0 self.update_for_lives()
[docs] def handle_player_died(self) -> None: """Well poo; our player died.""" if not self.node: return if self._show_death: bs.animate( self.node, 'opacity', { 0.00: 1.0, 0.05: 0.0, 0.10: 1.0, 0.15: 0.0, 0.20: 1.0, 0.25: 0.0, 0.30: 1.0, 0.35: 0.0, 0.40: 1.0, 0.45: 0.0, 0.50: 1.0, 0.55: 0.2, }, ) lives = self._player.lives if lives == 0: bs.timer(0.6, self.update_for_lives)
[docs] @override def handlemessage(self, msg: Any) -> Any: if isinstance(msg, bs.DieMessage): self.node.delete() return None return super().handlemessage(msg)
[docs] class Player(bs.Player['Team']): """Our player type for this game.""" def __init__(self) -> None: self.lives = 0 self.icons: list[Icon] = []
[docs] class Team(bs.Team[Player]): """Our team type for this game.""" def __init__(self) -> None: self.survival_seconds: int | None = None self.spawn_order: list[Player] = []
# ba_meta export bascenev1.GameActivity
[docs] class EliminationGame(bs.TeamGameActivity[Player, Team]): """Game type where last player(s) left alive win.""" name = 'Elimination' description = 'Last remaining alive wins.' scoreconfig = bs.ScoreConfig( label='Survived', scoretype=bs.ScoreType.SECONDS, none_is_winner=True ) # Show messages when players die since it's meaningful here. announce_player_deaths = True allow_mid_activity_joins = False
[docs] @override @classmethod def get_available_settings( cls, sessiontype: type[bs.Session] ) -> list[bs.Setting]: settings = [ bs.IntSetting( 'Lives Per Player', default=1, min_value=1, max_value=10, increment=1, ), bs.IntChoiceSetting( 'Time Limit', choices=[ ('None', 0), ('1 Minute', 60), ('2 Minutes', 120), ('5 Minutes', 300), ('10 Minutes', 600), ('20 Minutes', 1200), ], default=0, ), bs.FloatChoiceSetting( 'Respawn Times', choices=[ ('Shorter', 0.25), ('Short', 0.5), ('Normal', 1.0), ('Long', 2.0), ('Longer', 4.0), ], default=1.0, ), bs.BoolSetting('Epic Mode', default=False), ] if issubclass(sessiontype, bs.DualTeamSession): settings.append(bs.BoolSetting('Solo Mode', default=False)) settings.append( bs.BoolSetting('Balance Total Lives', default=False) ) return settings
[docs] @override @classmethod def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: return issubclass(sessiontype, bs.DualTeamSession) or issubclass( sessiontype, bs.FreeForAllSession )
[docs] @override @classmethod def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: assert bs.app.classic is not None return bs.app.classic.getmaps('melee')
def __init__(self, settings: dict): super().__init__(settings) self._scoreboard = Scoreboard() self._start_time: float | None = None self._vs_text: bs.Actor | None = None self._round_end_timer: bs.Timer | None = None self._epic_mode = bool(settings['Epic Mode']) self._lives_per_player = int(settings['Lives Per Player']) self._time_limit = float(settings['Time Limit']) self._balance_total_lives = bool( settings.get('Balance Total Lives', False) ) self._solo_mode = bool(settings.get('Solo Mode', False)) # Base class overrides: self.slow_motion = self._epic_mode self.default_music = ( bs.MusicType.EPIC if self._epic_mode else bs.MusicType.SURVIVAL )
[docs] @override def get_instance_description(self) -> str | Sequence: return ( 'Last team standing wins.' if isinstance(self.session, bs.DualTeamSession) else 'Last one standing wins.' )
[docs] @override def get_instance_description_short(self) -> str | Sequence: return ( 'last team standing wins' if isinstance(self.session, bs.DualTeamSession) else 'last one standing wins' )
[docs] @override def on_player_join(self, player: Player) -> None: player.lives = self._lives_per_player if self._solo_mode: player.team.spawn_order.append(player) self._update_solo_mode() else: # Create our icon and spawn. player.icons = [Icon(player, position=(0, 50), scale=0.8)] if player.lives > 0: self.spawn_player(player) # Don't waste time doing this until begin. if self.has_begun(): self._update_icons()
[docs] @override def on_begin(self) -> None: super().on_begin() self._start_time = bs.time() self.setup_standard_time_limit(self._time_limit) self.setup_standard_powerup_drops() if self._solo_mode: self._vs_text = bs.NodeActor( bs.newnode( 'text', attrs={ 'position': (0, 105), 'h_attach': 'center', 'h_align': 'center', 'maxwidth': 200, 'shadow': 0.5, 'vr_depth': 390, 'scale': 0.6, 'v_attach': 'bottom', 'color': (0.8, 0.8, 0.3, 1.0), 'text': bs.Lstr(resource='vsText'), }, ) ) # If balance-team-lives is on, add lives to the smaller team until # total lives match. if ( isinstance(self.session, bs.DualTeamSession) and self._balance_total_lives and self.teams[0].players and self.teams[1].players ): if self._get_total_team_lives( self.teams[0] ) < self._get_total_team_lives(self.teams[1]): lesser_team = self.teams[0] greater_team = self.teams[1] else: lesser_team = self.teams[1] greater_team = self.teams[0] add_index = 0 while self._get_total_team_lives( lesser_team ) < self._get_total_team_lives(greater_team): lesser_team.players[add_index].lives += 1 add_index = (add_index + 1) % len(lesser_team.players) self._update_icons() # We could check game-over conditions at explicit trigger points, # but lets just do the simple thing and poll it. bs.timer(1.0, self._update, repeat=True)
def _update_solo_mode(self) -> None: # For both teams, find the first player on the spawn order list with # lives remaining and spawn them if they're not alive. for team in self.teams: # Prune dead players from the spawn order. team.spawn_order = [p for p in team.spawn_order if p] for player in team.spawn_order: assert isinstance(player, Player) if player.lives > 0: if not player.is_alive(): self.spawn_player(player) break def _update_icons(self) -> None: # pylint: disable=too-many-branches # In free-for-all mode, everyone is just lined up along the bottom. if isinstance(self.session, bs.FreeForAllSession): count = len(self.teams) x_offs = 85 xval = x_offs * (count - 1) * -0.5 for team in self.teams: if len(team.players) == 1: player = team.players[0] for icon in player.icons: icon.set_position_and_scale((xval, 30), 0.7) icon.update_for_lives() xval += x_offs # In teams mode we split up teams. else: if self._solo_mode: # First off, clear out all icons. for player in self.players: player.icons = [] # Now for each team, cycle through our available players # adding icons. for team in self.teams: if team.id == 0: xval = -60 x_offs = -78 else: xval = 60 x_offs = 78 is_first = True test_lives = 1 while True: players_with_lives = [ p for p in team.spawn_order if p and p.lives >= test_lives ] if not players_with_lives: break for player in players_with_lives: player.icons.append( Icon( player, position=(xval, (40 if is_first else 25)), scale=1.0 if is_first else 0.5, name_maxwidth=130 if is_first else 75, name_scale=0.8 if is_first else 1.0, flatness=0.0 if is_first else 1.0, shadow=0.5 if is_first else 1.0, show_death=is_first, show_lives=False, ) ) xval += x_offs * (0.8 if is_first else 0.56) is_first = False test_lives += 1 # Non-solo mode. else: for team in self.teams: if team.id == 0: xval = -50 x_offs = -85 else: xval = 50 x_offs = 85 for player in team.players: for icon in player.icons: icon.set_position_and_scale((xval, 30), 0.7) icon.update_for_lives() xval += x_offs def _get_spawn_point(self, player: Player) -> bs.Vec3 | None: del player # Unused. # In solo-mode, if there's an existing live player on the map, spawn at # whichever spot is farthest from them (keeps the action spread out). if self._solo_mode: living_player = None living_player_pos = None for team in self.teams: for tplayer in team.players: if tplayer.is_alive(): assert tplayer.node ppos = tplayer.node.position living_player = tplayer living_player_pos = ppos break if living_player: assert living_player_pos is not None player_pos = bs.Vec3(living_player_pos) points: list[tuple[float, bs.Vec3]] = [] for team in self.teams: start_pos = bs.Vec3(self.map.get_start_position(team.id)) points.append( ((start_pos - player_pos).length(), start_pos) ) # Hmm.. we need to sort vectors too? points.sort(key=lambda x: x[0]) return points[-1][1] return None
[docs] @override def spawn_player(self, player: Player) -> bs.Actor: actor = self.spawn_player_spaz(player, self._get_spawn_point(player)) if not self._solo_mode: bs.timer(0.3, bs.Call(self._print_lives, player)) # If we have any icons, update their state. for icon in player.icons: icon.handle_player_spawned() return actor
def _print_lives(self, player: Player) -> None: from bascenev1lib.actor import popuptext # We get called in a timer so it's possible our player has left/etc. if not player or not player.is_alive() or not player.node: return popuptext.PopupText( 'x' + str(player.lives - 1), color=(1, 1, 0, 1), offset=(0, -0.8, 0), random_offset=0.0, scale=1.8, position=player.node.position, ).autoretain()
[docs] @override def on_player_leave(self, player: Player) -> None: super().on_player_leave(player) player.icons = [] # Remove us from spawn-order. if self._solo_mode: if player in player.team.spawn_order: player.team.spawn_order.remove(player) # Update icons in a moment since our team will be gone from the # list then. bs.timer(0, self._update_icons) # If the player to leave was the last in spawn order and had # their final turn currently in-progress, mark the survival time # for their team. if self._get_total_team_lives(player.team) == 0: assert self._start_time is not None player.team.survival_seconds = int(bs.time() - self._start_time)
def _get_total_team_lives(self, team: Team) -> int: return sum(player.lives for player in team.players)
[docs] @override def handlemessage(self, msg: Any) -> Any: if isinstance(msg, bs.PlayerDiedMessage): # Augment standard behavior. super().handlemessage(msg) player: Player = msg.getplayer(Player) player.lives -= 1 if player.lives < 0: logging.exception( "Got lives < 0 in Elim; this shouldn't happen. solo: %s", self._solo_mode, ) player.lives = 0 # If we have any icons, update their state. for icon in player.icons: icon.handle_player_died() # Play big death sound on our last death # or for every one in solo mode. if self._solo_mode or player.lives == 0: SpazFactory.get().single_player_death_sound.play() # If we hit zero lives, we're dead (and our team might be too). if player.lives == 0: # If the whole team is now dead, mark their survival time. if self._get_total_team_lives(player.team) == 0: assert self._start_time is not None player.team.survival_seconds = int( bs.time() - self._start_time ) else: # Otherwise, in regular mode, respawn. if not self._solo_mode: self.respawn_player(player) # In solo, put ourself at the back of the spawn order. if self._solo_mode: player.team.spawn_order.remove(player) player.team.spawn_order.append(player)
def _update(self) -> None: if self._solo_mode: # For both teams, find the first player on the spawn order # list with lives remaining and spawn them if they're not alive. for team in self.teams: # Prune dead players from the spawn order. team.spawn_order = [p for p in team.spawn_order if p] for player in team.spawn_order: assert isinstance(player, Player) if player.lives > 0: if not player.is_alive(): self.spawn_player(player) self._update_icons() break # If we're down to 1 or fewer living teams, start a timer to end # the game (allows the dust to settle and draws to occur if deaths # are close enough). if len(self._get_living_teams()) < 2: self._round_end_timer = bs.Timer(0.5, self.end_game) def _get_living_teams(self) -> list[Team]: return [ team for team in self.teams if len(team.players) > 0 and any(player.lives > 0 for player in team.players) ]
[docs] @override def end_game(self) -> None: if self.has_ended(): return results = bs.GameResults() self._vs_text = None # Kill our 'vs' if its there. for team in self.teams: results.set_team_score(team, team.survival_seconds) self.end(results=results)