Source code for mini_arcade_core.scenes.systems.builtins.capture_hotkeys
"""
Reusable capture/replay hotkey system using action-map bindings.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from typing import TYPE_CHECKING, Protocol
from mini_arcade_core.backend.keys import Key
from mini_arcade_core.scenes.systems.base_system import BaseSystem
from mini_arcade_core.scenes.systems.builtins.actions import (
ActionMap,
DigitalActionBinding,
)
from mini_arcade_core.scenes.systems.phases import SystemPhase
if TYPE_CHECKING:
from mini_arcade_core.runtime.services import RuntimeServices
[docs]
class CaptureContext(Protocol):
"""
Structural context for capture hotkey systems.
"""
input_frame: object
commands: object
[docs]
@dataclass(frozen=True)
class CaptureHotkey:
"""
One hotkey toggle configuration.
"""
enabled: bool = True
key: Key | None = None
# pylint: disable=too-many-instance-attributes
[docs]
@dataclass(frozen=True)
class SceneCaptureConfig:
"""
Scene-level capture controls configuration.
"""
screenshot: CaptureHotkey = field(
default_factory=lambda: CaptureHotkey(enabled=True, key=Key.F9)
)
video_toggle: CaptureHotkey = field(
default_factory=lambda: CaptureHotkey(enabled=True, key=Key.F12)
)
replay_record_toggle: CaptureHotkey = field(
default_factory=lambda: CaptureHotkey(enabled=False, key=Key.F10)
)
replay_play_toggle: CaptureHotkey = field(
default_factory=lambda: CaptureHotkey(enabled=False, key=Key.F11)
)
screenshot_label: str | None = None
replay_file: str | None = None
replay_game_id: str = "mini-arcade"
replay_initial_scene: str | None = None
replay_fps: int = 60
[docs]
def any_enabled(self) -> bool:
"""
Return True if at least one capture feature is enabled.
"""
return any(
(
self.screenshot.enabled,
self.video_toggle.enabled,
self.replay_record_toggle.enabled,
self.replay_play_toggle.enabled,
)
)
[docs]
def with_scene_defaults(self, scene_id: str) -> "SceneCaptureConfig":
"""
Fill scene-derived defaults while preserving explicit overrides.
"""
replay_file = self.replay_file
if replay_file is None and (
self.replay_record_toggle.enabled
or self.replay_play_toggle.enabled
):
replay_file = f"{scene_id}_replay.marc"
return replace(
self,
screenshot_label=self.screenshot_label or scene_id,
replay_file=replay_file,
replay_initial_scene=self.replay_initial_scene or scene_id,
)
# pylint: disable=too-many-instance-attributes
[docs]
@dataclass(frozen=True)
class CaptureHotkeysConfig:
"""
Per-scene capture workflow configuration.
"""
screenshot_label: str | None = None
replay_file: str | None = None
replay_game_id: str = "mini-arcade"
replay_initial_scene: str = "unknown"
replay_fps: int = 60
action_toggle_video: str = "capture_toggle_video"
action_toggle_replay_record: str = "capture_toggle_replay_record"
action_toggle_replay_play: str = "capture_toggle_replay_play"
action_screenshot: str = "capture_screenshot"
[docs]
@classmethod
def from_scene_capture_config(
cls, cfg: SceneCaptureConfig
) -> "CaptureHotkeysConfig":
"""
Build an action-driven capture config from the scene key config.
"""
return cls(
screenshot_label=cfg.screenshot_label,
replay_file=cfg.replay_file,
replay_game_id=cfg.replay_game_id,
replay_initial_scene=cfg.replay_initial_scene or "unknown",
replay_fps=cfg.replay_fps,
)
[docs]
def action_map_from_scene_capture_config(
scene_cfg: SceneCaptureConfig,
*,
hotkeys_cfg: CaptureHotkeysConfig | None = None,
) -> ActionMap:
"""
Build default capture action bindings from SceneCaptureConfig key hotkeys.
"""
cfg = hotkeys_cfg or CaptureHotkeysConfig.from_scene_capture_config(
scene_cfg
)
bindings: dict[str, DigitalActionBinding] = {}
if scene_cfg.screenshot.enabled and scene_cfg.screenshot.key is not None:
bindings[cfg.action_screenshot] = DigitalActionBinding(
keys=(scene_cfg.screenshot.key,)
)
if (
scene_cfg.video_toggle.enabled
and scene_cfg.video_toggle.key is not None
):
bindings[cfg.action_toggle_video] = DigitalActionBinding(
keys=(scene_cfg.video_toggle.key,)
)
if (
scene_cfg.replay_record_toggle.enabled
and scene_cfg.replay_record_toggle.key is not None
):
bindings[cfg.action_toggle_replay_record] = DigitalActionBinding(
keys=(scene_cfg.replay_record_toggle.key,)
)
if (
scene_cfg.replay_play_toggle.enabled
and scene_cfg.replay_play_toggle.key is not None
):
bindings[cfg.action_toggle_replay_play] = DigitalActionBinding(
keys=(scene_cfg.replay_play_toggle.key,)
)
return ActionMap(bindings=bindings)
[docs]
@dataclass
class CaptureHotkeysSystem(BaseSystem[CaptureContext]):
"""
Handles screenshot/replay/video commands in a reusable way.
"""
services: RuntimeServices
action_map: ActionMap
cfg: CaptureHotkeysConfig = CaptureHotkeysConfig()
name: str = "capture_hotkeys"
phase: int = SystemPhase.CONTROL
order: int = 13
[docs]
def step(self, ctx: CaptureContext) -> None:
# Local import avoids a circular import chain:
# engine.commands -> scenes.models -> scenes.sim_scene -> this module.
# pylint: disable=import-outside-toplevel
from mini_arcade_core.engine.commands import (
ScreenshotCommand,
StartReplayPlayCommand,
StartReplayRecordCommand,
StopReplayPlayCommand,
StopReplayRecordCommand,
ToggleVideoRecordCommand,
)
# pylint: enable=import-outside-toplevel
snap = self.action_map.read(ctx.input_frame)
cap = self.services.capture
if (
snap.pressed(self.cfg.action_screenshot)
and self.cfg.screenshot_label
):
ctx.commands.push(
ScreenshotCommand(label=self.cfg.screenshot_label)
)
if snap.pressed(self.cfg.action_toggle_video):
ctx.commands.push(ToggleVideoRecordCommand())
if self.cfg.replay_file is None:
return
if snap.pressed(self.cfg.action_toggle_replay_record):
if cap.replay_recording:
ctx.commands.push(StopReplayRecordCommand())
else:
if cap.replay_playing:
ctx.commands.push(StopReplayPlayCommand())
ctx.commands.push(
StartReplayRecordCommand(
filename=self.cfg.replay_file,
game_id=self.cfg.replay_game_id,
initial_scene=self.cfg.replay_initial_scene,
fps=self.cfg.replay_fps,
)
)
if snap.pressed(self.cfg.action_toggle_replay_play):
if cap.replay_playing:
ctx.commands.push(StopReplayPlayCommand())
else:
if cap.replay_recording:
ctx.commands.push(StopReplayRecordCommand())
ctx.commands.push(
StartReplayPlayCommand(path=self.cfg.replay_file)
)