Bombsquad-Ballistica-Modded.../dist/ba_data/python/bacommon/clienteffect.py
2026-07-22 16:32:40 +05:30

306 lines
8.4 KiB
Python

# Released under the MIT License. See LICENSE for details.
#
"""ClientEffect related functionality.
.. warning::
This is an internal api and subject to change at any time. Do not use
it in mod code.
"""
import datetime
from enum import Enum
from dataclasses import dataclass, field
from typing import Annotated, override, assert_never
from efro.dataclassio import ioprepped, IOAttrs, IOMultiType
from bacommon import langstr
from bacommon.langstr import LangStrSpec
from bacommon.assetref import SoundSpec
#: First engine build carrying the v2 client-effect machinery
#: (``ScreenMessageV2``/``PlaySoundV2`` + resolve-before-run).
#: Servers use this to emit the right form per client build.
V2_EFFECTS_MIN_BUILD = 22931
class EffectTypeID(Enum):
"""Type ID for each of our subclasses."""
UNKNOWN = 'u'
LEGACY_SCREEN_MESSAGE = 'm'
SCREEN_MESSAGE = 'sm'
SCREEN_MESSAGE_V2 = 'sm2'
SOUND = 's'
SOUND_V2 = 's2'
DELAY = 'd'
CHEST_WAIT_TIME_ANIMATION = 't'
TICKETS_ANIMATION = 'ta'
TOKENS_ANIMATION = 'toa'
class Effect(IOMultiType[EffectTypeID]):
"""Something that can happen on the client.
This can include screen messages, sounds, visual effects, etc.
"""
@override
@classmethod
def get_type_id(cls) -> EffectTypeID:
# Require child classes to supply this themselves. If we did a
# full type registry/lookup here it would require us to import
# everything and would prevent lazy loading.
raise NotImplementedError()
@override
@classmethod
def get_type_id_storage_name(cls) -> str:
# Pin to the original default for back-compat with stored data.
return '_dciotype'
@override
@classmethod
def get_type(cls, type_id: EffectTypeID) -> type[Effect]:
"""Return the subclass for each of our type-ids."""
# pylint: disable=cyclic-import
# pylint: disable=too-many-return-statements
t = EffectTypeID
if type_id is t.UNKNOWN:
return Unknown
if type_id is t.LEGACY_SCREEN_MESSAGE:
return LegacyScreenMessage
if type_id is t.SCREEN_MESSAGE:
return ScreenMessage
if type_id is t.SCREEN_MESSAGE_V2:
return ScreenMessageV2
if type_id is t.SOUND:
return PlaySound
if type_id is t.SOUND_V2:
return PlaySoundV2
if type_id is t.DELAY:
return Delay
if type_id is t.CHEST_WAIT_TIME_ANIMATION:
return ChestWaitTimeAnimation
if type_id is t.TICKETS_ANIMATION:
return TicketsAnimation
if type_id is t.TOKENS_ANIMATION:
return TokensAnimation
# Important to make sure we provide all types.
assert_never(type_id)
@override
@classmethod
def get_unknown_type_fallback(cls) -> Effect:
# If we encounter some future message type we don't know
# anything about, drop in a placeholder.
return Unknown()
@ioprepped
@dataclass
class Unknown(Effect):
"""Fallback substitute for types we don't recognize."""
@override
@classmethod
def get_type_id(cls) -> EffectTypeID:
return EffectTypeID.UNKNOWN
@ioprepped
@dataclass
class LegacyScreenMessage(Effect):
"""Display a screen-message (Legacy version).
This will be processed as a legacy client Lstr with translation category
'serverResponses'.
When possible, migrate to using :class:`ScreenMessage`.
"""
message: Annotated[str, IOAttrs('m')]
subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field(
default_factory=list
)
color: Annotated[
tuple[float, float, float], IOAttrs('c', store_default=False)
] = (1.0, 1.0, 1.0)
@override
@classmethod
def get_type_id(cls) -> EffectTypeID:
return EffectTypeID.LEGACY_SCREEN_MESSAGE
@ioprepped
@dataclass
class ScreenMessage(Effect):
"""Display a screen-message.
Supported on engine build 22606 or newer.
This version does no translation by default (expecting translation
to happen server-side). Pass a LangStrSpec json string and set is_lstr=True
for client-side translation.
"""
message: Annotated[str, IOAttrs('m')]
color: Annotated[
tuple[float, float, float], IOAttrs('c', store_default=False)
] = (1.0, 1.0, 1.0)
is_lstr: Annotated[bool, IOAttrs('l', store_default=False)] = False
@override
@classmethod
def get_type_id(cls) -> EffectTypeID:
return EffectTypeID.SCREEN_MESSAGE
@ioprepped
@dataclass
class ScreenMessageV2(Effect):
"""Display a screen-message (asset-package l-string version).
The message is a language-agnostic
:class:`~bacommon.langstr.LangStrSpec`; the client resolves the referenced
asset-package(s) in its own locale and decodes before display (see
:func:`collect_apverids`). Only understood by clients new enough to
carry the v2 effect machinery — older ones drop it as
:class:`Unknown` — so gate on engine build or dual-send with a
legacy form where the message matters.
"""
message: Annotated[LangStrSpec, IOAttrs('m')]
color: Annotated[
tuple[float, float, float], IOAttrs('c', store_default=False)
] = (1.0, 1.0, 1.0)
@override
@classmethod
def get_type_id(cls) -> EffectTypeID:
return EffectTypeID.SCREEN_MESSAGE_V2
class Sound(Enum):
"""Sounds that can be played."""
UNKNOWN = 'u'
CASH_REGISTER = 'c'
ERROR = 'e'
POWER_DOWN = 'p'
GUN_COCKING = 'g'
@ioprepped
@dataclass
class PlaySound(Effect):
"""Play a sound."""
sound: Annotated[Sound, IOAttrs('s', enum_fallback=Sound.UNKNOWN)]
volume: Annotated[float, IOAttrs('v', store_default=False)] = 1.0
@override
@classmethod
def get_type_id(cls) -> EffectTypeID:
return EffectTypeID.SOUND
@ioprepped
@dataclass
class PlaySoundV2(Effect):
"""Play a sound from an asset-package.
Unlike :class:`PlaySound`'s fixed :class:`Sound` set, this can play
any packaged sound via a typed
:class:`~bacommon.assetref.SoundSpec`; the client resolves the
referenced asset-package before playing (see
:func:`collect_apverids`). Only understood by clients new enough to
carry the v2 effect machinery — older ones drop it as
:class:`Unknown`.
"""
sound: Annotated[SoundSpec, IOAttrs('s')]
volume: Annotated[float, IOAttrs('v', store_default=False)] = 1.0
@override
@classmethod
def get_type_id(cls) -> EffectTypeID:
return EffectTypeID.SOUND_V2
def collect_apverids(effects: list[Effect], acc: set[str]) -> None:
"""Gather every asset-package-version a list of effects references.
The v2 effect forms are self-describing (name-based ``LangStrSpec`` values
and typed asset refs), so the packages a client must resolve before
running the effects are derived by walking them — nothing extra
rides the wire. Mirrors the doc-ui-v2 pattern.
"""
for effect in effects:
if isinstance(effect, ScreenMessageV2):
langstr.collect_apverids(effect.message, acc)
elif isinstance(effect, PlaySoundV2):
acc.add(effect.sound.apverid)
@ioprepped
@dataclass
class ChestWaitTimeAnimation(Effect):
"""Animate chest wait time changing."""
chestid: Annotated[str, IOAttrs('c')]
duration: Annotated[float, IOAttrs('u')]
startvalue: Annotated[datetime.datetime, IOAttrs('o')]
endvalue: Annotated[datetime.datetime, IOAttrs('n')]
@override
@classmethod
def get_type_id(cls) -> EffectTypeID:
return EffectTypeID.CHEST_WAIT_TIME_ANIMATION
@ioprepped
@dataclass
class TicketsAnimation(Effect):
"""Animate tickets count."""
duration: Annotated[float, IOAttrs('u')]
startvalue: Annotated[int, IOAttrs('s')]
endvalue: Annotated[int, IOAttrs('e')]
@override
@classmethod
def get_type_id(cls) -> EffectTypeID:
return EffectTypeID.TICKETS_ANIMATION
@ioprepped
@dataclass
class TokensAnimation(Effect):
"""Animate tokens count."""
duration: Annotated[float, IOAttrs('u')]
startvalue: Annotated[int, IOAttrs('s')]
endvalue: Annotated[int, IOAttrs('e')]
@override
@classmethod
def get_type_id(cls) -> EffectTypeID:
return EffectTypeID.TOKENS_ANIMATION
@ioprepped
@dataclass
class Delay(Effect):
"""Delay effect processing."""
seconds: Annotated[float, IOAttrs('s')]
@override
@classmethod
def get_type_id(cls) -> EffectTypeID:
return EffectTypeID.DELAY