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.
- mini_arcade_core/__init__.py +51 -63
- mini_arcade_core/backend/__init__.py +2 -6
- mini_arcade_core/backend/backend.py +148 -8
- mini_arcade_core/backend/events.py +1 -1
- mini_arcade_core/{keymaps/sdl.py → backend/sdl_map.py} +1 -1
- mini_arcade_core/engine/__init__.py +0 -0
- mini_arcade_core/engine/commands.py +169 -0
- mini_arcade_core/engine/game.py +369 -0
- mini_arcade_core/engine/render/__init__.py +0 -0
- mini_arcade_core/engine/render/packet.py +56 -0
- mini_arcade_core/engine/render/pipeline.py +63 -0
- mini_arcade_core/engine/render/viewport.py +203 -0
- mini_arcade_core/managers/__init__.py +0 -22
- mini_arcade_core/managers/cheats.py +71 -240
- mini_arcade_core/managers/inputs.py +5 -3
- mini_arcade_core/runtime/__init__.py +0 -0
- mini_arcade_core/runtime/audio/__init__.py +0 -0
- mini_arcade_core/runtime/audio/audio_adapter.py +20 -0
- mini_arcade_core/runtime/audio/audio_port.py +36 -0
- mini_arcade_core/runtime/capture/__init__.py +0 -0
- mini_arcade_core/runtime/capture/capture_adapter.py +143 -0
- mini_arcade_core/runtime/capture/capture_port.py +51 -0
- mini_arcade_core/runtime/context.py +53 -0
- mini_arcade_core/runtime/file/__init__.py +0 -0
- mini_arcade_core/runtime/file/file_adapter.py +20 -0
- mini_arcade_core/runtime/file/file_port.py +31 -0
- mini_arcade_core/runtime/input/__init__.py +0 -0
- mini_arcade_core/runtime/input/input_adapter.py +49 -0
- mini_arcade_core/runtime/input/input_port.py +31 -0
- mini_arcade_core/runtime/input_frame.py +71 -0
- mini_arcade_core/runtime/scene/__init__.py +0 -0
- mini_arcade_core/runtime/scene/scene_adapter.py +97 -0
- mini_arcade_core/runtime/scene/scene_port.py +149 -0
- mini_arcade_core/runtime/services.py +35 -0
- mini_arcade_core/runtime/window/__init__.py +0 -0
- mini_arcade_core/runtime/window/window_adapter.py +90 -0
- mini_arcade_core/runtime/window/window_port.py +109 -0
- mini_arcade_core/scenes/__init__.py +0 -22
- mini_arcade_core/scenes/autoreg.py +1 -1
- mini_arcade_core/scenes/registry.py +21 -19
- mini_arcade_core/scenes/sim_scene.py +41 -0
- mini_arcade_core/scenes/systems/__init__.py +0 -0
- mini_arcade_core/scenes/systems/base_system.py +40 -0
- mini_arcade_core/scenes/systems/system_pipeline.py +57 -0
- mini_arcade_core/sim/__init__.py +0 -0
- mini_arcade_core/sim/protocols.py +41 -0
- mini_arcade_core/sim/runner.py +222 -0
- mini_arcade_core/spaces/__init__.py +0 -12
- mini_arcade_core/spaces/d2/__init__.py +0 -30
- mini_arcade_core/spaces/d2/boundaries2d.py +10 -1
- mini_arcade_core/spaces/d2/collision2d.py +25 -28
- mini_arcade_core/spaces/d2/geometry2d.py +18 -0
- mini_arcade_core/spaces/d2/kinematics2d.py +2 -8
- mini_arcade_core/spaces/d2/physics2d.py +9 -0
- mini_arcade_core/ui/__init__.py +0 -26
- mini_arcade_core/ui/menu.py +271 -85
- mini_arcade_core/utils/__init__.py +10 -0
- mini_arcade_core/utils/deprecated_decorator.py +45 -0
- mini_arcade_core/utils/logging.py +168 -0
- {mini_arcade_core-0.10.0.dist-info → mini_arcade_core-1.0.1.dist-info}/METADATA +1 -1
- mini_arcade_core-1.0.1.dist-info/RECORD +66 -0
- {mini_arcade_core-0.10.0.dist-info → mini_arcade_core-1.0.1.dist-info}/WHEEL +1 -1
- mini_arcade_core/commands.py +0 -84
- mini_arcade_core/entity.py +0 -72
- mini_arcade_core/game.py +0 -287
- mini_arcade_core/keymaps/__init__.py +0 -15
- mini_arcade_core/managers/base.py +0 -132
- mini_arcade_core/managers/entities.py +0 -38
- mini_arcade_core/managers/overlays.py +0 -53
- mini_arcade_core/managers/system.py +0 -26
- mini_arcade_core/scenes/model.py +0 -34
- mini_arcade_core/scenes/runtime.py +0 -29
- mini_arcade_core/scenes/scene.py +0 -109
- mini_arcade_core/scenes/system.py +0 -69
- mini_arcade_core/ui/overlays.py +0 -41
- mini_arcade_core-0.10.0.dist-info/RECORD +0 -40
- /mini_arcade_core/{keymaps → backend}/keys.py +0 -0
- {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)
|