Source code for bascenev1lib.game.thelaststand

# Released under the MIT License. See LICENSE for details.
#
"""Defines the last stand minigame."""

from __future__ import annotations

import random
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, override

import bascenev1 as bs

from bascenev1lib.actor.playerspaz import PlayerSpaz
from bascenev1lib.actor.bomb import TNTSpawner
from bascenev1lib.actor.scoreboard import Scoreboard
from bascenev1lib.actor.powerupbox import PowerupBoxFactory, PowerupBox
from bascenev1lib.actor.spazbot import (
    SpazBotSet,
    SpazBotDiedMessage,
    BomberBot,
    BomberBotPro,
    BomberBotProShielded,
    BrawlerBot,
    BrawlerBotPro,
    BrawlerBotProShielded,
    TriggerBot,
    TriggerBotPro,
    TriggerBotProShielded,
    ChargerBot,
    StickyBot,
    ExplodeyBot,
)

if TYPE_CHECKING:
    from typing import Any, Sequence

    from bascenev1lib.actor.spazbot import SpazBot


[docs] @dataclass class SpawnInfo: """Spawning info for a particular bot type.""" spawnrate: float increase: float dincrease: float
[docs] class Player(bs.Player['Team']): """Our player type for this game."""
[docs] class Team(bs.Team[Player]): """Our team type for this game."""
[docs] class TheLastStandGame(bs.CoopGameActivity[Player, Team]): """Slow motion how-long-can-you-last game.""" name = 'The Last Stand' description = 'Final glorious epic slow motion battle to the death.' tips = [ 'This level never ends, but a high score here\n' 'will earn you eternal respect throughout the world.' ] # Show messages when players die since it matters here. announce_player_deaths = True # And of course the most important part. slow_motion = True default_music = bs.MusicType.EPIC def __init__(self, settings: dict): settings['map'] = 'Rampage' super().__init__(settings) self._new_wave_sound = bs.getsound('scoreHit01') self._winsound = bs.getsound('score') self._cashregistersound = bs.getsound('cashRegister') self._spawn_center = (0, 5.5, -4.14) self._tntspawnpos = (0, 5.5, -6) self._powerup_center = (0, 7, -4.14) self._powerup_spread = (7, 2) self._preset = str(settings.get('preset', 'default')) self._excludepowerups: list[str] = [] self._scoreboard: Scoreboard | None = None self._score = 0 self._bots = SpazBotSet() self._dingsound = bs.getsound('dingSmall') self._dingsoundhigh = bs.getsound('dingSmallHigh') self._tntspawner: TNTSpawner | None = None self._bot_update_interval: float | None = None self._bot_update_timer: bs.Timer | None = None self._powerup_drop_timer = None # For each bot type: [spawnrate, increase, d_increase] self._bot_spawn_types = { BomberBot: SpawnInfo(1.00, 0.00, 0.000), BomberBotPro: SpawnInfo(0.00, 0.05, 0.001), BomberBotProShielded: SpawnInfo(0.00, 0.02, 0.002), BrawlerBot: SpawnInfo(1.00, 0.00, 0.000), BrawlerBotPro: SpawnInfo(0.00, 0.05, 0.001), BrawlerBotProShielded: SpawnInfo(0.00, 0.02, 0.002), TriggerBot: SpawnInfo(0.30, 0.00, 0.000), TriggerBotPro: SpawnInfo(0.00, 0.05, 0.001), TriggerBotProShielded: SpawnInfo(0.00, 0.02, 0.002), ChargerBot: SpawnInfo(0.30, 0.05, 0.000), StickyBot: SpawnInfo(0.10, 0.03, 0.001), ExplodeyBot: SpawnInfo(0.05, 0.02, 0.002), }
[docs] @override def on_transition_in(self) -> None: # (Pylint bug?) pylint: disable=missing-function-docstring super().on_transition_in() bs.timer(1.3, self._new_wave_sound.play) self._scoreboard = Scoreboard( label=bs.Lstr(resource='scoreText'), score_split=0.5 )
[docs] @override def on_begin(self) -> None: super().on_begin() # Spit out a few powerups and start dropping more shortly. self._drop_powerups(standard_points=True) bs.timer(2.0, bs.WeakCall(self._start_powerup_drops)) bs.timer(0.001, bs.WeakCall(self._start_bot_updates)) self.setup_low_life_warning_sound() self._update_scores() self._tntspawner = TNTSpawner( position=self._tntspawnpos, respawn_time=10.0 )
[docs] @override def spawn_player(self, player: Player) -> bs.Actor: # (Pylint bug?) pylint: disable=missing-function-docstring pos = ( self._spawn_center[0] + random.uniform(-1.5, 1.5), self._spawn_center[1], self._spawn_center[2] + random.uniform(-1.5, 1.5), ) return self.spawn_player_spaz(player, position=pos)
def _start_bot_updates(self) -> None: self._bot_update_interval = 3.3 - 0.3 * (len(self.players)) self._update_bots() self._update_bots() if len(self.players) > 2: self._update_bots() if len(self.players) > 3: self._update_bots() self._bot_update_timer = bs.Timer( self._bot_update_interval, bs.WeakCall(self._update_bots) ) def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None: if poweruptype is None: poweruptype = PowerupBoxFactory.get().get_random_powerup_type( excludetypes=self._excludepowerups ) PowerupBox( position=self.map.powerup_spawn_points[index], poweruptype=poweruptype, ).autoretain() def _start_powerup_drops(self) -> None: self._powerup_drop_timer = bs.Timer( 3.0, bs.WeakCall(self._drop_powerups), repeat=True ) def _drop_powerups( self, standard_points: bool = False, force_first: str | None = None ) -> None: """Generic powerup drop.""" from bascenev1lib.actor import powerupbox if standard_points: pts = self.map.powerup_spawn_points for i in range(len(pts)): bs.timer( 1.0 + i * 0.5, bs.WeakCall( self._drop_powerup, i, force_first if i == 0 else None ), ) else: drop_pt = ( self._powerup_center[0] + random.uniform( -1.0 * self._powerup_spread[0], 1.0 * self._powerup_spread[0], ), self._powerup_center[1], self._powerup_center[2] + random.uniform( -self._powerup_spread[1], self._powerup_spread[1] ), ) # Drop one random one somewhere. powerupbox.PowerupBox( position=drop_pt, poweruptype=PowerupBoxFactory.get().get_random_powerup_type( excludetypes=self._excludepowerups ), ).autoretain()
[docs] def do_end(self, outcome: str) -> None: """End the game.""" if outcome == 'defeat': self.fade_to_red() self.end( delay=2.0, results={ 'outcome': outcome, 'score': self._score, 'playerinfos': self.initialplayerinfos, }, )
def _update_bots(self) -> None: assert self._bot_update_interval is not None self._bot_update_interval = max(0.5, self._bot_update_interval * 0.98) self._bot_update_timer = bs.Timer( self._bot_update_interval, bs.WeakCall(self._update_bots) ) botspawnpts: list[Sequence[float]] = [ [-5.0, 5.5, -4.14], [0.0, 5.5, -4.14], [5.0, 5.5, -4.14], ] dists = [0.0, 0.0, 0.0] playerpts: list[Sequence[float]] = [] for player in self.players: try: if player.is_alive(): assert isinstance(player.actor, PlayerSpaz) assert player.actor.node playerpts.append(player.actor.node.position) except Exception: logging.exception('Error updating bots.') for i in range(3): for playerpt in playerpts: dists[i] += abs(playerpt[0] - botspawnpts[i][0]) dists[i] += random.random() * 5.0 # Minor random variation. if dists[0] > dists[1] and dists[0] > dists[2]: spawnpt = botspawnpts[0] elif dists[1] > dists[2]: spawnpt = botspawnpts[1] else: spawnpt = botspawnpts[2] spawnpt = ( spawnpt[0] + 3.0 * (random.random() - 0.5), spawnpt[1], 2.0 * (random.random() - 0.5) + spawnpt[2], ) # Normalize our bot type total and find a random number within that. total = 0.0 for spawninfo in self._bot_spawn_types.values(): total += spawninfo.spawnrate randval = random.random() * total # Now go back through and see where this value falls. total = 0 bottype: type[SpazBot] | None = None for spawntype, spawninfo in self._bot_spawn_types.items(): total += spawninfo.spawnrate if randval <= total: bottype = spawntype break spawn_time = 1.0 assert bottype is not None self._bots.spawn_bot(bottype, pos=spawnpt, spawn_time=spawn_time) # After every spawn we adjust our ratios slightly to get more # difficult. for spawninfo in self._bot_spawn_types.values(): spawninfo.spawnrate += spawninfo.increase spawninfo.increase += spawninfo.dincrease def _update_scores(self) -> None: score = self._score # Achievements apply to the default preset only. if self._preset == 'default': if score >= 250: self._award_achievement('Last Stand Master') if score >= 500: self._award_achievement('Last Stand Wizard') if score >= 1000: self._award_achievement('Last Stand God') assert self._scoreboard is not None self._scoreboard.set_team_value(self.teams[0], score, max_score=None)
[docs] @override def handlemessage(self, msg: Any) -> Any: # (Pylint bug?) pylint: disable=missing-function-docstring if isinstance(msg, bs.PlayerDiedMessage): player = msg.getplayer(Player) self.stats.player_was_killed(player) bs.timer(0.1, self._checkroundover) elif isinstance(msg, bs.PlayerScoredMessage): self._score += msg.score self._update_scores() elif isinstance(msg, SpazBotDiedMessage): pts, importance = msg.spazbot.get_death_points(msg.how) target: Sequence[float] | None if msg.killerplayer: assert msg.spazbot.node target = msg.spazbot.node.position self.stats.player_scored( msg.killerplayer, pts, target=target, kill=True, screenmessage=False, importance=importance, ) diesound = ( self._dingsound if importance == 1 else self._dingsoundhigh ) diesound.play(volume=0.6) # Normally we pull scores from the score-set, but if there's no # player lets be explicit. else: self._score += pts self._update_scores() else: super().handlemessage(msg)
[docs] @override def end_game(self) -> None: # (Pylint bug?) pylint: disable=missing-function-docstring # Tell our bots to celebrate just to rub it in. self._bots.final_celebrate() bs.setmusic(None) bs.pushcall(bs.WeakCall(self.do_end, 'defeat'))
def _checkroundover(self) -> None: """End the round if conditions are met.""" if not any(player.is_alive() for player in self.teams[0].players): self.end_game()
# Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum