"""
Module defining the replay container types
"""
import dataclasses
from enum import IntEnum, IntFlag
from typing import Any, Callable, Dict, List, Optional
#: Latest replay version supported by dustmaker/dustmod
LATEST_VERSION = 4
[docs]class IntentStream(IntEnum):
"""Enumeration of the different intent streams in the order they
are listed within the replay binary format.
"""
#: X intent: -1 for left, 0 for neutral, 1 for right
X = 0
#: Y intent: -1 for up, 0 for nuetral, 1 for down
Y = 1
#: jump intent: 0 for not pressed, 1 for pressed and unused, 2 for pressed
#: and used.
JUMP = 2
#: dash intent: 0 for not pressed, 1 for pressed and unused, 2 for pressed
#: and used (2 is only present for weird subframe things).
DASH = 3
#: fall intent: 0 for not pressed, 1 for pressed and unused, 2 for pressed
#: and used (2 is only present for weird subframe things).
FALL = 4
#: light intent: 0 for not pressed, 10 for pressed and unused, 11 for pressed
#: and used, 1-9 counts down from 10 after the key is released and unused,
#: during this time the intent will be consumed if possible from the player
#: state.
LIGHT = 5
#: heavy intent: 0 for not pressed, 10 for pressed and unused, 11 for pressed
#: and used, 1-9 counts down from 10 after the key is released and unused,
#: during this time the intent will be consumed if possible from the player
#: state.
HEAVY = 6
#: taunt intent: 0 for not pressed, 1 for pressed and unused, 2 for pressed
#: and used.
TAUNT = 7
#: mouse x: float in the range [0.0, 1.0] where 0.0 corresponds to the left of
#: the screen and 1.0 corresponds to the right of the screen. This is internally
#: stored with 16 bits of accuracy.
MOUSE_X = 8
#: mouse y: float in the range [0.0, 1.0] where 0.0 corresponds to the top of
#: the screen and 1.0 corresponds to the bottom of the screen. This is internally
#: stored with 16 bits of accuracy.
MOUSE_Y = 9
#: mouse state: bit mask of current mouse state as defined in :class:`MouseState`.
MOUSE_STATE = 10
[docs]class MouseState(IntFlag):
"""Mouse state bitmask values associated with the
:attr:`IntentStream.MOUSE_STATE` intent.
"""
WHEEL_UP = 1
WHEEL_DOWN = 2
LEFT_CLICK = 4
RIGHT_CLICK = 8
MIDDLE_CLICK = 16
[docs]class Character(IntEnum):
"""Numeric character IDs for each playable character"""
DUSTMAN = 0
DUSTGIRL = 1
DUSTKID = 2
DUSTWORTH = 3
SLIMEBOSS = 4
TRASHKING = 5
LEAFSPRITE = 6
DUSTWRAITH = 7
@dataclasses.dataclass
class _IntentMetadata:
"""Container class for metadata about an intent stream"""
bits: int
version: int = 1
to_repr: Callable[[int], Any] = lambda x: x
to_bits: Callable[[Any], int] = lambda x: x
default: Any = 0
_INTENT_META = (
_IntentMetadata(
bits=2,
to_repr=lambda x: x - 1,
to_bits=lambda x: x + 1,
), # X
_IntentMetadata(
bits=2,
to_repr=lambda x: x - 1,
to_bits=lambda x: x + 1,
), # Y
_IntentMetadata(bits=2), # JUMP
_IntentMetadata(bits=2), # DASH
_IntentMetadata(bits=2), # FALL
_IntentMetadata(bits=4), # LIGHT
_IntentMetadata(bits=4), # HEAVY
_IntentMetadata(
bits=4,
version=3,
), # TAUNT
_IntentMetadata(
bits=16,
to_repr=lambda x: x / 32767.0,
to_bits=lambda x: int(round(x * 32767)),
version=4,
default=0.0,
), # MOUSE_X
_IntentMetadata(
bits=16,
to_repr=lambda x: x / 32767.0,
to_bits=lambda x: int(round(x * 32767)),
version=4,
default=0.0,
), # MOUSE_Y
_IntentMetadata(
bits=8,
to_repr=MouseState,
to_bits=int,
version=4,
default=MouseState(0),
), # MOUSE_STATE
)
[docs]@dataclasses.dataclass
class PlayerData:
"""Container class for a single player's replay data"""
character: Character = Character.DUSTMAN
#: The intent data parsed from the replay file. These may have length
#: smaller than the number of frames in the replay in which case
#: the neutral value for that intent should be considered the active intent
#: on those frames. Use `:meth:`get_intent_value` to automatically deal
#: with this when reading replays.
intents: Dict[IntentStream, List[Any]] = dataclasses.field(default_factory=dict)
[docs] def get_intent_value(self, intent: IntentStream, frame: int) -> Any:
"""Returns the value for the given intent at the given frame"""
values = self.intents.get(intent)
if values is None or not (0 <= frame < len(values)):
return _INTENT_META[intent].default
return values[frame]
[docs]@dataclasses.dataclass
class EntityFrame:
"""Container class for a single frame worth of entity desync data."""
#: Frame timer for this entity frame.
frame: int = 0
#: X position of the entity. This has a resolution of a tenth of a pixel.
x_pos: float = 0.0
#: Y position of the entity. This has a resolution of a tenth of a pixel.
y_pos: float = 0.0
#: X-speed of the entity. This has a resolution of 0.01 pixels/s.
x_speed: float = 0.0
#: Y-speed of the entity. This has a resolution of 0.01 pixels/s.
y_speed: float = 0.0
[docs]@dataclasses.dataclass
class EntityData:
"""Container class for all the desync frame data for an entity. Note that
the engine only stores desync data every 8 frames (and then slower than that
eventually for long replays) and only stores the data if the entity moved
significantly.
"""
#: Frame data for the given entity. This should appear in increasing order
#: of frame time but the times might not increase at the same rate.
frames: List[EntityFrame] = dataclasses.field(default_factory=list)
[docs]@dataclasses.dataclass
class Replay:
"""Container class for a replay"""
#: The format of the replay file. If writing a replay just use
#: LATEST_VERSION unless you want compatibility with vanilla.
version: int = LATEST_VERSION
#: The username associated with the replay. If this is unset no
#: username header will be included. If feeding the replay binary
#: directly to dustmod make sure to include a username as it does
#: expect to find a replay header.
username: bytes = b""
#: The level filename associated with the replay.
level: bytes = b""
#: The length of the replay in frames.
frames: int = 0
#: Per-player replay data
players: List[PlayerData] = dataclasses.field(default_factory=list)
#: Entity desync data per entity. This maps the "replay uid" to the
#: EntityData captured for that entity. There are two fixed UIDs
entities: Dict[int, EntityData] = dataclasses.field(default_factory=dict)
[docs] def get_player_entity_data(self, player: int = 1) -> Optional[EntityData]:
"""Get the entity data for the given player entity.
Args:
player (int, optional): The player to get the entity data for indexed from 1
"""
return self.entities.get(2 * player)
[docs] def get_camera_entity_data(self, player: int = 1) -> Optional[EntityData]:
"""Get the entity data for the camera following the given player.
Args:
player (int, optional): The player to get the camera of indexed from 1
"""
return self.entities.get(1 + 2 * player)