# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to scores and statistics."""
from __future__ import annotations
import random
import weakref
import logging
from typing import TYPE_CHECKING
from dataclasses import dataclass
import babase
import _bascenev1
if TYPE_CHECKING:
from typing import Any, Sequence
import bascenev1
@dataclass
class PlayerScoredMessage:
"""Informs something that a bascenev1.Player scored.
Category: **Message Classes**
"""
score: int
"""The score value."""
class PlayerRecord:
"""Stats for an individual player in a bascenev1.Stats object.
Category: **Gameplay Classes**
This does not necessarily correspond to a bascenev1.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,
sessionplayer: bascenev1.SessionPlayer,
stats: bascenev1.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: bascenev1.Timer | None = None
self._multi_kill_count = 0
self._stats = weakref.ref(stats)
self._last_sessionplayer: bascenev1.SessionPlayer | None = None
self._sessionplayer: bascenev1.SessionPlayer | None = None
self._sessionteam: weakref.ref[bascenev1.SessionTeam] | None = None
self.streak = 0
self.associate_with_sessionplayer(sessionplayer)
@property
def team(self) -> bascenev1.SessionTeam:
"""The bascenev1.SessionTeam the last associated player was last on.
This can still return a valid result even if the player is gone.
Raises a bascenev1.SessionTeamNotFoundError if the team no longer
exists.
"""
assert self._sessionteam is not None
team = self._sessionteam()
if team is None:
raise babase.SessionTeamNotFoundError()
return team
@property
def player(self) -> bascenev1.SessionPlayer:
"""Return the instance's associated bascenev1.SessionPlayer.
Raises a bascenev1.SessionPlayerNotFoundError if the player
no longer exists.
"""
if not self._sessionplayer:
raise babase.SessionPlayerNotFoundError()
return self._sessionplayer
[docs]
def getname(self, full: bool = False) -> str:
"""Return the player entry's name."""
return self.name_full if full else self.name
[docs]
def get_icon(self) -> dict[str, Any]:
"""Get the icon for this instance's player."""
player = self._last_sessionplayer
assert player is not None
return player.get_icon()
[docs]
def cancel_multi_kill_timer(self) -> None:
"""Cancel any multi-kill timer for this player entry."""
self._multi_kill_timer = None
[docs]
def getactivity(self) -> bascenev1.Activity | None:
"""Return the bascenev1.Activity this instance is associated with.
Returns None if the activity no longer exists."""
stats = self._stats()
if stats is not None:
return stats.getactivity()
return None
[docs]
def associate_with_sessionplayer(
self, sessionplayer: bascenev1.SessionPlayer
) -> None:
"""Associate this entry with a bascenev1.SessionPlayer."""
self._sessionteam = weakref.ref(sessionplayer.sessionteam)
self.character = sessionplayer.character
self._last_sessionplayer = sessionplayer
self._sessionplayer = sessionplayer
self.streak = 0
def _end_multi_kill(self) -> None:
self._multi_kill_timer = None
self._multi_kill_count = 0
[docs]
def get_last_sessionplayer(self) -> bascenev1.SessionPlayer:
"""Return the last bascenev1.Player we were associated with."""
assert self._last_sessionplayer is not None
return self._last_sessionplayer
[docs]
def submit_kill(self, showpoints: bool = True) -> None:
"""Submit a kill for this player entry."""
# FIXME Clean this up.
# pylint: disable=too-many-statements
self._multi_kill_count += 1
stats = self._stats()
assert stats
if self._multi_kill_count == 1:
score = 0
name = None
delay = 0.0
color = (0.0, 0.0, 0.0, 1.0)
scale = 1.0
sound = None
elif self._multi_kill_count == 2:
score = 20
name = babase.Lstr(resource='twoKillText')
color = (0.1, 1.0, 0.0, 1)
scale = 1.0
delay = 0.0
sound = stats.orchestrahitsound1
elif self._multi_kill_count == 3:
score = 40
name = babase.Lstr(resource='threeKillText')
color = (1.0, 0.7, 0.0, 1)
scale = 1.1
delay = 0.3
sound = stats.orchestrahitsound2
elif self._multi_kill_count == 4:
score = 60
name = babase.Lstr(resource='fourKillText')
color = (1.0, 1.0, 0.0, 1)
scale = 1.2
delay = 0.6
sound = stats.orchestrahitsound3
elif self._multi_kill_count == 5:
score = 80
name = babase.Lstr(resource='fiveKillText')
color = (1.0, 0.5, 0.0, 1)
scale = 1.3
delay = 0.9
sound = stats.orchestrahitsound4
else:
score = 100
name = babase.Lstr(
resource='multiKillText',
subs=[('${COUNT}', str(self._multi_kill_count))],
)
color = (1.0, 0.5, 0.0, 1)
scale = 1.3
delay = 1.0
sound = stats.orchestrahitsound4
def _apply(
name2: babase.Lstr,
score2: int,
showpoints2: bool,
color2: tuple[float, float, float, float],
scale2: float,
sound2: bascenev1.Sound | None,
) -> None:
# pylint: disable=too-many-positional-arguments
from bascenev1lib.actor.popuptext import PopupText
# Only award this if they're still alive and we can get
# a current position for them.
our_pos: babase.Vec3 | None = None
if self._sessionplayer:
if self._sessionplayer.activityplayer is not None:
try:
our_pos = self._sessionplayer.activityplayer.position
except babase.NotFoundError:
pass
if our_pos is None:
return
# Jitter position a bit since these often come in clusters.
our_pos = babase.Vec3(
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(
babase.Lstr(
value=(('+' + str(score2) + ' ') if showpoints2 else '')
+ '${N}',
subs=[('${N}', name2)],
),
color=color2,
scale=scale2,
position=our_pos,
).autoretain()
if sound2:
sound2.play()
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:
_bascenev1.timer(
0.3 + delay,
babase.Call(
_apply, name, score, showpoints, color, scale, sound
),
)
# Keep the tally rollin'...
# set a timer for a bit in the future.
self._multi_kill_timer = _bascenev1.Timer(1.0, self._end_multi_kill)
class Stats:
"""Manages scores and statistics for a bascenev1.Session.
Category: **Gameplay Classes**
"""
def __init__(self) -> None:
self._activity: weakref.ref[bascenev1.Activity] | None = None
self._player_records: dict[str, PlayerRecord] = {}
self.orchestrahitsound1: bascenev1.Sound | None = None
self.orchestrahitsound2: bascenev1.Sound | None = None
self.orchestrahitsound3: bascenev1.Sound | None = None
self.orchestrahitsound4: bascenev1.Sound | None = None
[docs]
def setactivity(self, activity: bascenev1.Activity | None) -> 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.expired:
logging.exception('Unexpected finalized activity.')
else:
with activity.context:
self._load_activity_media()
[docs]
def getactivity(self) -> bascenev1.Activity | None:
"""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 = _bascenev1.getsound('orchestraHit')
self.orchestrahitsound2 = _bascenev1.getsound('orchestraHit2')
self.orchestrahitsound3 = _bascenev1.getsound('orchestraHit3')
self.orchestrahitsound4 = _bascenev1.getsound('orchestraHit4')
[docs]
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 = {}
[docs]
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
[docs]
def register_sessionplayer(self, player: bascenev1.SessionPlayer) -> None:
"""Register a bascenev1.SessionPlayer with this score-set."""
assert player.exists() # Invalid refs should never be passed to funcs.
name = player.getname()
if name in self._player_records:
# If the player already exists, update his character and such as
# it may have changed.
self._player_records[name].associate_with_sessionplayer(player)
else:
name_full = player.getname(full=True)
self._player_records[name] = PlayerRecord(
name, name_full, player, self
)
[docs]
def get_records(self) -> dict[str, bascenev1.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_sessionplayer()
if lastplayer and lastplayer.getname() == record_id:
records[record_id] = record
return records
[docs]
def player_scored(
self,
player: bascenev1.Player,
base_points: int = 1,
*,
target: Sequence[float] | None = None,
kill: bool = False,
victim_player: bascenev1.Player | None = None,
scale: float = 1.0,
color: Sequence[float] | None = None,
title: str | babase.Lstr | None = 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
from bascenev1lib.actor.popuptext import PopupText
from bascenev1._gameactivity import GameActivity
del victim_player # Currently unused.
name = player.getname()
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.getname(full=True, icon=False)
activity.show_zoom_message(
babase.Lstr(
resource='nameScoresText',
subs=[('${NAME}', name_full)],
),
color=babase.normalized_color(player.team.color),
)
except Exception:
logging.exception('Error showing big_message.')
# If we currently have a actor, pop up a score over it.
if display and showpoints:
our_pos = player.node.position if player.node else 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 = babase.Lstr(
value='+${A} ${B}',
subs=[('${A}', str(points)), ('${B}', title)],
)
else:
sval = babase.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:
_bascenev1.broadcastmessage(
babase.Lstr(
resource='nameScoresText', subs=[('${NAME}', name)]
),
top=True,
color=player.color,
image=player.get_icon(),
)
except Exception:
logging.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
[docs]
def player_was_killed(
self,
player: bascenev1.Player,
killed: bool = False,
killer: bascenev1.Player | None = None,
) -> None:
"""Should be called when a player is killed."""
name = player.getname()
prec = self._player_records[name]
prec.streak = 0
if killed:
prec.accum_killed_count += 1
prec.killed_count += 1
try:
if killed and _bascenev1.getactivity().announce_player_deaths:
if killer is player:
_bascenev1.broadcastmessage(
babase.Lstr(
resource='nameSuicideText', subs=[('${NAME}', name)]
),
top=True,
color=player.color,
image=player.get_icon(),
)
elif killer is not None:
if killer.team is player.team:
_bascenev1.broadcastmessage(
babase.Lstr(
resource='nameBetrayedText',
subs=[
('${NAME}', killer.getname()),
('${VICTIM}', name),
],
),
top=True,
color=killer.color,
image=killer.get_icon(),
)
else:
_bascenev1.broadcastmessage(
babase.Lstr(
resource='nameKilledText',
subs=[
('${NAME}', killer.getname()),
('${VICTIM}', name),
],
),
top=True,
color=killer.color,
image=killer.get_icon(),
)
else:
_bascenev1.broadcastmessage(
babase.Lstr(
resource='nameDiedText', subs=[('${NAME}', name)]
),
top=True,
color=player.color,
image=player.get_icon(),
)
except Exception:
logging.exception('Error announcing kill.')