diff --git a/plugins/minigames.json b/plugins/minigames.json index 5da3292..ccac9a2 100644 --- a/plugins/minigames.json +++ b/plugins/minigames.json @@ -436,6 +436,7 @@ } ], "versions": { + "3.0.0": null, "1.0.2": { "api_version": 9, "commit_sha": "0225234", diff --git a/plugins/minigames/hot_potato.py b/plugins/minigames/hot_potato.py index 07f9212..34fd647 100644 --- a/plugins/minigames/hot_potato.py +++ b/plugins/minigames/hot_potato.py @@ -1,7 +1,7 @@ -# Porting to api 8 made easier by baport.(https://github.com/bombsquad-community/baport) """ Hot Potato by themikirog + Version 3 A random player(s) gets Marked. They will die if they don't pass the mark to other players. @@ -14,27 +14,28 @@ """ - # ba_meta require api 9 # (see https://ballistica.net/wiki/meta-tag-system) - from __future__ import annotations from typing import TYPE_CHECKING, override # Define only what we need and nothing more import babase -import bascenev1 as bs +import bascenev1 as ba from bascenev1lib.actor.spaz import SpazFactory from bascenev1lib.actor.spaz import PickupMessage from bascenev1lib.actor.spaz import BombDiedMessage from bascenev1lib.actor.playerspaz import PlayerSpaz -from bascenev1lib.actor.bomb import Bomb -from bascenev1lib.actor.bomb import Blast +from bascenev1lib.actor.bomb import Bomb, Blast, ExplodeMessage +from bascenev1lib.gameutils import SharedObjects from enum import Enum import random +IMPACT_BOMB_RADIUS_SCALE = 0.7 +MARKED_KNOCKBACK_SCALE = 0.8 + if TYPE_CHECKING: pass @@ -51,6 +52,22 @@ FALL_PENALTIES = [1.5, 9.0, 10.0] +# Added a different fall penalty table if playing with lenient penalties. +LENIENT_FALL_PENALTIES = [1.5, + 2.0, + 2.5, + 3.0, + 3.5, + 4.0, + 4.5, + 5.0, + 5.5, + 6.0, + 7.0, + 8.0, + 9.0, + 10.0] + RED_COLOR = (1.0, 0.2, 0.2) YELLOW_COLOR = (1.0, 1.0, 0.2) @@ -73,11 +90,18 @@ class PlayerState(Enum): # The time it takes to go back to the REGULAR state gets more severe the more times the player dies by falling off the map. STUNNED = 3 +# I added some variety by giving Marked player unique bomb types. +# This enum makes it easier to keep track of them. +class MarkedBombTypes(Enum): + REGULAR = 0 + IMPACT = 1 + STICKY = 2 + # To make the game easier to parse, I added Elimination style icons to the bottom of the screen. # Here's the behavior of each icon. -class Icon(bs.Actor): +class Icon(ba.Actor): """Creates in in-game icon on screen.""" def __init__(self, @@ -93,11 +117,11 @@ class Icon(bs.Actor): self._player = player self._name_scale = name_scale - self._outline_tex = bs.gettexture('characterIconMask') + self._outline_tex = ba.gettexture('characterIconMask') # Character portrait icon = player.get_icon() - self.node = bs.newnode('image', + self.node = ba.newnode('image', delegate=self, attrs={ 'texture': icon['texture'], @@ -111,12 +135,12 @@ class Icon(bs.Actor): 'attach': 'bottomCenter' }) # Player name - self._name_text = bs.newnode( + self._name_text = ba.newnode( 'text', owner=self.node, attrs={ - 'text': babase.Lstr(value=player.getname()), - 'color': babase.safecolor(player.team.color), + 'text': ba.Lstr(value=player.getname()), + 'color': ba.safecolor(player.team.color), 'h_align': 'center', 'v_align': 'center', 'vr_depth': 410, @@ -127,7 +151,7 @@ class Icon(bs.Actor): 'v_attach': 'bottom' }) # Status text (such as Marked!, Stunned! and You're Out!) - self._marked_text = bs.newnode( + self._marked_text = ba.newnode( 'text', owner=self.node, attrs={ @@ -142,7 +166,7 @@ class Icon(bs.Actor): 'v_attach': 'bottom' }) # Status icon overlaying the character portrait - self._marked_icon = bs.newnode( + self._marked_icon = ba.newnode( 'text', owner=self.node, attrs={ @@ -201,10 +225,10 @@ class Icon(bs.Actor): # Animate text and icon animation_end_time = 1.5 if bool(self.activity.settings['Epic Mode']) else 3.0 - bs.animate(self._marked_icon, 'opacity', { + ba.animate(self._marked_icon, 'opacity', { 0: 1.0, animation_end_time: 0.0}) - bs.animate(self._marked_text, 'opacity', { + ba.animate(self._marked_text, 'opacity', { 0: 1.0, animation_end_time: 0.0}) @@ -228,6 +252,58 @@ class Icon(bs.Actor): self._marked_text.position = (position[0], position[1] - scale * 52.0) self._marked_text.scale = 0.8 * scale +# Bombs work as intended... with the exception of one mechanic. +# We have to patch it out. + +class PotatoBomb(Bomb): + + # Same function as before with bomb ownership passing code removed + @override + def _handle_hit(self, msg: ba.HitMessage) -> None: + ispunched = msg.srcnode and msg.srcnode.getnodetype() == 'spaz' + + # Normal bombs are triggered by non-punch impacts; + # impact-bombs by all impacts. + if not self._exploded and ( + not ispunched or self.bomb_type in ['impact', 'land_mine'] + ): + # Here lies a mechanic where bomb ownership passed with explosions. + # You won't be missed. RIP + + ba.timer( + 0.1 + random.random() * 0.1, + ba.WeakCall(self.handlemessage, ExplodeMessage()), + ) + assert self.node + self.node.handlemessage( + 'impulse', + msg.pos[0], + msg.pos[1], + msg.pos[2], + msg.velocity[0], + msg.velocity[1], + msg.velocity[2], + msg.magnitude, + msg.velocity_magnitude, + msg.radius, + 0, + msg.velocity[0], + msg.velocity[1], + msg.velocity[2], + ) + + if msg.srcnode: + pass + +class FootConnectMessage(object): + 'Player stands on ground' + pass + +class FootDisconnectMessage(object): + 'Player stops touching the ground' + pass + + # This gamemode heavily relies on edited player behavior. # We need that amount of control, so we're gonna create our own class and use the original PlayerSpaz as our blueprint. @@ -237,10 +313,35 @@ class PotatoPlayerSpaz(PlayerSpaz): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # unchanged Spaz __init__ code goes here + shared = SharedObjects.get() + self._touched_ground_count = 0 self.dropped_bombs = [] # we use this to track bombs thrown by the player + # Immediately turn off invincibility unless you're marked + self.node.invincible = (self.source_player.state == PlayerState.MARKED) + + footing_material = shared.footing_material + self.potato_material = ba.Material() + self.potato_material.add_actions( + conditions=('they_have_material', footing_material), + actions=( + ('message', 'our_node', 'at_connect', FootConnectMessage()), + ('message', 'our_node', 'at_disconnect', FootDisconnectMessage()) + ), + ) + + new_extras = list(self.node.extras_material) + new_extras.append(self.potato_material) + self.node.extras_material = tuple(new_extras) + + # We meed to modify how grabs work, but mostly small changes to prevent infinites + # This cooldown will work differently compared to punches, which we'll see later + self._able_to_pickup = True + self._pickup_timer: ba.Timer = None + self._pickup_cooldown = 0.5 + # Define a marked light - self.marked_light = bs.newnode('light', + self.marked_light = ba.newnode('light', owner=self.node, attrs={'position': self.node.position, 'radius': 0.15, @@ -249,7 +350,7 @@ class PotatoPlayerSpaz(PlayerSpaz): 'color': (1.0, 0.0, 0.0)}) # Pulsing red light when the player is Marked - bs.animate(self.marked_light, 'radius', { + ba.animate(self.marked_light, 'radius', { 0: 0.1, 0.3: 0.15, 0.6: 0.1}, @@ -257,12 +358,12 @@ class PotatoPlayerSpaz(PlayerSpaz): self.node.connectattr('position_center', self.marked_light, 'position') # Marked timer. It should be above our head, so we attach the text to the offset that's attached to the player. - self.marked_timer_offset = bs.newnode('math', owner=self.node, attrs={ + self.marked_timer_offset = ba.newnode('math', owner=self.node, attrs={ 'input1': (0, 1.2, 0), 'operation': 'add'}) self.node.connectattr('torso_position', self.marked_timer_offset, 'input2') - self.marked_timer_text = bs.newnode('text', owner=self.node, attrs={ + self.marked_timer_text = ba.newnode('text', owner=self.node, attrs={ 'text': '', 'in_world': True, 'shadow': 0.4, @@ -272,18 +373,79 @@ class PotatoPlayerSpaz(PlayerSpaz): 'h_align': 'center'}) self.marked_timer_offset.connectattr('output', self.marked_timer_text, 'position') + # We need to modify how grabs work to prevent inifinites. + # Since it's a tiny overhaul, we need to rewrite this function entirely. + # If you're not marked, releasing someone from a grab will prevent you from grabbing for a tiny bit. + def on_pickup_press(self) -> None: + if not self.node: + return + + t_ms = int(ba.time() * 1000.0) + assert isinstance(t_ms, int) + + if self._able_to_pickup: + _holding_someone: bool = self.node.hold_node.exists() + _marked = self.node.source_player.state == PlayerState.MARKED + if not _marked and _holding_someone: + self._able_to_pickup = False + self._pickup_timer = ba.Timer(self._pickup_cooldown, ba.WeakCall(self._on_pickup_timer_timeout)) + self.node.pickup_pressed = True + self.last_pickup_time_ms = t_ms + + self._turbo_filter_add_press('pickup') + + # If grab timer runs out, restore our ability to grab. + def _on_pickup_timer_timeout(self) -> None: + self._able_to_pickup = True + + # This function is based on the original drop_bomb function. + # What we wanna do here is spawn our own bomb instead of the original. + def create_bomb(self) -> stdbomb.Bomb | None: + if (self.bomb_count <= 0) or self.frozen: + return None + assert self.node + pos = self.node.position_forward + vel = self.node.velocity + + dropping_bomb = True + bomb_type = self.bomb_type + + bomb = PotatoBomb( + position=(pos[0], pos[1] - 0.0, pos[2]), + velocity=(vel[0], vel[1], vel[2]), + bomb_type=bomb_type, + blast_radius=self.blast_radius, + source_player=self.source_player, + owner=self.node, + ).autoretain() + + assert bomb.node + if dropping_bomb: + self.bomb_count -= 1 + bomb.node.add_death_action( + ba.WeakCall(self.handlemessage, BombDiedMessage()) + ) + self._pick_up(bomb.node) + + for clb in self._dropped_bomb_callbacks: + clb(self, bomb) + + return bomb + # Modified behavior when dropping bombs def drop_bomb(self) -> stdbomb.Bomb | None: # The original function returns the Bomb the player created. - # This is super helpful for us, since all we need is to mark the bombs red - # if they belong to the Marked player and nothing else. - bomb = super().drop_bomb() + # This is super helpful for us, since all we need here is to lightly tweak the bomb. + bomb = self.create_bomb() # Let's make sure the player actually created a new bomb if bomb: + # Keep track of who created the bomb in the first place, since this is crucial + self.creator_player = self.source_player + # Add our bomb to the list of our tracked bombs self.dropped_bombs.append(bomb) # Bring a light - bomb.bomb_marked_light = bs.newnode('light', + bomb.bomb_marked_light = ba.newnode('light', owner=bomb.node, attrs={'position': bomb.node.position, 'radius': 0.04, @@ -296,7 +458,7 @@ class PotatoPlayerSpaz(PlayerSpaz): self.set_bombs_marked() # When the bomb physics node dies, call a function. bomb.node.add_death_action( - bs.WeakCall(self.bomb_died, bomb)) + ba.WeakCall(self.bomb_died, bomb)) # Here's the function that gets called when one of the player's bombs dies. # We reference the player's dropped_bombs list and remove the bomb that died. @@ -311,16 +473,36 @@ class PotatoPlayerSpaz(PlayerSpaz): for bomb in self.dropped_bombs: bomb.bomb_marked_light.intensity = 20.0 if self._player.state == PlayerState.MARKED else 0.0 - # Since our gamemode relies heavily on players passing the mark to other players - # we need to have access to this message. This gets called when the player takes damage for any reason. def handlemessage(self, msg): - if isinstance(msg, bs.HitMessage): + # Since our gamemode relies heavily on players passing the mark to other players + # we need to have access to this message. This gets called when the player takes damage for any reason. + if isinstance(msg, ba.HitMessage): # This is basically the same HitMessage code as in the original Spaz. # The only difference is that there is no health bar and you can't die with punches or bombs. + # On top of that we also have to fix gamemode breaking exploits related to this change in gameplay. # Also some useless or redundant code was removed. # I'm still gonna comment all of it since we're here. if not self.node: return None + if self.node.invincible: + SpazFactory.get().block_sound.play( + 1.0, + position=self.node.position, + ) + return True + + # Here's all the damage and force calculations unchanged from the source. + mag = msg.magnitude * self.impact_scale + velocity_mag = msg.velocity_magnitude * self.impact_scale + damage_scale = 0.22 + + # If we're marked, decrease the stun so its less punishing. + victim_marked: bool = False + if hasattr(self.node.source_player, "state"): + if self.node.source_player.state == PlayerState.MARKED: + victim_marked = True + mag *= MARKED_KNOCKBACK_SCALE + velocity_mag *= MARKED_KNOCKBACK_SCALE # If the attacker is marked, pass that mark to us. self.activity.pass_mark(msg._source_player, self._player) @@ -332,22 +514,27 @@ class PotatoPlayerSpaz(PlayerSpaz): if self.source_player.state == PlayerState.STUNNED and msg._source_player != PlayerState.MARKED: self.node.handlemessage('flash') SpazFactory.get().block_sound.play(1, position=self.node.position) - - # Here's all the damage and force calculations unchanged from the source. - mag = msg.magnitude * self.impact_scale - velocity_mag = msg.velocity_magnitude * self.impact_scale - damage_scale = 0.22 + return True # We use them to apply a physical force to the player. # Normally this is also used for damage, but we we're not gonna do it. # We're still gonna calculate it, because it's still responsible for knockback. + # However knockback is also stun, which can be problematic in this mode where you can't die. + # If a player is already stunned, let's disable all knockback. + use_knockback = not self.node.knockout > 0.0 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], + velocity_mag, msg.radius, not use_knockback, msg.force_direction[0], msg.force_direction[1], msg.force_direction[2]) damage = int(damage_scale * self.node.damage) + + # We're multiplying damage after applying knockback so that it still feels like we did a lot of damage. + # This new damage value influences particles and sound, so we gotta keep it the same. + if victim_marked: + damage /= MARKED_KNOCKBACK_SCALE + self.node.handlemessage('hurt_sound') # That's how we play spaz node's hurt sound # Play punch impact sounds based on damage if it was a punch. @@ -366,7 +553,7 @@ class PotatoPlayerSpaz(PlayerSpaz): # Throw up some chunks. assert msg.force_direction is not None - bs.emitfx(position=msg.pos, + ba.emitfx(position=msg.pos, velocity=(msg.force_direction[0] * 0.5, msg.force_direction[1] * 0.5, msg.force_direction[2] * 0.5), @@ -374,7 +561,7 @@ class PotatoPlayerSpaz(PlayerSpaz): scale=0.3, spread=0.03) - bs.emitfx(position=msg.pos, + ba.emitfx(position=msg.pos, chunk_type='sweat', velocity=(msg.force_direction[0] * 1.3, msg.force_direction[1] * 1.3 + 5.0, @@ -389,7 +576,7 @@ class PotatoPlayerSpaz(PlayerSpaz): 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 = ba.newnode( 'light', attrs={ 'position': punchpos, @@ -398,20 +585,20 @@ class PotatoPlayerSpaz(PlayerSpaz): 'height_attenuated': False, 'color': flash_color }) - bs.timer(0.06, light.delete) + ba.timer(0.06, light.delete) - flash = bs.newnode('flash', + flash = ba.newnode('flash', attrs={ 'position': punchpos, 'size': 0.17 + 0.17 * hurtiness, 'color': flash_color }) - bs.timer(0.06, flash.delete) + ba.timer(0.06, flash.delete) # Physics collision particles. if msg.hit_type == 'impact': assert msg.force_direction is not None - bs.emitfx(position=msg.pos, + ba.emitfx(position=msg.pos, velocity=(msg.force_direction[0] * 2.0, msg.force_direction[1] * 2.0, msg.force_direction[2] * 2.0), @@ -437,32 +624,52 @@ class PotatoPlayerSpaz(PlayerSpaz): # Let's get all collision data if we can. Otherwise cancel. try: - collision = bs.getcollision() + collision = ba.getcollision() opposingnode = collision.opposingnode - except bs.NotFoundError: + except ba.NotFoundError: return True + # Don't allow picking up of invincible dudes. + try: + if opposingnode.invincible: + return True + except Exception: + pass + # Our grabber needs to be a Spaz if opposingnode.getnodetype() == 'spaz': # Disallow grabbing if a healthy player tries to grab us and we're stunned. # If they're marked, continue with our scheduled program. # It's the same sound and flashing behavior as hitting a stunned player as a healthy player. - if (opposingnode.source_player.state == PlayerState.STUNNED and self.source_player.state != PlayerState.MARKED): - opposingnode.handlemessage('flash') - - SpazFactory.get().block_sound.play(1.0, position=opposingnode.position) - return True - # If they're marked and we're healthy or stunned, pass that mark along to us. - elif opposingnode.source_player.state in [PlayerState.REGULAR, PlayerState.STUNNED] and self.source_player.state == PlayerState.MARKED: - self.activity.pass_mark(self.source_player, opposingnode.source_player) + if hasattr(opposingnode.source_player, "state"): + if (opposingnode.source_player.state == PlayerState.STUNNED and self.source_player.state != PlayerState.MARKED): + opposingnode.handlemessage('flash') + SpazFactory.get().block_sound.play( + 1.0, + position=self.node.position, + ) + return True + # If they're marked and we're healthy or stunned, pass that mark along to us. + elif opposingnode.source_player.state in [PlayerState.REGULAR, PlayerState.STUNNED] and self.source_player.state == PlayerState.MARKED: + self.activity.pass_mark(self.source_player, opposingnode.source_player) # Our work is done. Continue with the rest of the grabbing behavior as usual. super().handlemessage(msg) + # Called if we touch the ground thanks to an extra material we added to the Spaz. + elif isinstance(msg, FootConnectMessage): + self._touched_ground_count += 1 + # If we are stunned but the time is at 0, only remove stun if we land. + if self.source_player: + if self._touched_ground_count > 0 and self.source_player.stunned_time_remaining <= 0.0: + self.source_player.stun_remove() + # Called if we stop touching the ground thanks to an extra material we added to the Spaz. + elif isinstance(msg, FootDisconnectMessage): + self._touched_ground_count -= 1 # Dying is important in this gamemode and as such we need to address this behavior. - elif isinstance(msg, bs.DieMessage): + elif isinstance(msg, ba.DieMessage): # If a player left the game, inform our gamemode logic. - if msg.how == bs.DeathType.LEFT_GAME: + if msg.how == ba.DeathType.LEFT_GAME: self.activity.player_left(self.source_player) # If a MARKED or STUNNED player dies, hide the text from the previous spaz. @@ -471,7 +678,7 @@ class PotatoPlayerSpaz(PlayerSpaz): self.marked_timer_text.color[1], self.marked_timer_text.color[2], 0.0) - bs.animate(self.marked_light, 'intensity', { + ba.animate(self.marked_light, 'intensity', { 0: self.marked_light.intensity, 0.5: 0.0}) @@ -484,7 +691,7 @@ class PotatoPlayerSpaz(PlayerSpaz): # A concept of a player is very useful to reference if we don't have a player character present (maybe they died). -class Player(bs.Player['Team']): +class Player(ba.Player['Team']): """Our player type for this game.""" def __init__(self) -> None: @@ -492,7 +699,7 @@ class Player(bs.Player['Team']): self.icon: Icon = None self.fall_times: int = 0 self.state: PlayerState = PlayerState.REGULAR - self.stunned_time_remaining = None + self.stunned_time_remaining = 0.0 # These are references to timers responsible for handling stunned behavior. self.stunned_timer = None self.stunned_update_timer = None @@ -509,6 +716,11 @@ class Player(bs.Player['Team']): # Let's proceed only if we're stunned if self.state != PlayerState.STUNNED: return + + # Don't remove stun if we're airborne + if self.actor._touched_ground_count <= 0: + return + # Do an explosion where we're standing. Normally it would throw us around, but we dealt # with this issue in PlayerSpaz's edited HitMessage in line 312. Blast(position=self.actor.node.position, @@ -525,19 +737,24 @@ class Player(bs.Player['Team']): # Let's remember our old state before we change it. old_state = self.state + # If we're stunned, disconnect our controls + if state == PlayerState.STUNNED: + self.actor.disconnect_controls_from_player() + # If we just became stunned, do all of this: if old_state != PlayerState.STUNNED and state == PlayerState.STUNNED: - self.actor.disconnect_controls_from_player() # Disallow all movement and actions + # Let's set our stun time based on the amount of times we fell out of the map. - if self.fall_times < len(FALL_PENALTIES): - stun_time = FALL_PENALTIES[self.fall_times] + fall_penalties_table = LENIENT_FALL_PENALTIES if self.actor.getactivity().lenient_fall_penalties else FALL_PENALTIES + if self.fall_times < len(fall_penalties_table): + stun_time = fall_penalties_table[self.fall_times] else: - stun_time = FALL_PENALTIES[len(FALL_PENALTIES) - 1] + stun_time = fall_penalties_table[len(fall_penalties_table) - 1] self.stunned_time_remaining = stun_time # Set our stun time remaining # Remove our stun once the time is up - self.stunned_timer = bs.Timer(stun_time + 0.1, babase.Call(self.stun_remove)) - self.stunned_update_timer = bs.Timer(0.1, babase.Call( + self.stunned_timer = ba.Timer(stun_time + 0.1, ba.Call(self.stun_remove)) + self.stunned_update_timer = ba.Timer(0.1, ba.Call( self.stunned_timer_tick), repeat=True) # Call a function every 0.1 seconds self.fall_times += 1 # Increase the amount of times we fell by one # Change the text above the Spaz's head to total stun time @@ -581,8 +798,7 @@ class Player(bs.Player['Team']): # ba_meta export bascenev1.GameActivity - -class HotPotato(bs.TeamGameActivity[Player, bs.Team]): +class HotPotato(ba.TeamGameActivity[Player, ba.Team]): # Let's define the basics like the name of the game, description and some tips that should appear at the start of a match. name = 'Hot Potato' @@ -596,6 +812,12 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): 'Falling can be a good escape strategy, but don\'t over rely on it.\nYou\'ll be defenseless if you respawn!', 'Stunned players are immune to healthy players, but not to Marked players!', 'Each fall when not Marked increases your time spent stunned.', + 'If two players are falling, it\'s better for the Marked player.\nIt might be worth it to get Marked just to avoid the falling penalty!', + 'If you\'re Marked and have tons of time, keep your cool and pick your targets.\nIt\'s last man that\'s standing that wins after all!', + 'Hitting knocked out players won\'t increase the knock out time.\nWait until they get up before doing it again!', + 'Grabs are instant and pass the mark.\nIf you\'re in a rush, grab the player!', + 'If you\'re Marked, go for shy and passive players.\nThey are often cornered and can\'t escape easily.', + 'Don\'t neglect your bombs! They can catch players\nthat would normally be able to get away easily!', 'Try throwing healthy players off the map to make their timers\nlonger the next time they get stunned.', 'Marked players don\'t get stunned when falling off the map.', 'For total disrespect, try throwing the Marked player off the map\nwithout getting marked yourself!', @@ -606,8 +828,8 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): # We're gonna distribute end of match session scores based on who dies first and who survives. # First place gets most points, then second, then third. - scoreconfig = bs.ScoreConfig(label='Place', - scoretype=bs.ScoreType.POINTS, + scoreconfig = ba.ScoreConfig(label='Place', + scoretype=ba.ScoreType.POINTS, lower_is_better=True) # These variables are self explanatory too. @@ -616,51 +838,84 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): # Let's define some settings the user can mess around with to fit their needs. available_settings = [ - bs.IntSetting('Elimination Timer', + ba.IntSetting('Elimination Timer', min_value=5, default=15, increment=1, ), - bs.BoolSetting('Marked Players use Impact Bombs', default=False), - bs.BoolSetting('Epic Mode', default=False), + ba.IntChoiceSetting( + 'Marked Player Bomb', + choices=[ + ('Regular', MarkedBombTypes.REGULAR.value), + ('Impact', MarkedBombTypes.IMPACT.value), + ('Sticky', MarkedBombTypes.STICKY.value), + ], + default=MarkedBombTypes.REGULAR.value, + ), + ba.BoolSetting('Lenient Fall Penalties', default=False), + ba.BoolSetting('Epic Mode', default=False), ] + # Update the gamemode's name to differentiate between different variants. + @override + @classmethod + def get_display_string(cls, settings: dict | None = None) -> babase.Lstr: + name = ba.Lstr(translate=('gameNames', cls.getname())) + if settings is not None: + + if 'Marked Player Bomb' in settings and settings['Marked Player Bomb'] == MarkedBombTypes.IMPACT.value: + name = ba.Lstr(value='Impact ${A}', + subs=[('${A}', name)]) + elif 'Marked Player Bomb' in settings and settings['Marked Player Bomb'] == MarkedBombTypes.STICKY.value: + name = ba.Lstr(value='Sticky ${A}', + subs=[('${A}', name)]) + + if 'Epic Mode' in settings and settings['Epic Mode']: + name = babase.Lstr( + resource='epicNameFilterText', subs=[('${NAME}', name)] + ) + return name + # Hot Potato is strictly a Free-For-All gamemode, so only picking the gamemode in FFA playlists. @override @classmethod - def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: - return issubclass(sessiontype, bs.FreeForAllSession) + def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool: + return issubclass(sessiontype, ba.FreeForAllSession) # Most maps should work in Hot Potato. Generally maps marked as 'melee' are the most versatile map types of them all. # As the name implies, fisticuffs are common forms of engagement. @override @classmethod - def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: - return bs.app.classic.getmaps('melee') + def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]: + assert ba.app.classic is not None + return ba.app.classic.getmaps('melee') # Here we define everything the gamemode needs, like sounds and settings. - def __init__(self, settings: dict): super().__init__(settings) self.settings = settings + # We set this to help with player spawning later. + self._next_ffa_start_index = 0 + # Let's define all of the sounds we need. - self._tick_sound = bs.getsound('tick') - self._player_eliminated_sound = bs.getsound('playerDeath') + self._tick_sound = ba.getsound('tick') + self._player_eliminated_sound = ba.getsound('playerDeath') # These next sounds are arrays instead of single sounds. # We'll use that fact later. - self._danger_tick_sounds = [bs.getsound('orchestraHit'), - bs.getsound('orchestraHit2'), - bs.getsound('orchestraHit3')] - self._marked_sounds = [bs.getsound('powerdown01'), - bs.getsound('activateBeep'), - bs.getsound('hiss')] + self._danger_tick_sounds = [ba.getsound('orchestraHit'), + ba.getsound('orchestraHit2'), + ba.getsound('orchestraHit3')] + self._marked_sounds = [ba.getsound('powerdown01'), + ba.getsound('activateBeep'), + ba.getsound('hiss')] # Normally play KOTH music, but switch to Epic music if we're in slow motion. self._epic_mode = bool(settings['Epic Mode']) + self.lenient_fall_penalties = bool(settings['Lenient Fall Penalties']) self.slow_motion = self._epic_mode - self.default_music = (bs.MusicType.EPIC if self._epic_mode else - bs.MusicType.SCARY) + self.default_music = (ba.MusicType.EPIC if self._epic_mode else + ba.MusicType.SCARY) # This description appears below the title card after it comes crashing when the game begins. def get_instance_description(self) -> str | Sequence: @@ -685,7 +940,7 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): # Returns every single marked player. # This piece of info is used excensively in this gamemode, so it's advantageous to have a function to cut on # work and make the gamemode easier to maintain - def get_marked_players(self) -> Sequence[bs.Player]: + def get_marked_players(self) -> Sequence[ba.Player]: marked_players = [] for p in self.players: if p.state == PlayerState.MARKED: @@ -696,14 +951,20 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): def mark(self, target: Player) -> None: target.set_state(PlayerState.MARKED) - bs.emitfx(position=target.actor.node.position, + ba.emitfx(position=target.actor.node.position, velocity=target.actor.node.velocity, chunk_type='spark', count=int(20.0+random.random()*20), scale=1.0, spread=1.0) - if bool(self.settings['Marked Players use Impact Bombs']): + + # Change their bomb type depending on the variant + if self.settings['Marked Player Bomb'] == MarkedBombTypes.IMPACT.value: + target.actor.blast_radius /= IMPACT_BOMB_RADIUS_SCALE # Increase blast radius of impact bombs to be on par with normal bombs target.actor.bomb_type = 'impact' + elif self.settings['Marked Player Bomb'] == MarkedBombTypes.STICKY.value: + target.actor.bomb_type = 'sticky' + target.actor.marked_timer_text.text = str(self.elimination_timer_display) # Removes the mark from the player. This restores the player to its initial state. @@ -711,6 +972,8 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): if target.state != PlayerState.MARKED: return + if self.settings['Marked Player Bomb'] == MarkedBombTypes.IMPACT.value: + target.actor.blast_radius *= IMPACT_BOMB_RADIUS_SCALE # Restore normal blast radius target.actor.bomb_type = 'normal' target.set_state(PlayerState.REGULAR) @@ -742,8 +1005,10 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): sound_volume = 1.0 / marked_player_amount for target in marked_players: - - self._tick_sound.play(sound_volume, target.actor.node.position) + self._tick_sound.play( + sound_volume, + position=target.actor.node.position, + ) target.actor.marked_timer_text.text = str(self.elimination_timer_display) # When counting down 3, 2, 1 play some dramatic sounds @@ -751,8 +1016,10 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): # We store our dramatic sounds in an array, so we target a specific element on the array # depending on time remaining. Arrays start at index 0, so we need to decrease # our variable by 1 to get the element index. - self._danger_tick_sounds[self.elimination_timer_display - 1].play(1.5) - + _tick_sound = self._danger_tick_sounds[self.elimination_timer_display - 1] + _tick_sound.play( + 1.5 + ) else: # Elimination timer is up! Let's eliminate all marked players. self.elimination_timer_display -= 1 # Decrease our timer by one second. @@ -769,18 +1036,20 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): velocity=target.actor.node.velocity, blast_radius=3.0, source_player=target).autoretain() - bs.emitfx(position=target.actor.node.position, + ba.emitfx(position=target.actor.node.position, velocity=target.actor.node.velocity, count=int(16.0+random.random()*60), scale=1.5, spread=2, chunk_type='spark') - target.actor.handlemessage(bs.DieMessage(how='marked_elimination')) + target.actor.handlemessage(ba.DieMessage(how='marked_elimination')) target.actor.shatter(extreme=True) self.match_placement.append(target.team) - self._player_eliminated_sound.play(1.0) + self._player_eliminated_sound.play( + 1.0 + ) # Let the gamemode know a Marked self.marked_players_died() @@ -794,14 +1063,13 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): # Let's add our lone survivor to the match placement list. self.match_placement.append(alive_players[0].team) # Wait a while to let this sink in before we announce our victor. - self._end_game_timer = bs.Timer(1.25, babase.Call(self.end_game)) + self._end_game_timer = ba.Timer(1.25, ba.Call(self.end_game)) else: # There's still players remaining, so let's wait a while before marking a new player. - self.new_mark_timer = bs.Timer( - 2.0 if self.slow_motion else 4.0, babase.Call(self.new_mark)) + self.new_mark_timer = ba.Timer(2.0 if self.slow_motion else 4.0, ba.Call(self.new_mark)) # Another extensively used function that returns all alive players. - def get_alive_players(self) -> Sequence[bs.Player]: + def get_alive_players(self) -> Sequence[ba.Player]: alive_players = [] for player in self.players: if player.state == PlayerState.ELIMINATED: @@ -812,6 +1080,7 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): # This function is called every time we want to start a new "round" by marking a random player. def new_mark(self) -> None: + #return # Don't mark a new player if we've already announced a victor. if self.has_ended(): @@ -836,14 +1105,17 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): # Set time until marked players explode self.elimination_timer_display = self.settings['Elimination Timer'] # Set a timer that calls _eliminate_tick every second - self.marked_tick_timer = bs.Timer(1.0, babase.Call(self._eliminate_tick), repeat=True) + self.marked_tick_timer = ba.Timer(1.0, ba.Call(self._eliminate_tick), repeat=True) # Mark all chosen victims and play a sound for new_victim in all_victims: # _marked_sounds is an array. # To make a nice marked sound effect, I play multiple sounds at once # All of them are contained in the array. for sound in self._marked_sounds: - bs.Sound.play(sound, 1.0, new_victim.actor.node.position) + sound.play( + 1.0, + position=new_victim.actor.node.position, + ) self.mark(new_victim) # This function is called when the gamemode first loads. @@ -857,11 +1129,10 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): # End the game if there's only one player if len(self.players) < 2: self.match_placement.append(self.players[0].team) - self._round_end_timer = bs.Timer(0.5, self.end_game) + self._round_end_timer = ba.Timer(0.5, self.end_game) else: # Pick random player(s) to get marked - self.new_mark_timer = bs.Timer( - 2.0 if self.slow_motion else 5.2, babase.Call(self.new_mark)) + self.new_mark_timer = ba.Timer(2.0 if self.slow_motion else 5.2, ba.Call(self.new_mark)) self._update_icons() # Create player state icons @@ -891,8 +1162,8 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): tip = self.tips.pop(random.randrange(len(self.tips))) tip_title = Lstr(value='${A}:', subs=[('${A}', Lstr(resource='tipText'))]) - icon: babase.Texture | None = None - sound: babase.Sound | None = None + icon: ba.Texture | None = None + sound: ba.Sound | None = None if isinstance(tip, GameTip): icon = tip.icon sound = tip.sound @@ -906,11 +1177,11 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): base_position = (75, 50) tip_scale = 0.8 tip_title_scale = 1.2 - vrmode = babase.app.env.vr # ba.app.vr_mode + vrmode = babase.app.env.vr #ba.app.vr_mode t_offs = -350.0 height_offs = 100.0 - tnode = bs.newnode('text', + tnode = ba.newnode('text', attrs={ 'text': tip_lstr, 'scale': tip_scale, @@ -926,7 +1197,7 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): }) t2pos = (base_position[0] + t_offs - (20 if icon is None else 82), base_position[1] + 2 + height_offs) - t2node = bs.newnode('text', + t2node = ba.newnode('text', owner=tnode, attrs={ 'text': tip_title, @@ -942,7 +1213,7 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): }) if icon is not None: ipos = (base_position[0] + t_offs - 40, base_position[1] + 1 + height_offs) - img = bs.newnode('image', + img = ba.newnode('image', attrs={ 'texture': icon, 'position': ipos, @@ -954,11 +1225,11 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): 'attach': 'bottomCenter' }) animate(img, 'opacity', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0}) - bs.timer(5.0, img.delete) + ba.timer(5.0, img.delete) if sound is not None: sound.play() - combine = bs.newnode('combine', + combine = ba.newnode('combine', owner=tnode, attrs={ 'input0': 1.0, @@ -969,7 +1240,7 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): combine.connectattr('output', tnode, 'color') combine.connectattr('output', t2node, 'color') animate(combine, 'input3', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0}) - bs.timer(5.0, tnode.delete) + ba.timer(5.0, tnode.delete) # This function is called when a player leaves the game. # This is only called when the player already joined with a character. @@ -994,47 +1265,109 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): player.set_state(PlayerState.ELIMINATED) + def get_spawn_position(self, respawning_player: Player) -> ba.Vec3: + + # Get all spawn points + spawn_points = self.map.ffa_spawn_points + + # Calculate the average distance of each spawn point to all alive players + # Make a copy of the self.players array + players_to_count = self.players.copy() + if respawning_player and respawning_player in players_to_count: + players_to_count.remove(respawning_player) + + # Use default FFA spawning if there's no players or more than 4 + player_pts = [player.position for player in players_to_count if player.is_alive()] + if len(player_pts) <= 0 or len(player_pts) > 4: + return self.map.get_ffa_start_position(self.players) + + target_distance = 4.0 # This distance seems to be the sweet spot + best_point = None + best_distance_diff = float('inf') + + def _getpt() -> Sequence[float]: + point = self.map.ffa_spawn_points[self._next_ffa_start_index] + self._next_ffa_start_index = (self._next_ffa_start_index + 1) % len( + self.map.ffa_spawn_points + ) + x_range = (-0.5, 0.5) if point[3] == 0.0 else (-point[3], point[3]) + z_range = (-0.5, 0.5) if point[5] == 0.0 else (-point[5], point[5]) + point = ( + point[0] + random.uniform(*x_range), + point[1], + point[2] + random.uniform(*z_range), + ) + return point + + # Try a buncha times to find a good point that's not too far from every player + for _i in range(10): + for box in self.map.ffa_spawn_points: + point = _getpt() + avg_distance = sum((player_pt - ba.Vec3(point[0], point[1], point[2])).length() for player_pt in player_pts) / len(player_pts) + distance_diff = abs(avg_distance - target_distance) + if distance_diff < best_distance_diff: + best_distance_diff = distance_diff + best_point = point + + # Go through all players and return distance to the closest player to the best point + closest_distance = float('inf') + for i in player_pts: + distance = (i - ba.Vec3(best_point[0], best_point[1], best_point[2])).length() + if distance < closest_distance: + closest_distance = distance + + # If we're too close, we give up and just do a regular FFA spawn + if closest_distance < 2.0: + return self.map.get_ffa_start_position(self.players) + + return best_point + # This function is called every time a player spawns @override def spawn_player(self, player: Player) -> ba.Actor: - position = self.map.get_ffa_start_position(self.players) + position = self.get_spawn_position(player) position = (position[0], position[1] - 0.3, # Move the spawn a bit lower position[2]) name = player.getname() - light_color = babase.normalized_color(player.color) - display_color = babase.safecolor(player.color, target_intensity=0.75) + light_color = ba.normalized_color(player.color) + display_color = ba.safecolor(player.color, target_intensity=0.75) # Here we actually crate the player character spaz = PotatoPlayerSpaz(color=player.color, highlight=player.highlight, character=player.character, player=player) - spaz.node.invincible = False # Immediately turn off invincibility + player.actor = spaz # Assign player character to the owner spaz.node.name = name spaz.node.name_color = display_color - spaz.connect_controls_to_player() + + if player.state != PlayerState.STUNNED: + spaz.connect_controls_to_player() # Move to the stand position and add a flash of light - spaz.handlemessage(bs.StandMessage(position, random.uniform(0, 360))) - t = bs.time() - self._spawn_sound.play(1.0, position=spaz.node.position) - light = bs.newnode('light', attrs={'color': light_color}) + spaz.handlemessage(ba.StandMessage(position, random.uniform(0, 360))) + t = ba.time() + self._spawn_sound.play( + 1.0, + position=spaz.node.position, + ) + light = ba.newnode('light', attrs={'color': light_color}) spaz.node.connectattr('position', light, 'position') - bs.animate(light, 'intensity', {0: 0, + ba.animate(light, 'intensity', {0: 0, 0.25: 1, 0.5: 0}) - bs.timer(0.5, light.delete) + ba.timer(0.5, light.delete) # Game reacts to various events @override def handlemessage(self, msg: Any) -> Any: # This is called if the player dies. - if isinstance(msg, bs.PlayerDiedMessage): + if isinstance(msg, ba.PlayerDiedMessage): super().handlemessage(msg) player = msg.getplayer(Player) @@ -1053,13 +1386,13 @@ class HotPotato(bs.TeamGameActivity[Player, bs.Team]): if player.state == PlayerState.MARKED: self.mark(player) - # This is called when we want to end the game and announce a victor + # This is called when we want to end the game and announce the winner @override def end_game(self) -> None: # Proceed only if the game hasn't ended yet. if self.has_ended(): return - results = bs.GameResults() + results = ba.GameResults() # By this point our match placement list should be filled with all players. # Players that died/left earliest should be the first entries. # We're gonna use array indexes to decide match placements.