mini-arcade-core 0.10.0__py3-none-any.whl → 1.0.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 (78) hide show
  1. mini_arcade_core/__init__.py +51 -63
  2. mini_arcade_core/backend/__init__.py +2 -6
  3. mini_arcade_core/backend/backend.py +148 -8
  4. mini_arcade_core/backend/events.py +1 -1
  5. mini_arcade_core/{keymaps/sdl.py → backend/sdl_map.py} +1 -1
  6. mini_arcade_core/engine/__init__.py +0 -0
  7. mini_arcade_core/engine/commands.py +169 -0
  8. mini_arcade_core/engine/game.py +369 -0
  9. mini_arcade_core/engine/render/__init__.py +0 -0
  10. mini_arcade_core/engine/render/packet.py +56 -0
  11. mini_arcade_core/engine/render/pipeline.py +63 -0
  12. mini_arcade_core/engine/render/viewport.py +203 -0
  13. mini_arcade_core/managers/__init__.py +0 -22
  14. mini_arcade_core/managers/cheats.py +71 -240
  15. mini_arcade_core/managers/inputs.py +5 -3
  16. mini_arcade_core/runtime/__init__.py +0 -0
  17. mini_arcade_core/runtime/audio/__init__.py +0 -0
  18. mini_arcade_core/runtime/audio/audio_adapter.py +20 -0
  19. mini_arcade_core/runtime/audio/audio_port.py +36 -0
  20. mini_arcade_core/runtime/capture/__init__.py +0 -0
  21. mini_arcade_core/runtime/capture/capture_adapter.py +143 -0
  22. mini_arcade_core/runtime/capture/capture_port.py +51 -0
  23. mini_arcade_core/runtime/context.py +53 -0
  24. mini_arcade_core/runtime/file/__init__.py +0 -0
  25. mini_arcade_core/runtime/file/file_adapter.py +20 -0
  26. mini_arcade_core/runtime/file/file_port.py +31 -0
  27. mini_arcade_core/runtime/input/__init__.py +0 -0
  28. mini_arcade_core/runtime/input/input_adapter.py +49 -0
  29. mini_arcade_core/runtime/input/input_port.py +31 -0
  30. mini_arcade_core/runtime/input_frame.py +71 -0
  31. mini_arcade_core/runtime/scene/__init__.py +0 -0
  32. mini_arcade_core/runtime/scene/scene_adapter.py +97 -0
  33. mini_arcade_core/runtime/scene/scene_port.py +149 -0
  34. mini_arcade_core/runtime/services.py +35 -0
  35. mini_arcade_core/runtime/window/__init__.py +0 -0
  36. mini_arcade_core/runtime/window/window_adapter.py +90 -0
  37. mini_arcade_core/runtime/window/window_port.py +109 -0
  38. mini_arcade_core/scenes/__init__.py +0 -22
  39. mini_arcade_core/scenes/autoreg.py +1 -1
  40. mini_arcade_core/scenes/registry.py +21 -19
  41. mini_arcade_core/scenes/sim_scene.py +41 -0
  42. mini_arcade_core/scenes/systems/__init__.py +0 -0
  43. mini_arcade_core/scenes/systems/base_system.py +40 -0
  44. mini_arcade_core/scenes/systems/system_pipeline.py +57 -0
  45. mini_arcade_core/sim/__init__.py +0 -0
  46. mini_arcade_core/sim/protocols.py +41 -0
  47. mini_arcade_core/sim/runner.py +222 -0
  48. mini_arcade_core/spaces/__init__.py +0 -12
  49. mini_arcade_core/spaces/d2/__init__.py +0 -30
  50. mini_arcade_core/spaces/d2/boundaries2d.py +10 -1
  51. mini_arcade_core/spaces/d2/collision2d.py +25 -28
  52. mini_arcade_core/spaces/d2/geometry2d.py +18 -0
  53. mini_arcade_core/spaces/d2/kinematics2d.py +2 -8
  54. mini_arcade_core/spaces/d2/physics2d.py +9 -0
  55. mini_arcade_core/ui/__init__.py +0 -26
  56. mini_arcade_core/ui/menu.py +271 -85
  57. mini_arcade_core/utils/__init__.py +10 -0
  58. mini_arcade_core/utils/deprecated_decorator.py +45 -0
  59. mini_arcade_core/utils/logging.py +168 -0
  60. {mini_arcade_core-0.10.0.dist-info → mini_arcade_core-1.0.1.dist-info}/METADATA +1 -1
  61. mini_arcade_core-1.0.1.dist-info/RECORD +66 -0
  62. {mini_arcade_core-0.10.0.dist-info → mini_arcade_core-1.0.1.dist-info}/WHEEL +1 -1
  63. mini_arcade_core/commands.py +0 -84
  64. mini_arcade_core/entity.py +0 -72
  65. mini_arcade_core/game.py +0 -287
  66. mini_arcade_core/keymaps/__init__.py +0 -15
  67. mini_arcade_core/managers/base.py +0 -132
  68. mini_arcade_core/managers/entities.py +0 -38
  69. mini_arcade_core/managers/overlays.py +0 -53
  70. mini_arcade_core/managers/system.py +0 -26
  71. mini_arcade_core/scenes/model.py +0 -34
  72. mini_arcade_core/scenes/runtime.py +0 -29
  73. mini_arcade_core/scenes/scene.py +0 -109
  74. mini_arcade_core/scenes/system.py +0 -69
  75. mini_arcade_core/ui/overlays.py +0 -41
  76. mini_arcade_core-0.10.0.dist-info/RECORD +0 -40
  77. /mini_arcade_core/{keymaps → backend}/keys.py +0 -0
  78. {mini_arcade_core-0.10.0.dist-info → mini_arcade_core-1.0.1.dist-info}/licenses/LICENSE +0 -0
