mini-arcade-native-backend 0.6.0__cp310-cp310-win_amd64.whl → 1.0.0__cp310-cp310-win_amd64.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.
@@ -4,531 +4,57 @@ mini-arcade native backend package.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- import os
8
- import sys
9
- from dataclasses import dataclass
10
- from pathlib import Path
11
- from typing import Optional, Union
12
-
13
- # --- 1) Make sure Windows can find SDL2.dll when using vcpkg ------------------
14
-
15
- if sys.platform == "win32":
16
- # a) If running as a frozen PyInstaller exe (e.g. DejaBounce.exe),
17
- # SDL2.dll will live next to the executable. Add that dir.
18
- if getattr(sys, "frozen", False):
19
- exe_dir = Path(sys.executable).resolve().parent
20
- try:
21
- os.add_dll_directory(str(exe_dir))
22
- except (FileNotFoundError, OSError):
23
- # If this somehow fails, we still try other fallbacks.
24
- pass
25
-
26
- # b) Dev / vcpkg fallback: use VCPKG_ROOT if available.
27
- vcpkg_root = os.environ.get("VCPKG_ROOT")
28
- if vcpkg_root:
29
- # Typical vcpkg layout: <VCPKG_ROOT>/installed/x64-windows/bin/SDL2.dll
30
- sdl_bin = os.path.join(vcpkg_root, "installed", "x64-windows", "bin")
31
- if os.path.isdir(sdl_bin):
32
- try:
33
- os.add_dll_directory(sdl_bin)
34
- except (FileNotFoundError, OSError):
35
- pass
36
-
37
- # --- 2) Now import native extension and core types ----------------------------
38
-
39
- # Justification: Need to import core after setting DLL path on Windows
40
- # pylint: disable=wrong-import-position
41
- # Justification: When mini-arcade-core is installed in editable mode, import-error
42
- # false positive can occur.
43
- # pylint: disable=import-error
44
- from mini_arcade_core.backend import ( # pyright: ignore[reportMissingImports]
45
- Backend,
46
- WindowSettings,
47
- )
48
- from mini_arcade_core.backend.events import ( # pyright: ignore[reportMissingImports]
49
- Event,
50
- EventType,
51
- )
52
- from mini_arcade_core.backend.sdl_map import ( # pyright: ignore[reportMissingImports]
53
- SDL_KEYCODE_TO_KEY,
54
- )
55
-
56
- # Justification: Importing the native extension module
57
- # pylint: disable=import-self,no-name-in-module
58
- from . import _native as native
59
-
60
- # pylint: enable=import-error
61
-
62
-
63
- # --- 2) Now import core + define NativeBackend as before ---
64
-
65
-
66
- __all__ = ["NativeBackend", "native"]
67
-
68
- Alpha = Union[float, int]
69
-
70
- _NATIVE_TO_CORE = {
71
- native.EventType.Unknown: EventType.UNKNOWN,
72
- native.EventType.Quit: EventType.QUIT,
73
- native.EventType.KeyDown: EventType.KEYDOWN,
74
- native.EventType.KeyUp: EventType.KEYUP,
75
- native.EventType.MouseMotion: EventType.MOUSEMOTION,
76
- native.EventType.MouseButtonDown: EventType.MOUSEBUTTONDOWN,
77
- native.EventType.MouseButtonUp: EventType.MOUSEBUTTONUP,
78
- native.EventType.MouseWheel: EventType.MOUSEWHEEL,
79
- native.EventType.WindowResized: EventType.WINDOWRESIZED,
80
- native.EventType.TextInput: EventType.TEXTINPUT,
81
- }
82
-
83
-
84
- @dataclass
85
- class BackendSettings:
86
- """
87
- Settings for the NativeBackend.
88
-
89
- :ivar font_path (Optional[str]): Optional path to a TTF font file to load.
90
- :ivar font_size (int): Font size in points to use when loading the font.
91
- :ivar sounds (Optional[dict[str, str]]): Optional dictionary mapping sound IDs to file paths.
92
- """
93
-
94
- font_path: Optional[str] = None
95
- font_size: int = 24
96
- sounds: Optional[dict[str, str]] = None # sound_id -> path
97
-
98
-
99
- # TODO: Refactor backend interface into smaller protocols?
100
- # Justification: Many public methods needed for backend interface
101
- # pylint: disable=too-many-public-methods,too-many-instance-attributes
102
- class NativeBackend(Backend):
103
- """Adapter that makes the C++ Engine usable as a mini-arcade backend."""
104
-
105
- def __init__(self, backend_settings: BackendSettings | None = None):
106
- """
107
- :param backend_settings: Optional settings for the backend.
108
- :type backend_settings: BackendSettings | None
109
- """
110
- self._engine = native.Engine()
111
-
112
- self._font_path = (
113
- backend_settings.font_path if backend_settings else None
114
- )
115
- self._font_size = (
116
- backend_settings.font_size if backend_settings else 24
117
- )
118
- self._default_font_id: int | None = None
119
- self._fonts_by_size: dict[int, int] = {}
120
-
121
- self._sounds = backend_settings.sounds if backend_settings else None
122
-
123
- self._vp_offset_x = 0
124
- self._vp_offset_y = 0
125
- self._vp_scale = 1.0
126
-
127
- def _get_font_id(self, font_size: int | None) -> int:
128
- # No font loaded -> keep current “no-op” behavior
129
- if self._font_path is None:
130
- return -1
131
-
132
- # Default font
133
- if font_size is None:
134
- return (
135
- self._default_font_id
136
- if self._default_font_id is not None
137
- else -1
138
- )
139
-
140
- if font_size <= 0:
141
- raise ValueError(f"font_size must be > 0, got {font_size}")
142
-
143
- # Cached
144
- cached = self._fonts_by_size.get(font_size)
145
- if cached is not None:
146
- return cached
147
-
148
- # Lazily load and cache
149
- font_id = self._engine.load_font(self._font_path, int(font_size))
150
- self._fonts_by_size[font_size] = font_id
151
- return font_id
152
-
153
- def init(self, window_settings: WindowSettings):
154
- """
155
- Initialize the backend with a window of given width, height, and title.
156
-
157
- :param window_settings: Settings for the backend window.
158
- :type window_settings: WindowSettings
159
- """
160
- title = ""
161
- self._engine.init(window_settings.width, window_settings.height, title)
162
-
163
- # Load font if provided
164
- if self._font_path is not None:
165
- self._default_font_id = self._engine.load_font(
166
- self._font_path, self._font_size
167
- )
168
- self._fonts_by_size[self._font_size] = self._default_font_id
169
-
170
- # Load sounds if provided
171
- if self._sounds is not None:
172
- for sound_id, path in self._sounds.items():
173
- self.load_sound(sound_id, path)
174
-
175
- def set_window_title(self, title: str):
176
- """
177
- Set the window title.
178
-
179
- :param title: Title of the window.
180
- :type title: str
181
- """
182
- self._engine.set_window_title(title)
183
-
184
- def set_clear_color(self, r: int, g: int, b: int):
185
- """
186
- Set the background/clear color used by begin_frame.
187
-
188
- :param r: Red component (0-255).
189
- :type r: int
190
-
191
- :param g: Green component (0-255).
192
- :type g: int
193
-
194
- :param b: Blue component (0-255).
195
- :type b: int
196
- """
197
- self._engine.set_clear_color(int(r), int(g), int(b))
198
-
199
- # Justification: Many local variables needed for event mapping
200
- # pylint: disable=too-many-locals
201
- def poll_events(self) -> list[Event]:
202
- """
203
- Poll for events from the backend and return them as a list of Event objects.
204
-
205
- :return: List of Event objects representing the polled events.
206
- :rtype: list[Event]
207
- """
208
- out: list[Event] = []
209
- for ev in self._engine.poll_events():
210
- etype = _NATIVE_TO_CORE.get(ev.type, EventType.UNKNOWN)
211
-
212
- key = None
213
- key_code = None
214
- scancode = None
215
- mod = None
216
- repeat = None
217
-
218
- x = y = dx = dy = None
219
- button = None
220
- wheel = None
221
- size = None
222
- text = None
223
-
224
- if etype in (EventType.KEYDOWN, EventType.KEYUP):
225
- raw_key = int(getattr(ev, "key", 0) or 0)
226
- key_code = raw_key if raw_key != 0 else None
227
- key = SDL_KEYCODE_TO_KEY.get(raw_key) if raw_key != 0 else None
228
-
229
- scancode = (
230
- int(ev.scancode) if getattr(ev, "scancode", 0) else None
231
- )
232
- mod = int(ev.mod) if getattr(ev, "mod", 0) else None
233
-
234
- rep = int(getattr(ev, "repeat", 0) or 0)
235
- repeat = bool(rep) if etype == EventType.KEYDOWN else None
236
-
237
- elif etype == EventType.MOUSEMOTION:
238
- x = int(ev.x)
239
- y = int(ev.y)
240
- dx = int(ev.dx)
241
- dy = int(ev.dy)
242
-
243
- elif etype in (EventType.MOUSEBUTTONDOWN, EventType.MOUSEBUTTONUP):
244
- button = int(ev.button) if ev.button else None
245
- x = int(ev.x)
246
- y = int(ev.y)
247
-
248
- elif etype == EventType.MOUSEWHEEL:
249
- wx = int(ev.wheel_x)
250
- wy = int(ev.wheel_y)
251
- wheel = (wx, wy) if (wx or wy) else None
252
-
253
- elif etype == EventType.WINDOWRESIZED:
254
- w = int(ev.width)
255
- h = int(ev.height)
256
- size = (w, h) if (w and h) else None
257
-
258
- elif etype == EventType.TEXTINPUT:
259
- t = getattr(ev, "text", "")
260
- text = t if t else None
261
-
262
- out.append(
263
- Event(
264
- type=etype,
265
- key=key,
266
- key_code=key_code,
267
- scancode=scancode,
268
- mod=mod,
269
- repeat=repeat,
270
- x=x,
271
- y=y,
272
- dx=dx,
273
- dy=dy,
274
- button=button,
275
- wheel=wheel,
276
- size=size,
277
- text=text,
278
- )
279
- )
280
- return out
281
-
282
- # pylint: enable=too-many-locals
283
-
284
- def begin_frame(self):
285
- """Begin a new frame for rendering."""
286
- self._engine.begin_frame()
287
-
288
- def end_frame(self):
289
- """End the current frame for rendering."""
290
- self._engine.end_frame()
291
-
292
- @staticmethod
293
- def _alpha_to_u8(alpha: Alpha | None) -> int:
294
- """Convert CSS-like alpha (0..1) to uint8 (0..255)."""
295
- if alpha is None:
296
- return 255
297
-
298
- # disallow booleans (since bool is a subclass of int)
299
- if isinstance(alpha, bool):
300
- raise TypeError("alpha must be a float in [0,1], not bool")
301
-
302
- # If it's an int-like value, treat as 0..255
303
- if isinstance(alpha, int):
304
- if alpha < 0 or alpha > 255:
305
- raise ValueError(
306
- f"int alpha must be in [0, 255], got {alpha!r}"
307
- )
308
- return int(alpha)
309
-
310
- # Otherwise treat as float 0..1
311
- a = float(alpha)
312
- if a < 0.0 or a > 1.0:
313
- raise ValueError(f"float alpha must be in [0, 1], got {alpha!r}")
314
- return int(round(a * 255))
315
-
316
- @staticmethod
317
- def _get_color_values(color: tuple[int, ...]) -> int:
318
- """
319
- Extract alpha value from color tuple (r,g,b) or (r,g,b,a).
320
- If missing, returns default.
321
- """
322
- if len(color) == 3:
323
- r, g, b = color
324
- a_u8 = 255
325
- elif len(color) == 4:
326
- r, g, b, a = color
327
- a_u8 = NativeBackend._alpha_to_u8(a)
328
- else:
329
- raise ValueError(
330
- f"Color must be (r,g,b) or (r,g,b,a), got {color!r}"
331
- )
332
-
333
- return (int(r), int(g), int(b), a_u8)
334
-
335
- # pylint: disable=too-many-arguments,too-many-positional-arguments
336
- def draw_rect(
337
- self,
338
- x: int,
339
- y: int,
340
- w: int,
341
- h: int,
342
- color: tuple[int, ...] = (255, 255, 255),
343
- ):
344
- """
345
- Draw a rectangle at the specified position with given width and height.
346
-
347
- :param x: X coordinate of the rectangle's top-left corner.
348
- :type x: int
349
-
350
- :param y: Y coordinate of the rectangle's top-left corner.
351
- :type y: int
352
-
353
- :param w: Width of the rectangle.
354
- :type w: int
355
-
356
- :param h: Height of the rectangle.
357
- :type h: int
358
-
359
- :param color: Color of the rectangle as (r, g, b) or (r, g, b, a).
360
- :type color: tuple[int, ...]
361
- """
362
- r, g, b, a = self._get_color_values(color)
363
- sx = int(round(self._vp_offset_x + x * self._vp_scale)) # top-left x
364
- sy = int(round(self._vp_offset_y + y * self._vp_scale)) # top-left y
365
- sw = int(round(w * self._vp_scale)) # width
366
- sh = int(round(h * self._vp_scale)) # height
367
- self._engine.draw_rect(sx, sy, sw, sh, r, g, b, a)
368
- # self._engine.draw_rect(x, y, w, h, r, g, b, a)
369
-
370
- def draw_text(
371
- self,
372
- x: int,
373
- y: int,
374
- text: str,
375
- color: tuple[int, int, int] = (255, 255, 255),
376
- font_size: int | None = None,
377
- ):
378
- """
379
- Draw text at the given position using the loaded font.
380
- If no font is loaded, this is a no-op.
381
-
382
- :param x: X coordinate for the text position.
383
- :type x: int
384
-
385
- :param y: Y coordinate for the text position.
386
- :type y: int
387
-
388
- :param text: The text string to draw.
389
- :type text: str
390
-
391
- :param color: Color of the text as (r, g, b).
392
- :type color: tuple[int, int, int]
393
- """
394
- r, g, b, a = self._get_color_values(color)
395
- font_id = self._get_font_id(font_size)
396
- sx = int(round(self._vp_offset_x + x * self._vp_scale))
397
- sy = int(round(self._vp_offset_y + y * self._vp_scale))
398
-
399
- # optional but recommended: scale font size too
400
- if font_size is not None:
401
- scaled = max(8, int(round(font_size * self._vp_scale)))
402
- else:
403
- scaled = None
404
-
405
- font_id = self._get_font_id(scaled)
406
- self._engine.draw_text(
407
- text, sx, sy, int(r), int(g), int(b), int(a), font_id
408
- )
409
- # self._engine.draw_text(
410
- # text, x, y, int(r), int(g), int(b), int(a), font_id
411
- # )
412
-
413
- # pylint: enable=too-many-arguments,too-many-positional-arguments
414
-
415
- def capture_frame(self, path: str | None = None) -> bool:
416
- """
417
- Capture the current frame.
418
-
419
- :param path: Optional file path to save the captured frame (e.g., PNG).
420
- :type path: str | None
421
-
422
- :return: True if the frame was successfully captured (and saved if path provided),
423
- False otherwise.
424
- :rtype: bool
425
- """
426
- if path is None:
427
- raise ValueError("Path must be provided to capture frame.")
428
- return self._engine.capture_frame(path)
429
-
430
- def measure_text(
431
- self, text: str, font_size: int | None = None
432
- ) -> tuple[int, int]:
433
- """
434
- Measure text size (width, height) in pixels for the active font.
435
-
436
- Returns (0,0) if no font is loaded (matches draw_text no-op behavior).
437
- """
438
- font_id = self._get_font_id(font_size)
439
- w, h = self._engine.measure_text(text, font_id)
440
- return int(w), int(h)
441
-
442
- def init_audio(
443
- self, frequency: int = 44100, channels: int = 2, chunk_size: int = 2048
444
- ):
445
- """Initialize SDL_mixer audio."""
446
- self._engine.init_audio(int(frequency), int(channels), int(chunk_size))
447
-
448
- def shutdown_audio(self):
449
- """Shutdown SDL_mixer audio and free loaded sounds."""
450
- self._engine.shutdown_audio()
451
-
452
- def load_sound(self, sound_id: str, path: str):
453
- """
454
- Load a WAV sound and store it by ID.
455
- Example: backend.load_sound("hit", "assets/sfx/hit.wav")
456
- """
457
- if not sound_id:
458
- raise ValueError("sound_id cannot be empty")
459
-
460
- p = Path(path)
461
- if not p.exists():
462
- raise FileNotFoundError(f"Sound file not found: {p}")
463
-
464
- self._engine.load_sound(sound_id, str(p))
465
-
466
- def play_sound(self, sound_id: str, loops: int = 0):
467
- """
468
- Play a loaded sound.
469
- loops=0 => play once
470
- loops=-1 => infinite loop
471
- loops=1 => play twice (SDL convention)
472
- """
473
- self._engine.play_sound(sound_id, int(loops))
474
-
475
- def set_master_volume(self, volume: int):
476
- """
477
- Master volume: 0..128
478
- """
479
- self._engine.set_master_volume(int(volume))
480
-
481
- def set_sound_volume(self, sound_id: str, volume: int):
482
- """
483
- Per-sound volume: 0..128
484
- """
485
- self._engine.set_sound_volume(sound_id, int(volume))
486
-
487
- def stop_all_sounds(self):
488
- """Stop all channels."""
489
- self._engine.stop_all_sounds()
490
-
491
- def set_viewport_transform(
492
- self, offset_x: int, offset_y: int, scale: float
493
- ) -> None:
494
- self._vp_offset_x = int(offset_x)
495
- self._vp_offset_y = int(offset_y)
496
- self._vp_scale = float(scale)
497
-
498
- def clear_viewport_transform(self) -> None:
499
- self._vp_offset_x = 0
500
- self._vp_offset_y = 0
501
- self._vp_scale = 1.0
502
-
503
- def resize_window(self, width: int, height: int) -> None:
504
- self._engine.resize_window(int(width), int(height))
505
-
506
- def set_clip_rect(self, x: int, y: int, w: int, h: int) -> None:
507
- self._engine.set_clip_rect(int(x), int(y), int(w), int(h))
508
-
509
- def clear_clip_rect(self) -> None:
510
- self._engine.clear_clip_rect()
511
-
512
- # Justification: Many arguments needed for line drawing
513
- # pylint: disable=too-many-arguments,too-many-positional-arguments
514
- def draw_line(
515
- self,
516
- x1: int,
517
- y1: int,
518
- x2: int,
519
- y2: int,
520
- color: tuple[int, ...] = (255, 255, 255),
521
- ) -> None:
522
- r, g, b, a = self._get_color_values(color)
523
-
524
- sx1 = int(round(self._vp_offset_x + x1 * self._vp_scale))
525
- sy1 = int(round(self._vp_offset_y + y1 * self._vp_scale))
526
- sx2 = int(round(self._vp_offset_x + x2 * self._vp_scale))
527
- sy2 = int(round(self._vp_offset_y + y2 * self._vp_scale))
528
-
529
- self._engine.draw_line(
530
- sx1, sy1, sx2, sy2, int(r), int(g), int(b), int(a)
7
+ from typing import TYPE_CHECKING
8
+
9
+ from .dlls import setup_windows_dll_search_paths
10
+
11
+ setup_windows_dll_search_paths()
12
+
13
+ if TYPE_CHECKING:
14
+ # Justification: Need to import core after setting DLL path on Windows
15
+ # pylint: disable=wrong-import-position
16
+ from .config import (
17
+ AudioSettings,
18
+ BackendSettings,
19
+ FontSettings,
20
+ RendererSettings,
21
+ WindowSettings,
22
+ )
23
+ from .native_backend import NativeBackend
24
+
25
+ __all__ = [
26
+ "NativeBackend",
27
+ "BackendSettings",
28
+ "WindowSettings",
29
+ "RendererSettings",
30
+ "FontSettings",
31
+ "AudioSettings",
32
+ ]
33
+
34
+
35
+ # NOTE: Momentary __getattr__ to avoid circular imports for type hints
36
+ # pylint: disable=import-outside-toplevel,possibly-unused-variable
37
+ def __getattr__(name: str):
38
+ if name == "NativeBackend":
39
+ from .native_backend import NativeBackend
40
+
41
+ return NativeBackend
42
+
43
+ if name in {
44
+ "AudioSettings",
45
+ "BackendSettings",
46
+ "FontSettings",
47
+ "RendererSettings",
48
+ "WindowSettings",
49
+ }:
50
+ from .config import (
51
+ AudioSettings,
52
+ BackendSettings,
53
+ FontSettings,
54
+ RendererSettings,
55
+ WindowSettings,
531
56
  )
532
57
 
58
+ return locals()[name]
533
59
 
534
- # pylint: enable=too-many-arguments,too-many-positional-arguments
60
+ raise AttributeError(name)