diff --git a/plugins/minigames.json b/plugins/minigames.json index 510551e..2db4b01 100644 --- a/plugins/minigames.json +++ b/plugins/minigames.json @@ -654,5 +654,24 @@ "md5sum": "197a377652ab0c3bfbe1ca07833924b4" } } + }, + "supersmash": { + "description": "SuperSmash", + "external_url": "", + "authors": [ + { + "name": "Mrmaxmeier", + "email": "", + "discord": "" + }, + { + "name": "JoseAngel", + "email": "", + "discord": "joseang3l" + } + ], + "versions": { + "1.0.0": null + } } } \ No newline at end of file diff --git a/plugins/minigames/SuperSmash.py b/plugins/minigames/SuperSmash.py new file mode 100644 index 0000000..3736360 --- /dev/null +++ b/plugins/minigames/SuperSmash.py @@ -0,0 +1,956 @@ +# To learn more, see https://ballistica.net/wiki/meta-tag-system +# ba_meta require api 8 + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import babase +import random +import bauiv1 as bui +import bascenev1 as bs +from babase import _math +from bascenev1lib.actor.spaz import Spaz +from bascenev1lib.actor.spazfactory import SpazFactory +from bascenev1lib.actor.scoreboard import Scoreboard +from bascenev1lib.game import elimination +from bascenev1lib.game.elimination import Icon, Player, Team +from bascenev1lib.actor.bomb import Bomb, Blast +from bascenev1lib.actor.playerspaz import PlayerSpaz, PlayerSpazHurtMessage + +if TYPE_CHECKING: + from typing import Any, Type, List, Sequence, Optional + + +class Icon(Icon): + 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 > 1: + 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 + + +class PowBox(Bomb): + + def __init__(self, + position: Sequence[float] = (0.0, 1.0, 0.0), + velocity: Sequence[float] = (0.0, 0.0, 0.0)) -> None: + Bomb.__init__(self, + position, + velocity, + bomb_type='tnt', + blast_radius=2.5, + source_player=None, + owner=None) + self.set_pow_text() + + def set_pow_text(self) -> None: + m = bs.newnode('math', + owner=self.node, + attrs={'input1': (0, 0.7, 0), + 'operation': 'add'}) + self.node.connectattr('position', m, 'input2') + + self._pow_text = bs.newnode('text', + owner=self.node, + attrs={'text': 'POW!', + 'in_world': True, + 'shadow': 1.0, + 'flatness': 1.0, + 'color': (1, 1, 0.4), + 'scale': 0.0, + 'h_align': 'center'}) + m.connectattr('output', self._pow_text, 'position') + bs.animate(self._pow_text, 'scale', {0: 0.0, 1.0: 0.01}) + + def pow(self) -> None: + self.explode() + + def handlemessage(self, m: Any) -> Any: + if isinstance(m, babase.PickedUpMessage): + self._heldBy = m.node + elif isinstance(m, bs.DroppedMessage): + bs.timer(0.6, self.pow) + Bomb.handlemessage(self, m) + + +class SSPlayerSpaz(PlayerSpaz): + multiplyer = 1 + is_dead = False + + def oob_effect(self) -> None: + if self.is_dead: + return + self.is_dead = True + if self.multiplyer > 1.25: + blast_type = 'tnt' + radius = min(self.multiplyer * 5, 20) + else: + # penalty for killing people with low multiplyer + blast_type = 'ice' + radius = 7.5 + Blast(position=self.node.position, + blast_radius=radius, + blast_type=blast_type).autoretain() + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, bs.HitMessage): + if not self.node: + return None + if self.node.invincible: + SpazFactory.get().block_sound.play(1.0, position=self.node.position) + return True + + # If we were recently hit, don't count this as another. + # (so punch flurries and bomb pileups essentially count as 1 hit) + local_time = int(bs.time() * 1000) + assert isinstance(local_time, int) + if (self._last_hit_time is None + or local_time - self._last_hit_time > 1000): + self._num_times_hit += 1 + self._last_hit_time = local_time + + mag = msg.magnitude * self.impact_scale + velocity_mag = msg.velocity_magnitude * self.impact_scale + damage_scale = 0.22 + + # If they've got a shield, deliver it to that instead. + if self.shield: + if msg.flat_damage: + damage = msg.flat_damage * self.impact_scale + else: + # Hit our spaz with an impulse but tell it to only return + # theoretical damage; not apply the impulse. + assert msg.force_direction is not None + self.node.handlemessage( + 'impulse', msg.pos[0], msg.pos[1], msg.pos[2], + msg.velocity[0], msg.velocity[1], msg.velocity[2], mag, + velocity_mag, msg.radius, 1, msg.force_direction[0], + msg.force_direction[1], msg.force_direction[2]) + damage = damage_scale * self.node.damage + + assert self.shield_hitpoints is not None + self.shield_hitpoints -= int(damage) + self.shield.hurt = ( + 1.0 - + float(self.shield_hitpoints) / self.shield_hitpoints_max) + + # Its a cleaner event if a hit just kills the shield + # without damaging the player. + # However, massive damage events should still be able to + # damage the player. This hopefully gives us a happy medium. + max_spillover = SpazFactory.get().max_shield_spillover_damage + if self.shield_hitpoints <= 0: + + # FIXME: Transition out perhaps? + self.shield.delete() + self.shield = None + SpazFactory.get().shield_down_sound.play(1.0, position=self.node.position) + + # Emit some cool looking sparks when the shield dies. + npos = self.node.position + bs.emitfx(position=(npos[0], npos[1] + 0.9, npos[2]), + velocity=self.node.velocity, + count=random.randrange(20, 30), + scale=1.0, + spread=0.6, + chunk_type='spark') + + else: + SpazFactory.get().shield_hit_sound.play(0.5, position=self.node.position) + + # Emit some cool looking sparks on shield hit. + assert msg.force_direction is not None + bs.emitfx(position=msg.pos, + velocity=(msg.force_direction[0] * 1.0, + msg.force_direction[1] * 1.0, + msg.force_direction[2] * 1.0), + count=min(30, 5 + int(damage * 0.005)), + scale=0.5, + spread=0.3, + chunk_type='spark') + + # If they passed our spillover threshold, + # pass damage along to spaz. + if self.shield_hitpoints <= -max_spillover: + leftover_damage = -max_spillover - self.shield_hitpoints + shield_leftover_ratio = leftover_damage / damage + + # Scale down the magnitudes applied to spaz accordingly. + mag *= shield_leftover_ratio + velocity_mag *= shield_leftover_ratio + else: + return True # Good job shield! + else: + shield_leftover_ratio = 1.0 + + if msg.flat_damage: + damage = int(msg.flat_damage * self.impact_scale * + shield_leftover_ratio) + else: + # Hit it with an impulse and get the resulting damage. + assert msg.force_direction is not None + self.node.handlemessage( + 'impulse', msg.pos[0], msg.pos[1], msg.pos[2], + msg.velocity[0], msg.velocity[1], msg.velocity[2], mag, + velocity_mag, msg.radius, 0, msg.force_direction[0], + msg.force_direction[1], msg.force_direction[2]) + + damage = int(damage_scale * self.node.damage) + self.node.handlemessage('hurt_sound') + + # Play punch impact sound based on damage if it was a punch. + if msg.hit_type == 'punch': + self.on_punched(damage) + + # If damage was significant, lets show it. + # if damage > 350: + # assert msg.force_direction is not None + # babase.show_damage_count('-' + str(int(damage / 10)) + '%', + # msg.pos, msg.force_direction) + + # Let's always add in a super-punch sound with boxing + # gloves just to differentiate them. + if msg.hit_subtype == 'super_punch': + SpazFactory.get().punch_sound_stronger.play(1.0, position=self.node.position) + if damage > 500: + sounds = SpazFactory.get().punch_sound_strong + sound = sounds[random.randrange(len(sounds))] + else: + sound = SpazFactory.get().punch_sound + sound.play(1.0, position=self.node.position) + + # Throw up some chunks. + assert msg.force_direction is not None + bs.emitfx(position=msg.pos, + velocity=(msg.force_direction[0] * 0.5, + msg.force_direction[1] * 0.5, + msg.force_direction[2] * 0.5), + count=min(10, 1 + int(damage * 0.0025)), + scale=0.3, + spread=0.03) + + bs.emitfx(position=msg.pos, + chunk_type='sweat', + velocity=(msg.force_direction[0] * 1.3, + msg.force_direction[1] * 1.3 + 5.0, + msg.force_direction[2] * 1.3), + count=min(30, 1 + int(damage * 0.04)), + scale=0.9, + spread=0.28) + + # Momentary flash. + hurtiness = damage * 0.003 + punchpos = (msg.pos[0] + msg.force_direction[0] * 0.02, + msg.pos[1] + msg.force_direction[1] * 0.02, + msg.pos[2] + msg.force_direction[2] * 0.02) + flash_color = (1.0, 0.8, 0.4) + light = bs.newnode( + 'light', + attrs={ + 'position': punchpos, + 'radius': 0.12 + hurtiness * 0.12, + 'intensity': 0.3 * (1.0 + 1.0 * hurtiness), + 'height_attenuated': False, + 'color': flash_color + }) + bs.timer(0.06, light.delete) + + flash = bs.newnode('flash', + attrs={ + 'position': punchpos, + 'size': 0.17 + 0.17 * hurtiness, + 'color': flash_color + }) + bs.timer(0.06, flash.delete) + + if msg.hit_type == 'impact': + assert msg.force_direction is not None + bs.emitfx(position=msg.pos, + velocity=(msg.force_direction[0] * 2.0, + msg.force_direction[1] * 2.0, + msg.force_direction[2] * 2.0), + count=min(10, 1 + int(damage * 0.01)), + scale=0.4, + spread=0.1) + if self.hitpoints > 0: + + # It's kinda crappy to die from impacts, so lets reduce + # impact damage by a reasonable amount *if* it'll keep us alive + if msg.hit_type == 'impact' and damage > self.hitpoints: + # Drop damage to whatever puts us at 10 hit points, + # or 200 less than it used to be whichever is greater + # (so it *can* still kill us if its high enough) + newdamage = max(damage - 200, self.hitpoints - 10) + damage = newdamage + self.node.handlemessage('flash') + + # If we're holding something, drop it. + if damage > 0.0 and self.node.hold_node: + self.node.hold_node = None + # self.hitpoints -= damage + self.multiplyer += min(damage / 2000, 0.15) + if damage/2000 > 0.05: + self.set_score_text(str(int((self.multiplyer-1)*100))+'%') + # self.node.hurt = 1.0 - float( + # self.hitpoints) / self.hitpoints_max + self.node.hurt = 0.0 + + # If we're cursed, *any* damage blows us up. + if self._cursed and damage > 0: + bs.timer( + 0.05, + bs.WeakCall(self.curse_explode, + msg.get_source_player(bs.Player))) + + # If we're frozen, shatter.. otherwise die if we hit zero + # if self.frozen and (damage > 200 or self.hitpoints <= 0): + # self.shatter() + # elif self.hitpoints <= 0: + # self.node.handlemessage( + # bs.DieMessage(how=babase.DeathType.IMPACT)) + + # If we're dead, take a look at the smoothed damage value + # (which gives us a smoothed average of recent damage) and shatter + # us if its grown high enough. + # if self.hitpoints <= 0: + # damage_avg = self.node.damage_smoothed * damage_scale + # if damage_avg > 1000: + # self.shatter() + + source_player = msg.get_source_player(type(self._player)) + if source_player: + self.last_player_attacked_by = source_player + self.last_attacked_time = bs.time() + self.last_attacked_type = (msg.hit_type, msg.hit_subtype) + Spaz.handlemessage(self, bs.HitMessage) # Augment standard behavior. + activity = self._activity() + if activity is not None and self._player.exists(): + activity.handlemessage(PlayerSpazHurtMessage(self)) + + elif isinstance(msg, bs.DieMessage): + self.oob_effect() + super().handlemessage(msg) + elif isinstance(msg, bs.PowerupMessage): + if msg.poweruptype == 'health': + if self.multiplyer > 2: + self.multiplyer *= 0.5 + else: + self.multiplyer *= 0.75 + self.multiplyer = max(1, self.multiplyer) + self.set_score_text(str(int((self.multiplyer-1)*100))+"%") + super().handlemessage(msg) + else: + super().handlemessage(msg) + + +class Player(bs.Player['Team']): + """Our player type for this game.""" + + +class Team(bs.Team[Player]): + """Our team type for this game.""" + + def __init__(self) -> None: + self.score = 0 + + +# ba_meta export bascenev1.GameActivity +class SuperSmash(bs.TeamGameActivity[Player, Team]): + + name = 'Super Smash' + description = 'Knock everyone off the map.' + + # Print messages when players die since it matters here. + announce_player_deaths = True + + @classmethod + def get_available_settings( + cls, sessiontype: Type[bs.Session]) -> List[babase.Setting]: + settings = [ + bs.IntSetting( + 'Kills to Win Per Player', + min_value=1, + default=5, + 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('Boxing Gloves', default=False), + bs.BoolSetting('Epic Mode', default=False), + ] + if issubclass(sessiontype, bs.FreeForAllSession): + settings.append( + bs.BoolSetting('Allow Negative Scores', default=False)) + return settings + + @classmethod + def supports_session_type(cls, sessiontype: Type[bs.Session]) -> bool: + return (issubclass(sessiontype, bs.DualTeamSession) + or issubclass(sessiontype, bs.FreeForAllSession)) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[bs.Session]) -> List[str]: + maps = bs.app.classic.getmaps('melee') + for m in ['Lake Frigid', 'Hockey Stadium', 'Football Stadium']: + # remove maps without bounds + maps.remove(m) + return maps + + def __init__(self, settings: dict): + super().__init__(settings) + self._scoreboard = Scoreboard() + self._score_to_win: int | None = None + self._dingsound = bs.getsound('dingSmall') + self._epic_mode = bool(settings['Epic Mode']) + self._kills_to_win_per_player = int( + settings['Kills to Win Per Player']) + self._time_limit = float(settings['Time Limit']) + self._allow_negative_scores = bool( + settings.get('Allow Negative Scores', False)) + self._boxing_gloves = bool(settings['Boxing Gloves']) + + # Base class overrides. + self.slow_motion = self._epic_mode + self.default_music = (bs.MusicType.EPIC if self._epic_mode else + bs.MusicType.SURVIVAL) + + def get_instance_description(self) -> str | Sequence: + return 'Knock everyone off the map.' + + def get_instance_description_short(self) -> str | Sequence: + return 'Knock off the map.' + + 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(enable_tnt=False) + self._pow = None + self._tnt_drop_timer = bs.timer(1.0 * 0.30, + bs.WeakCall(self._drop_pow_box), + repeat=True) + + # Base kills needed to win on the size of the largest team. + self._score_to_win = (self._kills_to_win_per_player * + max(1, max(len(t.players) for t in self.teams))) + self._update_scoreboard() + + def _drop_pow_box(self) -> None: + if self._pow is not None and self._pow: + return + if len(self.map.tnt_points) == 0: + return + pos = random.choice(self.map.tnt_points) + pos = (pos[0], pos[1] + 1, pos[2]) + self._pow = PowBox(position=pos, velocity=(0.0, 1.0, 0.0)) + + def spawn_player(self, player: Player) -> bs.Actor: + if isinstance(self.session, bs.DualTeamSession): + position = self.map.get_start_position(player.team.id) + else: + # otherwise do free-for-all spawn locations + position = self.map.get_ffa_start_position(self.players) + angle = None + + name = player.getname() + light_color = _math.normalized_color(player.color) + display_color = babase.safecolor(player.color, target_intensity=0.75) + + spaz = SSPlayerSpaz(color=player.color, + highlight=player.highlight, + character=player.character, + player=player) + + player.actor = spaz + assert spaz.node + + # If this is co-op and we're on Courtyard or Runaround, add the + # material that allows us to collide with the player-walls. + # FIXME: Need to generalize this. + if isinstance(self.session, bs.CoopSession) and self.map.getname() in [ + 'Courtyard', 'Tower D' + ]: + mat = self.map.preloaddata['collide_with_wall_material'] + assert isinstance(spaz.node.materials, tuple) + assert isinstance(spaz.node.roller_materials, tuple) + spaz.node.materials += (mat, ) + spaz.node.roller_materials += (mat, ) + + spaz.node.name = name + spaz.node.name_color = display_color + spaz.connect_controls_to_player() + + # Move to the stand position and add a flash of light. + spaz.handlemessage( + bs.StandMessage( + position, + angle if angle is not None else random.uniform(0, 360))) + self._spawn_sound.play(1, position=spaz.node.position) + light = bs.newnode('light', attrs={'color': light_color}) + spaz.node.connectattr('position', light, 'position') + bs.animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0}) + bs.timer(0.5, light.delete) + + if self._boxing_gloves: + spaz.equip_boxing_gloves() + + return spaz + + def handlemessage(self, msg: Any) -> Any: + + if isinstance(msg, bs.PlayerDiedMessage): + # Augment standard behavior. + super().handlemessage(msg) + + player = msg.getplayer(Player) + self.respawn_player(player) + + killer = msg.getkillerplayer(Player) + if killer is None: + return None + + # Handle team-kills. + if killer.team is player.team: + + # In free-for-all, killing yourself loses you a point. + if isinstance(self.session, bs.FreeForAllSession): + new_score = player.team.score - 1 + if not self._allow_negative_scores: + new_score = max(0, new_score) + player.team.score = new_score + + # In teams-mode it gives a point to the other team. + else: + self._dingsound.play() + for team in self.teams: + if team is not killer.team: + team.score += 1 + + # Killing someone on another team nets a kill. + else: + killer.team.score += 1 + self._dingsound.play() + + # In FFA show scores since its hard to find on the scoreboard. + if isinstance(killer.actor, SSPlayerSpaz) and killer.actor: + killer.actor.set_score_text(str(killer.team.score) + '/' + + str(self._score_to_win), + color=killer.team.color, + flash=True) + + self._update_scoreboard() + + # If someone has won, set a timer to end shortly. + # (allows the dust to clear and draws to occur if deaths are + # close enough) + assert self._score_to_win is not None + if any(team.score >= self._score_to_win for team in self.teams): + bs.timer(0.5, self.end_game) + + else: + return super().handlemessage(msg) + return None + + def _update_scoreboard(self) -> None: + for team in self.teams: + self._scoreboard.set_team_value(team, team.score, + self._score_to_win) + + def end_game(self) -> None: + results = bs.GameResults() + for team in self.teams: + results.set_team_score(team, team.score) + self.end(results=results) + + +class Player2(bs.Player['Team']): + """Our player type for this game.""" + + def __init__(self) -> None: + self.lives = 0 + self.icons: List[Icon] = [] + + +class Team2(bs.Team[Player]): + """Our team type for this game.""" + + def __init__(self) -> None: + self.survival_seconds: Optional[int] = None + self.spawn_order: List[Player] = [] + + +# ba_meta export bascenev1.GameActivity +class SuperSmashElimination(bs.TeamGameActivity[Player2, Team2]): + + name = 'Super Smash Elimination' + description = 'Knock everyone off the map.' + scoreconfig = bs.ScoreConfig(label='Survived', + scoretype=bs.ScoreType.SECONDS, + none_is_winner=True) + + # Print messages when players die since it matters here. + announce_player_deaths = True + + @classmethod + def get_available_settings( + cls, sessiontype: Type[bs.Session]) -> List[babase.Setting]: + settings = [ + bs.IntSetting( + 'Lives (0 = Unlimited)', + min_value=0, + default=3, + 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('Boxing Gloves', default=False), + bs.BoolSetting('Epic Mode', default=False), + ] + return settings + + @classmethod + def supports_session_type(cls, sessiontype: Type[bs.Session]) -> bool: + return (issubclass(sessiontype, bs.DualTeamSession) + or issubclass(sessiontype, bs.FreeForAllSession)) + + @classmethod + def get_supported_maps(cls, sessiontype: Type[bs.Session]) -> List[str]: + maps = bs.app.classic.getmaps('melee') + for m in ['Lake Frigid', 'Hockey Stadium', 'Football Stadium']: + # remove maps without bounds + maps.remove(m) + return maps + + def __init__(self, settings: dict): + super().__init__(settings) + self.lives = int(settings['Lives (0 = Unlimited)']) + self.time_limit_only = (self.lives == 0) + if self.time_limit_only: + settings['Time Limit'] = max(60, settings['Time Limit']) + + self._epic_mode = bool(settings['Epic Mode']) + self._time_limit = float(settings['Time Limit']) + + self._start_time: Optional[float] = 1.0 + + self._boxing_gloves = bool(settings['Boxing Gloves']) + 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) + + def get_instance_description(self) -> str | Sequence: + return 'Knock everyone off the map.' + + def get_instance_description_short(self) -> str | Sequence: + return 'Knock off the map.' + + 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(enable_tnt=False) + self._pow = None + self._tnt_drop_timer = bs.timer(1.0 * 0.30, + bs.WeakCall(self._drop_pow_box), + repeat=True) + self._update_icons() + bs.timer(1.0, self.check_end_game, repeat=True) + + def _drop_pow_box(self) -> None: + if self._pow is not None and self._pow: + return + if len(self.map.tnt_points) == 0: + return + pos = random.choice(self.map.tnt_points) + pos = (pos[0], pos[1] + 1, pos[2]) + self._pow = PowBox(position=pos, velocity=(0.0, 1.0, 0.0)) + + def on_player_join(self, player: Player) -> None: + + if self.has_begun(): + if (all(teammate.lives == 0 for teammate in player.team.players) + and player.team.survival_seconds is None): + player.team.survival_seconds = 0 + bs.broadcastmessage( + babase.Lstr(resource='playerDelayedJoinText', + subs=[('${PLAYER}', player.getname(full=True))]), + color=(0, 1, 0), + ) + return + + player.lives = self.lives + # create our icon and spawn + player.icons = [Icon(player, + position=(0.0, 50), + scale=0.8)] + if player.lives > 0 or self.time_limit_only: + self.spawn_player(player) + + # dont waste time doing this until begin + if self.has_begun(): + self._update_icons() + + def on_player_leave(self, player: Player) -> None: + super().on_player_leave(player) + player.icons = None + + # update icons in a moment since our team + # will be gone from the list then + bs.timer(0.0, self._update_icons) + bs.timer(0.1, self.check_end_game, repeat=True) + + 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: + print('WTF have', len(team.players), 'players in ffa team') + elif len(team.players) == 1: + player = team.players[0] + if len(player.icons) != 1: + print( + 'WTF have', + len(player.icons), + 'icons in non-solo elim') + 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: + if len(player.icons) != 1: + print( + 'WTF have', + len(player.icons), + 'icons in non-solo elim') + for icon in player.icons: + icon.set_position_and_scale((xval, 30), 0.7) + icon.update_for_lives() + xval += x_offs + + # overriding the default character spawning.. + def spawn_player(self, player: Player) -> bs.Actor: + if isinstance(self.session, bs.DualTeamSession): + position = self.map.get_start_position(player.team.id) + else: + # otherwise do free-for-all spawn locations + position = self.map.get_ffa_start_position(self.players) + angle = None + + name = player.getname() + light_color = _math.normalized_color(player.color) + display_color = babase.safecolor(player.color, target_intensity=0.75) + + spaz = SSPlayerSpaz(color=player.color, + highlight=player.highlight, + character=player.character, + player=player) + + player.actor = spaz + assert spaz.node + + # If this is co-op and we're on Courtyard or Runaround, add the + # material that allows us to collide with the player-walls. + # FIXME: Need to generalize this. + if isinstance(self.session, bs.CoopSession) and self.map.getname() in [ + 'Courtyard', 'Tower D' + ]: + mat = self.map.preloaddata['collide_with_wall_material'] + assert isinstance(spaz.node.materials, tuple) + assert isinstance(spaz.node.roller_materials, tuple) + spaz.node.materials += (mat, ) + spaz.node.roller_materials += (mat, ) + + spaz.node.name = name + spaz.node.name_color = display_color + spaz.connect_controls_to_player() + + # Move to the stand position and add a flash of light. + spaz.handlemessage( + bs.StandMessage( + position, + angle if angle is not None else random.uniform(0, 360))) + self._spawn_sound.play(1, position=spaz.node.position) + light = bs.newnode('light', attrs={'color': light_color}) + spaz.node.connectattr('position', light, 'position') + bs.animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0}) + bs.timer(0.5, light.delete) + + # If we have any icons, update their state. + for icon in player.icons: + icon.handle_player_spawned() + + if self._boxing_gloves: + spaz.equip_boxing_gloves() + + return spaz + + def _get_total_team_lives(self, team: Team) -> int: + return sum(player.lives for player in team.players) + + 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: + 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 player.lives == 0: + SpazFactory.get().single_player_death_sound.play() + + # if we hit zero lives we're dead and the game might be over + if player.lives == 0 and not self.time_limit_only: + # 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) + # we still have lives; yay! + else: + self.respawn_player(player) + + bs.timer(0.1, self.check_end_game, repeat=True) + + else: + return super().handlemessage(msg) + return None + + def check_end_game(self) -> None: + if len(self._get_living_teams()) < 2: + 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) + ] + + 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)