@@ -1,22 +0,0 @@
1
- """
2
- Managers module for Mini Arcade Core.
3
- Provides various manager classes for handling game entities and resources.
4
- """
5
-
6
- from __future__ import annotations
7
-
8
- from .cheats import BaseCheatCommand, CheatCode, CheatManager
9
- from .entities import EntityManager
10
- from .inputs import InputManager
11
- from .overlays import OverlayManager
12
- from .system import SystemManager
13
-
14
- __all__ = [
15
- "EntityManager",
16
- "OverlayManager",
17
- "CheatCode",
18
- "CheatManager",
19
- "BaseCheatCommand",
20
- "InputManager",
21
- "SystemManager",
22
- ]
@@ -5,80 +5,19 @@ Provides cheat codes and related functionality.
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
- from abc import ABC, abstractmethod
9
8
  from collections import deque
10
- from dataclasses import dataclass
11
- from enum import Enum
12
- from typing import (
13
- TYPE_CHECKING,
14
- Callable,
15
- Deque,
16
- Dict,
17
- Generic,
18
- Optional,
19
- Protocol,
20
- Sequence,
21
- TypeVar,
22
- )
23
-
24
- from mini_arcade_core.backend import Event, EventType
25
- from mini_arcade_core.scenes.system import BaseSceneSystem
26
-
27
- if TYPE_CHECKING:
28
- from mini_arcade_core.scenes import Scene
9
+ from dataclasses import dataclass, field
10
+ from typing import Callable, Deque, Dict, Optional, Sequence, TypeVar
11
+
12
+ from mini_arcade_core.engine.commands import Command, CommandQueue
13
+ from mini_arcade_core.runtime.input_frame import InputFrame
29
14
 
30
15
  # Justification: We want to keep the type variable name simple here.
31
16
  # pylint: disable=invalid-name
32
17
  TContext = TypeVar("TContext")
33
- TScene = TypeVar("TScene", bound="Scene")
34
18
  # pylint: enable=invalid-name
35
19
 
36
20
 
