mini-arcade-core 1.2.0__py3-none-any.whl → 1.2.2__py3-none-any.whl
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/engine/commands.py +69 -1
- mini_arcade_core/engine/game.py +5 -3
- mini_arcade_core/engine/loop/runner.py +27 -4
- mini_arcade_core/runtime/capture/capture_service.py +96 -0
- mini_arcade_core/runtime/capture/capture_service_protocol.py +125 -0
- mini_arcade_core/runtime/capture/capture_settings.py +22 -0
- mini_arcade_core/runtime/capture/capture_worker.py +174 -0
- mini_arcade_core/runtime/capture/replay.py +132 -0
- mini_arcade_core/runtime/capture/replay_format.py +120 -0
- mini_arcade_core/runtime/capture/{capture_adapter.py → screenshot_capturer.py} +29 -35
- mini_arcade_core/runtime/input_frame.py +79 -1
- mini_arcade_core/runtime/services.py +5 -3
- {mini_arcade_core-1.2.0.dist-info → mini_arcade_core-1.2.2.dist-info}/METADATA +1 -1
- {mini_arcade_core-1.2.0.dist-info → mini_arcade_core-1.2.2.dist-info}/RECORD +16 -10
- {mini_arcade_core-1.2.0.dist-info → mini_arcade_core-1.2.2.dist-info}/WHEEL +0 -0
- {mini_arcade_core-1.2.0.dist-info → mini_arcade_core-1.2.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -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()
|
mini_arcade_core/engine/game.py
CHANGED
|
@@ -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),
|
|
@@ -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()
|
|
@@ -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
|
|
@@ -8,11 +8,16 @@ from dataclasses import dataclass
|
|
|
8
8
|
from datetime import datetime
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Optional
|
|
11
|
+
from uuid import uuid4
|
|
11
12
|
|
|
12
13
|
from PIL import Image
|
|
13
14
|
|
|
14
15
|
from mini_arcade_core.backend import Backend
|
|
15
16
|
from mini_arcade_core.runtime.capture.capture_port import CapturePort
|
|
17
|
+
from mini_arcade_core.runtime.capture.capture_worker import (
|
|
18
|
+
CaptureJob,
|
|
19
|
+
CaptureWorker,
|
|
20
|
+
)
|
|
16
21
|
from mini_arcade_core.utils import logger
|
|
17
22
|
|
|
18
23
|
|
|
@@ -73,7 +78,7 @@ class CapturePathBuilder:
|
|
|
73
78
|
return Path(self.directory) / run_id / name
|
|
74
79
|
|
|
75
80
|
|
|
76
|
-
class
|
|
81
|
+
class ScreenshotCapturer(CapturePort):
|
|
77
82
|
"""
|
|
78
83
|
Adapter for capturing frames.
|
|
79
84
|
|
|
@@ -87,9 +92,12 @@ class CaptureAdapter(CapturePort):
|
|
|
87
92
|
self,
|
|
88
93
|
backend: Backend,
|
|
89
94
|
path_builder: Optional[CapturePathBuilder] = None,
|
|
95
|
+
worker: Optional[CaptureWorker] = None,
|
|
90
96
|
):
|
|
91
97
|
self.backend = backend
|
|
92
98
|
self.path_builder = path_builder or CapturePathBuilder()
|
|
99
|
+
self.worker = worker or CaptureWorker()
|
|
100
|
+
self.worker.start()
|
|
93
101
|
|
|
94
102
|
def _bmp_to_image(self, bmp_path: str, out_path: str):
|
|
95
103
|
img = Image.open(bmp_path)
|
|
@@ -98,25 +106,7 @@ class CaptureAdapter(CapturePort):
|
|
|
98
106
|
def screenshot(self, label: str | None = None) -> str:
|
|
99
107
|
label = label or "shot"
|
|
100
108
|
out_path = self.path_builder.build(label)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
# If backend only saves BMP, capture to a temp bmp next to output
|
|
104
|
-
bmp_path = out_path.with_suffix(".bmp")
|
|
105
|
-
|
|
106
|
-
self.backend.capture.bmp(str(bmp_path))
|
|
107
|
-
if not bmp_path.exists():
|
|
108
|
-
raise RuntimeError("Backend capture.bmp did not create BMP file")
|
|
109
|
-
|
|
110
|
-
self._bmp_to_image(str(bmp_path), str(out_path))
|
|
111
|
-
try:
|
|
112
|
-
bmp_path.unlink(missing_ok=True)
|
|
113
|
-
# Justification: Various exceptions can occur on file deletion
|
|
114
|
-
# pylint: disable=broad-exception-caught
|
|
115
|
-
except Exception:
|
|
116
|
-
logger.warning(f"Failed to delete temporary BMP file: {bmp_path}")
|
|
117
|
-
# pylint: enable=broad-exception-caught
|
|
118
|
-
|
|
119
|
-
return str(out_path)
|
|
109
|
+
return self._capture_to(out_path, job_id=uuid4().hex)
|
|
120
110
|
|
|
121
111
|
def screenshot_bytes(self) -> bytes:
|
|
122
112
|
data = self.backend.capture.bmp(path=None)
|
|
@@ -127,24 +117,28 @@ class CaptureAdapter(CapturePort):
|
|
|
127
117
|
def screenshot_sim(
|
|
128
118
|
self, run_id: str, frame_index: int, label: str = "frame"
|
|
129
119
|
) -> str:
|
|
130
|
-
"""Screenshot for simulation frames."""
|
|
131
120
|
out_path = self.path_builder.build_sim(run_id, frame_index, label)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
bmp_path = out_path.with_suffix(".bmp")
|
|
135
|
-
self.backend.capture.bmp(str(bmp_path))
|
|
121
|
+
return self._capture_to(out_path, job_id=f"{run_id}:{frame_index}")
|
|
136
122
|
|
|
137
|
-
|
|
138
|
-
|
|
123
|
+
def _capture_to(self, out_path: Path, job_id: str) -> str:
|
|
124
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
139
125
|
|
|
140
|
-
|
|
126
|
+
bmp_path = out_path.with_suffix(f".{uuid4().hex}.bmp")
|
|
127
|
+
bmp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
141
128
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
# pylint: disable=broad-exception-caught
|
|
146
|
-
except Exception:
|
|
147
|
-
logger.warning(f"Failed to delete temporary BMP file: {bmp_path}")
|
|
148
|
-
# pylint: enable=broad-exception-caught
|
|
129
|
+
ok_native = self.backend.capture.bmp(str(bmp_path))
|
|
130
|
+
if not ok_native or not bmp_path.exists():
|
|
131
|
+
raise RuntimeError("Backend capture.bmp failed to create BMP file")
|
|
149
132
|
|
|
133
|
+
ok = self.worker.enqueue(
|
|
134
|
+
CaptureJob(job_id=job_id, out_path=out_path, bmp_path=bmp_path)
|
|
135
|
+
)
|
|
136
|
+
if not ok:
|
|
137
|
+
logger.warning("Screenshot dropped: capture queue full")
|
|
138
|
+
try:
|
|
139
|
+
bmp_path.unlink(missing_ok=True)
|
|
140
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
# IMPORTANT: async, so it's "queued"
|
|
150
144
|
return str(out_path)
|
|
@@ -4,7 +4,7 @@ Input frame data structure for capturing input state per frame.
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
-
from dataclasses import dataclass, field
|
|
7
|
+
from dataclasses import asdict, dataclass, field
|
|
8
8
|
from typing import Dict, FrozenSet, Tuple
|
|
9
9
|
|
|
10
10
|
from mini_arcade_core.backend.keys import Key
|
|
@@ -24,6 +24,32 @@ class ButtonState:
|
|
|
24
24
|
pressed: bool
|
|
25
25
|
released: bool
|
|
26
26
|
|
|
27
|
+
def to_dict(self) -> Dict[str, bool]:
|
|
28
|
+
"""
|
|
29
|
+
Convert the ButtonState to a dictionary.
|
|
30
|
+
|
|
31
|
+
:return: Dictionary representation of the ButtonState.
|
|
32
|
+
:rtype: Dict[str, bool]
|
|
33
|
+
"""
|
|
34
|
+
return asdict(self)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_dict(cls, data: Dict[str, bool]) -> ButtonState:
|
|
38
|
+
"""
|
|
39
|
+
Create a ButtonState from a dictionary.
|
|
40
|
+
|
|
41
|
+
:param data: Dictionary containing button state data.
|
|
42
|
+
:type data: Dict[str, bool]
|
|
43
|
+
|
|
44
|
+
:return: ButtonState instance.
|
|
45
|
+
:rtype: ButtonState
|
|
46
|
+
"""
|
|
47
|
+
return cls(
|
|
48
|
+
down=data.get("down", False),
|
|
49
|
+
pressed=data.get("pressed", False),
|
|
50
|
+
released=data.get("released", False),
|
|
51
|
+
)
|
|
52
|
+
|
|
27
53
|
|
|
28
54
|
# TODO: Solve too-many-instance-attributes warning later
|
|
29
55
|
# Justification: This data class needs multiple attributes to capture input state.
|
|
@@ -67,5 +93,57 @@ class InputFrame:
|
|
|
67
93
|
# Window/OS quit request
|
|
68
94
|
quit: bool = False
|
|
69
95
|
|
|
96
|
+
def to_dict(self) -> Dict[str, object]:
|
|
97
|
+
"""
|
|
98
|
+
Convert the InputFrame to a dictionary.
|
|
99
|
+
|
|
100
|
+
:return: Dictionary representation of the InputFrame.
|
|
101
|
+
:rtype: Dict[str, object]
|
|
102
|
+
"""
|
|
103
|
+
data = asdict(self)
|
|
104
|
+
|
|
105
|
+
# Convert ButtonState objects to dicts
|
|
106
|
+
data["buttons"] = {
|
|
107
|
+
name: state.to_dict() for name, state in self.buttons.items()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Convert FrozenSet to list for serialization
|
|
111
|
+
data["keys_down"] = [k.value for k in self.keys_down]
|
|
112
|
+
data["keys_pressed"] = [k.value for k in self.keys_pressed]
|
|
113
|
+
data["keys_released"] = [k.value for k in self.keys_released]
|
|
114
|
+
return data
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def from_dict(cls, data: Dict[str, object]) -> InputFrame:
|
|
118
|
+
"""
|
|
119
|
+
Create an InputFrame from a dictionary.
|
|
120
|
+
|
|
121
|
+
:param data: Dictionary containing input frame data.
|
|
122
|
+
:type data: Dict[str, object]
|
|
123
|
+
|
|
124
|
+
:return: InputFrame instance.
|
|
125
|
+
:rtype: InputFrame
|
|
126
|
+
"""
|
|
127
|
+
return cls(
|
|
128
|
+
frame_index=data.get("frame_index", 0),
|
|
129
|
+
dt=data.get("dt", 0.0),
|
|
130
|
+
keys_down=frozenset(Key(v) for v in data.get("keys_down", [])),
|
|
131
|
+
keys_pressed=frozenset(
|
|
132
|
+
Key(v) for v in data.get("keys_pressed", [])
|
|
133
|
+
),
|
|
134
|
+
keys_released=frozenset(
|
|
135
|
+
Key(v) for v in data.get("keys_released", [])
|
|
136
|
+
),
|
|
137
|
+
buttons={
|
|
138
|
+
name: ButtonState.from_dict(state)
|
|
139
|
+
for name, state in data.get("buttons", {}).items()
|
|
140
|
+
},
|
|
141
|
+
axes=data.get("axes", {}),
|
|
142
|
+
mouse_pos=tuple(data.get("mouse_pos", (0, 0))),
|
|
143
|
+
mouse_delta=tuple(data.get("mouse_delta", (0, 0))),
|
|
144
|
+
text_input=data.get("text_input", ""),
|
|
145
|
+
quit=data.get("quit", False),
|
|
146
|
+
)
|
|
147
|
+
|
|
70
148
|
|
|
71
149
|
# pylint: enable=too-many-instance-attributes
|
|
@@ -7,7 +7,9 @@ from __future__ import annotations
|
|
|
7
7
|
from dataclasses import dataclass
|
|
8
8
|
|
|
9
9
|
from mini_arcade_core.runtime.audio.audio_port import AudioPort
|
|
10
|
-
from mini_arcade_core.runtime.capture.
|
|
10
|
+
from mini_arcade_core.runtime.capture.capture_service_protocol import (
|
|
11
|
+
CaptureServicePort,
|
|
12
|
+
)
|
|
11
13
|
from mini_arcade_core.runtime.file.file_port import FilePort
|
|
12
14
|
from mini_arcade_core.runtime.input.input_port import InputPort
|
|
13
15
|
from mini_arcade_core.runtime.render.render_port import RenderServicePort
|
|
@@ -24,7 +26,7 @@ class RuntimeServices:
|
|
|
24
26
|
:ivar scenes (ScenePort): Scene management service port.
|
|
25
27
|
:ivar audio (AudioPort): Audio service port.
|
|
26
28
|
:ivar files (FilePort): File service port.
|
|
27
|
-
:ivar capture (
|
|
29
|
+
:ivar capture (CaptureServicePort): Capture service port.
|
|
28
30
|
:ivar input (InputPort): Input handling service port.
|
|
29
31
|
:ivar render (RenderServicePort): Rendering service port.
|
|
30
32
|
"""
|
|
@@ -32,7 +34,7 @@ class RuntimeServices:
|
|
|
32
34
|
window: WindowPort
|
|
33
35
|
audio: AudioPort
|
|
34
36
|
files: FilePort
|
|
35
|
-
capture:
|
|
37
|
+
capture: CaptureServicePort
|
|
36
38
|
input: InputPort
|
|
37
39
|
render: RenderServicePort
|
|
38
40
|
scenes: SceneQueryPort
|
|
@@ -8,14 +8,14 @@ mini_arcade_core/backend/types.py,sha256=EW0bW4MvsEZKot0Z1h_5LuFSzoYGiJBphTquBz4
|
|
|
8
8
|
mini_arcade_core/bus.py,sha256=2Etpoa-UWhk33xJjqDlY5YslPDJEjxNoIEVtF3C73vs,1558
|
|
9
9
|
mini_arcade_core/engine/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
10
|
mini_arcade_core/engine/cheats.py,sha256=jMx2a8YnaNCkCG5MPmIzz4uHuS7-_aYf0J45cv2-3v0,5569
|
|
11
|
-
mini_arcade_core/engine/commands.py,sha256=
|
|
12
|
-
mini_arcade_core/engine/game.py,sha256=
|
|
11
|
+
mini_arcade_core/engine/commands.py,sha256=oe5VWpFtIEdxQu2m5_pFXnwY1LJ3uhv8wOP27t01tfI,7400
|
|
12
|
+
mini_arcade_core/engine/game.py,sha256=DikhhLK2byDbMxSZH0TsjZOR_qY3mfR3EhXa0J79FX4,5812
|
|
13
13
|
mini_arcade_core/engine/game_config.py,sha256=4AP8n0Uk1HKEdPLOrV1xsySzBljAh8VhZASNrxPIMMc,1034
|
|
14
14
|
mini_arcade_core/engine/gameplay_settings.py,sha256=W8WBwfAvGZftkL4aMnOTx6SsGxwG-9Ou1Ey0AeWPCxs,549
|
|
15
15
|
mini_arcade_core/engine/loop/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
16
|
mini_arcade_core/engine/loop/config.py,sha256=Sj1LrdnD_aACmHUuRQuKB7bDbDZy60WVe8sJHRmzmIU,461
|
|
17
17
|
mini_arcade_core/engine/loop/hooks.py,sha256=nmZi-35iMsZoPedTdZzsIKJP6O_iFUeKjB8xc4-XTHU,2417
|
|
18
|
-
mini_arcade_core/engine/loop/runner.py,sha256=
|
|
18
|
+
mini_arcade_core/engine/loop/runner.py,sha256=M4M-V0En4MIlUbWXtbCCs24PWDNIWcWEiyI86xhcyeE,9652
|
|
19
19
|
mini_arcade_core/engine/loop/state.py,sha256=fzXQ9GP05PVNXEBTgIwA4qjMujxdUae3CXM6uRQz92Y,858
|
|
20
20
|
mini_arcade_core/engine/managers.py,sha256=eQJYe-xYtRha-FWxzJ3DcpwlcHwiT5sGt4oCD9ZPxEE,664
|
|
21
21
|
mini_arcade_core/engine/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
@@ -46,8 +46,14 @@ mini_arcade_core/runtime/audio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5
|
|
|
46
46
|
mini_arcade_core/runtime/audio/audio_adapter.py,sha256=lnP35txPzSKX1_il0nXcK7RMF5Qp9Qhi9YMh_7LTdPM,588
|
|
47
47
|
mini_arcade_core/runtime/audio/audio_port.py,sha256=jBd9WabN41uK3MHjg_1n4AOw83NivJlGE2m430WZTnk,831
|
|
48
48
|
mini_arcade_core/runtime/capture/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
49
|
-
mini_arcade_core/runtime/capture/capture_adapter.py,sha256=XBtiKw3AS2dzB4QogPm9kjhiQAenS25guX87tg-zK58,4882
|
|
50
49
|
mini_arcade_core/runtime/capture/capture_port.py,sha256=niHi0pAo10mC9p73FxFkYBIGLOLRN0PiOvxE4Zgo5fM,1162
|
|
50
|
+
mini_arcade_core/runtime/capture/capture_service.py,sha256=9ig0Wi0OZisefUDse7-v-_SUR1IPGAQmc15S2PZz-rY,2917
|
|
51
|
+
mini_arcade_core/runtime/capture/capture_service_protocol.py,sha256=f17yKG7vPluITZyjCutsGsYlm-FAKE0zgcRhXD-wtUM,3308
|
|
52
|
+
mini_arcade_core/runtime/capture/capture_settings.py,sha256=vDFdZi1HAF10cwrgM-46oVSu4Q5dk-p2F-4-TDBq0Ko,479
|
|
53
|
+
mini_arcade_core/runtime/capture/capture_worker.py,sha256=XAzL28jD8jnGoG3bvsd9yQW5S4Nv7licNJn94QTIzp4,5393
|
|
54
|
+
mini_arcade_core/runtime/capture/replay.py,sha256=oDKY5oDLJS2PU9F57CinDMkDsLJui9V67t-jsjgVyWE,3471
|
|
55
|
+
mini_arcade_core/runtime/capture/replay_format.py,sha256=VekOS4A-N8zrL_m7ByuqTZqMNxAoN9EFStRpSkSnk_k,3387
|
|
56
|
+
mini_arcade_core/runtime/capture/screenshot_capturer.py,sha256=_pDg1U0587MocyADH2b2IpoN8t3dc2XdjrOOz115c20,4652
|
|
51
57
|
mini_arcade_core/runtime/context.py,sha256=ONKQryO3KEOOqHaByxCUola07kdjrnvr4WfXwgwTobk,1777
|
|
52
58
|
mini_arcade_core/runtime/file/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
53
59
|
mini_arcade_core/runtime/file/file_adapter.py,sha256=09q7G9Qijml9d4AAjo6HLC1yuoVTjE_7xaT8apT4mk0,523
|
|
@@ -55,13 +61,13 @@ mini_arcade_core/runtime/file/file_port.py,sha256=p1MouCSHXZw--rWNMw3aYBLU-of8mX
|
|
|
55
61
|
mini_arcade_core/runtime/input/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
56
62
|
mini_arcade_core/runtime/input/input_adapter.py,sha256=vExQiwFIWTI3zYD8lmnD9TvoQPZvJfI6IINPJUqAdQ0,1467
|
|
57
63
|
mini_arcade_core/runtime/input/input_port.py,sha256=d4ptftwf92_LJdyaUMFxIsLHXBINzQyJACHn4laNyxQ,746
|
|
58
|
-
mini_arcade_core/runtime/input_frame.py,sha256=
|
|
64
|
+
mini_arcade_core/runtime/input_frame.py,sha256=DnikpT7LP-HUETnHG6Kan_j11bBXAQyf8XqW6O2FZhc,4948
|
|
59
65
|
mini_arcade_core/runtime/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
60
66
|
mini_arcade_core/runtime/render/render_port.py,sha256=Sqp-JBh-iRzzGtgnO_nU1KiJEqyrTYPRDQbg04HdR0A,507
|
|
61
67
|
mini_arcade_core/runtime/scene/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
62
68
|
mini_arcade_core/runtime/scene/scene_query_adapter.py,sha256=FNkqXgwxfugX_xqqFlZl0ELXsrW_gco5Au0tJhMGLgQ,909
|
|
63
69
|
mini_arcade_core/runtime/scene/scene_query_port.py,sha256=qTikQVxOkJCdoMoH_lbe_ctJj7SWeJnnqDo6Ee0N_pQ,1019
|
|
64
|
-
mini_arcade_core/runtime/services.py,sha256=
|
|
70
|
+
mini_arcade_core/runtime/services.py,sha256=GE9e-rYL62NwdV6haMrSLqXYhZrLDsAAlIsIPJncW6E,1285
|
|
65
71
|
mini_arcade_core/runtime/window/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
66
72
|
mini_arcade_core/runtime/window/window_adapter.py,sha256=VLZGYBVl7sGMmnk5mVowDleTyciAfE-Tc2woNFvRrgE,2890
|
|
67
73
|
mini_arcade_core/runtime/window/window_port.py,sha256=HBy2OjsZzlxbBDQiTqlKEbIaejpN1zDp5whgvKxZxaY,2322
|
|
@@ -86,7 +92,7 @@ mini_arcade_core/utils/__init__.py,sha256=id1C0au8r1oIzGha42xXwnI9ojcU1hxPgto6QS
|
|
|
86
92
|
mini_arcade_core/utils/deprecated_decorator.py,sha256=yrrW2ZqPskK-4MUTyIrMb465Wc54X2poV53ZQutZWqc,1140
|
|
87
93
|
mini_arcade_core/utils/logging.py,sha256=ygKpey6nikp30PrNDP_yRs8pxPPRbsQ0ivR6LUuEn3Q,6413
|
|
88
94
|
mini_arcade_core/utils/profiler.py,sha256=vLzrxDfAplgKGxpuzk4eFJx4t5DU5M3DQAn6sfS5D_4,8733
|
|
89
|
-
mini_arcade_core-1.2.
|
|
90
|
-
mini_arcade_core-1.2.
|
|
91
|
-
mini_arcade_core-1.2.
|
|
92
|
-
mini_arcade_core-1.2.
|
|
95
|
+
mini_arcade_core-1.2.2.dist-info/METADATA,sha256=uTEmFUmLixZzpR4AfgIe9dZsT6T09VpahwGipIcWRWU,8188
|
|
96
|
+
mini_arcade_core-1.2.2.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
|
|
97
|
+
mini_arcade_core-1.2.2.dist-info/licenses/LICENSE,sha256=3lHAuV0584cVS5vAqi2uC6GcsVgxUijvwvtZckyvaZ4,1096
|
|
98
|
+
mini_arcade_core-1.2.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|