diff --git a/plugins/minigames/hot_potato.py b/plugins/minigames/hot_potato.py new file mode 100644 index 0000000..e2af100 --- /dev/null +++ b/plugins/minigames/hot_potato.py @@ -0,0 +1,1017 @@ +""" + + Hot Potato by TheMikirog#1984 + +""" + +# ba_meta require api 7 + +from __future__ import annotations + +from typing import TYPE_CHECKING + +# Define only what we need and nothing more +import ba +from bastd.actor.spaz import SpazFactory +from bastd.actor.spaz import PickupMessage +from bastd.actor.spaz import BombDiedMessage +from bastd.actor.playerspaz import PlayerSpaz +from bastd.actor.bomb import Bomb +from bastd.actor.bomb import Blast +from enum import Enum +import random + +if TYPE_CHECKING: + pass + +# Let's define stun times for falling. +# First element is stun for the first fall, second element is stun for the second fall and so on. +# If we fall more than the amount of elements on this list, we'll use the last entry. +FALL_PENALTIES = [1.5, + 2.5, + 3.5, + 5.0, + 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) + + +# The player in Hot Potato can be in one of these states: +class PlayerState(Enum): + # REGULAR - the state all players start in. + REGULAR = 0 + # MARKED - when a player is marked, they'll be eliminated when the timer hits zero. + # Marked players pass the mark to REGULAR or STUNNED players by harming or grabbing other players. + # MARKED players respawn instantly if they somehow get knocked off the map. + MARKED = 1 + # ELIMINATED - a player is eliminated if the timer runs out during the MARKED state or they leave the game. + # These players can't win and won't respawn. + ELIMINATED = 2 + # STUNNED - if a REGULAR player falls out of the map, they'll receive the STUNNED state. + # STUNNED players are incapable of all movement and actions. + # STUNNED players can still get MARKED, but can't be punched, grabbed or knocked around by REGULAR players. + # STUNNED players will go back to the REGULAR state after several seconds. + # 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 + +# 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(ba.Actor): + """Creates in in-game icon on screen.""" + + def __init__(self, + player: Player, + position: tuple[float, float], + scale: float, + name_scale: float = 1.0, + name_maxwidth: float = 100.0, + shadow: float = 1.0): + super().__init__() + + # Define the player this icon belongs to + self._player = player + self._name_scale = name_scale + + self._outline_tex = ba.gettexture('characterIconMask') + + # Character portrait + icon = player.get_icon() + self.node = ba.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' + }) + # Player name + self._name_text = ba.newnode( + 'text', + owner=self.node, + attrs={ + 'text': ba.Lstr(value=player.getname()), + 'color': ba.safecolor(player.team.color), + 'h_align': 'center', + 'v_align': 'center', + 'vr_depth': 410, + 'maxwidth': name_maxwidth, + 'shadow': shadow, + 'flatness': 1.0, + 'h_attach': 'center', + 'v_attach': 'bottom' + }) + # Status text (such as Marked!, Stunned! and You're Out!) + self._marked_text = ba.newnode( + 'text', + owner=self.node, + attrs={ + 'text': '', + 'color': (1, 0.1, 0.0), + 'h_align': 'center', + 'v_align': 'center', + 'vr_depth': 430, + 'shadow': 1.0, + 'flatness': 1.0, + 'h_attach': 'center', + 'v_attach': 'bottom' + }) + # Status icon overlaying the character portrait + self._marked_icon = ba.newnode( + 'text', + owner=self.node, + attrs={ + 'text': ba.charstr(ba.SpecialChar.HAL), + 'color': (1, 1, 1), + 'h_align': 'center', + 'v_align': 'center', + 'vr_depth': 430, + 'shadow': 0.0, + 'opacity': 0.0, + 'flatness': 1.0, + 'scale': 2.1, + 'h_attach': 'center', + 'v_attach': 'bottom' + }) + self.set_marked_icon(player.state) + self.set_position_and_scale(position, scale) + + # Change our icon's appearance depending on the player state. + def set_marked_icon(self, type: PlayerState) -> None: + pos = self.node.position + # Regular players get no icons or status text + if type is PlayerState.REGULAR: + self._marked_icon.text = '' + self._marked_text.text = '' + self._marked_icon.opacity = 0.0 + self._name_text.flatness = 1.0 + assert self.node + self.node.color = (1.0, 1.0, 1.0) + # Marked players get ALL of the attention - red portrait, red text and icon overlaying the portrait + elif type is PlayerState.MARKED: + self._marked_icon.text = ba.charstr(ba.SpecialChar.HAL) + self._marked_icon.position = (pos[0] - 1, pos[1] - 13) + self._marked_icon.opacity = 1.0 + self._marked_text.text = 'Marked!' + self._marked_text.color = (1.0, 0.0, 0.0) + self._name_text.flatness = 0.0 + assert self.node + self.node.color = (1.0, 0.2, 0.2) + # Stunned players are just as important - yellow portrait, yellow text and moon icon. + elif type is PlayerState.STUNNED: + self._marked_icon.text = ba.charstr(ba.SpecialChar.MOON) + self._marked_icon.position = (pos[0] - 2, pos[1] - 12) + self._marked_icon.opacity = 1.0 + self._marked_text.text = 'Stunned!' + self._marked_text.color = (1.0, 1.0, 0.0) + assert self.node + self.node.color = (0.75, 0.75, 0.0) + # Eliminated players get special treatment. + # We make the portrait semi-transparent, while adding some visual flair with an fading skull icon and text. + elif type is PlayerState.ELIMINATED: + self._marked_icon.text = ba.charstr(ba.SpecialChar.SKULL) + self._marked_icon.position = (pos[0] - 2, pos[1] - 12) + self._marked_text.text = 'You\'re Out!' + self._marked_text.color = (0.5, 0.5, 0.5) + + # Animate text and icon + animation_end_time = 1.5 if bool(self.activity.settings['Epic Mode']) else 3.0 + ba.animate(self._marked_icon,'opacity', { + 0: 1.0, + animation_end_time: 0.0}) + ba.animate(self._marked_text,'opacity', { + 0: 1.0, + animation_end_time: 0.0}) + + self._name_text.opacity = 0.2 + assert self.node + self.node.color = (0.7, 0.3, 0.3) + self.node.opacity = 0.2 + else: + # If we beef something up, let the game know we made a mess in the code by providing a non-existant state. + raise Exception("invalid PlayerState type") + + # Set where our icon is positioned on the screen and how big it is. + 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 + self._marked_text.position = (position[0], position[1] - scale * 52.0) + self._marked_text.scale = 0.8 * scale + +# 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. +class PotatoPlayerSpaz(PlayerSpaz): + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) # unchanged Spaz __init__ code goes here + self.dropped_bombs = [] # we use this to track bombs thrown by the player + + # Define a marked light + self.marked_light = ba.newnode('light', + owner=self.node, + attrs={'position':self.node.position, + 'radius':0.15, + 'intensity':0.0, + 'height_attenuated':False, + 'color': (1.0, 0.0, 0.0)}) + + # Pulsing red light when the player is Marked + ba.animate(self.marked_light,'radius',{ + 0: 0.1, + 0.3: 0.15, + 0.6: 0.1}, + loop = True) + 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 = 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 = ba.newnode('text', owner = self.node, attrs = { + 'text': '', + 'in_world': True, + 'shadow': 0.4, + 'color': (RED_COLOR[0], RED_COLOR[1], RED_COLOR[2], 0.0), + 'flatness': 0, + 'scale': 0.02, + 'h_align': 'center'}) + self.marked_timer_offset.connectattr('output', self.marked_timer_text, 'position') + + # 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() + # Let's make sure the player actually created a new bomb + if bomb: + # Add our bomb to the list of our tracked bombs + self.dropped_bombs.append(bomb) + # Bring a light + bomb.bomb_marked_light = ba.newnode('light', + owner=bomb.node, + attrs={'position':bomb.node.position, + 'radius':0.04, + 'intensity':0.0, + 'height_attenuated':False, + 'color': (1.0, 0.0, 0.0)}) + # Attach the light to the bomb + bomb.node.connectattr('position',bomb.bomb_marked_light,'position') + # Let's adjust all lights for all bombs that we own. + self.set_bombs_marked() + # When the bomb physics node dies, call a function. + bomb.node.add_death_action( + 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. + def bomb_died(self, bomb): + self.dropped_bombs.remove(bomb) + + # Go through all the bombs this player has in the world. + # Paint them red if the owner is marked, turn off the light otherwise. + # We need this light to inform the player about bombs YOU DON'T want to get hit by. + def set_bombs_marked(self): + 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, 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. + # 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 the attacker is marked, pass that mark to us. + self.activity.pass_mark(msg._source_player, self._player) + + # When stun timer runs out, we explode. Let's make sure our own explosion does throw us around. + if msg.hit_type == 'stun_blast' and msg._source_player == self.source_player: return True + # If the attacker is healthy and we're stunned, do a flash and play a sound, then ignore the rest of the code. + if self.source_player.state == PlayerState.STUNNED and msg._source_player != PlayerState.MARKED: + self.node.handlemessage('flash') + ba.playsound(SpazFactory.get().block_sound, + 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 + + # 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. + 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') # That's how we play spaz node's hurt sound + + # Play punch impact sounds based on damage if it was a punch. + # We don't show damage percentages, because it's irrelevant. + if msg.hit_type == 'punch': + self.on_punched(damage) + + if damage >= 500: + sounds = SpazFactory.get().punch_sound_strong + sound = sounds[random.randrange(len(sounds))] + elif damage >= 100: + sound = SpazFactory.get().punch_sound + else: + sound = SpazFactory.get().punch_sound_weak + ba.playsound(sound, 1.0, position=self.node.position) + + # Throw up some chunks. + assert msg.force_direction is not None + ba.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) + + ba.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. This spawns around where the Spaz's punch would be (we're kind of guessing here). + 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 = ba.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 + }) + ba.timer(0.06, light.delete) + + flash = ba.newnode('flash', + attrs={ + 'position': punchpos, + 'size': 0.17 + 0.17 * hurtiness, + 'color': flash_color + }) + ba.timer(0.06, flash.delete) + + # Physics collision particles. + if msg.hit_type == 'impact': + assert msg.force_direction is not None + ba.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) + + # Briefly flash when hit. + # We shouldn't play this if we're dead. + if self.hitpoints > 0: + + 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 + # If we get grabbed, this function is called. + # We want to pass along the mark with grabs too. + elif isinstance(msg, PickupMessage): + # Make sure our body exists. + if not self.node: + return None + + # Let's get all collision data if we can. Otherwise cancel. + try: + collision = ba.getcollision() + opposingnode = collision.opposingnode + except ba.NotFoundError: + return True + + # 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') + ba.playsound(SpazFactory.get().block_sound, + 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) + + # Our work is done. Continue with the rest of the grabbing behavior as usual. + super().handlemessage(msg) + # Dying is important in this gamemode and as such we need to address this behavior. + elif isinstance(msg, ba.DieMessage): + + # If a player left the game, inform our gamemode logic. + 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. + if self.source_player.state in [PlayerState.MARKED, PlayerState.STUNNED]: + self.marked_timer_text.color = (self.marked_timer_text.color[0], + self.marked_timer_text.color[1], + self.marked_timer_text.color[2], + 0.0) + ba.animate(self.marked_light,'intensity',{ + 0: self.marked_light.intensity, + 0.5: 0.0}) + + # Continue with the rest of the behavior. + super().handlemessage(msg) + # If a message is something we haven't modified yet, let's pass it along to the original. + else: super().handlemessage(msg) + +# A concept of a player is very useful to reference if we don't have a player character present (maybe they died). +class Player(ba.Player['Team']): + """Our player type for this game.""" + + def __init__(self) -> None: + # Most of these are self explanatory. + self.icon: Icon = None + self.fall_times: int = 0 + self.state: PlayerState = PlayerState.REGULAR + self.stunned_time_remaining = None + # These are references to timers responsible for handling stunned behavior. + self.stunned_timer = None + self.stunned_update_timer = None + + # If we're stunned, a timer calls this every 0.1 seconds. + def stunned_timer_tick(self) -> None: + # Decrease our time remaining then change the text displayed above the Spaz's head + self.stunned_time_remaining -= 0.1 + self.stunned_time_remaining = max(0.0, self.stunned_time_remaining) + self.actor.marked_timer_text.text = str(round(self.stunned_time_remaining, 2)) + + # When stun time is up, call this function. + def stun_remove(self) -> None: + # Let's proceed only if we're stunned + if self.state != PlayerState.STUNNED: 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, + velocity=self.actor.node.velocity, + blast_radius=2.5, + hit_type='stun_blast', # This hit type allows us to ignore our own stun blast explosions. + source_player=self).autoretain() + # Let's switch our state back to healthy. + self.set_state(PlayerState.REGULAR) + + # States are a key part of this gamemode and a lot of logic has to be done to acknowledge these state changes. + def set_state(self, state: PlayerState) -> None: + # Let's remember our old state before we change it. + old_state = self.state + + # 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] + else: + stun_time = FALL_PENALTIES[len(FALL_PENALTIES) - 1] + + self.stunned_time_remaining = stun_time # Set our stun time remaining + self.stunned_timer = ba.Timer(stun_time + 0.1, ba.Call(self.stun_remove)) # Remove our stun once the time is up + 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 + self.actor.marked_timer_text.text = str(stun_time) # Change the text above the Spaz's head to total stun time + + # If we were stunned, but now we're not, let's reconnect our controls. + # CODING CHALLENGE: to punch or bomb immediately after the stun ends, you need to + # time the button press frame-perfectly in order for it to work. + # What if we could press the button shortly before stun ends to do the action as soon as possible? + # If you're feeling up to the challenge, feel free to implement that! + if old_state == PlayerState.STUNNED and state != PlayerState.STUNNED: + self.actor.connect_controls_to_player() + + # When setting a state that is not STUNNED, clear all timers. + if state != PlayerState.STUNNED: + self.stunned_timer = None + self.stunned_update_timer = None + + # Here's all the light and text colors that we set depending on the state. + if state == PlayerState.MARKED: + self.actor.marked_light.intensity = 1.5 + self.actor.marked_light.color = (1.0, 0.0, 0.0) + self.actor.marked_timer_text.color = (RED_COLOR[0], + RED_COLOR[1], + RED_COLOR[2], + 1.0) + elif state == PlayerState.STUNNED: + self.actor.marked_light.intensity = 0.5 + self.actor.marked_light.color = (1.0, 1.0, 0.0) + self.actor.marked_timer_text.color = (YELLOW_COLOR[0], + YELLOW_COLOR[1], + YELLOW_COLOR[2], + 1.0) + else: + self.actor.marked_light.intensity = 0.0 + self.actor.marked_timer_text.text = '' + + self.state = state + self.actor.set_bombs_marked() # Light our bombs red if we're Marked, removes the light otherwise + self.icon.set_marked_icon(state) # Update our icon + + +# ba_meta export game +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' + description = ('A random player gets marked.\n' + 'Pass the mark to other players.\n' + 'Marked player gets eliminated when time runs out.\n' + 'Last one standing wins!') + tips = [ + 'You can pass the mark not only with punches and grabs, but bombs as well.', + 'If you\'re not marked, DON\'T fall off the map!\nEach fall will be punished with immobility.', + '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.', + '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!', + 'Feeling evil? Throw healthy players towards the Marked player!', + 'Red bombs belong to the Marked player!\nWatch out for those!', + 'Stunned players explode when their stun timer runs out.\nIf that time is close to zero, keep your distance!' + ] + + # 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 = ba.ScoreConfig(label='Place', + scoretype=ba.ScoreType.POINTS, + lower_is_better=True) + + # These variables are self explanatory too. + show_kill_points = False + allow_mid_activity_joins = False + + # Let's define some settings the user can mess around with to fit their needs. + available_settings = [ + ba.IntSetting('Elimination Timer', + min_value=5, + default=15, + increment=1, + ), + ba.BoolSetting('Marked Players use Impact Bombs', default=False), + ba.BoolSetting('Epic Mode', default=False), + ] + + # Hot Potato is strictly a Free-For-All gamemode, so only picking the gamemode in FFA playlists. + @classmethod + 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. + @classmethod + def get_supported_maps(cls, sessiontype: type[ba.Session]) -> list[str]: + return ba.getmaps('melee') + + # Here we define everything the gamemode needs, like sounds and settings. + def __init__(self, settings: dict): + super().__init__(settings) + self.settings = settings + + # Let's define all of the sounds we need. + 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 = [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.slow_motion = self._epic_mode + 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: + return 'Pass the mark to someone else before you explode!' + + # This is the tiny text that is displayed in the corner during the game as a quick reminder of the objective. + def get_instance_description_short(self) -> str | Sequence: + return 'pass the mark' + + # Set up our player every time they join. + # Because you can't join mid-match, this will always be called at the beginning of the game. + def on_player_join(self, player: Player) -> None: + + player.state = PlayerState.REGULAR + player.fall_times = 0 + + # Create our icon and spawn. + if not self.has_begun(): + player.icon = Icon(player, position=(0, 50), scale=0.8) + self.spawn_player(player) + + # 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[ba.Player]: + marked_players = [] + for p in self.players: + if p.state == PlayerState.MARKED: + marked_players.append(p) + return marked_players + + # Marks a player. This sets their state, spawns some particles and sets the timer text above their heads. + def mark(self, target: Player) -> None: + target.set_state(PlayerState.MARKED) + + 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']): + target.actor.bomb_type = 'impact' + 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. + def remove_mark(self, target: Player) -> None: + if target.state != PlayerState.MARKED: + return + + target.actor.bomb_type = 'normal' + + target.set_state(PlayerState.REGULAR) + target.actor.marked_timer_text.text = '' + + # Pass the mark from one player to another. + # This is more desirable than calling mark and remove_mark functions constantly and gives us + # more control over the mark spreading mechanic. + def pass_mark(self, marked_player: Player, hit_player: Player) -> None: + # Make sure both players meet the requirements + if not marked_player or not hit_player: return + if marked_player.state == PlayerState.MARKED and hit_player.state != PlayerState.MARKED: + self.mark(hit_player) + self.remove_mark(marked_player) + + # This function is called every second a marked player exists. + def _eliminate_tick(self) -> None: + marked_players = self.get_marked_players() + marked_player_amount = len(marked_players) + + # If there is no marked players, raise an exception. + # This is used for debugging purposes, which lets us know we messed up somewhere else in the code. + if len(self.get_marked_players()) == 0: + raise Exception("no marked players!") + + self.elimination_timer_display -= 1 # Decrease our timer by one second. + if self.elimination_timer_display > 1: + sound_volume = 1.0 / marked_player_amount + + for target in marked_players: + ba.playsound(self._tick_sound, sound_volume, 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 + if self.elimination_timer_display <= 3: + # 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. + ba.playsound(self._danger_tick_sounds[self.elimination_timer_display - 1], 1.5) + else: + # Elimination timer is up! Let's eliminate all marked players. + self._eliminate_marked_players() + + # This function explodes all marked players + def _eliminate_marked_players(self) -> None: + self.marked_tick_timer = None + for target in self.get_marked_players(): + target.set_state(PlayerState.ELIMINATED) + target.actor.marked_timer_text.text = '' + + Blast(position=target.actor.node.position, + velocity=target.actor.node.velocity, + blast_radius=3.0, + source_player=target).autoretain() + 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(ba.DieMessage(how='marked_elimination')) + target.actor.shatter(extreme=True) + + self.match_placement.append(target.team) + + ba.playsound(self._player_eliminated_sound, 1.0) + + # Let the gamemode know a Marked + self.marked_players_died() + + # This function should be called when a Marked player dies, like when timer runs out or they leave the game. + def marked_players_died(self) -> bool: + alive_players = self.get_alive_players() + # Is there only one player remaining? Or none at all? Let's end the gamemode + if len(alive_players) < 2: + if len(alive_players) == 1: + self.match_placement.append(alive_players[0].team) # Let's add our lone survivor to the match placement list. + # Wait a while to let this sink in before we announce our victor. + 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 = 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[ba.Player]: + alive_players = [] + for player in self.players: + if player.state == PlayerState.ELIMINATED: continue # Ignore players who have been eliminated + if player.is_alive(): + alive_players.append(player) + return alive_players + + # This function is called every time we want to start a new "round" by marking a random player. + def new_mark(self) -> None: + + # Don't mark a new player if we've already announced a victor. + if self.has_ended(): + return + + possible_targets = self.get_alive_players() + all_victims = [] + # Let's mark TWO players at once if there's 6 or more players. Helps with the pacing. + multi_choice = len(possible_targets) > 5 + + if multi_choice: + # Pick our first victim at random. + first_victim = random.choice(possible_targets) + all_victims.append(first_victim) + possible_targets.remove(first_victim) + # Let's pick our second victim, but this time excluding the player we picked earlier. + all_victims.append(random.choice(possible_targets)) + else: + # Pick one victim at random. + all_victims = [random.choice(possible_targets)] + + self.elimination_timer_display = self.settings['Elimination Timer'] # Set time until marked players explode + self.marked_tick_timer = ba.Timer(1.0, ba.Call(self._eliminate_tick), repeat=True) # Set a timer that calls _eliminate_tick every second + # 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: + ba.playsound(sound, 1.0, new_victim.actor.node.position) + self.mark(new_victim) + + # This function is called the gamemode first loads. + def on_begin(self) -> None: + super().on_begin() # Do standard gamemode on_begin behavior + + self.elimination_timer_display = 0 + self.match_placement = [] + + # 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 = ba.Timer(0.5, self.end_game) + else: + # Pick a random player(s) to get marked + 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 + + # This function creates and positions player state icons + def _update_icons(self): + count = len(self.teams) + x_offs = 100 + xval = x_offs * (count - 1) * -0.5 + # FUN FACT: In FFA games, every player belongs to a one-player team. + for team in self.teams: + if len(team.players) == 1: + player = team.players[0] + player.icon.set_position_and_scale((xval, 50), 0.8) + xval += x_offs + + # Hot Potato can be a bit much, so I opted to show gameplay tips at the start of the match. + # However because I put player state icons, the tips overlay the icons. + # I'm gonna modify this function to move the tip text above the icons. + def _show_tip(self) -> None: + + from ba._gameutils import animate, GameTip + from ba._generated.enums import SpecialChar + from ba._language import Lstr + + # If there's any tips left on the list, display one. + if self.tips: + tip = self.tips.pop(random.randrange(len(self.tips))) + tip_title = Lstr(value='${A}:', + subs=[('${A}', Lstr(resource='tipText'))]) + icon: ba.Texture | None = None + sound: ba.Sound | None = None + if isinstance(tip, GameTip): + icon = tip.icon + sound = tip.sound + tip = tip.text + assert isinstance(tip, str) + + # Do a few substitutions. + tip_lstr = Lstr(translate=('tips', tip), + subs=[('${PICKUP}', + ba.charstr(SpecialChar.TOP_BUTTON))]) + base_position = (75, 50) + tip_scale = 0.8 + tip_title_scale = 1.2 + vrmode = ba.app.vr_mode + + t_offs = -350.0 + height_offs = 100.0 + tnode = ba.newnode('text', + attrs={ + 'text': tip_lstr, + 'scale': tip_scale, + 'maxwidth': 900, + 'position': (base_position[0] + t_offs, + base_position[1] + height_offs), + 'h_align': 'left', + 'vr_depth': 300, + 'shadow': 1.0 if vrmode else 0.5, + 'flatness': 1.0 if vrmode else 0.5, + 'v_align': 'center', + 'v_attach': 'bottom' + }) + t2pos = (base_position[0] + t_offs - (20 if icon is None else 82), + base_position[1] + 2 + height_offs) + t2node = ba.newnode('text', + owner=tnode, + attrs={ + 'text': tip_title, + 'scale': tip_title_scale, + 'position': t2pos, + 'h_align': 'right', + 'vr_depth': 300, + 'shadow': 1.0 if vrmode else 0.5, + 'flatness': 1.0 if vrmode else 0.5, + 'maxwidth': 140, + 'v_align': 'center', + 'v_attach': 'bottom' + }) + if icon is not None: + ipos = (base_position[0] + t_offs - 40, base_position[1] + 1 + height_offs) + img = ba.newnode('image', + attrs={ + 'texture': icon, + 'position': ipos, + 'scale': (50, 50), + 'opacity': 1.0, + 'vr_depth': 315, + 'color': (1, 1, 1), + 'absolute_scale': True, + 'attach': 'bottomCenter' + }) + animate(img, 'opacity', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0}) + ba.timer(5.0, img.delete) + if sound is not None: + ba.playsound(sound) + + combine = ba.newnode('combine', + owner=tnode, + attrs={ + 'input0': 1.0, + 'input1': 0.8, + 'input2': 1.0, + 'size': 4 + }) + combine.connectattr('output', tnode, 'color') + combine.connectattr('output', t2node, 'color') + animate(combine, 'input3', {0: 0, 1.0: 1, 4.0: 1, 5.0: 0}) + 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. + def player_left(self, player: Player) -> None: + # If the leaving player is marked, remove the mark + if player.state == PlayerState.MARKED: + self.remove_mark(player) + + # If the leaving player is stunned, remove all stun timers + elif player.state == PlayerState.STUNNED: + player.stunned_timer = None + player.stunned_update_timer = None + + if len(self.get_marked_players()) == len(self.get_alive_players()): + for i in self.get_marked_players(): + self.remove_mark(i) + + if len(self.get_marked_players()) == 0: + self.marked_tick_timer = None + self.marked_players_died() + + player.set_state(PlayerState.ELIMINATED) + + def spawn_player(self, player: Player) -> ba.Actor: + position = self.map.get_ffa_start_position(self.players) + position = (position[0], + position[1] - 0.3, + position[2]) + + name = player.getname() + + light_color = ba.normalized_color(player.color) + display_color = ba.safecolor(player.color, target_intensity=0.75) + + spaz = PotatoPlayerSpaz(color=player.color, + highlight=player.highlight, + character=player.character, + player=player) + spaz.node.invincible = False + player.actor = spaz + + 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(ba.StandMessage(position, random.uniform(0, 360))) + t = ba.time(ba.TimeType.BASE) + ba.playsound(self._spawn_sound, 1.0, position=spaz.node.position) + light = ba.newnode('light', attrs={'color': light_color}) + spaz.node.connectattr('position', light, 'position') + ba.animate(light, 'intensity', {0: 0, + 0.25: 1, + 0.5: 0}) + ba.timer(0.5, light.delete) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, ba.PlayerDiedMessage): + super().handlemessage(msg) + player = msg.getplayer(Player) + + # If a player gets eliminated, don't respawn + if msg.how == 'marked_elimination': return + + self.spawn_player(player) + + # If a REGULAR player dies, they respawn STUNNED. + # If a STUNNED player dies, reapply all visual effects. + if player.state in [PlayerState.REGULAR, PlayerState.STUNNED]: + player.set_state(PlayerState.STUNNED) + + # If a MARKED player falls off the map, apply the MARKED effects on the new spaz that respawns. + if player.state == PlayerState.MARKED: + self.mark(player) + + def end_game(self) -> None: + if self.has_ended(): + return + results = ba.GameResults() + self.match_placement.reverse() + for team in self.teams: + results.set_team_score(team, self.match_placement.index(team) + 1) + self.end(results=results)