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.
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/PKG-INFO +1 -1
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/pyproject.toml +1 -1
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/commands.py +69 -1
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/game.py +5 -3
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/runner.py +27 -4
- mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/capture_service.py +96 -0
- mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/capture_service_protocol.py +125 -0
- mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/capture_settings.py +22 -0
- mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/replay.py +132 -0
- mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/replay_format.py +120 -0
- 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
- mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/input_frame.py +149 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/services.py +5 -3
- mini_arcade_core-1.2.1/src/mini_arcade_core/runtime/input_frame.py +0 -71
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/LICENSE +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/README.md +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/backend.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/events.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/keys.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/sdl_map.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/types.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/bus.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/cheats.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/game_config.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/gameplay_settings.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/config.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/hooks.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/state.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/managers.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/context.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/base.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/crt.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/registry.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/vignette.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/frame_packet.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/packet.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/base.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/begin_frame.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/end_frame.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/lighting.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/postfx.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/ui.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/world.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/pipeline.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/render_service.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/viewport.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/models.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/scene_manager.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/audio_adapter.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/audio_port.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/capture/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/capture/capture_port.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/capture/capture_worker.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/context.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/file_adapter.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/file_port.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/input_adapter.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/input_port.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/render/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/render/render_port.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/scene_query_adapter.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/scene_query_port.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/window_adapter.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/window_port.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/autoreg.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/debug_overlay.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/registry.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/sim_scene.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/base_system.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/system_pipeline.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/boundaries2d.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/collision2d.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/geometry2d.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/kinematics2d.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/physics2d.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/ui/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/ui/menu.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/__init__.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/deprecated_decorator.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/logging.py +0 -0
- {mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/profiler.py +0 -0
|
@@ -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.
|
|
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
|
|
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.
|
|
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),
|
|
73
|
+
window=WindowAdapter(self.backend),
|
|
74
74
|
audio=SDLAudioAdapter(self.backend),
|
|
75
75
|
files=LocalFilesAdapter(),
|
|
76
|
-
capture=
|
|
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),
|
{mini_arcade_core-1.2.1 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/runner.py
RENAMED
|
@@ -139,14 +139,37 @@ class EngineRunner:
|
|
|
139
139
|
def _build_input(
|
|
140
140
|
self, events, *, frame: FrameState, timer: FrameTimer | None
|
|
141
141
|
):
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|