mini-arcade-core 1.2.0__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.0 → mini_arcade_core-1.2.2}/PKG-INFO +1 -1
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/pyproject.toml +1 -1
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/commands.py +69 -1
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/game.py +5 -3
- {mini_arcade_core-1.2.0 → 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/capture_worker.py +174 -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.0/src/mini_arcade_core/runtime/capture/capture_adapter.py → mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/screenshot_capturer.py +29 -35
- mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/input_frame.py +149 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/services.py +5 -3
- mini_arcade_core-1.2.0/src/mini_arcade_core/runtime/input_frame.py +0 -71
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/LICENSE +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/README.md +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/backend.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/events.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/keys.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/sdl_map.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/types.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/bus.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/cheats.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/game_config.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/gameplay_settings.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/config.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/hooks.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/state.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/managers.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/context.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/base.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/crt.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/registry.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/vignette.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/frame_packet.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/packet.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/base.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/begin_frame.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/end_frame.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/lighting.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/postfx.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/ui.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/world.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/pipeline.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/render_service.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/viewport.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/models.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/scene_manager.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/audio_adapter.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/audio_port.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/capture/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/capture/capture_port.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/context.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/file_adapter.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/file_port.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/input_adapter.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/input_port.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/render/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/render/render_port.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/scene_query_adapter.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/scene_query_port.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/window_adapter.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/window_port.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/autoreg.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/debug_overlay.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/registry.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/sim_scene.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/base_system.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/system_pipeline.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/boundaries2d.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/collision2d.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/geometry2d.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/kinematics2d.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/physics2d.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/ui/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/ui/menu.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/__init__.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/deprecated_decorator.py +0 -0
- {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/logging.py +0 -0
- {mini_arcade_core-1.2.0 → 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.0 → 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,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Capture worker thread for saving screenshots.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from queue import Empty, Queue
|
|
10
|
+
from threading import Event, Thread
|
|
11
|
+
from typing import Callable, Optional
|
|
12
|
+
|
|
13
|
+
from PIL import Image
|
|
14
|
+
|
|
15
|
+
from mini_arcade_core.utils import logger
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class CaptureJob:
|
|
20
|
+
"""
|
|
21
|
+
Job representing a screenshot to be saved.
|
|
22
|
+
|
|
23
|
+
:ivar job_id (str): Unique identifier for the capture job.
|
|
24
|
+
:ivar out_path (Path): Destination path for the saved screenshot.
|
|
25
|
+
:ivar bmp_path (Path): Temporary path of the bitmap image to be saved.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
job_id: str
|
|
29
|
+
out_path: Path
|
|
30
|
+
bmp_path: Path # <-- file-based now
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class CaptureResult:
|
|
35
|
+
"""
|
|
36
|
+
Result of a completed capture job.
|
|
37
|
+
|
|
38
|
+
:ivar job_id (str): Unique identifier for the capture job.
|
|
39
|
+
:ivar out_path (Path): Destination path where the screenshot was saved.
|
|
40
|
+
:ivar ok (bool): Whether the capture was successful.
|
|
41
|
+
:ivar error (Optional[str]): Error message if the capture failed.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
job_id: str
|
|
45
|
+
out_path: Path
|
|
46
|
+
ok: bool
|
|
47
|
+
error: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class WorkerConfig:
|
|
52
|
+
"""
|
|
53
|
+
Configuration options for the CaptureWorker.
|
|
54
|
+
|
|
55
|
+
:ivar queue_size (int): Maximum number of jobs to queue.
|
|
56
|
+
:ivar on_done (Optional[Callable[[CaptureResult], None]]):
|
|
57
|
+
Optional callback invoked when a job is done.
|
|
58
|
+
:ivar name (str): Name of the worker thread.
|
|
59
|
+
:ivar daemon (bool): Whether the thread is a daemon thread.
|
|
60
|
+
:ivar delete_temp (bool): Whether to delete temporary bitmap files after saving.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
queue_size: int = 64
|
|
64
|
+
on_done: Optional[Callable[[CaptureResult], None]] = None
|
|
65
|
+
name: str = "capture-worker"
|
|
66
|
+
daemon: bool = True
|
|
67
|
+
delete_temp: bool = True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class CaptureWorker:
|
|
71
|
+
"""Capture worker thread for saving screenshots asynchronously."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
worker_config: WorkerConfig | None = None,
|
|
76
|
+
):
|
|
77
|
+
"""
|
|
78
|
+
:param queue_size: Maximum number of jobs to queue.
|
|
79
|
+
:type queue_size: int
|
|
80
|
+
:param on_done: Optional callback invoked when a job is done.
|
|
81
|
+
:type on_done: Optional[Callable[[CaptureResult], None]]
|
|
82
|
+
:param name: Name of the worker thread.
|
|
83
|
+
:type name: str
|
|
84
|
+
:param daemon: Whether the thread is a daemon thread.
|
|
85
|
+
:type daemon: bool
|
|
86
|
+
:param delete_temp: Whether to delete temporary bitmap files after saving.
|
|
87
|
+
:type delete_temp: bool
|
|
88
|
+
"""
|
|
89
|
+
if worker_config is None:
|
|
90
|
+
worker_config = WorkerConfig()
|
|
91
|
+
self._q: Queue[CaptureJob] = Queue(maxsize=worker_config.queue_size)
|
|
92
|
+
self._stop = Event()
|
|
93
|
+
self._thread = Thread(
|
|
94
|
+
target=self._run,
|
|
95
|
+
name=worker_config.name,
|
|
96
|
+
daemon=worker_config.daemon,
|
|
97
|
+
)
|
|
98
|
+
self._on_done = worker_config.on_done
|
|
99
|
+
self._delete_temp = worker_config.delete_temp
|
|
100
|
+
|
|
101
|
+
def start(self):
|
|
102
|
+
"""Start the capture worker thread."""
|
|
103
|
+
if self._thread.is_alive():
|
|
104
|
+
return
|
|
105
|
+
self._stop.clear()
|
|
106
|
+
self._thread.start()
|
|
107
|
+
|
|
108
|
+
def stop(self):
|
|
109
|
+
"""Stop the capture worker thread."""
|
|
110
|
+
self._stop.set()
|
|
111
|
+
if self._thread.is_alive():
|
|
112
|
+
self._thread.join(timeout=2.0)
|
|
113
|
+
|
|
114
|
+
def enqueue(self, job: CaptureJob) -> bool:
|
|
115
|
+
"""
|
|
116
|
+
Enqueue a capture job.
|
|
117
|
+
|
|
118
|
+
:param job: CaptureJob to enqueue.
|
|
119
|
+
:type job: CaptureJob
|
|
120
|
+
:return: True if the job was enqueued successfully, False otherwise.
|
|
121
|
+
:rtype: bool
|
|
122
|
+
"""
|
|
123
|
+
if self._stop.is_set():
|
|
124
|
+
return False
|
|
125
|
+
try:
|
|
126
|
+
self._q.put_nowait(job)
|
|
127
|
+
return True
|
|
128
|
+
# Justification: Queue.put_nowait can raise a broad exception
|
|
129
|
+
# pylint: disable=broad-exception-caught
|
|
130
|
+
except Exception:
|
|
131
|
+
return False
|
|
132
|
+
# pylint: enable=broad-exception-caught
|
|
133
|
+
|
|
134
|
+
def _run(self):
|
|
135
|
+
while not self._stop.is_set():
|
|
136
|
+
try:
|
|
137
|
+
job = self._q.get(timeout=0.1)
|
|
138
|
+
except Empty:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
job.out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
|
|
144
|
+
img = Image.open(str(job.bmp_path))
|
|
145
|
+
img.save(str(job.out_path))
|
|
146
|
+
|
|
147
|
+
if self._delete_temp:
|
|
148
|
+
try:
|
|
149
|
+
job.bmp_path.unlink(missing_ok=True)
|
|
150
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
151
|
+
logger.warning(
|
|
152
|
+
f"Failed to delete temp bmp: {job.bmp_path}"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
res = CaptureResult(
|
|
156
|
+
job_id=job.job_id, out_path=job.out_path, ok=True
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
except Exception as exc: # pylint: disable=broad-exception-caught
|
|
160
|
+
logger.exception("CaptureWorker failed to save screenshot")
|
|
161
|
+
res = CaptureResult(
|
|
162
|
+
job_id=job.job_id,
|
|
163
|
+
out_path=job.out_path,
|
|
164
|
+
ok=False,
|
|
165
|
+
error=str(exc),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if self._on_done:
|
|
169
|
+
try:
|
|
170
|
+
self._on_done(res)
|
|
171
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
172
|
+
logger.warning("CaptureWorker on_done callback failed")
|
|
173
|
+
|
|
174
|
+
self._q.task_done()
|