# 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