diff --git a/plugins/minigames/Collector.py b/plugins/minigames/Collector.py new file mode 100644 index 0000000..da4b416 --- /dev/null +++ b/plugins/minigames/Collector.py @@ -0,0 +1,636 @@ +# ba_meta require api 8 +# (see https://ballistica.net/wiki/meta-tag-system) + +''' + Gamemode: Collector + Creator: TheMikirog + Website: https://bombsquadjoyride.blogspot.com/ + + This is a gamemode purely made by me just to spite unchallenged modders + out there that put out crap to the market. + We don't want gamemodes that are just the existing ones + with some novelties! Gamers deserve more! + + In this gamemode you have to kill others in order to get their Capsules. + Capsules can be collected and staked in your inventory, + how many as you please. + After you kill an enemy that carries some of them, + they drop a respective amount of Capsules they carried + two more. + Your task is to collect these Capsules, + get to the flag and score them KOTH style. + You can't score if you don't have any Capsules with you. + The first player or team to get to the required ammount wins. + This is a gamemode all about trying to stay alive + and picking your battles in order to win. + A rare skill in BombSquad, where everyone is overly aggressive. +''' + +from __future__ import annotations + +import weakref +from enum import Enum +from typing import TYPE_CHECKING + +import babase +import bauiv1 as bui +import bascenev1 as bs +import random +from bascenev1lib.actor.flag import Flag +from bascenev1lib.actor.popuptext import PopupText +from bascenev1lib.actor.playerspaz import PlayerSpaz +from bascenev1lib.actor.scoreboard import Scoreboard +from bascenev1lib.gameutils import SharedObjects + +if TYPE_CHECKING: + from typing import Any, Sequence + + +lang = bs.app.lang.language +if lang == 'Spanish': + name = 'Coleccionista' + description = ('Elimina a tus oponentes para robar sus cápsulas.\n' + '¡Recolecta y anota en el punto de depósito!') + description_ingame = 'Obtén ${ARG1} cápsulas de tus enemigos.' + description_short = 'colecciona ${ARG1} cápsulas' + tips = [( + '¡Si tu oponente cae fuera del mapa, sus cápsulas desapareceran!\n' + 'No intestes matar a tus enemigos arrojándolos al vacio.'), + 'No te apresures. ¡Puedes perder tus cápsulas rápidamente!', + ('¡No dejes que el jugador con más cápsulas anote!\n' + '¡Intenta atraparlo si puedes!'), + ('¡Las Capsulas de la Suerte te dan 4 cápsulas en lugar de 2' + 'y tienen un 8% de probabilidad de aparecer después de matar'), + ('¡No te quedes en un solo lugar! Muevete más rapido que tu enemigo, ' + '¡con suerte conseguirás algunas cápsulas!'), + ] + capsules_to_win = 'Cápsulas para Ganar' + capsules_death = 'Cápsulas al Morir' + lucky_capsules = 'Cápsulas de la Suerte' + bonus = '¡BONUS!' + full_capacity = '¡Capacidad Completa!' +else: + name = 'Collector' + description = ('Kill your opponents to steal their Capsules.\n' + 'Collect them and score at the Deposit point!') + description_ingame = 'Score ${ARG1} capsules from your enemies.' + description_short = 'collect ${ARG1} capsules' + tips = [( + 'Making you opponent fall down the pit makes his Capsules wasted!\n' + 'Try not to kill enemies by throwing them off the cliff.'), + 'Don\'t be too reckless. You can lose your loot quite quickly!', + ('Don\'t let the leading player score his Capsules ' + 'at the Deposit Point!\nTry to catch him if you can!'), + ('Lucky Capsules give 4 to your inventory and they have 8% chance ' + 'of spawning after kill!'), + ('Don\'t camp in one place! Make your move first, ' + 'so hopefully you get some dough!'), + ] + capsules_to_win = 'Capsules to Win' + capsules_death = 'Capsules on Death' + lucky_capsules = 'Allow Lucky Capsules' + bonus = 'BONUS!' + full_capacity = 'Full Capacity!' + + +class FlagState(Enum): + """States our single flag can be in.""" + + NEW = 0 + UNCONTESTED = 1 + CONTESTED = 2 + HELD = 3 + + +class Player(bs.Player['Team']): + """Our player type for this game.""" + + def __init__(self) -> None: + self.time_at_flag = 0 + self.capsules = 0 + self.light = None + + +class Team(bs.Team[Player]): + """Our team type for this game.""" + + def __init__(self) -> None: + self.score = 0 + + +# ba_meta export bascenev1.GameActivity +class CollectorGame(bs.TeamGameActivity[Player, Team]): + + name = name + description = description + tips = tips + + # 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( + capsules_to_win, + min_value=1, + default=10, + increment=1, + ), + bs.IntSetting( + capsules_death, + min_value=1, + max_value=10, + default=2, + 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(lucky_capsules, default=True), + 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]: + return bs.app.classic.getmaps('keep_away') + + def __init__(self, settings: dict): + super().__init__(settings) + shared = SharedObjects.get() + self._scoreboard = Scoreboard() + self._score_to_win: int | None = None + self._swipsound = bs.getsound('swip') + self._lucky_sound = bs.getsound('ding') + + self._flag_pos: Sequence[float] | None = None + self._flag_state: FlagState | None = None + self._flag: Flag | None = None + self._flag_light: bs.Node | None = None + self._scoring_team: weakref.ref[Team] | None = None + self._time_limit = float(settings['Time Limit']) + self._epic_mode = bool(settings['Epic Mode']) + + self._capsules_to_win = int(settings[capsules_to_win]) + self._capsules_death = int(settings[capsules_death]) + self._lucky_capsules = bool(settings[lucky_capsules]) + self._capsules: list[Any] = [] + + self._capsule_mesh = bs.getmesh('bomb') + self._capsule_tex = bs.gettexture('bombColor') + self._capsule_lucky_tex = bs.gettexture('bombStickyColor') + self._collect_sound = bs.getsound('powerup01') + self._lucky_collect_sound = bs.getsound('cashRegister2') + + self._capsule_material = bs.Material() + self._capsule_material.add_actions( + conditions=('they_have_material', shared.player_material), + actions=('call', 'at_connect', self._on_capsule_player_collide), + ) + + self._flag_region_material = bs.Material() + self._flag_region_material.add_actions( + conditions=('they_have_material', shared.player_material), + actions=( + ('modify_part_collision', 'collide', True), + ('modify_part_collision', 'physical', False), + ( + 'call', + 'at_connect', + babase.Call(self._handle_player_flag_region_collide, True), + ), + ( + 'call', + 'at_disconnect', + babase.Call(self._handle_player_flag_region_collide, False), + ), + ), + ) + + # Base class overrides. + self.slow_motion = self._epic_mode + self.default_music = ( + bs.MusicType.EPIC if self._epic_mode else bs.MusicType.SCARY + ) + + def get_instance_description(self) -> str | Sequence: + return description_ingame, self._score_to_win + + def get_instance_description_short(self) -> str | Sequence: + return description_short, self._score_to_win + + def create_team(self, sessionteam: bs.SessionTeam) -> Team: + return Team() + + def on_team_join(self, team: Team) -> None: + self._update_scoreboard() + + def on_begin(self) -> None: + super().on_begin() + shared = SharedObjects.get() + self.setup_standard_time_limit(self._time_limit) + self.setup_standard_powerup_drops() + + # Base kills needed to win on the size of the largest team. + self._score_to_win = self._capsules_to_win * max( + 1, max(len(t.players) for t in self.teams) + ) + self._update_scoreboard() + + if isinstance(self.session, bs.FreeForAllSession): + self._flag_pos = self.map.get_flag_position(random.randint(0, 1)) + else: + self._flag_pos = self.map.get_flag_position(None) + + bs.timer(1.0, self._tick, repeat=True) + self._flag_state = FlagState.NEW + Flag.project_stand(self._flag_pos) + self._flag = Flag( + position=self._flag_pos, touchable=False, color=(1, 1, 1) + ) + self._flag_light = bs.newnode( + 'light', + attrs={ + 'position': self._flag_pos, + 'intensity': 0.2, + 'height_attenuated': False, + 'radius': 0.4, + 'color': (0.2, 0.2, 0.2), + }, + ) + # Flag region. + flagmats = [self._flag_region_material, shared.region_material] + bs.newnode( + 'region', + attrs={ + 'position': self._flag_pos, + 'scale': (1.8, 1.8, 1.8), + 'type': 'sphere', + 'materials': flagmats, + }, + ) + self._update_flag_state() + + def _tick(self) -> None: + self._update_flag_state() + + if self._scoring_team is None: + scoring_team = None + else: + scoring_team = self._scoring_team() + + if not scoring_team: + return + + if isinstance(self.session, bs.FreeForAllSession): + players = self.players + else: + players = scoring_team.players + + for player in players: + if player.time_at_flag > 0: + self.stats.player_scored( + player, 3, screenmessage=False, display=False + ) + if player.capsules > 0: + if self._flag_state != FlagState.HELD: + return + if scoring_team.score >= self._score_to_win: + return + + player.capsules -= 1 + scoring_team.score += 1 + self._handle_capsule_storage(( + self._flag_pos[0], + self._flag_pos[1]+1, + self._flag_pos[2] + ), player) + self._collect_sound.play(0.8, position=self._flag_pos) + + self._update_scoreboard() + if player.capsules > 0: + assert self._flag is not None + self._flag.set_score_text( + str(self._score_to_win - scoring_team.score)) + + # winner + if scoring_team.score >= self._score_to_win: + self.end_game() + + def end_game(self) -> None: + results = bs.GameResults() + for team in self.teams: + results.set_team_score(team, team.score) + self.end(results=results, announce_delay=0) + + def _update_flag_state(self) -> None: + holding_teams = set( + player.team for player in self.players if player.time_at_flag + ) + prev_state = self._flag_state + assert self._flag_light + assert self._flag is not None + assert self._flag.node + if len(holding_teams) > 1: + self._flag_state = FlagState.CONTESTED + self._scoring_team = None + self._flag_light.color = (0.6, 0.6, 0.1) + self._flag.node.color = (1.0, 1.0, 0.4) + elif len(holding_teams) == 1: + holding_team = list(holding_teams)[0] + self._flag_state = FlagState.HELD + self._scoring_team = weakref.ref(holding_team) + self._flag_light.color = babase.normalized_color(holding_team.color) + self._flag.node.color = holding_team.color + else: + self._flag_state = FlagState.UNCONTESTED + self._scoring_team = None + self._flag_light.color = (0.2, 0.2, 0.2) + self._flag.node.color = (1, 1, 1) + if self._flag_state != prev_state: + self._swipsound.play() + + def _handle_player_flag_region_collide(self, colliding: bool) -> None: + try: + spaz = bs.getcollision().opposingnode.getdelegate(PlayerSpaz, True) + except bs.NotFoundError: + return + + if not spaz.is_alive(): + return + + player = spaz.getplayer(Player, True) + + # Different parts of us can collide so a single value isn't enough + # also don't count it if we're dead (flying heads shouldn't be able to + # win the game :-) + if colliding and player.is_alive(): + player.time_at_flag += 1 + else: + player.time_at_flag = max(0, player.time_at_flag - 1) + + self._update_flag_state() + + def _update_scoreboard(self) -> None: + for team in self.teams: + self._scoreboard.set_team_value( + team, team.score, self._score_to_win + ) + + def _drop_capsule(self, player: Player) -> None: + pt = player.node.position + + # Throw out capsules that the victim has + 2 more to keep the game running + for i in range(player.capsules + self._capsules_death): + # How far from each other these capsules should spawn + w = 0.6 + # How much these capsules should fly after spawning + s = 0.005 - (player.capsules * 0.01) + self._capsules.append( + Capsule( + position=(pt[0] + random.uniform(-w, w), + pt[1] + 0.75 + random.uniform(-w, w), + pt[2]), + velocity=(random.uniform(-s, s), + random.uniform(-s, s), + random.uniform(-s, s)), + lucky=False)) + if random.randint(1, 12) == 1 and self._lucky_capsules: + # How far from each other these capsules should spawn + w = 0.6 + # How much these capsules should fly after spawning + s = 0.005 + self._capsules.append( + Capsule( + position=(pt[0] + random.uniform(-w, w), + pt[1] + 0.75 + random.uniform(-w, w), + pt[2]), + velocity=(random.uniform(-s, s), + random.uniform(-s, s), + random.uniform(-s, s)), + lucky=True)) + + def _on_capsule_player_collide(self) -> None: + if self.has_ended(): + return + collision = bs.getcollision() + + # Be defensive here; we could be hitting the corpse of a player + # who just left/etc. + try: + capsule = collision.sourcenode.getdelegate(Capsule, True) + player = collision.opposingnode.getdelegate( + PlayerSpaz, True + ).getplayer(Player, True) + except bs.NotFoundError: + return + + if not player.is_alive(): + return + + if capsule.node.color_texture == self._capsule_lucky_tex: + player.capsules += 4 + PopupText( + bonus, + color=(1, 1, 0), + scale=1.5, + position=capsule.node.position + ).autoretain() + self._lucky_collect_sound.play(1.0, position=capsule.node.position) + bs.emitfx( + position=capsule.node.position, + velocity=(0, 0, 0), + count=int(6.4+random.random()*24), + scale=1.2, + spread=2.0, + chunk_type='spark') + bs.emitfx( + position=capsule.node.position, + velocity=(0, 0, 0), + count=int(4.0+random.random()*6), + emit_type='tendrils') + else: + player.capsules += 1 + self._collect_sound.play(0.6, position=capsule.node.position) + # create a flash + light = bs.newnode( + 'light', + attrs={ + 'position': capsule.node.position, + 'height_attenuated': False, + 'radius': 0.1, + 'color': (1, 1, 0)}) + + # Create a short text informing about your inventory + self._handle_capsule_storage(player.position, player) + + bs.animate(light, 'intensity', { + 0: 0, + 0.1: 0.5, + 0.2: 0 + }, loop=False) + bs.timer(0.2, light.delete) + capsule.handlemessage(bs.DieMessage()) + + def _update_player_light(self, player: Player, capsules: int) -> None: + if player.light: + intensity = 0.04 * capsules + bs.animate(player.light, 'intensity', { + 0.0: player.light.intensity, + 0.1: intensity + }) + + def newintensity(): + player.light.intensity = intensity + bs.timer(0.1, newintensity) + else: + player.light = bs.newnode( + 'light', + attrs={ + 'height_attenuated': False, + 'radius': 0.2, + 'intensity': 0.0, + 'color': (0.2, 1, 0.2) + }) + player.node.connectattr('position', player.light, 'position') + + def _handle_capsule_storage(self, pos: float, player: Player) -> None: + capsules = player.capsules + text = str(capsules) + scale = 1.75 + (0.02 * capsules) + if capsules > 10: + player.capsules = 10 + text = full_capacity + color = (1, 0.85, 0) + elif capsules > 7: + color = (1, 0, 0) + scale = 2.4 + elif capsules > 5: + color = (1, 0.4, 0.4) + scale = 2.1 + elif capsules > 3: + color = (1, 1, 0.4) + scale = 2.0 + else: + color = (1, 1, 1) + scale = 1.9 + PopupText( + text, + color=color, + scale=scale, + position=(pos[0], pos[1]-1, pos[2]) + ).autoretain() + self._update_player_light(player, capsules) + + def handlemessage(self, msg: Any) -> Any: + if isinstance(msg, bs.PlayerDiedMessage): + super().handlemessage(msg) # Augment default. + # No longer can count as time_at_flag once dead. + player = msg.getplayer(Player) + player.time_at_flag = 0 + self._update_flag_state() + self._drop_capsule(player) + player.capsules = 0 + self._update_player_light(player, 0) + self.respawn_player(player) + else: + return super().handlemessage(msg) + + +class Capsule(bs.Actor): + + def __init__(self, + position: Sequence[float] = (0.0, 1.0, 0.0), + velocity: Sequence[float] = (0.0, 0.5, 0.0), + lucky: bool = False): + super().__init__() + shared = SharedObjects.get() + activity = self.getactivity() + + # spawn just above the provided point + self._spawn_pos = (position[0], position[1], position[2]) + + if lucky: + activity._lucky_sound.play(1.0, self._spawn_pos) + + self.node = bs.newnode( + 'prop', + attrs={ + 'mesh': activity._capsule_mesh, + 'color_texture': activity._capsule_lucky_tex if lucky else ( + activity._capsule_tex), + 'body': 'crate' if lucky else 'capsule', + 'reflection': 'powerup' if lucky else 'soft', + 'body_scale': 0.65 if lucky else 0.3, + 'density': 6.0 if lucky else 4.0, + 'reflection_scale': [0.15], + 'shadow_size': 0.65 if lucky else 0.6, + 'position': self._spawn_pos, + 'velocity': velocity, + 'materials': [ + shared.object_material, activity._capsule_material] + }, + delegate=self) + bs.animate(self.node, 'mesh_scale', { + 0.0: 0.0, + 0.1: 0.9 if lucky else 0.6, + 0.16: 0.8 if lucky else 0.5 + }) + self._light_capsule = bs.newnode( + 'light', + attrs={ + 'position': self._spawn_pos, + 'height_attenuated': False, + 'radius': 0.5 if lucky else 0.1, + 'color': (0.2, 0.2, 0) if lucky else (0.2, 1, 0.2) + }) + self.node.connectattr('position', self._light_capsule, 'position') + + def handlemessage(self, msg: Any): + if isinstance(msg, bs.DieMessage): + self.node.delete() + bs.animate(self._light_capsule, 'intensity', { + 0: 1.0, + 0.05: 0.0 + }, loop=False) + bs.timer(0.05, self._light_capsule.delete) + elif isinstance(msg, bs.OutOfBoundsMessage): + self.handlemessage(bs.DieMessage()) + elif isinstance(msg, bs.HitMessage): + self.node.handlemessage( + 'impulse', + msg.pos[0], msg.pos[1], msg.pos[2], + msg.velocity[0]/8, msg.velocity[1]/8, msg.velocity[2]/8, + 1.0*msg.magnitude, 1.0*msg.velocity_magnitude, msg.radius, 0, + msg.force_direction[0], msg.force_direction[1], + msg.force_direction[2]) + else: + return super().handlemessage(msg)