37
- @dataclass
38
- class BaseCheatCommand(ABC, Generic[TContext]):
39
- """
40
- Base class for cheat codes.
41
-
42
- :ivar enabled (bool): Whether the cheat code is enabled.
43
- """
44
-
45
- enabled: bool = True
46
-
47
- def __call__(self, context: TContext) -> None:
48
- """
49
- Execute the cheat code with the given context.
50
-
51
- :param context: Context object for cheat execution.
52
- :type context: TContext
53
- """
54
- if not self.enabled:
55
- return False
56
- self.execute(context)
57
- return True
58
-
59
- @abstractmethod
60
- def execute(self, context: TContext):
61
- """
62
- Execute the cheat code with the given context.
63
-
64
- :param context: Context object for cheat execution.
65
- :type context: TContext
66
- """
67
- raise NotImplementedError("CheatCommand.execute must be overridden.")
68
-
69
-
70
- class CheatAction(Protocol[TContext]):
71
- """
72
- Protocol for cheat code actions.
73
-
74
- :ivar enabled: Whether the cheat action is enabled.
75
- """
76
-
77
- enabled: bool
78
-
79
- def __call__(self, ctx: TContext) -> bool: ...
80
-
81
-
82
21
  @dataclass(frozen=True)
83
22
  class CheatCode:
84
23
  """
@@ -93,141 +32,130 @@ class CheatCode:
93
32
 
94
33
  name: str
95
34
  sequence: tuple[str, ...]
96
- action: CheatAction[TContext]
35
+ command_factory: Optional[Callable[[TContext], Command]] = None
97
36
  clear_buffer_on_match: bool = False
98
37
  enabled: bool = True
99
38
 
100
39
 
40
+ @dataclass
101
41
  class CheatManager:
102
42
  """
103
43
  Reusable cheat code matcher.
104
44
  Keeps a rolling buffer of recent keys and triggers callbacks on sequence match.
105
45
  """
106
46
 
107
- def __init__(
108
- self,
109
- buffer_size: int = 16,
110
- *,
111
- enabled: bool = True,
112
- normalize: Optional[Callable[[str], str]] = None,
113
- key_getter: Optional[Callable[[object], Optional[str]]] = None,
114
- ):
115
- """
116
- :param buffer_size: Maximum size of the input buffer.
117
- :type buffer_size: int
118
-
119
- :param enabled: Whether the cheat manager is enabled.
120
- :type enabled: bool
121
-
122
- :param normalize: Optional function to normalize key strings.
123
- :type normalize: Callable[[str], str] | None
124
-
125
- :param key_getter: Optional function to extract key string from event object.
126
- :type key_getter: Callable[[object], Optional[str]] | None
127
- """
128
- self.enabled = enabled
129
- self._buffer: Deque[str] = deque(maxlen=buffer_size)
130
- self._codes: Dict[str, CheatCode] = {}
47
+ buffer_size: int = 16
48
+ enabled: bool = True
49
+ _buffer: Deque[str] = field(default_factory=lambda: deque(maxlen=16))
50
+ _codes: Dict[str, CheatCode[TContext]] = field(default_factory=dict)
131
51
 
132
- self._normalize = normalize or (lambda s: s.strip().upper())
133
- # key_getter: how to extract a key from an engine/backend event object
134
- self._key_getter = key_getter or self._default_key_getter
52
+ def __post_init__(self):
53
+ # ensure deque maxlen matches buffer_size
54
+ self._buffer = deque(maxlen=self.buffer_size)
135
55
 
136
- # Justification: We want to keep the number of arguments manageable here.
56
+ # TODO: ISolve too-many-arguments warning later
57
+ # Justification: The method needs multiple optional parameters for flexibility.
137
58
  # pylint: disable=too-many-arguments
138
- def register_command(
59
+ def register(
139
60
  self,
140
61
  name: str,
141
- sequence: Sequence[str],
142
- command: BaseCheatCommand[TContext],
143
62
  *,
63
+ sequence: Sequence[str],
64
+ command_factory: Callable[[TContext], Command],
144
65
  clear_buffer_on_match: bool = False,
145
66
  enabled: bool = True,
146
67
  ):
147
68
  """
