Source code for bascenev1lib.game.targetpractice

# Released under the MIT License. See LICENSE for details.
#
"""Implements Target Practice game."""

# ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system)

from __future__ import annotations

import random
from typing import TYPE_CHECKING, override

import bascenev1 as bs

from bascenev1lib.actor.scoreboard import Scoreboard
from bascenev1lib.actor.onscreencountdown import OnScreenCountdown
from bascenev1lib.actor.bomb import Bomb
from bascenev1lib.actor.popuptext import PopupText

if TYPE_CHECKING:
    from typing import Any, Sequence

    from bascenev1lib.actor.bomb import Blast


[docs] class Player(bs.Player['Team']): """Our player type for this game.""" def __init__(self) -> None: self.streak = 0
[docs] class Team(bs.Team[Player]): """Our team type for this game.""" def __init__(self) -> None: self.score = 0
# ba_meta export bascenev1.GameActivity
[docs] class TargetPracticeGame(bs.TeamGameActivity[Player, Team]): """Game where players try to hit targets with bombs.""" name = 'Target Practice' description = 'Bomb as many targets as you can.' available_settings = [ bs.IntSetting('Target Count', min_value=1, default=3), bs.BoolSetting('Enable Impact Bombs', default=True), bs.BoolSetting('Enable Triple Bombs', default=True), ] default_music = bs.MusicType.FORWARD_MARCH
[docs] @override @classmethod def get_supported_maps(cls, sessiontype: type[bs.Session]) -> list[str]: return ['Doom Shroom']
[docs] @override @classmethod def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: # We support any teams or versus sessions. return issubclass(sessiontype, bs.CoopSession) or issubclass( sessiontype, bs.MultiTeamSession )
def __init__(self, settings: dict): super().__init__(settings) self._scoreboard = Scoreboard() self._targets: list[Target] = [] self._update_timer: bs.Timer | None = None self._countdown: OnScreenCountdown | None = None self._target_count = int(settings['Target Count']) self._enable_impact_bombs = bool(settings['Enable Impact Bombs']) self._enable_triple_bombs = bool(settings['Enable Triple Bombs'])
[docs] @override def on_team_join(self, team: Team) -> None: # (Pylint Bug?) pylint: disable=missing-function-docstring if self.has_begun(): self.update_scoreboard()
[docs] @override def on_begin(self) -> None: super().on_begin() self.update_scoreboard() # Number of targets is based on player count. for i in range(self._target_count): bs.timer(5.0 + i * 1.0, self._spawn_target) self._update_timer = bs.Timer(1.0, self._update, repeat=True) self._countdown = OnScreenCountdown(60, endcall=self.end_game) bs.timer(4.0, self._countdown.start)
[docs] @override def spawn_player(self, player: Player) -> bs.Actor: # (Pylint Bug?) pylint: disable=missing-function-docstring spawn_center = (0, 3, -5) pos = ( spawn_center[0] + random.uniform(-1.5, 1.5), spawn_center[1], spawn_center[2] + random.uniform(-1.5, 1.5), ) # Reset their streak. player.streak = 0 spaz = self.spawn_player_spaz(player, position=pos) # Give players permanent triple impact bombs and wire them up # to tell us when they drop a bomb. if self._enable_impact_bombs: spaz.bomb_type = 'impact' if self._enable_triple_bombs: spaz.set_bomb_count(3) spaz.add_dropped_bomb_callback(self._on_spaz_dropped_bomb) return spaz
def _spawn_target(self) -> None: # Generate a few random points; we'll use whichever one is farthest # from our existing targets (don't want overlapping targets). points = [] for _i in range(4): # Calc a random point within a circle. while True: xpos = random.uniform(-1.0, 1.0) ypos = random.uniform(-1.0, 1.0) if xpos * xpos + ypos * ypos < 1.0: break points.append(bs.Vec3(8.0 * xpos, 2.2, -3.5 + 5.0 * ypos)) def get_min_dist_from_target(pnt: bs.Vec3) -> float: return min((t.get_dist_from_point(pnt) for t in self._targets)) # If we have existing targets, use the point with the highest # min-distance-from-targets. if self._targets: point = max(points, key=get_min_dist_from_target) else: point = points[0] self._targets.append(Target(position=point)) def _on_spaz_dropped_bomb(self, spaz: bs.Actor, bomb: bs.Actor) -> None: del spaz # Unused. # Wire up this bomb to inform us when it blows up. assert isinstance(bomb, Bomb) bomb.add_explode_callback(self._on_bomb_exploded) def _on_bomb_exploded(self, bomb: Bomb, blast: Blast) -> None: assert blast.node pos = blast.node.position # Debugging: throw a locator down where we landed. # bs.newnode('locator', attrs={'position':blast.node.position}) # Feed the explosion point to all our targets and get points in return. # Note: we operate on a copy of self._targets since the list may change # under us if we hit stuff (don't wanna get points for new targets). player = bomb.get_source_player(Player) if not player: # It's possible the player left after throwing the bomb. return bullseye = any( target.do_hit_at_position(pos, player) for target in list(self._targets) ) if bullseye: player.streak += 1 else: player.streak = 0 def _update(self) -> None: """Misc. periodic updating.""" # Clear out targets that have died. self._targets = [t for t in self._targets if t]
[docs] @override def handlemessage(self, msg: Any) -> Any: # (Pylint Bug?) pylint: disable=missing-function-docstring # When players die, respawn them. if isinstance(msg, bs.PlayerDiedMessage): super().handlemessage(msg) # Do standard stuff. player = msg.getplayer(Player) assert player is not None self.respawn_player(player) # Kick off a respawn. elif isinstance(msg, Target.TargetHitMessage): # A target is telling us it was hit and will die soon.. # ..so make another one. self._spawn_target() else: super().handlemessage(msg)
[docs] def update_scoreboard(self) -> None: """Update the game scoreboard with current team values.""" for team in self.teams: self._scoreboard.set_team_value(team, team.score)
[docs] @override def end_game(self) -> None: """End the game.""" results = bs.GameResults() for team in self.teams: results.set_team_score(team, team.score) self.end(results)
[docs] class Target(bs.Actor): """A target practice target."""
[docs] class TargetHitMessage: """Inform an object a target was hit."""
def __init__(self, position: Sequence[float]): self._r1 = 0.45 self._r2 = 1.1 self._r3 = 2.0 self._rfudge = 0.15 super().__init__() self._position = bs.Vec3(position) self._hit = False # It can be handy to test with this on to make sure the projection # isn't too far off from the actual object. show_in_space = False loc1 = bs.newnode( 'locator', attrs={ 'shape': 'circle', 'position': position, 'color': (0, 1, 0), 'opacity': 0.5, 'draw_beauty': show_in_space, 'additive': True, }, ) loc2 = bs.newnode( 'locator', attrs={ 'shape': 'circleOutline', 'position': position, 'color': (0, 1, 0), 'opacity': 0.3, 'draw_beauty': False, 'additive': True, }, ) loc3 = bs.newnode( 'locator', attrs={ 'shape': 'circleOutline', 'position': position, 'color': (0, 1, 0), 'opacity': 0.1, 'draw_beauty': False, 'additive': True, }, ) self._nodes = [loc1, loc2, loc3] bs.animate_array(loc1, 'size', 1, {0: [0.0], 0.2: [self._r1 * 2.0]}) bs.animate_array(loc2, 'size', 1, {0.05: [0.0], 0.25: [self._r2 * 2.0]}) bs.animate_array(loc3, 'size', 1, {0.1: [0.0], 0.3: [self._r3 * 2.0]}) bs.getsound('laserReverse').play()
[docs] @override def exists(self) -> bool: return bool(self._nodes)
[docs] @override def handlemessage(self, msg: Any) -> Any: if isinstance(msg, bs.DieMessage): for node in self._nodes: node.delete() self._nodes = [] else: super().handlemessage(msg)
[docs] def get_dist_from_point(self, pos: bs.Vec3) -> float: """Given a point, returns distance squared from it.""" return (pos - self._position).length()
[docs] def do_hit_at_position(self, pos: Sequence[float], player: Player) -> bool: """Handle a bomb hit at the given position.""" # pylint: disable=too-many-statements activity = self.activity # Ignore hits if the game is over or if we've already been hit if activity.has_ended() or self._hit or not self._nodes: return False diff = bs.Vec3(pos) - self._position # Disregard Y difference. Our target point probably isn't exactly # on the ground anyway. diff[1] = 0.0 dist = diff.length() bullseye = False if dist <= self._r3 + self._rfudge: # Inform our activity that we were hit self._hit = True activity.handlemessage(self.TargetHitMessage()) keys: dict[float, Sequence[float]] = { 0.0: (1.0, 0.0, 0.0), 0.049: (1.0, 0.0, 0.0), 0.05: (1.0, 1.0, 1.0), 0.1: (0.0, 1.0, 0.0), } cdull = (0.3, 0.3, 0.3) popupcolor: Sequence[float] if dist <= self._r1 + self._rfudge: bullseye = True self._nodes[1].color = cdull self._nodes[2].color = cdull bs.animate_array(self._nodes[0], 'color', 3, keys, loop=True) popupscale = 1.8 popupcolor = (1, 1, 0, 1) streak = player.streak points = 10 + min(20, streak * 2) bs.getsound('bellHigh').play() if streak > 0: bs.getsound( 'orchestraHit4' if streak > 3 else ( 'orchestraHit3' if streak > 2 else ( 'orchestraHit2' if streak > 1 else 'orchestraHit' ) ) ).play() elif dist <= self._r2 + self._rfudge: self._nodes[0].color = cdull self._nodes[2].color = cdull bs.animate_array(self._nodes[1], 'color', 3, keys, loop=True) popupscale = 1.25 popupcolor = (1, 0.5, 0.2, 1) points = 4 bs.getsound('bellMed').play() else: self._nodes[0].color = cdull self._nodes[1].color = cdull bs.animate_array(self._nodes[2], 'color', 3, keys, loop=True) popupscale = 1.0 popupcolor = (0.8, 0.3, 0.3, 1) points = 2 bs.getsound('bellLow').play() # Award points/etc.. (technically should probably leave this up # to the activity). popupstr = '+' + str(points) # If there's more than 1 player in the game, include their # names and colors so they know who got the hit. if len(activity.players) > 1: popupcolor = bs.safecolor(player.color, target_intensity=0.75) popupstr += ' ' + player.getname() PopupText( popupstr, position=self._position, color=popupcolor, scale=popupscale, ).autoretain() # Give this player's team points and update the score-board. player.team.score += points assert isinstance(activity, TargetPracticeGame) activity.update_scoreboard() # Also give this individual player points # (only applies in teams mode). assert activity.stats is not None activity.stats.player_scored( player, points, showpoints=False, screenmessage=False ) bs.animate_array( self._nodes[0], 'size', 1, {0.8: self._nodes[0].size, 1.0: [0.0]}, ) bs.animate_array( self._nodes[1], 'size', 1, {0.85: self._nodes[1].size, 1.05: [0.0]}, ) bs.animate_array( self._nodes[2], 'size', 1, {0.9: self._nodes[2].size, 1.1: [0.0]}, ) bs.timer(1.1, bs.Call(self.handlemessage, bs.DieMessage())) return bullseye
# 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