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.
Files changed (58) hide show
  1. mini_arcade_core/__init__.py +14 -42
  2. mini_arcade_core/backend/__init__.py +1 -2
  3. mini_arcade_core/backend/backend.py +182 -184
  4. mini_arcade_core/backend/types.py +5 -1
  5. mini_arcade_core/engine/commands.py +8 -8
  6. mini_arcade_core/engine/game.py +54 -354
  7. mini_arcade_core/engine/game_config.py +40 -0
  8. mini_arcade_core/engine/gameplay_settings.py +24 -0
  9. mini_arcade_core/engine/loop/config.py +20 -0
  10. mini_arcade_core/engine/loop/hooks.py +77 -0
  11. mini_arcade_core/engine/loop/runner.py +272 -0
  12. mini_arcade_core/engine/loop/state.py +32 -0
  13. mini_arcade_core/engine/managers.py +24 -0
  14. mini_arcade_core/engine/render/context.py +0 -2
  15. mini_arcade_core/engine/render/effects/base.py +2 -2
  16. mini_arcade_core/engine/render/effects/crt.py +4 -4
  17. mini_arcade_core/engine/render/effects/registry.py +1 -1
  18. mini_arcade_core/engine/render/effects/vignette.py +8 -8
  19. mini_arcade_core/engine/render/passes/begin_frame.py +1 -1
  20. mini_arcade_core/engine/render/passes/end_frame.py +1 -1
  21. mini_arcade_core/engine/render/passes/postfx.py +1 -1
  22. mini_arcade_core/engine/render/passes/ui.py +1 -1
  23. mini_arcade_core/engine/render/passes/world.py +6 -6
  24. mini_arcade_core/engine/render/pipeline.py +7 -6
  25. mini_arcade_core/engine/render/viewport.py +10 -4
  26. mini_arcade_core/engine/scenes/models.py +54 -0
  27. mini_arcade_core/engine/scenes/scene_manager.py +213 -0
  28. mini_arcade_core/runtime/audio/audio_adapter.py +4 -3
  29. mini_arcade_core/runtime/audio/audio_port.py +0 -4
  30. mini_arcade_core/runtime/capture/capture_adapter.py +53 -31
  31. mini_arcade_core/runtime/capture/capture_port.py +0 -4
  32. mini_arcade_core/runtime/capture/capture_worker.py +174 -0
  33. mini_arcade_core/runtime/context.py +8 -6
  34. mini_arcade_core/runtime/scene/scene_query_adapter.py +31 -0
  35. mini_arcade_core/runtime/scene/scene_query_port.py +38 -0
  36. mini_arcade_core/runtime/services.py +3 -2
  37. mini_arcade_core/runtime/window/window_adapter.py +43 -41
  38. mini_arcade_core/runtime/window/window_port.py +3 -17
  39. mini_arcade_core/scenes/debug_overlay.py +5 -4
  40. mini_arcade_core/scenes/registry.py +11 -1
  41. mini_arcade_core/scenes/sim_scene.py +14 -14
  42. mini_arcade_core/ui/menu.py +54 -16
  43. mini_arcade_core/utils/__init__.py +2 -1
  44. mini_arcade_core/utils/logging.py +47 -18
  45. mini_arcade_core/utils/profiler.py +283 -0
  46. {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.1.dist-info}/METADATA +1 -1
  47. mini_arcade_core-1.2.1.dist-info/RECORD +93 -0
  48. {mini_arcade_core-1.1.1.dist-info → mini_arcade_core-1.2.1.dist-info}/WHEEL +1 -1
  49. mini_arcade_core/managers/inputs.py +0 -284
  50. mini_arcade_core/runtime/scene/scene_adapter.py +0 -125
  51. mini_arcade_core/runtime/scene/scene_port.py +0 -170
  52. mini_arcade_core/sim/protocols.py +0 -41
  53. mini_arcade_core/sim/runner.py +0 -222
  54. mini_arcade_core-1.1.1.dist-info/RECORD +0 -85
  55. /mini_arcade_core/{managers → engine}/cheats.py +0 -0
  56. /mini_arcade_core/{managers → engine/loop}/__init__.py +0 -0
  57. /mini_arcade_core/{sim → engine/scenes}/__init__.py +0 -0
  58. {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
- """Adapter for capturing frames."""
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
- out_path.parent.mkdir(parents=True, exist_ok=True)
109
+ logger.critical(f"Capturing screenshot to: {out_path}")
95
110
 
96
- # If backend only saves BMP, capture to a temp bmp next to output
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.capture_frame(str(bmp_path))
100
- if not bmp_path.exists():
101
- raise RuntimeError("Backend capture_frame did not create BMP file")
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
- self._bmp_to_image(str(bmp_path), str(out_path))
104
- try:
105
- bmp_path.unlink(missing_ok=True)
106
- # Justification: Various exceptions can occur on file deletion
107
- # pylint: disable=broad-exception-caught
108
- except Exception:
109
- logger.warning(f"Failed to delete temporary BMP file: {bmp_path}")
110
- # pylint: enable=broad-exception-caught
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.capture_frame(path=None)
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
- if not bmp_path.exists():
131
- raise RuntimeError("Backend capture_frame did not create BMP file")
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._bmp_to_image(str(bmp_path), str(out_path))
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
- try:
136
- bmp_path.unlink(missing_ok=True)
137
- # Justification: Various exceptions can occur on file deletion
138
- # pylint: disable=broad-exception-caught
139
- except Exception:
140
- logger.warning(f"Failed to delete temporary BMP file: {bmp_path}")
141
- # pylint: enable=broad-exception-caught
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, GameSettings
14
- from mini_arcade_core.managers.cheats import CheatManager
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 (GameSettings): Game 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: GameSettings
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.cheat_manager,
53
+ command_queue=game_entity.managers.command_queue,
54
+ cheats=game_entity.managers.cheats,
53
55
  )