148
- Register a new cheat code that triggers a BaseCheatCommand.
69
+ Register a new cheat code.
149
70
 
150
- :param name: Unique name for the cheat code.
71
+ :param name: Unique name of the cheat code.
151
72
  :type name: str
152
73
 
153
74
  :param sequence: Sequence of key strings that trigger the cheat.
154
75
  :type sequence: Sequence[str]
155
76
 
156
- :param command: BaseCheatCommand to execute when the cheat is activated.
157
- :type command: BaseCheatCommand[TContext]
77
+ :param command_factory: Factory function to create the Command when the cheat is activated.
78
+ :type command_factory: Callable[[TContext], Command]
158
79
 
159
80
  :param clear_buffer_on_match: Whether to clear the input buffer after a match.
160
81
  :type clear_buffer_on_match: bool
161
82
 
162
83
  :param enabled: Whether the cheat code is enabled.
163
84
  :type enabled: bool
85
+
86
+ :raises ValueError: If name is empty or sequence is empty.
164
87
  """
165
88
  if not name:
166
- raise ValueError("Cheat code name must be non-empty.")
89
+ raise ValueError("Cheat name must be non-empty.")
167
90
  if not sequence:
168
- raise ValueError(
169
- f"Cheat code '{name}' sequence must be non-empty."
170
- )
91
+ raise ValueError(f"Cheat '{name}' sequence must be non-empty.")
171
92
 
172
- norm_seq = tuple(self._normalize(k) for k in sequence)
93
+ norm_seq = tuple(self._norm(s) for s in sequence)
173
94
  self._codes[name] = CheatCode(
174
95
  name=name,
175
96
  sequence=norm_seq,
176
- action=command,
97
+ command_factory=command_factory,
177
98
  clear_buffer_on_match=clear_buffer_on_match,
178
99
  enabled=enabled,
179
100
  )
180
101
 
181
102
  # pylint: enable=too-many-arguments
182
103
 
183
- def unregister_code(self, name: str):
184
- """
185
- Unregister a cheat code by name.
186
-
187
- :param name: Name of the cheat code to unregister.
188
- :type name: str
104
+ def process_frame(
105
+ self,
106
+ input_frame: InputFrame,
107
+ *,
108
+ context: TContext,
109
+ queue: CommandQueue,
110
+ ) -> list[str]:
189
111
  """
190
- self._codes.pop(name, None)
112
+ Process an InputFrame for cheat code matches.
191
113
 
192
- def clear_buffer(self):
193
- """Clear the input buffer."""
194
- self._buffer.clear()
114
+ :param input_frame: InputFrame containing current inputs.
115
+ :type input_frame: InputFrame
195
116
 
196
- def process_event(self, event: object, context: TContext) -> list[str]:
197
- """
198
- Call from Scene when a key is pressed.
199
- Returns list of cheat names matched (often 0 or 1).
117
+ :param context: Context to pass to command factories.
118
+ :type context: TContext
200
119
 
201
- :param event: The event object from the backend/engine.
202
- :type event: object
120
+ :param queue: CommandQueue to push matched commands into.
121
+ :type queue: CommandQueue
203
122
 
204
- :param context: Context object passed to cheat callbacks.
205
- :type context: TContext
123
+ :return: List of names of matched cheat codes.
124
+ :rtype: list[str]
206
125
  """
207
126
  if not self.enabled:
208
127
  return []
209
- key = self._key_getter(event)
210
- if not key:
211
- return []
212
- return self.process_key(key, context)
213
128
 
214
- def process_key(self, key: str, context: TContext) -> list[str]:
129
+ matched: list[str] = []
130
+ for key in input_frame.keys_pressed:
131
+ key_name = getattr(key, "name", str(key))
132
+ matched.extend(
133
+ self.process_key(key_name, context=context, queue=queue)
134
+ )
135
+ return matched
136
+
137
+ def process_key(
138
+ self, key: str, *, context: TContext, queue: CommandQueue
139
+ ) -> list[str]:
215
140
  """
