mini-arcade-core 1.2.1__tar.gz → 1.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/PKG-INFO +1 -1
  2. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/pyproject.toml +1 -1
  3. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/commands.py +69 -1
  4. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/game.py +5 -3
  5. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/runner.py +27 -4
  6. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/capture_service.py +96 -0
  7. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/capture_service_protocol.py +125 -0
  8. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/capture_settings.py +22 -0
  9. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/replay.py +132 -0
  10. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/replay_format.py +120 -0
  11. mini_arcade_core-1.2.1/src/mini_arcade_core/runtime/capture/capture_adapter.py → mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/screenshot_capturer.py +9 -30
  12. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/input_frame.py +149 -0
  13. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/services.py +5 -3
  14. mini_arcade_core-1.2.1/src/mini_arcade_core/runtime/input_frame.py +0 -71
  15. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/LICENSE +0 -0
  16. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/README.md +0 -0
  17. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/__init__.py +0 -0
  18. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/__init__.py +0 -0
  19. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/backend.py +0 -0
  20. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/events.py +0 -0
  21. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/keys.py +0 -0
  22. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/sdl_map.py +0 -0
  23. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/types.py +0 -0
  24. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/bus.py +0 -0
  25. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/__init__.py +0 -0
  26. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/cheats.py +0 -0
  27. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/game_config.py +0 -0
  28. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/gameplay_settings.py +0 -0
  29. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/__init__.py +0 -0
  30. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/config.py +0 -0
  31. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/hooks.py +0 -0
  32. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/state.py +0 -0
  33. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/managers.py +0 -0
  34. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/__init__.py +0 -0
  35. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/context.py +0 -0
  36. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/__init__.py +0 -0
  37. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/base.py +0 -0
  38. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/crt.py +0 -0
  39. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/registry.py +0 -0
  40. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/vignette.py +0 -0
  41. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/frame_packet.py +0 -0
  42. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/packet.py +0 -0
  43. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/__init__.py +0 -0
  44. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/base.py +0 -0
  45. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/begin_frame.py +0 -0
  46. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/end_frame.py +0 -0
  47. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/lighting.py +0 -0
  48. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/postfx.py +0 -0
  49. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/ui.py +0 -0
  50. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/world.py +0 -0
  51. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/pipeline.py +0 -0
  52. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/render_service.py +0 -0
  53. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/viewport.py +0 -0
  54. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/__init__.py +0 -0
  55. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/models.py +0 -0
  56. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/scene_manager.py +0 -0
  57. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/__init__.py +0 -0
  58. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/__init__.py +0 -0
  59. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/audio_adapter.py +0 -0
  60. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/audio_port.py +0 -0
  61. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/capture/__init__.py +0 -0
  62. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/capture/capture_port.py +0 -0
  63. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/capture/capture_worker.py +0 -0
  64. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/context.py +0 -0
  65. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/__init__.py +0 -0
  66. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/file_adapter.py +0 -0
  67. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/file_port.py +0 -0
  68. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/__init__.py +0 -0
  69. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/input_adapter.py +0 -0
  70. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/input_port.py +0 -0
  71. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/render/__init__.py +0 -0
  72. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/render/render_port.py +0 -0
  73. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/__init__.py +0 -0
  74. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/scene_query_adapter.py +0 -0
  75. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/scene_query_port.py +0 -0
  76. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/__init__.py +0 -0
  77. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/window_adapter.py +0 -0
  78. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/window_port.py +0 -0
  79. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/__init__.py +0 -0
  80. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/autoreg.py +0 -0
  81. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/debug_overlay.py +0 -0
  82. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/registry.py +0 -0
  83. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/sim_scene.py +0 -0
  84. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/__init__.py +0 -0
  85. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/base_system.py +0 -0
  86. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/system_pipeline.py +0 -0
  87. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/__init__.py +0 -0
  88. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/__init__.py +0 -0
  89. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/boundaries2d.py +0 -0
  90. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/collision2d.py +0 -0
  91. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/geometry2d.py +0 -0
  92. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/kinematics2d.py +0 -0
  93. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/physics2d.py +0 -0
  94. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/ui/__init__.py +0 -0
  95. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/ui/menu.py +0 -0
  96. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/__init__.py +0 -0
  97. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/deprecated_decorator.py +0 -0
  98. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/logging.py +0 -0
  99. {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/profiler.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-arcade-core
3
- Version: 1.2.1
3
+ Version: 1.2.2
4
4
  Summary: Tiny scene-based game loop core for small arcade games.
5
5
  License: Copyright (c) 2025 Santiago Rincón
6
6
 
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "mini-arcade-core"
7
- version = "1.2.1"
7
+ version = "1.2.2"
8
8
  description = "Tiny scene-based game loop core for small arcade games."
9
9
  authors = [
10
10
  { name = "Santiago Rincon", email = "rincores@gmail.com" },
@@ -5,9 +5,11 @@ Command protocol for executing commands with a given context.
5
5
  from __future__ import annotations
6
6
 
7
7
  from dataclasses import dataclass, field
8
+ from pathlib import Path
8
9
  from typing import TYPE_CHECKING, List, Optional, Protocol, TypeVar
9
10
 
10
11
  from mini_arcade_core.engine.scenes.models import ScenePolicy
12
+ from mini_arcade_core.runtime.capture.replay_format import ReplayHeader
11
13
 
12
14
  if TYPE_CHECKING:
13
15
  from mini_arcade_core.runtime.services import RuntimeServices
@@ -121,7 +123,7 @@ class ScreenshotCommand(Command):
121
123
  self,
122
124
  context: CommandContext,
123
125
  ):
124
- context.services.capture.screenshot(label=self.label, mode="manual")
126
+ context.services.capture.screenshot(label=self.label)
125
127
 
126
128
 
127
129
  @dataclass(frozen=True)
@@ -216,3 +218,69 @@ class ToggleEffectCommand(Command):
216
218
  if stack is None:
217
219
  return
218
220
  stack.toggle(self.effect_id)
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class StartReplayRecordCommand(Command):
225
+ """
226
+ Start recording a replay to the specified file.
227
+
228
+ :ivar filename (str): The filename to save the replay to.
229
+ :ivar game_id (str): Identifier of the game.
230
+ :ivar initial_scene (str): The initial scene of the replay.
231
+ :ivar seed (int): The random seed used in the replay.
232
+ :ivar fps (int): Frames per second for the replay.
233
+ """
234
+
235
+ filename: str
236
+ game_id: str = "mini-arcade"
237
+ initial_scene: str = "unknown"
238
+ seed: int = 0
239
+ fps: int = 60
240
+
241
+ def execute(self, context: CommandContext):
242
+ header = ReplayHeader(
243
+ game_id=self.game_id,
244
+ initial_scene=self.initial_scene,
245
+ seed=self.seed,
246
+ fps=self.fps,
247
+ )
248
+ context.services.capture.start_replay_record(
249
+ filename=self.filename,
250
+ header=header,
251
+ )
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class StopReplayRecordCommand(Command):
256
+ """Stop recording the current replay."""
257
+
258
+ def execute(self, context: CommandContext):
259
+ context.services.capture.stop_replay_record()
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class StartReplayPlayCommand(Command):
264
+ """
265
+ Start playing back a replay from the specified file.
266
+
267
+ :ivar path (str): The path to the replay file.
268
+ :ivar change_scene (bool): Whether to change to the replay's initial scene.
269
+ """
270
+
271
+ path: str
272
+ change_scene: bool = True
273
+
274
+ def execute(self, context: CommandContext):
275
+ header = context.services.capture.start_replay_play(Path(self.path))
276
+ if self.change_scene:
277
+ # NOTE: **IMPORTANT** align game state with the replay header
278
+ context.managers.scenes.change(header.initial_scene)
279
+
280
+
281
+ @dataclass(frozen=True)
282
+ class StopReplayPlayCommand(Command):
283
+ """Stop playing back the current replay."""
284
+
285
+ def execute(self, context: CommandContext):
286
+ context.services.capture.stop_replay_play()
@@ -24,7 +24,7 @@ from mini_arcade_core.engine.render.pipeline import RenderPipeline
24
24
  from mini_arcade_core.engine.render.render_service import RenderService
25
25
  from mini_arcade_core.engine.scenes.scene_manager import SceneAdapter
26
26
  from mini_arcade_core.runtime.audio.audio_adapter import SDLAudioAdapter
27
- from mini_arcade_core.runtime.capture.capture_adapter import CaptureAdapter
27
+ from mini_arcade_core.runtime.capture.capture_service import CaptureService
28
28
  from mini_arcade_core.runtime.file.file_adapter import LocalFilesAdapter
29
29
  from mini_arcade_core.runtime.input.input_adapter import InputAdapter
30
30
  from mini_arcade_core.runtime.scene.scene_query_adapter import (
@@ -70,10 +70,12 @@ class Game:
70
70
  ),
71
71
  )
