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
@@ -0,0 +1,369 @@
1
+ """
2
+ Game core module defining the Game class and configuration.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from time import perf_counter, sleep
9
+ from typing import Dict, Literal
10
+
11
+ from mini_arcade_core.backend import Backend, WindowSettings
12
+ from mini_arcade_core.backend.events import EventType
13
+ from mini_arcade_core.engine.commands import (
14
+ CommandContext,
15
+ CommandQueue,
16
+ QuitCommand,
17
+ )
18
+ from mini_arcade_core.engine.render.packet import RenderPacket
19
+ from mini_arcade_core.engine.render.pipeline import RenderPipeline
20
+ from mini_arcade_core.managers.cheats import CheatManager
21
+ from mini_arcade_core.runtime.audio.audio_adapter import SDLAudioAdapter
22
+ from mini_arcade_core.runtime.capture.capture_adapter import CaptureAdapter
23
+ from mini_arcade_core.runtime.file.file_adapter import LocalFilesAdapter
24
+ from mini_arcade_core.runtime.input.input_adapter import InputAdapter
25
+ from mini_arcade_core.runtime.input_frame import InputFrame
26
+ from mini_arcade_core.runtime.scene.scene_adapter import SceneAdapter
27
+ from mini_arcade_core.runtime.services import RuntimeServices
28
+ from mini_arcade_core.runtime.window.window_adapter import WindowAdapter
29
+ from mini_arcade_core.scenes.registry import SceneRegistry
30
+ from mini_arcade_core.utils import logger
31
+
32
+
33
+ @dataclass
34
+ class WindowConfig:
35
+ """
36
+ Configuration for a game window (not implemented).
37
+
38
+ :ivar width (int): Width of the window in pixels.
39
+ :ivar height (int): Height of the window in pixels.
40
+ :ivar background_color (tuple[int, int, int]): RGB background color.
41
+ :ivar title (str): Title of the window.
42
+ """
43
+
44
+ width: int
45
+ height: int
46
+ background_color: tuple[int, int, int]
47
+ title: str
48
+
49
+
50
+ @dataclass
51
+ class GameConfig:
52
+ """
53
+ Configuration options for the Game.
54
+
55
+ :ivar window (WindowConfig | None): Optional window configuration.
56
+ :ivar fps (int): Target frames per second.
57
+ :ivar backend (Backend | None): Optional Backend instance to use for rendering and input.
58
+ """
59
+
60
+ window: WindowConfig | None = None
61
+ fps: int = 60
62
+ backend: Backend | None = None
63
+
64
+
65
+ Difficulty = Literal["easy", "normal", "hard", "insane"]
66
+
67
+
68
+ @dataclass
69
+ class GameSettings:
70
+ """
71
+ Game settings that can be modified during gameplay.
72
+
73
+ :ivar difficulty (Difficulty): Current game difficulty level.
74
+ """
75
+
76
+ difficulty: Difficulty = "normal"
77
+
78
+
79
+ def _neutral_input(frame_index: int, dt: float) -> InputFrame:
80
+ """Create a neutral InputFrame with no input events."""
81
+ return InputFrame(frame_index=frame_index, dt=dt)
82
+
83
+
84
+ @dataclass
85
+ class FrameTimer:
86
+ """
87
+ Simple frame timer for marking and reporting time intervals.
88
+
89
+ :ivar enabled (bool): Whether timing is enabled.
90
+ :ivar marks (Dict[str, float]): Recorded time marks.
91
+ """
92
+
93
+ enabled: bool = False
94
+ marks: Dict[str, float] = field(default_factory=dict)
95
+
96
+ def mark(self, name: str):
97
+ """
98
+ Record a time mark with the given name.
99
+
100
+ :param name: Name of the mark.
101
+ :type name: str
102
+ """
103
+ if not self.enabled:
104
+ return
105
+ self.marks[name] = perf_counter()
106
+
107
+ def diff_ms(self, start: str, end: str) -> float:
108
+ """
109
+ Get the time difference in milliseconds between two marks.
110
+
111
+ :param start: Name of the start mark.
112
+ :type start: str
113
+
114
+ :param end: Name of the end mark.
115
+ :type end: str
116
+
117
+ :return: Time difference in milliseconds.
118
+ :rtype: float
119
+ """
120
+ return (self.marks[end] - self.marks[start]) * 1000.0
121
+
122
+ def report_ms(self) -> Dict[str, float]:
123
+ """
124
+ Returns diffs between consecutive marks in insertion order.
125
+
126
+ :return: Dictionary mapping "start->end" to time difference in milliseconds.
127
+ :rtype: Dict[str, float]
128
+ """
129
+ if not self.enabled:
130
+ return {}
131
+
132
+ keys = list(self.marks.keys())
133
+ out: Dict[str, float] = {}
134
+ for a, b in zip(keys, keys[1:]):
135
+ out[f"{a}->{b}"] = self.diff_ms(a, b)
136
+ return out
137
+
138
+ def clear(self):
139
+ """Clear all recorded marks."""
140
+ if not self.enabled:
141
+ return
142
+ self.marks.clear()
143
+
144
+
145
+ # TODO: Fix too-many-instance-attributes warning
146
+ # Justification: Core game class with many dependencies.
147
+ # pylint: disable=too-many-instance-attributes
148
+ class Game:
149
+ """Core game object responsible for managing the main loop and active scene."""
150
+
151
+ def __init__(
152
+ self, config: GameConfig, registry: SceneRegistry | None = None
153
+ ):
154
+ """
155
+ :param config: Game configuration options.
156
+ :type config: GameConfig
157
+
158
+ :param registry: Optional SceneRegistry for scene management.
159
+ :type registry: SceneRegistry | None
160
+
161
+ :raises ValueError: If the provided config does not have a valid Backend.
162
+ """
163
+ self.config = config
164
+ self._running: bool = False
165
+
166
+ if config.backend is None:
167
+ raise ValueError(
168
+ "GameConfig.backend must be set to a Backend instance"
169
+ )
170
+ if config.window is None:
171
+ raise ValueError("GameConfig.window must be set")
172
+
173
+ self.backend: Backend = config.backend
174
+ self.registry = registry or SceneRegistry(_factories={})
175
+ self.settings = GameSettings()
176
+ self.services = RuntimeServices(
177
+ window=WindowAdapter(
178
+ self.backend,
179
+ WindowSettings(
180
+ width=self.config.window.width,
181
+ height=self.config.window.height,
182
+ ),
183
+ ),
184
+ scenes=SceneAdapter(self.registry, self),
185
+ audio=SDLAudioAdapter(self.backend),
186
+ files=LocalFilesAdapter(),
187
+ capture=CaptureAdapter(self.backend),
188
+ input=InputAdapter(),
189
+ )
190
+
191
+ self.command_queue = CommandQueue()
192
+ self.cheat_manager = CheatManager()
193
+
194
+ def quit(self):
195
+ """Request that the main loop stops."""
196
+ self._running = False
197
+
198
+ # TODO: Fix too-many-statements and too-many-locals warnings
199
+ # Justification: Main game loop with multiple responsibilities.
200
+ # pylint: disable=too-many-statements,too-many-locals
201
+ def run(self, initial_scene_id: str):
202
+ """
203
+ Run the main loop starting with the given scene.
204
+
205
+ This is intentionally left abstract so you can plug pygame, pyglet,
206
+ or another backend.
207
+
208
+ :param initial_scene_id: The scene id to start the game with (must be registered).
209
+ :type initial_scene_id: str
210
+ """
211
+ backend = self.backend
212
+
213
+ self._initialize_window()
214
+
215
+ self.services.scenes.change(initial_scene_id)
216
+
217
+ pipeline = RenderPipeline()
218
+
219
+ self._running = True
220
+ target_dt = 1.0 / self.config.fps if self.config.fps > 0 else 0.0
221
+ last_time = perf_counter()
222
+ frame_index = 0
223
+
224
+ # cache packets so blocked-update scenes still render their last frame
225
+ packet_cache: dict[int, RenderPacket] = {}
226
+
227
+ timer = FrameTimer(enabled=True)
228
+ # report_every = 60 # print once per second at 60fps
229
+
230
+ # TODO: Integrate SimRunner for simulation stepping
231
+ # TODO: Fix assignment-from-no-return warning in self.services.input.build
232
+ # & self.services.scenes.input_entry
233
+ # Justification: These methods are expected to return values.
234
+ # pylint: disable=assignment-from-no-return
235
+
236
+ while self._running:
237
+ timer.clear()
238
+ timer.mark("frame_start")
239
+
240
+ now = perf_counter()
241
+ dt = now - last_time
242
+ last_time = now
243
+
244
+ events = list(backend.poll_events())
245
+
246
+ for e in events:
247
+ if e.type == EventType.WINDOWRESIZED and e.size:
248
+ w, h = e.size
249
+ logger.debug(f"Window resized event: {w}x{h}")
250
+ self.services.window.on_window_resized(w, h)
251
+ timer.mark("events_polled")
252
+
253
+ input_frame = self.services.input.build(events, frame_index, dt)
254
+ timer.mark("input_built")
255
+
256
+ # Window/OS quit (close button)
257
+ if input_frame.quit:
258
+ self.command_queue.push(QuitCommand())
259
+
260
+ # who gets input?
261
+ input_entry = self.services.scenes.input_entry()
262
+ if input_entry is None:
263
+ break
264
+
265
+ # tick policy-aware scenes
266
+ timer.mark("tick_start")
267
+ for entry in self.services.scenes.update_entries():
268
+ scene = entry.scene
269
+ effective_input = (
270
+ input_frame
271
+ if entry is input_entry
272
+ else _neutral_input(frame_index, dt)
273
+ )
274
+
275
+ packet = scene.tick(effective_input, dt)
276
+ packet_cache[id(scene)] = packet
277
+ timer.mark("tick_end")
278
+
279
+ timer.mark("command_ctx_start")
280
+ command_context = CommandContext(
281
+ services=self.services,
282
+ commands=self.command_queue,
283
+ settings=self.settings,
284
+ world=self._resolve_world(),
285
+ )
286
+ timer.mark("command_ctx_end")
287
+
288
+ timer.mark("cheats_start")
289
+ self.cheat_manager.process_frame(
290
+ input_frame,
291
+ context=command_context,
292
+ queue=self.command_queue,
293
+ )
294
+ timer.mark("cheats_end")
295
+
296
+ # Execute commands at the end of the frame (consistent write path)
297
+ timer.mark("cmd_exec_start")
298
+ for cmd in self.command_queue.drain():
299
+ cmd.execute(command_context)
300
+ timer.mark("cmd_exec_end")
301
+
302
+ timer.mark("render_start")
303
+ backend.begin_frame()
304
+ timer.mark("begin_frame_done")
305
+
306
+ vp = self.services.window.get_viewport()
307
+ for entry in self.services.scenes.visible_entries():
308
+ scene = entry.scene
309
+ packet = packet_cache.get(id(scene))
310
+ if packet is None:
311
+ # bootstrap (first frame visible but not updated)
312
+ packet = scene.tick(_neutral_input(frame_index, 0.0), 0.0)
313
+ packet_cache[id(scene)] = packet
314
+
315
+ pipeline.draw_packet(backend, packet, vp)
316
+
317
+ timer.mark("draw_done")
318
+ backend.end_frame()
319
+ timer.mark("end_frame_done")
320
+
321
+ timer.mark("sleep_start")
322
+ if target_dt > 0 and dt < target_dt:
323
+ sleep(target_dt - dt)
324
+ timer.mark("sleep_end")
325
+
326
+ # --- report ---
327
+ # if timer.enabled and (
328
+ # frame_index % report_every == 0 and frame_index > 0
329
+ # ):
330
+ # ms = timer.report_ms()
331
+ # total = (perf_counter() - timer.marks["frame_start"]) * 1000.0
332
+ # logger.debug(
333
+ # f"[Frame {frame_index}] total={total:.2f}ms | {ms}"
334
+ # )
335
+
336
+ frame_index += 1
337
+
338
+ # pylint: enable=assignment-from-no-return
339
+
340
+ # exit remaining scenes
341
+ self.services.scenes.clean()
342
+
343
+ # pylint: enable=too-many-statements,too-many-locals
344
+
345
+ def _initialize_window(self):
346
+ """Initialize the game window based on the configuration."""
347
+ self.services.window.set_window_size(
348
+ self.config.window.width, self.config.window.height
349
+ )
350
+ self.services.window.set_title(self.config.window.title)
351
+
352
+ br, bg, bb = self.config.window.background_color
353
+ self.services.window.set_clear_color(br, bg, bb)
354
+
355
+ # the “authoring resolution”
356
+ self.services.window.set_virtual_resolution(800, 600)
357
+
358
+ def _resolve_world(self) -> object | None:
359
+ # Prefer gameplay world underneath overlays:
360
+ # scan from top to bottom and pick the first scene that has .world
361
+ for entry in reversed(self.services.scenes.visible_entries()):
362
+ scene = entry.scene
363
+ world = getattr(scene, "world", None)
364
+ if world is not None:
365
+ return world
366
+ return None
367
+
368
+
369
+ # pylint: enable=too-many-instance-attributes
File without changes
@@ -0,0 +1,56 @@
1
+ """
2
+ Render packet module.
3
+ Defines the RenderPacket class and related types."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Callable, Iterable, Protocol, runtime_checkable
9
+
10
+ from mini_arcade_core.backend import Backend
11
+
12
+ DrawOp = Callable[[Backend], None]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class RenderPacket:
17
+ """
18
+ Minimal render packet for v1.
19
+
20
+ It is intentionally backend-agnostic: each op is a callable that knows
21
+ how to draw itself using the Backend instance.
22
+
23
+ Later you can replace DrawOp with typed primitives + passes.
24
+ """
25
+
26
+ ops: tuple[DrawOp, ...] = ()
27
+ meta: dict[str, object] = field(default_factory=dict)
28
+
29
+ @staticmethod
30
+ def from_ops(ops: Iterable[DrawOp], **meta: object) -> "RenderPacket":
31
+ """
32
+ Create a RenderPacket from an iterable of DrawOps and optional meta.
33
+
34
+ :param ops: Iterable of DrawOp callables.
35
+ :type ops: Iterable[DrawOp]
36
+
37
+ :return: RenderPacket instance.
38
+ :rtype: RenderPacket
39
+ """
40
+ return RenderPacket(ops=tuple(ops), meta=dict(meta))
41
+
42
+
43
+ # TODO: Implement later
44
+ @runtime_checkable
45
+ class Renderable(Protocol):
46
+ """
47
+ Optional convenience: any object that can produce a RenderPacket.
48
+ """
49
+
50
+ def render(self) -> RenderPacket:
51
+ """
52
+ Produce a RenderPacket for this object.
53
+
54
+ :return: RenderPacket instance.
55
+ :rtype: RenderPacket
56
+ """
@@ -0,0 +1,63 @@
1
+ """
2
+ Render pipeline module.
3
+ Defines the RenderPipeline class for rendering RenderPackets.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from mini_arcade_core.backend import Backend
11
+ from mini_arcade_core.engine.render.packet import RenderPacket
12
+ from mini_arcade_core.engine.render.viewport import ViewportState
13
+
14
+
15
+ @dataclass
16
+ class RenderPipeline:
17
+ """
18
+ Minimal pipeline for v1.
19
+
20
+ Later you can expand this into passes:
21
+ - build draw list
22
+ - cull
23
+ - sort
24
+ - backend draw pass
25
+ """
26
+
27
+ def draw_packet(
28
+ self,
29
+ backend: Backend,
30
+ packet: RenderPacket,
31
+ viewport_state: ViewportState,
32
+ ):
33
+ """
34
+ Draw the given RenderPacket using the provided Backend.
35
+
36
+ :param backend: Backend to use for drawing.
37
+ :type backend: Backend
38
+
39
+ :param packet: RenderPacket to draw.
40
+ :type packet: RenderPacket
41
+ """
42
+ if not packet:
43
+ return
44
+
45
+ backend.set_viewport_transform(
46
+ viewport_state.offset_x,
47
+ viewport_state.offset_y,
48
+ viewport_state.scale,
49
+ )
50
+
51
+ # backend.set_clip_rect(
52
+ # viewport_state.offset_x,
53
+ # viewport_state.offset_y,
54
+ # viewport_state.viewport_w,
55
+ # viewport_state.viewport_h,
56
+ # )
57
+
58
+ try:
59
+ for op in packet.ops:
60
+ op(backend)
61
+ finally:
62
+ backend.clear_clip_rect()
63
+ backend.clear_viewport_transform()
@@ -0,0 +1,203 @@
1
+ """
2
+ Viewport management for virtual to screen coordinate transformations.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+
10
+ from mini_arcade_core.utils import logger
11
+
12
+
13
+ class ViewportMode(str, Enum):
14
+ """
15
+ Viewport scaling modes.
16
+
17
+ :cvar FIT: Scale to fit within window, preserving aspect ratio (letterbox).
18
+ :cvar FILL: Scale to fill entire window, preserving aspect ratio (crop).
19
+ """
20
+
21
+ FIT = "fit" # letterbox
22
+ FILL = "fill" # crop
23
+
24
+
25
+ # Justification: Many attributes needed to describe viewport state
26
+ # pylint: disable=too-many-instance-attributes
27
+ @dataclass(frozen=True)
28
+ class ViewportState:
29
+ """
30
+ Current state of the viewport.
31
+
32
+ :ivar virtual_w (int): Virtual canvas width.
33
+ :ivar virtual_h (int): Virtual canvas height.
34
+ :ivar window_w (int): Current window width.
35
+ :ivar window_h (int): Current window height.
36
+ :ivar mode (ViewportMode): Current viewport mode.
37
+ :ivar scale (float): Current scale factor.
38
+ :ivar viewport_w (int): Width of the viewport rectangle on screen.
39
+ :ivar viewport_h (int): Height of the viewport rectangle on screen.
40
+ :ivar offset_x (int): X offset of the viewport rectangle on screen.
41
+ :ivar offset_y (int): Y offset of the viewport rectangle on screen.
42
+ """
43
+
44
+ virtual_w: int
45
+ virtual_h: int
46
+
47
+ window_w: int
48
+ window_h: int
49
+
50
+ mode: ViewportMode
51
+ scale: float
52
+
53
+ # viewport rect in screen pixels where the virtual canvas lands
54
+ # (can be larger than window in FILL mode -> offsets can be negative)
55
+ viewport_w: int
56
+ viewport_h: int
57
+ offset_x: int
58
+ offset_y: int
59
+
60
+
61
+ # pylint: enable=too-many-instance-attributes
62
+
63
+
64
+ class Viewport:
65
+ """
66
+ Manages viewport transformations between virtual and screen coordinates.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ virtual_w: int,
72
+ virtual_h: int,
73
+ mode: ViewportMode = ViewportMode.FIT,
74
+ ):
75
+ """
76
+ :param virtual_w: Virtual canvas width.
77
+ :type virtual_w: int
78
+
79
+ :param virtual_h: Virtual canvas height.
80
+ :type virtual_h: int
81
+
82
+ :param mode: Viewport scaling mode.
83
+ :type mode: ViewportMode
84
+ """
85
+ self._virtual_w = int(virtual_w)
86
+ self._virtual_h = int(virtual_h)
87
+ self._mode = mode
88
+ self._state: ViewportState | None = None
89
+
90
+ def set_virtual_resolution(self, w: int, h: int):
91
+ """
92
+ Set a new virtual resolution.
93
+
94
+ :param w: New virtual width.
95
+ :type w: int
96
+
97
+ :param h: New virtual height.
98
+ :type h: int
99
+ """
100
+ self._virtual_w = int(w)
101
+ self._virtual_h = int(h)
102
+ if self._state:
103
+ self.resize(self._state.window_w, self._state.window_h)
104
+
105
+ def set_mode(self, mode: ViewportMode):
106
+ """
107
+ Set a new viewport mode.
108
+
109
+ :param mode: New viewport mode.
110
+ :type mode: ViewportMode
111
+ """
112
+ self._mode = mode
113
+ if self._state:
114
+ self.resize(self._state.window_w, self._state.window_h)
115
+
116
+ def resize(self, window_w: int, window_h: int):
117
+ """
118
+ Resize the viewport based on the current window size.
119
+
120
+ :param window_w: Current window width.
121
+ :type window_w: int
122
+
123
+ :param window_h: Current window height.
124
+ :type window_h: int
125
+ """
126
+ window_w = int(window_w)
127
+ window_h = int(window_h)
128
+
129
+ sx = window_w / self._virtual_w
130
+ sy = window_h / self._virtual_h
131
+ scale = min(sx, sy) if self._mode == ViewportMode.FIT else max(sx, sy)
132
+
133
+ vw = int(round(self._virtual_w * scale))
134
+ vh = int(round(self._virtual_h * scale))
135
+ ox = int(round((window_w - vw) / 2))
136
+ oy = int(round((window_h - vh) / 2))
137
+
138
+ self._state = ViewportState(
139
+ virtual_w=self._virtual_w,
140
+ virtual_h=self._virtual_h,
141
+ window_w=window_w,
142
+ window_h=window_h,
143
+ mode=self._mode,
144
+ scale=float(scale),
145
+ viewport_w=vw,
146
+ viewport_h=vh,
147
+ offset_x=ox,
148
+ offset_y=oy,
149
+ )
150
+ logger.debug(
151
+ f"Viewport resized: window=({window_w}x{window_h}), "
152
+ f"virtual=({self._virtual_w}x{self._virtual_h}), "
153
+ f"mode={self._mode}, scale={scale:.3f}, "
154
+ f"viewport=({vw}x{vh})@({ox},{oy})"
155
+ )
156
+
157
+ @property
158
+ def state(self) -> ViewportState:
159
+ """
160
+ Get the current viewport state.
161
+
162
+ :return: Current ViewportState.
163
+ :rtype: ViewportState
164
+
165
+ :raises RuntimeError: If the viewport has not been initialized.
166
+ """
167
+ if self._state is None:
168
+ raise RuntimeError(
169
+ "Viewport not initialized. Call resize(window_w, window_h)."
170
+ )
171
+ return self._state
172
+
173
+ def screen_to_virtual(self, x: float, y: float) -> tuple[float, float]:
174
+ """
175
+ Convert screen coordinates to virtual coordinates.
176
+
177
+ :param x: X coordinate on the screen.
178
+ :type x: float
179
+
180
+ :param y: Y coordinate on the screen.
181
+ :type y: float
182
+
183
+ :return: Corresponding virtual coordinates (x, y).
184
+ :rtype: tuple[float, float]
185
+ """
186
+ s = self.state
187
+ return ((x - s.offset_x) / s.scale, (y - s.offset_y) / s.scale)
188
+
189
+ def virtual_to_screen(self, x: float, y: float) -> tuple[float, float]:
190
+ """
191
+ Convert virtual coordinates to screen coordinates.
192
+
193
+ :param x: X coordinate in virtual space.
194
+ :type x: float
195
+
196
+ :param y: Y coordinate in virtual space.
197
+ :type y: float
198
+
199
+ :return: Corresponding screen coordinates (x, y).
200
+ :rtype: tuple[float, float]
201
+ """
202
+ s = self.state
203
+ return (s.offset_x + x * s.scale, s.offset_y + y * s.scale)