216
- Pure method for tests: feed a key string directly.
141
+ Process a single key input.
217
142
 
218
143
  :param key: The key string to process.
219
144
  :type key: str
220
145
 
221
- :param context: Context object passed to cheat callbacks.
146
+ :param context: Context to pass to command factories.
222
147
  :type context: TContext
223
148
 
224
- :return: List of cheat names matched.
149
+ :param queue: CommandQueue to push matched commands into.
150
+ :type queue: CommandQueue
151
+
152
+ :return: List of names of matched cheat codes.
225
153
  :rtype: list[str]
226
154
  """
227
155
  if not self.enabled:
228
156
  return []
229
157
 
230
- k = self._normalize(key)
158
+ k = self._norm(key)
231
159
  if not k:
232
160
  return []
233
161
 
@@ -235,121 +163,24 @@ class CheatManager:
235
163
  buf = tuple(self._buffer)
236
164
 
237
165
  matched: list[str] = []
238
- # Check if buffer ends with any cheat sequence
239
166
  for code in self._codes.values():
240
167
  if not code.enabled:
241
168
  continue
169
+
242
170
  seq = code.sequence
243
171
  if len(seq) > len(buf):
244
172
  continue
173
+
245
174
  if buf[-len(seq) :] == seq:
246
- code.action(context)
175
+ queue.push(code.command_factory(context))
247
176
  matched.append(code.name)
177
+
248
178
  if code.clear_buffer_on_match:
249
- self.clear_buffer()
250
- break # buffer changed; stop early
179
+ self._buffer.clear()
180
+ break
251
181
 
252
182
  return matched
253
183
 
254
184
  @staticmethod
255
- def _default_key_getter(event: object) -> Optional[str]:
256
- """
257
- Best-effort extraction:
258
- - event.key
259
- - event.key_code
260
- - event.code
261
- - event.scancode
262
- - dict-like {"key": "..."}
263
- Adjust/override with key_getter in your engine if needed.
264
-
265
- :param event: The event object.
266
- :type event: object
267
-
268
- :return: Extracted key string, or None if not found.
269
- :rtype: Optional[str]
270
- """
271
- if event is None:
272
- return None
273
-
274
- # dict-like
275
- if isinstance(event, dict):
276
- v = (
277
- event.get("key")
278
- or event.get("key_code")
279
- or event.get("code")
280
- or event.get("scancode")
281
- )
282
- if isinstance(v, Enum):
283
- return v.name
284
- return str(v) if v is not None else None
285
-
286
- # object-like
287
- for attr in ("key", "key_code", "code", "scancode"):
288
- if hasattr(event, attr):
289
- v = getattr(event, attr)
290
- if v is None:
291
- continue
292
- if isinstance(v, Enum):
293
- return v.name # <-- THIS is the important bit
294
- return str(v)
295
-
296
- return None
297
-
298
-
299
- class CheatSystem(BaseSceneSystem, Generic[TScene]):
300
- """
301
- Scene system for handling cheat codes.
302
-
303
- :ivar priority (int): Priority of the system (lower runs first).
304
- :ivar enabled (bool): Whether the system is enabled.
305
- """
306
-
307
- priority = 10
308
-
309
- def __init__(self, scene: TScene, buffer_size=16):
310
- """
311
- :param buffer_size: Size of the cheat input buffer.
312
- :type buffer_size: int
313
- """
314
- super().__init__(scene)
315
- self.cheats = CheatManager(buffer_size=buffer_size)
316
-
317
- def register(self, name: str, seq: list[str], cmd: Callable, **kwargs):
318
- """
319
- Helper to register a cheat command.
320
-
321
- :param name: Unique name for the cheat code.
322
- :type name: str
323
-
324
- :param seq: Sequence of key strings that trigger the cheat.
325
- :type seq: list[str]
326
-
327
- :param cmd: Callable to execute when the cheat is activated.
328
- :type cmd: Callable
329
-
330
- :param kwargs: Additional keyword arguments for cheat registration.
331
- """
332
- self.cheats.register_command(name, seq, cmd, **kwargs)
333
-
334
- def on_enter(self):
335
- """
336
- Called when the scene is entered.
337
- """
338
- # register codes here (or via a builder)
339
-
340
- def handle_event(self, event: Event) -> bool:
341
- """
342
- Handle an event.
343
-
344
- :param event: The event to handle.
345
- :type event: Event
346
-
347
- :param scene: The scene receiving the event.
348
- :type scene: TScene
349
-
350
- :return: True if the event was handled, False otherwise.
351
- :rtype: bool
352
- """
353
- if event.type == EventType.KEYDOWN:
354
- self.cheats.process_event(event, self.scene)
355
- return False # usually don't consume
185
+ def _norm(s: str) -> str:
186
+ return s.strip().upper()
@@ -2,6 +2,10 @@
2
2
  Input manager for handling input bindings and commands.
3
3
  """
