mini-arcade-core 1.1.1__py3-none-any.whl → 1.2.1__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/__init__.py +14 -42
- mini_arcade_core/backend/__init__.py +1 -2
- mini_arcade_core/backend/backend.py +182 -184
- mini_arcade_core/backend/types.py +5 -1
- mini_arcade_core/engine/commands.py +8 -8
- mini_arcade_core/engine/game.py +54 -354
- mini_arcade_core/engine/game_config.py +40 -0
- mini_arcade_core/engine/gameplay_settings.py +24 -0
- mini_arcade_core/engine/loop/config.py +20 -0
- mini_arcade_core/engine/loop/hooks.py +77 -0
- mini_arcade_core/engine/loop/runner.py +272 -0
- mini_arcade_core/engine/loop/state.py +32 -0
- mini_arcade_core/engine/managers.py +24 -0
- mini_arcade_core/engine/render/context.py +0 -2
- mini_arcade_core/engine/render/effects/base.py +2 -2
- mini_arcade_core/engine/render/effects/crt.py +4 -4
- mini_arcade_core/engine/render/effects/registry.py +1 -1
- mini_arcade_core/engine/render/effects/vignette.py +8 -8
- mini_arcade_core/engine/render/passes/begin_frame.py +1 -1
- mini_arcade_core/engine/render/passes/end_frame.py +1 -1
- mini_arcade_core/engine/render/passes/postfx.py +1 -1
- mini_arcade_core/engine/render/passes/ui.py +1 -1
- mini_arcade_core/engine/render/passes/world.py +6 -6
- mini_arcade_core/engine/render/pipeline.py +7 -6
- mini_arcade_core/engine/render/viewport.py +10 -4
- mini_arcade_core/engine/scenes/models.py +54 -0
- mini_arcade_core/engine/scenes/scene_manager.py +213 -0
- mini_arcade_core/runtime/audio/audio_adapter.py +4 -3
- mini_arcade_core/runtime/audio/audio_port.py +0 -4
- mini_arcade_core/runtime/capture/capture_adapter.py +53 -31
- mini_arcade_core/runtime/capture/capture_port.py +0 -4
- mini_arcade_core/runtime/capture/capture_worker.py +174 -0
- mini_arcade_core/runtime/context.py +8 -6
- mini_arcade_core/runtime/scene/scene_query_adapter.py +31 -0
- mini_arcade_core/runtime/scene/scene_query_port.py +38 -0
- mini_arcade_core/runtime/services.py +3 -2
- mini_arcade_core/runtime/window/window_adapter.py +43 -41
- mini_arcade_core/runtime/window/window_port.py +3 -17
- mini_arcade_core/scenes/debug_overlay.py +5 -4
- mini_arcade_core/scenes/registry.py +11 -1
- mini_arcade_core/scenes/sim_scene.py +14 -14
- mini_arcade_core/ui/menu.py +54 -16
- mini_arcade_core/utils/__init__.py +2 -1
- mini_arcade_core/utils/logging.py +47 -18
- mini_arcade_core/utils/profiler.py +283 -0
- {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.1.dist-info}/METADATA +1 -1
- mini_arcade_core-1.2.1.dist-info/RECORD +93 -0
- {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.1.dist-info}/WHEEL +1 -1
- mini_arcade_core/managers/inputs.py +0 -284
- mini_arcade_core/runtime/scene/scene_adapter.py +0 -125
- mini_arcade_core/runtime/scene/scene_port.py +0 -170
- mini_arcade_core/sim/protocols.py +0 -41
- mini_arcade_core/sim/runner.py +0 -222
- mini_arcade_core-1.1.1.dist-info/RECORD +0 -85
- /mini_arcade_core/{managers → engine}/cheats.py +0 -0
- /mini_arcade_core/{managers → engine/loop}/__init__.py +0 -0
- /mini_arcade_core/{sim → engine/scenes}/__init__.py +0 -0
- {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Models for scene management in the engine.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from mini_arcade_core.scenes.sim_scene import SimScene
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ScenePolicy:
|
|
14
|
+
"""
|
|
15
|
+
Controls how a scene behaves in the scene stack.
|
|
16
|
+
|
|
17
|
+
blocks_update: if True, scenes below do not tick/update (pause modal)
|
|
18
|
+
blocks_input: if True, scenes below do not receive input
|
|
19
|
+
is_opaque: if True, scenes below are not rendered
|
|
20
|
+
receives_input: if True, scene can receive input
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
blocks_update: bool = False
|
|
24
|
+
blocks_input: bool = False
|
|
25
|
+
is_opaque: bool = False
|
|
26
|
+
receives_input: bool = True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class SceneEntry:
|
|
31
|
+
"""
|
|
32
|
+
An entry in the scene stack.
|
|
33
|
+
|
|
34
|
+
:ivar scene_id (str): Identifier of the scene.
|
|
35
|
+
:ivar scene (SimScene): The scene instance.
|
|
36
|
+
:ivar is_overlay (bool): Whether the scene is an overlay.
|
|
37
|
+
:ivar policy (ScenePolicy): The scene's policy.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
scene_id: str
|
|
41
|
+
scene: SimScene
|
|
42
|
+
is_overlay: bool
|
|
43
|
+
policy: ScenePolicy
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class StackItem:
|
|
48
|
+
"""
|
|
49
|
+
An item in the scene stack.
|
|
50
|
+
|
|
51
|
+
:ivar entry (SceneEntry): The scene entry.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
entry: SceneEntry
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module providing runtime adapters for window and scene management.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import TYPE_CHECKING, List
|
|
8
|
+
|
|
9
|
+
from mini_arcade_core.engine.scenes.models import (
|
|
10
|
+
SceneEntry,
|
|
11
|
+
ScenePolicy,
|
|
12
|
+
StackItem,
|
|
13
|
+
)
|
|
14
|
+
from mini_arcade_core.runtime.context import RuntimeContext
|
|
15
|
+
from mini_arcade_core.scenes.registry import SceneRegistry
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from mini_arcade_core.engine.game import Game
|
|
19
|
+
from mini_arcade_core.scenes.sim_scene import SimScene
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SceneAdapter:
|
|
23
|
+
"""
|
|
24
|
+
Manages multiple scenes (not implemented).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, registry: SceneRegistry, game: Game):
|
|
28
|
+
self._registry = registry
|
|
29
|
+
self._stack: List[StackItem] = []
|
|
30
|
+
self._game = game
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def current_scene(self) -> SimScene | None:
|
|
34
|
+
"""
|
|
35
|
+
Get the currently active scene.
|
|
36
|
+
|
|
37
|
+
:return: The active Scene instance, or None if no scene is active.
|
|
38
|
+
:rtype: SimScene | None
|
|
39
|
+
"""
|
|
40
|
+
return self._stack[-1].entry.scene if self._stack else None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def visible_stack(self) -> List[SimScene]:
|
|
44
|
+
"""
|
|
45
|
+
Return the list of scenes that should be drawn (base + overlays).
|
|
46
|
+
We draw from the top-most non-overlay scene upward.
|
|
47
|
+
|
|
48
|
+
:return: List of visible Scene instances.
|
|
49
|
+
:rtype: List[SimScene]
|
|
50
|
+
"""
|
|
51
|
+
return [e.scene for e in self.visible_entries()]
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def listed_scenes(self) -> List[SimScene]:
|
|
55
|
+
"""
|
|
56
|
+
Return all scenes in the stack.
|
|
57
|
+
|
|
58
|
+
:return: List of all Scene instances in the stack.
|
|
59
|
+
:rtype: List[SimScene]
|
|
60
|
+
"""
|
|
61
|
+
return [item.entry.scene for item in self._stack]
|
|
62
|
+
|
|
63
|
+
def change(self, scene_id: str):
|
|
64
|
+
"""
|
|
65
|
+
Change the current scene to the specified scene.
|
|
66
|
+
|
|
67
|
+
:param scene_id: Identifier of the scene to switch to.
|
|
68
|
+
:type scene_id: str
|
|
69
|
+
"""
|
|
70
|
+
self.clean()
|
|
71
|
+
self.push(scene_id, as_overlay=False)
|
|
72
|
+
|
|
73
|
+
def push(
|
|
74
|
+
self,
|
|
75
|
+
scene_id: str,
|
|
76
|
+
*,
|
|
77
|
+
as_overlay: bool = False,
|
|
78
|
+
policy: ScenePolicy | None = None,
|
|
79
|
+
):
|
|
80
|
+
"""
|
|
81
|
+
Push a new scene onto the scene stack.
|
|
82
|
+
|
|
83
|
+
:param scene_id: Identifier of the scene to push.
|
|
84
|
+
:type scene_id: str
|
|
85
|
+
|
|
86
|
+
:param as_overlay: Whether to push the scene as an overlay.
|
|
87
|
+
:type as_overlay: bool
|
|
88
|
+
"""
|
|
89
|
+
# default policy based on overlay vs base
|
|
90
|
+
if policy is None:
|
|
91
|
+
# base scenes: do not block anything by default
|
|
92
|
+
policy = ScenePolicy()
|
|
93
|
+
runtime_context = RuntimeContext.from_game(self._game)
|
|
94
|
+
scene = self._registry.create(
|
|
95
|
+
scene_id, runtime_context
|
|
96
|
+
) # or whatever your factory call is
|
|
97
|
+
scene.on_enter()
|
|
98
|
+
|
|
99
|
+
entry = SceneEntry(
|
|
100
|
+
scene_id=scene_id,
|
|
101
|
+
scene=scene,
|
|
102
|
+
is_overlay=as_overlay,
|
|
103
|
+
policy=policy,
|
|
104
|
+
)
|
|
105
|
+
self._stack.append(StackItem(entry=entry))
|
|
106
|
+
|
|
107
|
+
def pop(self) -> SimScene | None:
|
|
108
|
+
"""
|
|
109
|
+
Pop the current scene from the scene stack.
|
|
110
|
+
|
|
111
|
+
:return: The popped Scene instance, or None if the stack was empty.
|
|
112
|
+
:rtype: SimScene | None
|
|
113
|
+
"""
|
|
114
|
+
if not self._stack:
|
|
115
|
+
return
|
|
116
|
+
item = self._stack.pop()
|
|
117
|
+
item.entry.scene.on_exit()
|
|
118
|
+
|
|
119
|
+
def clean(self):
|
|
120
|
+
"""Clean up all scenes from the scene stack."""
|
|
121
|
+
while self._stack:
|
|
122
|
+
self.pop()
|
|
123
|
+
|
|
124
|
+
def quit(self):
|
|
125
|
+
"""Quit the game"""
|
|
126
|
+
self._game.quit()
|
|
127
|
+
|
|
128
|
+
def visible_entries(self) -> list[SceneEntry]:
|
|
129
|
+
"""
|
|
130
|
+
Render from bottom->top unless an opaque entry exists; if so,
|
|
131
|
+
render only from that entry up.
|
|
132
|
+
|
|
133
|
+
:return: List of SceneEntry instances to render.
|
|
134
|
+
:rtype: list[SceneEntry]
|
|
135
|
+
"""
|
|
136
|
+
entries = [i.entry for i in self._stack]
|
|
137
|
+
# find highest opaque from top down; render starting there
|
|
138
|
+
for idx in range(len(entries) - 1, -1, -1):
|
|
139
|
+
if entries[idx].policy.is_opaque:
|
|
140
|
+
return entries[idx:]
|
|
141
|
+
return entries
|
|
142
|
+
|
|
143
|
+
def update_entries(self) -> list[SceneEntry]:
|
|
144
|
+
"""
|
|
145
|
+
Tick/update scenes considering blocks_update.
|
|
146
|
+
Typical: pause overlay blocks update below it.
|
|
147
|
+
|
|
148
|
+
:return: List of SceneEntry instances to update.
|
|
149
|
+
:rtype: list[SceneEntry]
|
|
150
|
+
"""
|
|
151
|
+
vis = self.visible_entries()
|
|
152
|
+
if not vis:
|
|
153
|
+
return []
|
|
154
|
+
out = []
|
|
155
|
+
for entry in reversed(vis): # top->down
|
|
156
|
+
out.append(entry)
|
|
157
|
+
if entry.policy.blocks_update:
|
|
158
|
+
break
|
|
159
|
+
return list(reversed(out)) # bottom->top order
|
|
160
|
+
|
|
161
|
+
def input_entry(self) -> SceneEntry | None:
|
|
162
|
+
"""
|
|
163
|
+
Who gets input this frame. If top blocks_input, only it receives input.
|
|
164
|
+
If not, top still gets input (v1 simple). Later you can allow fall-through.
|
|
165
|
+
|
|
166
|
+
:return: The SceneEntry that receives input, or None if no scenes are active.
|
|
167
|
+
:rtype: SceneEntry | None
|
|
168
|
+
"""
|
|
169
|
+
vis = self.visible_entries()
|
|
170
|
+
if not vis:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
# If some scene blocks input, only scenes at/above it can receive.
|
|
174
|
+
start_idx = 0
|
|
175
|
+
for idx in range(len(vis) - 1, -1, -1):
|
|
176
|
+
if vis[idx].policy.blocks_input:
|
|
177
|
+
start_idx = idx
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
candidates = vis[start_idx:]
|
|
181
|
+
|
|
182
|
+
# Pick the top-most candidate that actually receives input.
|
|
183
|
+
for entry in reversed(candidates):
|
|
184
|
+
if entry.policy.receives_input:
|
|
185
|
+
return entry
|
|
186
|
+
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
def has_scene(self, scene_id: str) -> bool:
|
|
190
|
+
"""
|
|
191
|
+
Check if a scene with the given ID exists in the stack.
|
|
192
|
+
|
|
193
|
+
:param scene_id: Identifier of the scene to check.
|
|
194
|
+
:type scene_id: str
|
|
195
|
+
|
|
196
|
+
:return: True if the scene exists in the stack, False otherwise.
|
|
197
|
+
:rtype: bool
|
|
198
|
+
"""
|
|
199
|
+
return any(item.entry.scene_id == scene_id for item in self._stack)
|
|
200
|
+
|
|
201
|
+
def remove_scene(self, scene_id: str):
|
|
202
|
+
"""
|
|
203
|
+
Remove a scene with the given ID from the stack.
|
|
204
|
+
|
|
205
|
+
:param scene_id: Identifier of the scene to remove.
|
|
206
|
+
:type scene_id: str
|
|
207
|
+
"""
|
|
208
|
+
# remove first match from top (overlay is usually near top)
|
|
209
|
+
for i in range(len(self._stack) - 1, -1, -1):
|
|
210
|
+
if self._stack[i].entry.scene_id == scene_id:
|
|
211
|
+
item = self._stack.pop(i)
|
|
212
|
+
item.entry.scene.on_exit()
|
|
213
|
+
return
|
|
@@ -4,17 +4,18 @@ Module providing runtime adapters for window and scene management.
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
from mini_arcade_core.backend import Backend
|
|
7
8
|
from mini_arcade_core.runtime.audio.audio_port import AudioPort
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class SDLAudioAdapter(AudioPort):
|
|
11
12
|
"""A no-op audio adapter."""
|
|
12
13
|
|
|
13
|
-
def __init__(self, backend):
|
|
14
|
+
def __init__(self, backend: Backend):
|
|
14
15
|
self.backend = backend
|
|
15
16
|
|
|
16
17
|
def load_sound(self, sound_id: str, file_path: str):
|
|
17
|
-
self.backend.load_sound(sound_id, file_path)
|
|
18
|
+
self.backend.audio.load_sound(sound_id, file_path)
|
|
18
19
|
|
|
19
20
|
def play(self, sound_id: str, loops: int = 0):
|
|
20
|
-
self.backend.play_sound(sound_id, loops)
|
|
21
|
+
self.backend.audio.play_sound(sound_id, loops)
|
|
@@ -4,14 +4,10 @@ Service interfaces for runtime components.
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
-
from mini_arcade_core.backend.backend import Backend
|
|
8
|
-
|
|
9
7
|
|
|
10
8
|
class AudioPort:
|
|
11
9
|
"""Interface for audio playback operations."""
|
|
12
10
|
|
|
13
|
-
backend: Backend
|
|
14
|
-
|
|
15
11
|
def load_sound(self, sound_id: str, file_path: str):
|
|
16
12
|
"""
|
|
17
13
|
Load a sound from a file and associate it with an identifier.
|
|
@@ -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
|
|
|
@@ -74,15 +79,25 @@ class CapturePathBuilder:
|
|
|
74
79
|
|
|
75
80
|
|
|
76
81
|
class CaptureAdapter(CapturePort):
|
|
77
|
-
"""
|
|
82
|
+
"""
|
|
83
|
+
Adapter for capturing frames.
|
|
84
|
+
|
|
85
|
+
:param backend: Backend instance to use for capturing frames.
|
|
86
|
+
:type backend: Backend
|
|
87
|
+
:param path_builder: Optional CapturePathBuilder for building file paths.
|
|
88
|
+
:type path_builder: CapturePathBuilder | None
|
|
89
|
+
"""
|
|
78
90
|
|
|
79
91
|
def __init__(
|
|
80
92
|
self,
|
|
81
93
|
backend: Backend,
|
|
82
94
|
path_builder: Optional[CapturePathBuilder] = None,
|
|
95
|
+
worker: Optional[CaptureWorker] = None,
|
|
83
96
|
):
|
|
84
97
|
self.backend = backend
|
|
85
98
|
self.path_builder = path_builder or CapturePathBuilder()
|
|
99
|
+
self.worker = worker or CaptureWorker()
|
|
100
|
+
self.worker.start()
|
|
86
101
|
|
|
87
102
|
def _bmp_to_image(self, bmp_path: str, out_path: str):
|
|
88
103
|
img = Image.open(bmp_path)
|
|
@@ -91,28 +106,32 @@ class CaptureAdapter(CapturePort):
|
|
|
91
106
|
def screenshot(self, label: str | None = None) -> str:
|
|
92
107
|
label = label or "shot"
|
|
93
108
|
out_path = self.path_builder.build(label)
|
|
94
|
-
|
|
109
|
+
logger.critical(f"Capturing screenshot to: {out_path}")
|
|
95
110
|
|
|
96
|
-
#
|
|
97
|
-
bmp_path = out_path.with_suffix(".bmp")
|
|
111
|
+
# temp BMP next to output (unique)
|
|
112
|
+
bmp_path = out_path.with_suffix(f".{uuid4().hex}.bmp")
|
|
113
|
+
bmp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
114
|
|
|
99
|
-
self.backend.
|
|
100
|
-
if not bmp_path.exists():
|
|
101
|
-
raise RuntimeError("Backend
|
|
115
|
+
ok_native = self.backend.capture.bmp(str(bmp_path)) # returns bool
|
|
116
|
+
if not ok_native or not bmp_path.exists():
|
|
117
|
+
raise RuntimeError("Backend capture.bmp failed to create BMP file")
|
|
102
118
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
119
|
+
job_id = uuid4().hex
|
|
120
|
+
ok = self.worker.enqueue(
|
|
121
|
+
CaptureJob(job_id=job_id, out_path=out_path, bmp_path=bmp_path)
|
|
122
|
+
)
|
|
123
|
+
if not ok:
|
|
124
|
+
logger.warning("Screenshot dropped: capture queue full")
|
|
125
|
+
# optional: cleanup temp bmp since we won't process it
|
|
126
|
+
try:
|
|
127
|
+
bmp_path.unlink(missing_ok=True)
|
|
128
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
129
|
+
pass
|
|
111
130
|
|
|
112
131
|
return str(out_path)
|
|
113
132
|
|
|
114
133
|
def screenshot_bytes(self) -> bytes:
|
|
115
|
-
data = self.backend.
|
|
134
|
+
data = self.backend.capture.bmp(path=None)
|
|
116
135
|
if data is None:
|
|
117
136
|
raise RuntimeError("Backend returned None for screenshot_bytes()")
|
|
118
137
|
return data
|
|
@@ -120,24 +139,27 @@ class CaptureAdapter(CapturePort):
|
|
|
120
139
|
def screenshot_sim(
|
|
121
140
|
self, run_id: str, frame_index: int, label: str = "frame"
|
|
122
141
|
) -> str:
|
|
123
|
-
"""Screenshot for simulation frames."""
|
|
124
142
|
out_path = self.path_builder.build_sim(run_id, frame_index, label)
|
|
125
|
-
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
-
|
|
127
|
-
bmp_path = out_path.with_suffix(".bmp")
|
|
128
|
-
self.backend.capture_frame(str(bmp_path))
|
|
129
143
|
|
|
130
|
-
|
|
131
|
-
|
|
144
|
+
bmp_path = out_path.with_suffix(f".{uuid4().hex}.bmp")
|
|
145
|
+
bmp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
132
146
|
|
|
133
|
-
self.
|
|
147
|
+
ok_native = self.backend.capture.bmp(str(bmp_path))
|
|
148
|
+
if not ok_native or not bmp_path.exists():
|
|
149
|
+
raise RuntimeError("Backend capture.bmp failed to create BMP file")
|
|
134
150
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
logger.warning(
|
|
141
|
-
|
|
151
|
+
job_id = f"{run_id}:{frame_index}"
|
|
152
|
+
ok = self.worker.enqueue(
|
|
153
|
+
CaptureJob(job_id=job_id, out_path=out_path, bmp_path=bmp_path)
|
|
154
|
+
)
|
|
155
|
+
if not ok:
|
|
156
|
+
logger.warning("Sim screenshot dropped: capture queue full")
|
|
157
|
+
try:
|
|
158
|
+
bmp_path.unlink(missing_ok=True)
|
|
159
|
+
# Justification: Broad exception catch for cleanup
|
|
160
|
+
# pylint: disable=broad-exception-caught
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
# pylint: enable=broad-exception-caught
|
|
142
164
|
|
|
143
165
|
return str(out_path)
|
|
@@ -4,14 +4,10 @@ Service interfaces for runtime components.
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
-
from mini_arcade_core.backend import Backend
|
|
8
|
-
|
|
9
7
|
|
|
10
8
|
class CapturePort:
|
|
11
9
|
"""Interface for frame capture operations."""
|
|
12
10
|
|
|
13
|
-
backend: Backend
|
|
14
|
-
|
|
15
11
|
def screenshot(self, label: str | None = None) -> str:
|
|
16
12
|
"""
|
|
17
13
|
Capture the current frame.
|
|
@@ -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()
|
|
@@ -9,12 +9,14 @@ from dataclasses import dataclass
|
|
|
9
9
|
from typing import TYPE_CHECKING
|
|
10
10
|
|
|
11
11
|
if TYPE_CHECKING:
|
|
12
|
+
from mini_arcade_core.engine.cheats import CheatManager
|
|
12
13
|
from mini_arcade_core.engine.commands import CommandQueue
|
|
13
|
-
from mini_arcade_core.engine.game import Game, GameConfig
|
|
14
|
-
from mini_arcade_core.
|
|
14
|
+
from mini_arcade_core.engine.game import Game, GameConfig
|
|
15
|
+
from mini_arcade_core.engine.gameplay_settings import GamePlaySettings
|
|
15
16
|
from mini_arcade_core.runtime.services import RuntimeServices
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
# TODO: Remove cheats and command_queue from here later if unused.
|
|
18
20
|
@dataclass(frozen=True)
|
|
19
21
|
class RuntimeContext:
|
|
20
22
|
"""
|
|
@@ -22,14 +24,14 @@ class RuntimeContext:
|
|
|
22
24
|
|
|
23
25
|
:ivar services (RuntimeServices): Runtime services.
|
|
24
26
|
:ivar config (GameConfig): Game configuration.
|
|
25
|
-
:ivar settings (
|
|
27
|
+
:ivar settings (GamePlaySettings): Game settings.
|
|
26
28
|
:ivar command_queue (CommandQueue | None): Optional command queue.
|
|
27
29
|
:ivar cheats (CheatManager | None): Optional cheat manager.
|
|
28
30
|
"""
|
|
29
31
|
|
|
30
32
|
services: RuntimeServices
|
|
31
33
|
config: GameConfig
|
|
32
|
-
settings:
|
|
34
|
+
settings: GamePlaySettings
|
|
33
35
|
command_queue: CommandQueue | None = None
|
|
34
36
|
cheats: CheatManager | None = None
|
|
35
37
|
|
|
@@ -48,6 +50,6 @@ class RuntimeContext:
|
|
|
48
50
|
services=game_entity.services,
|
|
49
51
|
config=game_entity.config,
|
|
50
52
|
settings=game_entity.settings,
|
|
51
|
-
command_queue=game_entity.command_queue,
|
|
52
|
-
cheats=game_entity.
|
|
53
|
+
command_queue=game_entity.managers.command_queue,
|
|
54
|
+
cheats=game_entity.managers.cheats,
|
|
53
55
|
)
|