72
72
  self.services = RuntimeServices(
73
- window=WindowAdapter(self.backend), # Turn into a manager?
73
+ window=WindowAdapter(self.backend),
74
74
  audio=SDLAudioAdapter(self.backend),
75
75
  files=LocalFilesAdapter(),
76
- capture=CaptureAdapter(self.backend),
76
+ capture=CaptureService(
77
+ self.backend
78
+ ), # NOTE: Should actually be a manager?
77
79
  input=InputAdapter(),
78
80
  render=RenderService(),
79
81
  scenes=SceneQueryAdapter(self.managers.scenes),
@@ -139,14 +139,37 @@ class EngineRunner:
139
139
  def _build_input(
140
140
  self, events, *, frame: FrameState, timer: FrameTimer | None
141
141
  ):
142
- # Build InputFrame from events.
143
- input_frame = self.services.input.build(
144
- events, frame.frame_index, frame.dt
145
- )
142
+ cap = self.services.capture
143
+
144
+ if cap.replay_playing:
145
+ input_frame = cap.next_replay_input()
146
+
147
+ # optional but recommended: keep runner's frame_index/dt authoritative
148
+ input_frame = InputFrame(
149
+ frame_index=frame.frame_index,
150
+ dt=frame.dt,
151
+ keys_down=input_frame.keys_down,
152
+ keys_pressed=input_frame.keys_pressed,
153
+ keys_released=input_frame.keys_released,
154
+ buttons=input_frame.buttons,
155
+ axes=input_frame.axes,
156
+ mouse_pos=input_frame.mouse_pos,
157
+ mouse_delta=input_frame.mouse_delta,
158
+ text_input=input_frame.text_input,
159
+ quit=input_frame.quit,
160
+ )
161
+ else:
162
+ input_frame = self.services.input.build(
163
+ events, frame.frame_index, frame.dt
164
+ )
165
+
146
166
  if timer:
147
167
  timer.mark("input_built")
168
+
148
169
  if input_frame.quit:
149
170
  self.managers.command_queue.push(QuitCommand())
171
+
172
+ cap.record_input(input_frame)
150
173
  return input_frame
151
174
 
152
175
  def _should_quit(self, input_frame: InputFrame) -> bool:
@@ -0,0 +1,96 @@
1
+ """
2
+ Capture service managing screenshots and replays.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from mini_arcade_core.backend import Backend
11
+ from mini_arcade_core.runtime.capture.capture_service_protocol import (
12
+ CaptureServicePort,
13
+ )
14
+ from mini_arcade_core.runtime.capture.capture_settings import CaptureSettings
15
+ from mini_arcade_core.runtime.capture.replay import (
16
+ ReplayPlayer,
17
+ ReplayRecorder,
18
+ ReplayRecorderConfig,
19
+ )
20
+ from mini_arcade_core.runtime.capture.replay_format import ReplayHeader
21
+ from mini_arcade_core.runtime.capture.screenshot_capturer import (
22
+ ScreenshotCapturer,
23
+ )
24
+ from mini_arcade_core.runtime.input_frame import InputFrame
25
+
26
+
27
+ class CaptureService(CaptureServicePort):
28
+ """
29
+ Owns:
30
+ - screenshots (delegated)
31
+ - replay recording (InputFrame stream)
32
+ - replay playback (feeds InputFrames)
33
+ - (later) video recording
34
+ """
35
+
36
+ # pylint: disable=too-many-arguments
37
+ def __init__(
38
+ self,
39
+ backend: Backend,
40
+ *,
41
+ screenshots: Optional[ScreenshotCapturer] = None,
42
+ replay_recorder: Optional[ReplayRecorder] = None,
43
+ replay_player: Optional[ReplayPlayer] = None,
44
+ settings: Optional[CaptureSettings] = None,
45
+ ):
46
+ self.backend = backend
47
+ self.settings = settings or CaptureSettings()
48
+
49
+ self.screenshots = screenshots or ScreenshotCapturer(backend)
50
+ self.replay_recorder = replay_recorder or ReplayRecorder()
51
+ self.replay_player = replay_player or ReplayPlayer()
52
+
53
+ # -------- screenshots --------
54
+ def screenshot(self, label: str | None = None) -> str:
55
+ return self.screenshots.screenshot(label)
56
+
57
+ def screenshot_sim(
58
+ self, run_id: str, frame_index: int, label: str = "frame"
59
+ ) -> str:
60
+ return self.screenshots.screenshot_sim(run_id, frame_index, label)
61
+
62
+ # -------- replays --------
63
+ @property
64
+ def replay_playing(self) -> bool:
65
+ return self.replay_player.active
66
+
67
+ @property
68
+ def replay_recording(self) -> bool:
69
+ return self.replay_recorder.active
70
+
71
+ def start_replay_record(
72
+ self,
73
+ *,
74
+ filename: str,
75
+ header: ReplayHeader,
76
+ ) -> None:
77
+ path = Path(self.settings.replays_dir) / filename
78
+ self.replay_recorder.start(
79
+ ReplayRecorderConfig(path=path, header=header)
80
+ )
81
+
82
+ def stop_replay_record(self) -> None:
83
+ self.replay_recorder.stop()
84
+
85
+ def record_input(self, frame: InputFrame) -> None:
86
+ self.replay_recorder.record(frame)
87
+
88
+ def start_replay_play(self, filename: str) -> ReplayHeader:
89
+ path = Path(self.settings.replays_dir) / filename
90
+ return self.replay_player.start(path)
91
+
92
+ def stop_replay_play(self) -> None:
93
+ self.replay_player.stop()
94
+
95
+ def next_replay_input(self) -> InputFrame:
96
+ return self.replay_player.next()
@@ -0,0 +1,125 @@
1
+ """
2
+ Capture service protocol
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from mini_arcade_core.backend import Backend
8
+ from mini_arcade_core.runtime.capture.capture_settings import CaptureSettings
9
+ from mini_arcade_core.runtime.capture.replay import (
10
+ ReplayPlayer,
11
+ ReplayRecorder,
12
+ )
13
+ from mini_arcade_core.runtime.capture.replay_format import ReplayHeader
14
+ from mini_arcade_core.runtime.capture.screenshot_capturer import (
15
+ ScreenshotCapturer,
16
+ )
17
+ from mini_arcade_core.runtime.input_frame import InputFrame
18
+
19
+
20
+ class CaptureServicePort:
21
+ """
22
+ Interface for the Capture Service.
23
+ """
24
+
25
+ backend: Backend
26
+ settings: CaptureSettings
27
+ screenshots: ScreenshotCapturer
28
+ replay_recorder: ReplayRecorder
29
+ replay_player: ReplayPlayer
30
+
31
+ # -------- screenshots --------
32
+ def screenshot(self, label: str | None = None) -> str:
33
+ """
34
+ Take a screenshot with an optional label.
35
+
36
+ :param label: Optional label for the screenshot.
37
+ :type label: str | None
38
+ :return: Path to the saved screenshot.
39
+ :rtype: str
40
+ """
41
+
42
+ def screenshot_sim(
43
+ self, run_id: str, frame_index: int, label: str = "frame"
44
+ ) -> str:
45
+ """
46
+ Take a screenshot for a simulation frame.
47
+
48
+ :param run_id: Unique identifier for the simulation run.
49
+ :type run_id: str
50
+
51
+ :param frame_index: Index of the frame in the simulation.
52
+ :type frame_index: int
53
+
54
+ :param label: Label for the screenshot.
55
+ :type label: str
56
+
57
+ :return: Path to the saved screenshot.
58
+ :rtype: str
59
+ """
60
+
61
+ # -------- replays --------
62
+ @property
63
+ def replay_playing(self) -> bool:
64
+ """
65
+ Check if a replay is currently being played back.
66
+
67
+ :return: True if a replay is active, False otherwise.
68
+ :rtype: bool
69
+ """
70
+
71
+ @property
72
+ def replay_recording(self) -> bool:
73
+ """
74
+ Check if a replay is currently being recorded.
75
+
76
+ :return: True if recording is active, False otherwise.
77
+ :rtype: bool
78
+ """
79
+
80
+ def start_replay_record(
81
+ self,
82
+ *,
83
+ filename: str,
84
+ header: ReplayHeader,
85
+ ) -> None:
86
+ """
87
+ Start recording a replay.
88
+
89
+ :param filename: The filename to save the replay to.
90
+ :type filename: str
91
+ :param header: The header information for the replay.
92
+ :type header: ReplayHeader
93
+ """
94
+
95
+ def stop_replay_record(self) -> None:
96
+ """Stop recording the current replay."""
97
+
98
+ def record_input(self, frame: InputFrame) -> None:
99
+ """
100
+ Record an input frame to the replay.
101
+
102
+ :param frame: The input frame to record.
103
+ :type frame: InputFrame
104
+ """
105
+
106
+ def start_replay_play(self, filename: str) -> ReplayHeader:
107
+ """
108
+ Start playing back a replay.
109
+
110
+ :param filename: The filename of the replay to play.
111
+ :type filename: str
112
+ :return: The header information of the replay.
113
+ :rtype: ReplayHeader
114
+ """
115
+
116
+ def stop_replay_play(self) -> None:
117
+ """Stop playing back the current replay."""
118
+
119
+ def next_replay_input(self) -> InputFrame:
120
+ """
121
+ Get the next input frame from the replay.
122
+
123
+ :return: The next input frame.
124
+ :rtype: InputFrame
125
+ """
@@ -0,0 +1,22 @@
1
+ """
2
+ Capture settings dataclass
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class CaptureSettings:
12
+ """
13
+ Settings for the Capture Service.
14
+
15
+ :ivar screenshots_dir: Directory to save screenshots.
16
+ :ivar screenshots_ext: File extension/format for screenshots.
17
+ :ivar replays_dir: Directory to save replays.
18
+ """
19
+
20
+ screenshots_dir: str = "screenshots"
21
+ screenshots_ext: str = "png"
22
+ replays_dir: str = "replays"
@@ -0,0 +1,132 @@
1
+ """
2
+ Replay recording and playback functionality.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Iterator, Optional
10
+
11
+ from mini_arcade_core.runtime.capture.replay_format import (
12
+ ReplayHeader,
13
+ ReplayReader,
14
+ ReplayWriter,
15
+ )
16
+ from mini_arcade_core.runtime.input_frame import InputFrame
17
+
18
+
19
+ @dataclass
20
+ class ReplayRecorderConfig:
21
+ """
22
+ Configuration for replay recording.
23
+
24
+ :ivar path (Path): Path to save the replay file.
25
+ :ivar header (ReplayHeader): Header information for the replay.
26
+ """
27
+
28
+ path: Path
29
+ header: ReplayHeader
30
+
31
+
32
+ class ReplayRecorder:
33
+ """Recorder for game replays."""
34
+
35
+ def __init__(self):
36
+ self._writer: Optional[ReplayWriter] = None
37
+
38
+ @property
39
+ def active(self) -> bool:
40
+ """
41
+ Check if the recorder is currently active.
42
+
43
+ :return: True if recording is active, False otherwise.
44
+ :rtype: bool
45
+ """
46
+ return self._writer is not None
47
+
48
+ def start(self, cfg: ReplayRecorderConfig) -> None:
49
+ """
50
+ Start recording a replay.
51
+
52
+ :param cfg: Configuration for the replay recorder.
53
+ :type cfg: ReplayRecorderConfig
54
+ """
55
+ if self._writer:
56
+ raise RuntimeError("ReplayRecorder already active")
57
+ self._writer = ReplayWriter(cfg.path, cfg.header)
58
+ self._writer.open()
59
+
60
+ def record(self, frame: InputFrame) -> None:
61
+ """
62
+ Record an input frame to the replay.
63
+
64
+ :param frame: The input frame to record.
65
+ :type frame: InputFrame
66
+ """
67
+ if self._writer:
68
+ self._writer.write_frame(frame)
69
+
70
+ def stop(self) -> None:
71
+ """Stop recording the current replay."""
72
+ if self._writer:
73
+ self._writer.close()
74
+ self._writer = None
75
+
76
+
77
+ class ReplayPlayer:
78
+ """Player for game replays."""
79
+
80
+ def __init__(self):
81
+ self._reader: Optional[ReplayReader] = None
82
+ self._it: Optional[Iterator[InputFrame]] = None
83
+ self.header: Optional[ReplayHeader] = None
84
+
85
+ @property
86
+ def active(self) -> bool:
87
+ """
88
+ Check if the player is currently active.
89
+
90
+ :return: True if a replay is being played, False otherwise.
91
+ :rtype: bool
92
+ """
93
+ return self._it is not None
94
+
95
+ def start(self, path: Path) -> ReplayHeader:
96
+ """
97
+ Start playing back a replay.
98
+
99
+ :param path: Path to the replay file.
100
+ :type path: Path
101
+ :return: The header information of the replay.
102
+ :rtype: ReplayHeader
103
+ """
104
+ if self._reader:
105
+ raise RuntimeError("ReplayPlayer already active")
106
+ self._reader = ReplayReader(path)
107
+ self.header = self._reader.open()
108
+ self._it = self._reader.frames()
109
+ return self.header
110
+
111
+ def next(self) -> InputFrame:
112
+ """
113
+ Get the next input frame from the replay.
114
+
115
+ :return: The next input frame.
116
+ :rtype: InputFrame
117
+ """
118
+ if not self._it:
119
+ raise RuntimeError("ReplayPlayer not active")
120
+ try:
121
+ return next(self._it)
122
+ except StopIteration as exc:
123
+ self.stop()
124
+ raise RuntimeError("Replay finished") from exc
125
+
126
+ def stop(self) -> None:
127
+ """Stop playing back the current replay."""
128
+ if self._reader:
129
+ self._reader.close()
130
+ self._reader = None
131
+ self._it = None
132
+ self.header = None
@@ -0,0 +1,120 @@
1
+ """
2
+ Replay file format for mini-arcade.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Iterator, Optional, TextIO
11
+
12
+ from mini_arcade_core.runtime.input_frame import InputFrame
13
+
14
+ REPLAY_MAGIC = "mini-arcade-replay"
15
+ REPLAY_VERSION = 1
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ReplayHeader:
20
+ """
21
+ Header information for a mini-arcade replay file.
22
+
23
+ :ivar magic: Magic string to identify the file type.
24
+ :ivar version: Version of the replay format.
25
+ :ivar game_id: Identifier for the game.
26
+ :ivar initial_scene: Name of the initial scene.
27
+ :ivar seed: Seed used for random number generation.
28
+ :ivar fps: Frames per second of the replay.
29
+ """
30
+
31
+ magic: str = REPLAY_MAGIC
32
+ version: int = REPLAY_VERSION
33
+ game_id: str = "unknown"
34
+ initial_scene: str = "unknown"
35
+ seed: int = 0
36
+ fps: int = 60
37
+
38
+
39
+ class ReplayWriter:
40
+ """Replay file writer."""
41
+
42
+ def __init__(self, path: Path, header: ReplayHeader):
43
+ """
44
+ :param path: Path to save the replay file.
45
+ :type path: Path
46
+ :param header: Header information for the replay.
47
+ :type header: ReplayHeader
48
+ """
49
+ self.path = path
50
+ self.header = header
51
+ self._f: Optional[TextIO] = None
52
+
53
+ def open(self) -> None:
54
+ """Open the replay file for writing."""
55
+ self.path.parent.mkdir(parents=True, exist_ok=True)
56
+ self._f = self.path.open("w", encoding="utf-8")
57
+ self._f.write(json.dumps(asdict(self.header)) + "\n")
58
+
59
+ def write_frame(self, frame: InputFrame) -> None:
60
+ """
61
+ Write an input frame to the replay file.
62
+
63
+ :param frame: Input frame to write.
64
+ :type frame: InputFrame
65
+ """
66
+ if not self._f:
67
+ raise RuntimeError("ReplayWriter is not open")
68
+ self._f.write(json.dumps(frame.to_dict()) + "\n")
69
+
70
+ def close(self) -> None:
71
+ """Close the replay file."""
72
+ if self._f:
73
+ self._f.close()
74
+ self._f = None
75
+
76
+
77
+ class ReplayReader:
78
+ """Replay file reader."""
79
+
80
+ def __init__(self, path: Path):
81
+ """
82
+ :param path: Path to the replay file.
83
+ :type path: Path"""
84
+ self.path = path
85
+ self.header: Optional[ReplayHeader] = None
86
+ self._f: Optional[TextIO] = None
87
+
88
+ def open(self) -> ReplayHeader:
89
+ """
90
+ Open the replay file for reading.
91
+
92
+ :return: The replay header.
93
+ :rtype: ReplayHeader
94
+ """
95
+ self._f = self.path.open("r", encoding="utf-8")
96
+ header_line = self._f.readline()
97
+ self.header = ReplayHeader(**json.loads(header_line))
98
+ if self.header.magic != REPLAY_MAGIC:
99
+ raise ValueError("Not a mini-arcade replay file")
100
+ return self.header
101
+
102
+ def frames(self) -> Iterator[InputFrame]:
103
+ """
104
+ Iterate over input frames in the replay file.
105
+
106
+ :return: Input frames from the replay.
107
+ :rtype: Iterator[InputFrame]
108
+ """
109
+ if not self._f:
110
+ raise RuntimeError("ReplayReader is not open")
111
+ for line in self._f:
112
+ if not line.strip():
113
+ continue
114
+ yield InputFrame.from_dict(json.loads(line))
115
+
116
+ def close(self) -> None:
117
+ """Close the replay file."""
118
+ if self._f:
119
+ self._f.close()
120
+ self._f = None