4
4
 
5
+ # TODO: Implement this manager into the new input system
6
+ # Justification: These module will be used later.
7
+ # pylint: disable=no-name-in-module,import-error,used-before-assignment
8
+
5
9
  from __future__ import annotations
6
10
 
7
11
  import logging
@@ -9,10 +13,10 @@ from dataclasses import dataclass
9
13
  from typing import TYPE_CHECKING, Callable, Dict, Optional
10
14
 
11
15
  from mini_arcade_core.backend import Event, EventType
12
- from mini_arcade_core.commands import BaseCommand, BaseSceneCommand
13
16
  from mini_arcade_core.keymaps import Key
14
17
 
15
18
  if TYPE_CHECKING:
19
+ from mini_arcade_core.engine.commands import BaseCommand, BaseSceneCommand
16
20
  from mini_arcade_core.scenes.scene import Scene
17
21
 
18
22
  logger = logging.getLogger(__name__)
@@ -139,8 +143,6 @@ class InputManager:
139
143
  )
140
144
  binding.command.execute(to_inject)
141
145
 
142
- # --- Convenience API ------------------------------------------------------
143
-
144
146
  def on_quit(self, command: BaseCommand, action: str = "quit"):
145
147
  """
146
148
  Bind a command to the QUIT event.
File without changes
File without changes
@@ -0,0 +1,20 @@
1
+ """
2
+ Module providing runtime adapters for window and scene management.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from mini_arcade_core.runtime.audio.audio_port import AudioPort
8
+
9
+
10
+ class SDLAudioAdapter(AudioPort):
11
+ """A no-op audio adapter."""
12
+
13
+ def __init__(self, backend):
14
+ self.backend = backend
15
+
16
+ def load_sound(self, sound_id: str, file_path: str):
17
+ self.backend.load_sound(sound_id, file_path)
18
+
19
+ def play(self, sound_id: str, loops: int = 0):
20
+ self.backend.play_sound(sound_id, loops)
@@ -0,0 +1,36 @@
1
+ """
2
+ Service interfaces for runtime components.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from mini_arcade_core.backend.backend import Backend
8
+
9
+
10
+ class AudioPort:
11
+ """Interface for audio playback operations."""
12
+
13
+ backend: Backend
14
+
15
+ def load_sound(self, sound_id: str, file_path: str):
16
+ """
17
+ Load a sound from a file and associate it with an identifier.
18
+
19
+ :param sound_id: Identifier to associate with the sound.
20
+ :type sound_id: str
21
+
22
+ :param file_path: Path to the sound file.
23
+ :type file_path: str
24
+ """
25
+
26
+ def play(self, sound_id: str, loops: int = 0):
27
+ """
28
+ Play the specified sound.
29
+
30
+ :param sound_id: Identifier of the sound to play.
31
+ :type sound_id: str
32
+
33
+ :param loops: Number of times to loop the sound.
34
+ 0 = play once, -1 = infinite loop, 1 = play twice, etc.
35
+ :type loops: int
36
+ """
File without changes
@@ -0,0 +1,143 @@
1
+ """
2
+ Module providing runtime adapters for window and scene management.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from PIL import Image
13
+
14
+ from mini_arcade_core.backend import Backend
15
+ from mini_arcade_core.runtime.capture.capture_port import CapturePort
16
+ from mini_arcade_core.utils import logger
17
+
18
+
19
+ @dataclass
20
+ class CapturePathBuilder:
21
+ """
22
+ Helper to build file paths for captured screenshots.
23
+
24
+ :ivar directory (str): Directory to save screenshots in.
25
+ :ivar prefix (str): Prefix for screenshot filenames.
26
+ :ivar ext (str): File extension/format for screenshots.
27
+ """
28
+
29
+ directory: str = "screenshots"
30
+ prefix: str = ""
31
+ ext: str = "png" # final output format
32
+
33
+ def build(self, label: str) -> Path:
34
+ """
35
+ Build a file path for a screenshot with the given label.
36
+
37
+ :param label: Label to include in the filename.
38
+ :type label: str
39
+
40
+ :return: Full path for the screenshot file.
41
+ :rtype: Path
42
+ """
43
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
44
+ safe_label = "".join(
45
+ c if c.isalnum() or c in ("-", "_") else "_" for c in label
46
+ )
47
+ name = f"{self.prefix}{stamp}_{safe_label}.{self.ext}"
48
+ return Path(self.directory) / name
49
+
50
+ def build_sim(self, run_id: str, frame_index: int, label: str) -> Path:
51
+ """
52
+ Build a file path for a simulation frame screenshot.
53
+
54
+ :param run_id: Unique identifier for the simulation run.
55
+ :type run_id: str
56
+
57
+ :param frame_index: Index of the frame in the simulation.
58
+ :type frame_index: int
59
+
60
+ :param label: Label to include in the filename.
61
+ :type label: str
62
+
63
+ :return: Full path for the screenshot file.
64
+ :rtype: Path
65
+ """
66
+ safe_label = "".join(
67
+ c if c.isalnum() or c in ("-", "_") else "_" for c in label
68
+ )
69
+ # deterministic: run_id + frame index
70
+ name = (
71
+ f"{self.prefix}{run_id}_f{frame_index:08d}_{safe_label}.{self.ext}"
72
+ )
73
+ return Path(self.directory) / run_id / name
74
+
75
+
76
+ class CaptureAdapter(CapturePort):
77
+ """Adapter for capturing frames."""
78
+
79
+ def __init__(
80
+ self,
81
+ backend: Backend,
82
+ path_builder: Optional[CapturePathBuilder] = None,
83
+ ):
84
+ self.backend = backend
85
+ self.path_builder = path_builder or CapturePathBuilder()
86
+
87
+ def _bmp_to_image(self, bmp_path: str, out_path: str):
88
+ img = Image.open(bmp_path)
89
+ img.save(out_path)
90
+
91
+ def screenshot(self, label: str | None = None) -> str:
92
+ label = label or "shot"
93
+ out_path = self.path_builder.build(label)
94
+ out_path.parent.mkdir(parents=True, exist_ok=True)
95
+
96
+ # If backend only saves BMP, capture to a temp bmp next to output
97
+ bmp_path = out_path.with_suffix(".bmp")
98
+
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")
102
+
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
111
+
112
+ return str(out_path)
113
+
114
+ def screenshot_bytes(self) -> bytes:
115
+ data = self.backend.capture_frame(path=None)
116
+ if data is None:
117
+ raise RuntimeError("Backend returned None for screenshot_bytes()")
118
+ return data
119
+
120
+ def screenshot_sim(
121
+ self, run_id: str, frame_index: int, label: str = "frame"
122
+ ) -> str:
123
+ """Screenshot for simulation frames."""
124
+ 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
+
130
+ if not bmp_path.exists():
131
+ raise RuntimeError("Backend capture_frame did not create BMP file")
132
+
133
+ self._bmp_to_image(str(bmp_path), str(out_path))
134
+
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
142
+
143
+ return str(out_path)