# Copyright (c) 2011-2019 Eric Froemling # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # ----------------------------------------------------------------------------- """Functionality related to scores and statistics.""" from __future__ import annotations import random import weakref from typing import TYPE_CHECKING from dataclasses import dataclass import _ba if TYPE_CHECKING: import ba from weakref import ReferenceType from typing import Any, Dict, Optional, Sequence, Union @dataclass class PlayerScoredMessage: # noinspection PyUnresolvedReferences """Informs something that a ba.Player scored. Category: Message Classes Attributes: score The score value. """ score: int class PlayerRecord: """Stats for an individual player in a ba.Stats object. Category: Gameplay Classes This does not necessarily correspond to a ba.Player that is still present (stats may be retained for players that leave mid-game) """ character: str def __init__(self, name: str, name_full: str, player: ba.Player, stats: ba.Stats): self.name = name self.name_full = name_full self.score = 0 self.accumscore = 0 self.kill_count = 0 self.accum_kill_count = 0 self.killed_count = 0 self.accum_killed_count = 0 self._multi_kill_timer: Optional[ba.Timer] = None self._multikillcount = 0 self._stats = weakref.ref(stats) self._last_player: Optional[ba.Player] = None self._player: Optional[ba.Player] = None self.associate_with_player(player) self._spaz: Optional[ReferenceType[ba.Actor]] = None self._team: Optional[ReferenceType[ba.Team]] = None self.streak = 0 @property def team(self) -> ba.Team: """The ba.Team the last associated player was last on. This can still return a valid result even if the player is gone. Raises a ba.TeamNotFoundError if the team no longer exists. """ assert self._team is not None team = self._team() if team is None: from ba._error import TeamNotFoundError raise TeamNotFoundError() return team @property def player(self) -> ba.Player: """Return the instance's associated ba.Player. Raises a ba.PlayerNotFoundError if the player no longer exists.""" if not self._player: from ba._error import PlayerNotFoundError raise PlayerNotFoundError() return self._player def get_name(self, full: bool = False) -> str: """Return the player entry's name.""" return self.name_full if full else self.name def get_icon(self) -> Dict[str, Any]: """Get the icon for this instance's player.""" player = self._last_player assert player is not None return player.get_icon() def get_spaz(self) -> Optional[ba.Actor]: """Return the player entry's spaz.""" if self._spaz is None: return None return self._spaz() def set_spaz(self, spaz: Optional[ba.Actor]) -> None: """(internal)""" self._spaz = weakref.ref(spaz) if spaz is not None else None def cancel_multi_kill_timer(self) -> None: """Cancel any multi-kill timer for this player entry.""" self._multi_kill_timer = None def getactivity(self) -> Optional[ba.Activity]: """Return the ba.Activity this instance is currently associated with. Returns None if the activity no longer exists.""" stats = self._stats() if stats is not None: return stats.getactivity() return None def associate_with_player(self, player: ba.Player) -> None: """Associate this entry with a ba.Player.""" self._team = weakref.ref(player.team) self.character = player.character self._last_player = player self._player = player self._spaz = None self.streak = 0 def _end_multi_kill(self) -> None: self._multi_kill_timer = None self._multikillcount = 0 def get_last_player(self) -> ba.Player: """Return the last ba.Player we were associated with.""" assert self._last_player is not None return self._last_player def submit_kill(self, showpoints: bool = True) -> None: """Submit a kill for this player entry.""" # FIXME Clean this up. # pylint: disable=too-many-statements from ba._lang import Lstr from ba._general import Call from ba._enums import TimeFormat self._multikillcount += 1 stats = self._stats() assert stats if self._multikillcount == 1: score = 0 name = None delay = 0 color = (0.0, 0.0, 0.0, 1.0) scale = 1.0 sound = None elif self._multikillcount == 2: score = 20 name = Lstr(resource='twoKillText') color = (0.1, 1.0, 0.0, 1) scale = 1.0 delay = 0 sound = stats.orchestrahitsound1 elif self._multikillcount == 3: score = 40 name = Lstr(resource='threeKillText') color = (1.0, 0.7, 0.0, 1) scale = 1.1 delay = 300 sound = stats.orchestrahitsound2 elif self._multikillcount == 4: score = 60 name = Lstr(resource='fourKillText') color = (1.0, 1.0, 0.0, 1) scale = 1.2 delay = 600 sound = stats.orchestrahitsound3 elif self._multikillcount == 5: score = 80 name = Lstr(resource='fiveKillText') color = (1.0, 0.5, 0.0, 1) scale = 1.3 delay = 900 sound = stats.orchestrahitsound4 else: score = 100 name = Lstr(resource='multiKillText', subs=[('${COUNT}', str(self._multikillcount))]) color = (1.0, 0.5, 0.0, 1) scale = 1.3 delay = 1000 sound = stats.orchestrahitsound4 def _apply(name2: str, score2: int, showpoints2: bool, color2: Sequence[float], scale2: float, sound2: ba.Sound) -> None: from bastd.actor.popuptext import PopupText # Only award this if they're still alive and we can get # their pos. try: actor = self.get_spaz() assert actor is not None assert actor.node our_pos = actor.node.position except Exception: return # Jitter position a bit since these often come in clusters. our_pos = (our_pos[0] + (random.random() - 0.5) * 2.0, our_pos[1] + (random.random() - 0.5) * 2.0, our_pos[2] + (random.random() - 0.5) * 2.0) activity = self.getactivity() if activity is not None: PopupText(Lstr( value=(('+' + str(score2) + ' ') if showpoints2 else '') + '${N}', subs=[('${N}', name2)]), color=color2, scale=scale2, position=our_pos).autoretain() _ba.playsound(sound2) self.score += score2 self.accumscore += score2 # Inform a running game of the score. if score2 != 0 and activity is not None: activity.handlemessage(PlayerScoredMessage(score=score2)) if name is not None: _ba.timer(300 + delay, Call(_apply, name, score, showpoints, color, scale, sound), timeformat=TimeFormat.MILLISECONDS) # Keep the tally rollin'... # set a timer for a bit in the future. self._multi_kill_timer = _ba.Timer(1.0, self._end_multi_kill) class Stats: """Manages scores and statistics for a ba.Session. category: Gameplay Classes """ def __init__(self) -> None: self._activity: Optional[ReferenceType[ba.Activity]] = None self._player_records: Dict[str, PlayerRecord] = {} self.orchestrahitsound1: Optional[ba.Sound] = None self.orchestrahitsound2: Optional[ba.Sound] = None self.orchestrahitsound3: Optional[ba.Sound] = None self.orchestrahitsound4: Optional[ba.Sound] = None def set_activity(self, activity: ba.Activity) -> None: """Set the current activity for this instance.""" self._activity = None if activity is None else weakref.ref(activity) # Load our media into this activity's context. if activity is not None: if activity.is_expired(): from ba import _error _error.print_error('unexpected finalized activity') else: with _ba.Context(activity): self._load_activity_media() def getactivity(self) -> Optional[ba.Activity]: """Get the activity associated with this instance. May return None. """ if self._activity is None: return None return self._activity() def _load_activity_media(self) -> None: self.orchestrahitsound1 = _ba.getsound('orchestraHit') self.orchestrahitsound2 = _ba.getsound('orchestraHit2') self.orchestrahitsound3 = _ba.getsound('orchestraHit3') self.orchestrahitsound4 = _ba.getsound('orchestraHit4') def reset(self) -> None: """Reset the stats instance completely.""" # Just to be safe, lets make sure no multi-kill timers are gonna go off # for no-longer-on-the-list players. for p_entry in list(self._player_records.values()): p_entry.cancel_multi_kill_timer() self._player_records = {} def reset_accum(self) -> None: """Reset per-sound sub-scores.""" for s_player in list(self._player_records.values()): s_player.cancel_multi_kill_timer() s_player.accumscore = 0 s_player.accum_kill_count = 0 s_player.accum_killed_count = 0 s_player.streak = 0 def register_player(self, player: ba.Player) -> None: """Register a player with this score-set.""" name = player.get_name() name_full = player.get_name(full=True) try: # If the player already exists, update his character and such as # it may have changed. self._player_records[name].associate_with_player(player) except Exception: # FIXME: Shouldn't use top level Exception catch for logic. # Should only have this as a fallback and always log it. self._player_records[name] = PlayerRecord(name, name_full, player, self) def get_records(self) -> Dict[str, ba.PlayerRecord]: """Get PlayerRecord corresponding to still-existing players.""" records = {} # Go through our player records and return ones whose player id still # corresponds to a player with that name. for record_id, record in self._player_records.items(): lastplayer = record.get_last_player() if lastplayer and lastplayer.get_name() == record_id: records[record_id] = record return records def _get_spaz(self, player: ba.Player) -> Optional[ba.Actor]: return self._player_records[player.get_name()].get_spaz() def player_got_new_spaz(self, player: ba.Player, spaz: ba.Actor) -> None: """Call this when a player gets a new Spaz.""" record = self._player_records[player.get_name()] if record.get_spaz() is not None: raise Exception("got 2 player_got_new_spaz() messages in a row" " without a lost-spaz message") record.set_spaz(spaz) def player_got_hit(self, player: ba.Player) -> None: """Call this when a player got hit.""" s_player = self._player_records[player.get_name()] s_player.streak = 0 def player_scored(self, player: ba.Player, base_points: int = 1, target: Sequence[float] = None, kill: bool = False, victim_player: ba.Player = None, scale: float = 1.0, color: Sequence[float] = None, title: Union[str, ba.Lstr] = None, screenmessage: bool = True, display: bool = True, importance: int = 1, showpoints: bool = True, big_message: bool = False) -> int: """Register a score for the player. Return value is actual score with multipliers and such factored in. """ # FIXME: Tidy this up. # pylint: disable=cyclic-import # pylint: disable=too-many-branches # pylint: disable=too-many-locals # pylint: disable=too-many-statements from bastd.actor.popuptext import PopupText from ba import _math from ba._gameactivity import GameActivity from ba._lang import Lstr del victim_player # currently unused name = player.get_name() s_player = self._player_records[name] if kill: s_player.submit_kill(showpoints=showpoints) display_color: Sequence[float] = (1.0, 1.0, 1.0, 1.0) if color is not None: display_color = color elif importance != 1: display_color = (1.0, 1.0, 0.4, 1.0) points = base_points # If they want a big announcement, throw a zoom-text up there. if display and big_message: try: assert self._activity is not None activity = self._activity() if isinstance(activity, GameActivity): name_full = player.get_name(full=True, icon=False) activity.show_zoom_message( Lstr(resource='nameScoresText', subs=[('${NAME}', name_full)]), color=_math.normalized_color(player.team.color)) except Exception: from ba import _error _error.print_exception('error showing big_message') # If we currently have a spaz, pop up a score over it. if display and showpoints: our_pos: Optional[Sequence[float]] try: spaz = s_player.get_spaz() assert spaz is not None assert spaz.node our_pos = spaz.node.position except Exception: our_pos = None if our_pos is not None: if target is None: target = our_pos # If display-pos is *way* lower than us, raise it up # (so we can still see scores from dudes that fell off cliffs). display_pos = (target[0], max(target[1], our_pos[1] - 2.0), min(target[2], our_pos[2] + 2.0)) activity = self.getactivity() if activity is not None: if title is not None: sval = Lstr(value='+${A} ${B}', subs=[('${A}', str(points)), ('${B}', title)]) else: sval = Lstr(value='+${A}', subs=[('${A}', str(points))]) PopupText(sval, color=display_color, scale=1.2 * scale, position=display_pos).autoretain() # Tally kills. if kill: s_player.accum_kill_count += 1 s_player.kill_count += 1 # Report non-kill scorings. try: if screenmessage and not kill: _ba.screenmessage(Lstr(resource='nameScoresText', subs=[('${NAME}', name)]), top=True, color=player.color, image=player.get_icon()) except Exception: from ba import _error _error.print_exception('error announcing score') s_player.score += points s_player.accumscore += points # Inform a running game of the score. if points != 0: activity = self._activity() if self._activity is not None else None if activity is not None: activity.handlemessage(PlayerScoredMessage(score=points)) return points def player_lost_spaz(self, player: ba.Player, killed: bool = False, killer: ba.Player = None) -> None: """Should be called when a player loses a spaz.""" from ba._lang import Lstr name = player.get_name() prec = self._player_records[name] prec.set_spaz(None) prec.streak = 0 if killed: prec.accum_killed_count += 1 prec.killed_count += 1 try: if killed and _ba.getactivity().announce_player_deaths: if killer == player: _ba.screenmessage(Lstr(resource='nameSuicideText', subs=[('${NAME}', name)]), top=True, color=player.color, image=player.get_icon()) elif killer is not None: if killer.team == player.team: _ba.screenmessage(Lstr(resource='nameBetrayedText', subs=[('${NAME}', killer.get_name()), ('${VICTIM}', name)]), top=True, color=killer.color, image=killer.get_icon()) else: _ba.screenmessage(Lstr(resource='nameKilledText', subs=[('${NAME}', killer.get_name()), ('${VICTIM}', name)]), top=True, color=killer.color, image=killer.get_icon()) else: _ba.screenmessage(Lstr(resource='nameDiedText', subs=[('${NAME}', name)]), top=True, color=player.color, image=player.get_icon()) except Exception: from ba import _error _error.print_exception('error announcing kill')