mini-arcade-native-backend 0.5.0__tar.gz → 0.5.3__tar.gz

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 (20) hide show
  1. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/.github/workflows/ci.yml +1 -1
  2. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/.github/workflows/release-publish.yml +1 -1
  3. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/CHANGELOG.md +28 -0
  4. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/CMakeLists.txt +2 -1
  5. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/PKG-INFO +1 -1
  6. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/cpp/bindings.cpp +34 -1
  7. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/cpp/engine.cpp +171 -16
  8. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/cpp/engine.h +29 -1
  9. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/pyproject.toml +1 -1
  10. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/src/mini_arcade_native_backend/__init__.py +136 -22
  11. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/.github/workflows/create-release-branch.yml +0 -0
  12. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/.github/workflows/release-finalize.yml +0 -0
  13. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/.gitignore +0 -0
  14. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/.vscode/settings.json +0 -0
  15. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/LICENSE +0 -0
  16. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/README.md +0 -0
  17. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/examples/native_backend_demo.py +0 -0
  18. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/poetry.lock +0 -0
  19. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/poetry.toml +0 -0
  20. {mini_arcade_native_backend-0.5.0 → mini_arcade_native_backend-0.5.3}/tests/test_init.py +0 -0
@@ -19,7 +19,7 @@ jobs:
19
19
  with:
20
20
  project-name: "mini-arcade-native-backend"
21
21
  lint-path: "src/mini_arcade_native_backend"
22
- apt-deps: "libsdl2-dev libsdl2-ttf-dev"
22
+ apt-deps: "libsdl2-dev libsdl2-ttf-dev libsdl2-mixer-dev"
23
23
  secrets:
24
24
  SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
25
25
  SLACK_CHANNEL_ID: ${{ secrets.SLACK_CHANNEL_ID }}
@@ -17,7 +17,7 @@ jobs:
17
17
  with:
18
18
  release_branch: ${{ inputs.release_branch }}
19
19
  version_file: "pyproject.toml"
20
- apt-deps: "libsdl2-dev libsdl2-ttf-dev"
20
+ apt-deps: "libsdl2-dev libsdl2-ttf-dev libsdl2-mixer-dev"
21
21
  poetry-build-args: "-f sdist"
22
22
  build_wheels: true
23
23
  secrets:
@@ -6,6 +6,34 @@ This project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.3] - 2026-01-23
10
+
11
+ - Internal changes only.
12
+
13
+ ## [0.5.2] - 2026-01-23
14
+
15
+ ### Added
16
+ - add window resizing and clipping functions to Engine and update bindings
17
+ - add audio management functions to Engine and integrate with NativeBackend
18
+
19
+ ### Fixed
20
+ - update apt dependencies to include libsdl2-mixer-dev for CI and release workflows
21
+
22
+ ### Changed
23
+ - add TODO for backend interface refactoring to improve structure
24
+
25
+ ## [0.5.1] - 2026-01-23
26
+
27
+ ### Added
28
+ - add window resizing and clipping functions to Engine and update bindings
29
+ - add audio management functions to Engine and integrate with NativeBackend
30
+
31
+ ### Fixed
32
+ - update apt dependencies to include libsdl2-mixer-dev for CI and release workflows
33
+
34
+ ### Changed
35
+ - add TODO for backend interface refactoring to improve structure
36
+
9
37
  ## [0.5.0] - 2026-01-21
10
38
 
11
39
  ### Added
@@ -10,6 +10,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
10
10
  find_package(pybind11 CONFIG REQUIRED)
11
11
  find_package(SDL2 CONFIG REQUIRED)
12
12
  find_package(SDL2_ttf CONFIG REQUIRED)
13
+ find_package(SDL2_mixer CONFIG REQUIRED)
13
14
 
14
15
  set(TARGET_NAME _native)
15
16
 
@@ -19,7 +20,7 @@ pybind11_add_module(${TARGET_NAME}
19
20
  cpp/engine.cpp
20
21
  )
21
22
 
22
- target_link_libraries(${TARGET_NAME} PRIVATE SDL2::SDL2 SDL2_ttf::SDL2_ttf)
23
+ target_link_libraries(${TARGET_NAME} PRIVATE SDL2::SDL2 SDL2_ttf::SDL2_ttf SDL2_mixer::SDL2_mixer)
23
24
 
24
25
  # Install the compiled extension into the Python package directory
25
26
  # so it ends up as mini_arcade_native_backend/_native.*.pyd
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: mini-arcade-native-backend
3
- Version: 0.5.0
3
+ Version: 0.5.3
4
4
  Summary: Native SDL2 backend for mini-arcade-core using SDL2 + pybind11.
5
5
  Author-Email: Santiago Rincon <rincores@gmail.com>
6
6
  License: Copyright (c) 2025 Santiago Rincón
@@ -89,5 +89,38 @@ PYBIND11_MODULE(_native, m) {
89
89
  &mini::Engine::measure_text,
90
90
  py::arg("text"),
91
91
  py::arg("font_id") = -1
92
- );
92
+ )
93
+
94
+ .def("init_audio", &mini::Engine::init_audio,
95
+ py::arg("frequency") = 44100,
96
+ py::arg("channels") = 2,
97
+ py::arg("chunk_size") = 2048)
98
+
99
+ .def("shutdown_audio", &mini::Engine::shutdown_audio)
100
+
101
+ .def("load_sound", &mini::Engine::load_sound,
102
+ py::arg("sound_id"),
103
+ py::arg("path"))
104
+
105
+ .def("play_sound", &mini::Engine::play_sound,
106
+ py::arg("sound_id"),
107
+ py::arg("loops") = 0)
108
+
109
+ .def("set_master_volume", &mini::Engine::set_master_volume,
110
+ py::arg("volume"))
111
+
112
+ .def("set_sound_volume", &mini::Engine::set_sound_volume,
113
+ py::arg("sound_id"),
114
+ py::arg("volume"))
115
+
116
+ .def("stop_all_sounds", &mini::Engine::stop_all_sounds)
117
+ .def("resize_window", &mini::Engine::resize_window,
118
+ py::arg("width"),
119
+ py::arg("height"))
120
+ .def("set_clip_rect", &mini::Engine::set_clip_rect,
121
+ py::arg("x"),
122
+ py::arg("y"),
123
+ py::arg("w"),
124
+ py::arg("h"))
125
+ .def("clear_clip_rect", &mini::Engine::clear_clip_rect);
93
126
  }
@@ -11,7 +11,6 @@ namespace mini {
11
11
  : window_(nullptr),
12
12
  renderer_(nullptr),
13
13
  initialized_(false),
14
- font_(nullptr),
15
14
  clear_color_{0, 0, 0, 255},
16
15
  default_font_id_(-1),
17
16
  default_alpha_(255)
@@ -20,10 +19,12 @@ namespace mini {
20
19
 
21
20
  Engine::~Engine()
22
21
  {
23
- if (font_ != nullptr) {
24
- TTF_CloseFont(font_);
25
- font_ = nullptr;
22
+ shutdown_audio();
23
+
24
+ for (TTF_Font* f : fonts_) {
25
+ if (f) TTF_CloseFont(f);
26
26
  }
27
+ fonts_.clear();
27
28
 
28
29
  if (renderer_ != nullptr) {
29
30
  SDL_DestroyRenderer(renderer_);
@@ -35,19 +36,14 @@ namespace mini {
35
36
  window_ = nullptr;
36
37
  }
37
38
 
39
+ SDL_StopTextInput();
40
+
38
41
  if (initialized_) {
42
+ TTF_Quit();
39
43
  SDL_Quit();
40
44
  initialized_ = false;
41
45
  }
42
46
 
43
- for (TTF_Font* f : fonts_) {
44
- if (f) TTF_CloseFont(f);
45
- }
46
- fonts_.clear();
47
-
48
- TTF_Quit();
49
- SDL_StopTextInput();
50
-
51
47
  }
52
48
 
53
49
  void Engine::init(int width, int height, const char* title)
@@ -72,7 +68,7 @@ namespace mini {
72
68
  SDL_WINDOWPOS_CENTERED,
73
69
  width,
74
70
  height,
75
- SDL_WINDOW_SHOWN
71
+ SDL_WINDOW_SHOWN | SDL_WINDOW_ALLOW_HIGHDPI | SDL_WINDOW_RESIZABLE
76
72
  );
77
73
 
78
74
  if (window_ == nullptr) {
@@ -87,6 +83,7 @@ namespace mini {
87
83
  -1,
88
84
  SDL_RENDERER_ACCELERATED
89
85
  );
86
+ // SDL_RenderSetLogicalSize(renderer_, width, height);
90
87
 
91
88
  if (renderer_ == nullptr) {
92
89
  std::string msg = std::string("SDL_CreateRenderer Error: ") + SDL_GetError();
@@ -105,7 +102,6 @@ namespace mini {
105
102
  initialized_ = true;
106
103
  }
107
104
 
108
-
109
105
  void Engine::set_window_title(const char* title)
110
106
  {
111
107
  if (!initialized_ || !window_) return;
@@ -309,6 +305,21 @@ namespace mini {
309
305
  std::vector<Event> events;
310
306
  SDL_Event sdl_event;
311
307
 
308
+ auto scale_mouse = [&](int &x, int &y, int &dx, int &dy) {
309
+ int ww=0, wh=0, rw=0, rh=0;
310
+ SDL_GetWindowSize(window_, &ww, &wh);
311
+ SDL_GetRendererOutputSize(renderer_, &rw, &rh);
312
+
313
+ if (ww > 0 && wh > 0) {
314
+ float sx = (float)rw / (float)ww;
315
+ float sy = (float)rh / (float)wh;
316
+ x = (int)lroundf(x * sx);
317
+ y = (int)lroundf(y * sy);
318
+ dx = (int)lroundf(dx * sx);
319
+ dy = (int)lroundf(dy * sy);
320
+ }
321
+ };
322
+
312
323
  while (SDL_PollEvent(&sdl_event)) {
313
324
  Event ev;
314
325
 
@@ -339,6 +350,7 @@ namespace mini {
339
350
  ev.y = sdl_event.motion.y;
340
351
  ev.dx = sdl_event.motion.xrel;
341
352
  ev.dy = sdl_event.motion.yrel;
353
+ scale_mouse(ev.x, ev.y, ev.dx, ev.dy);
342
354
  break;
343
355
 
344
356
  case SDL_MOUSEBUTTONDOWN:
@@ -353,6 +365,7 @@ namespace mini {
353
365
  ev.button = (int)sdl_event.button.button;
354
366
  ev.x = sdl_event.button.x;
355
367
  ev.y = sdl_event.button.y;
368
+ scale_mouse(ev.x, ev.y, ev.dx, ev.dy);
356
369
  break;
357
370
 
358
371
  case SDL_MOUSEWHEEL:
@@ -372,8 +385,14 @@ namespace mini {
372
385
  sdl_event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED)
373
386
  {
374
387
  ev.type = EventType::WindowResized;
375
- ev.width = sdl_event.window.data1;
376
- ev.height = sdl_event.window.data2;
388
+ int rw=0, rh=0;
389
+ if (renderer_ && SDL_GetRendererOutputSize(renderer_, &rw, &rh) == 0) {
390
+ ev.width = rw;
391
+ ev.height = rh;
392
+ } else {
393
+ ev.width = sdl_event.window.data1;
394
+ ev.height = sdl_event.window.data2;
395
+ }
377
396
  } else {
378
397
  continue; // ignore other window events
379
398
  }
@@ -412,4 +431,140 @@ namespace mini {
412
431
  return {w, h};
413
432
  }
414
433
 
434
+ void Engine::init_audio(int frequency, int channels, int chunk_size)
435
+ {
436
+ if (audio_initialized_) return;
437
+
438
+ // Make sure SDL audio subsystem is enabled
439
+ if ((SDL_WasInit(SDL_INIT_AUDIO) & SDL_INIT_AUDIO) == 0) {
440
+ if (SDL_InitSubSystem(SDL_INIT_AUDIO) != 0) {
441
+ throw std::runtime_error(std::string("SDL_InitSubSystem(AUDIO) Error: ") + SDL_GetError());
442
+ }
443
+ }
444
+
445
+ if (Mix_OpenAudio(frequency, MIX_DEFAULT_FORMAT, channels, chunk_size) != 0) {
446
+ throw std::runtime_error(std::string("Mix_OpenAudio Error: ") + Mix_GetError());
447
+ }
448
+
449
+ // Optional: allow OGG/MP3 decoding if you want
450
+ // Mix_Init(MIX_INIT_OGG | MIX_INIT_MP3);
451
+
452
+ Mix_AllocateChannels(16); // plenty for pong
453
+ Mix_Volume(-1, master_volume_); // -1 = all channels
454
+
455
+ audio_initialized_ = true;
456
+ }
457
+
458
+ void Engine::shutdown_audio()
459
+ {
460
+ if (!audio_initialized_) return;
461
+
462
+ stop_all_sounds();
463
+
464
+ for (auto& kv : sounds_) {
465
+ if (kv.second) {
466
+ Mix_FreeChunk(kv.second);
467
+ }
468
+ }
469
+ sounds_.clear();
470
+
471
+ Mix_CloseAudio();
472
+ // Mix_Quit(); // if you used Mix_Init
473
+
474
+ audio_initialized_ = false;
475
+ }
476
+
477
+ void Engine::load_sound(const std::string& sound_id, const std::string& path)
478
+ {
479
+ if (!audio_initialized_) {
480
+ // auto-init so Python doesn't need to care
481
+ init_audio();
482
+ }
483
+
484
+ if (sound_id.empty()) {
485
+ throw std::runtime_error("load_sound: sound_id is empty");
486
+ }
487
+
488
+ // If already loaded, free it first (support hot reload)
489
+ auto it = sounds_.find(sound_id);
490
+ if (it != sounds_.end() && it->second) {
491
+ Mix_FreeChunk(it->second);
492
+ it->second = nullptr;
493
+ }
494
+
495
+ Mix_Chunk* chunk = Mix_LoadWAV(path.c_str());
496
+ if (!chunk) {
497
+ throw std::runtime_error(std::string("Mix_LoadWAV Error: ") + Mix_GetError());
498
+ }
499
+
500
+ sounds_[sound_id] = chunk;
501
+ }
502
+
503
+ void Engine::play_sound(const std::string& sound_id, int loops)
504
+ {
505
+ if (!audio_initialized_) {
506
+ init_audio();
507
+ }
508
+
509
+ auto it = sounds_.find(sound_id);
510
+ if (it == sounds_.end() || it->second == nullptr) {
511
+ // be forgiving: if not preloaded, just do nothing
512
+ // (or throw if you want strict behavior)
513
+ return;
514
+ }
515
+
516
+ Mix_Chunk* chunk = it->second;
517
+
518
+ // -1 channel = pick first free channel
519
+ Mix_PlayChannel(-1, chunk, loops);
520
+ }
521
+
522
+ void Engine::set_master_volume(int volume)
523
+ {
524
+ if (volume < 0) volume = 0;
525
+ if (volume > MIX_MAX_VOLUME) volume = MIX_MAX_VOLUME;
526
+
527
+ master_volume_ = volume;
528
+ Mix_Volume(-1, master_volume_); // all channels
529
+ }
530
+
531
+ void Engine::set_sound_volume(const std::string& sound_id, int volume)
532
+ {
533
+ if (volume < 0) volume = 0;
534
+ if (volume > MIX_MAX_VOLUME) volume = MIX_MAX_VOLUME;
535
+
536
+ auto it = sounds_.find(sound_id);
537
+ if (it == sounds_.end() || it->second == nullptr) return;
538
+
539
+ Mix_VolumeChunk(it->second, volume);
540
+ }
541
+
542
+ void Engine::stop_all_sounds()
543
+ {
544
+ Mix_HaltChannel(-1);
545
+ }
546
+
547
+ void Engine::resize_window(int width, int height)
548
+ {
549
+ if (!initialized_ || !window_) return;
550
+ SDL_SetWindowSize(window_, width, height);
551
+
552
+ // if (renderer_) {
553
+ // SDL_RenderSetLogicalSize(renderer_, width, height);
554
+ // }
555
+ }
556
+
557
+ void Engine::set_clip_rect(int x, int y, int w, int h)
558
+ {
559
+ if (!initialized_ || renderer_ == nullptr) return;
560
+ SDL_Rect r{ x, y, w, h };
561
+ SDL_RenderSetClipRect(renderer_, &r);
562
+ }
563
+
564
+ void Engine::clear_clip_rect()
565
+ {
566
+ if (!initialized_ || renderer_ == nullptr) return;
567
+ SDL_RenderSetClipRect(renderer_, nullptr);
568
+ }
569
+
415
570
  } // namespace mini
@@ -2,6 +2,8 @@
2
2
 
3
3
  #include <SDL.h>
4
4
  #include <SDL_ttf.h>
5
+ #include <SDL_mixer.h>
6
+ #include <unordered_map>
5
7
  #include <vector>
6
8
  #include <string>
7
9
  #include <utility>
@@ -97,15 +99,41 @@ namespace mini {
97
99
  // Returns (width, height) in pixels. Returns (0,0) if no valid font or error.
98
100
  std::pair<int, int> measure_text(const char* text, int font_id = -1);
99
101
 
102
+ // --- Audio ---
103
+ // Initialize audio subsystem.
104
+ void init_audio(int frequency = 44100, int channels = 2, int chunk_size = 2048);
105
+ // Shutdown audio subsystem.
106
+ void shutdown_audio();
107
+
108
+ // Load, play, and manage sounds.
109
+ void load_sound(const std::string& sound_id, const std::string& path);
110
+ // Play a loaded sound by its ID.
111
+ void play_sound(const std::string& sound_id, int loops = 0);
112
+ // Set the volume for a specific sound (0..128).
113
+ void set_sound_volume(const std::string& sound_id, int volume); // 0..128
114
+ // Set the master volume for all sounds (0..128).
115
+ void set_master_volume(int volume); // 0..128
116
+ // Stop all currently playing sounds.
117
+ void stop_all_sounds();
118
+
119
+ // Resize the application window.
120
+ void resize_window(int width, int height);
121
+ // Set clipping rectangle for rendering.
122
+ void set_clip_rect(int x, int y, int w, int h);
123
+ // Clear clipping rectangle (disable clipping).
124
+ void clear_clip_rect();
125
+
100
126
  private:
101
127
  SDL_Window* window_; // The main application window.
102
128
  SDL_Renderer* renderer_; // The renderer for drawing.
103
129
  bool initialized_; // Whether the engine has been initialized.
104
- TTF_Font* font_; // The current font for text rendering.
105
130
  SDL_Color clear_color_; // The clear color for the screen.
106
131
  std::vector<TTF_Font*> fonts_; // Loaded fonts.
107
132
  int default_font_id_; // Default font index.
108
133
  int default_alpha_; // Default alpha value for drawing.
134
+ bool audio_initialized_ = false; // Whether audio subsystem is initialized.
135
+ int master_volume_ = MIX_MAX_VOLUME; // 128 is max volume
136
+ std::unordered_map<std::string, Mix_Chunk*> sounds_; // Audio data
109
137
  };
110
138
 
111
139
  } // namespace mini
@@ -8,7 +8,7 @@ build-backend = "scikit_build_core.build"
8
8
 
9
9
  [project]
10
10
  name = "mini-arcade-native-backend"
11
- version = "0.5.0"
11
+ version = "0.5.3"
12
12
  description = "Native SDL2 backend for mini-arcade-core using SDL2 + pybind11."
13
13
  authors = [
14
14
  { name = "Santiago Rincon", email = "rincores@gmail.com" },
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import os
8
8
  import sys
9
+ from dataclasses import dataclass
9
10
  from pathlib import Path
10
11
  from typing import Optional, Union
11
12
 
@@ -42,6 +43,7 @@ if sys.platform == "win32":
42
43
  # pylint: disable=import-error
43
44
  from mini_arcade_core.backend import ( # pyright: ignore[reportMissingImports]
44
45
  Backend,
46
+ WindowSettings,
45
47
  )
46
48
  from mini_arcade_core.backend.events import ( # pyright: ignore[reportMissingImports]
47
49
  Event,
@@ -79,23 +81,49 @@ _NATIVE_TO_CORE = {
79
81
  }
80
82
 
81
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
82
102
  class NativeBackend(Backend):
83
103
  """Adapter that makes the C++ Engine usable as a mini-arcade backend."""
84
104
 
85
- def __init__(self, font_path: str | None = None, font_size: int = 24):
105
+ def __init__(self, backend_settings: BackendSettings | None = None):
86
106
  """
87
- :param font_path: Optional path to a TTF font file to load.
88
- :type font_path: str | None
89
-
90
- :param font_size: Font size in points to use when loading the font.
91
- :type font_size: int
107
+ :param backend_settings: Optional settings for the backend.
108
+ :type backend_settings: BackendSettings | None
92
109
  """
93
110
  self._engine = native.Engine()
94
- self._font_path = font_path
95
- self._font_size = font_size
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
+ )
96
118
  self._default_font_id: int | None = None
97
119
  self._fonts_by_size: dict[int, int] = {}
98
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
+
99
127
  def _get_font_id(self, font_size: int | None) -> int:
100
128
  # No font loaded -> keep current “no-op” behavior
101
129
  if self._font_path is None:
@@ -122,22 +150,15 @@ class NativeBackend(Backend):
122
150
  self._fonts_by_size[font_size] = font_id
123
151
  return font_id
124
152
 
125
- def init(self, width: int, height: int, title: Optional[str] = None):
153
+ def init(self, window_settings: WindowSettings):
126
154
  """
127
155
  Initialize the backend with a window of given width, height, and title.
128
156
 
129
- :param width: Width of the window in pixels.
130
- :type width: int
131
-
132
- :param height: Height of the window in pixels.
133
- :type height: int
134
-
135
- :param title: Title of the window.
136
- :type title: Optional[str]
157
+ :param window_settings: Settings for the backend window.
158
+ :type window_settings: WindowSettings
137
159
  """
138
- if title is None:
139
- title = ""
140
- self._engine.init(width, height, title)
160
+ title = ""
161
+ self._engine.init(window_settings.width, window_settings.height, title)
141
162
 
142
163
  # Load font if provided
143
164
  if self._font_path is not None:
@@ -146,6 +167,11 @@ class NativeBackend(Backend):
146
167
  )
147
168
  self._fonts_by_size[self._font_size] = self._default_font_id
148
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
+
149
175
  def set_window_title(self, title: str):
150
176
  """
151
177
  Set the window title.
@@ -328,7 +354,12 @@ class NativeBackend(Backend):
328
354
  :type color: tuple[int, ...]
329
355
  """
330
356
  r, g, b, a = self._get_color_values(color)
331
- self._engine.draw_rect(x, y, w, h, r, g, b, a)
357
+ sx = int(round(self._vp_offset_x + x * self._vp_scale)) # top-left x
358
+ sy = int(round(self._vp_offset_y + y * self._vp_scale)) # top-left y
359
+ sw = int(round(w * self._vp_scale)) # width
360
+ sh = int(round(h * self._vp_scale)) # height
361
+ self._engine.draw_rect(sx, sy, sw, sh, r, g, b, a)
362
+ # self._engine.draw_rect(x, y, w, h, r, g, b, a)
332
363
 
333
364
  def draw_text(
334
365
  self,
@@ -356,9 +387,22 @@ class NativeBackend(Backend):
356
387
  """
357
388
  r, g, b, a = self._get_color_values(color)
358
389
  font_id = self._get_font_id(font_size)
390
+ sx = int(round(self._vp_offset_x + x * self._vp_scale))
391
+ sy = int(round(self._vp_offset_y + y * self._vp_scale))
392
+
393
+ # optional but recommended: scale font size too
394
+ if font_size is not None:
395
+ scaled = max(8, int(round(font_size * self._vp_scale)))
396
+ else:
397
+ scaled = None
398
+
399
+ font_id = self._get_font_id(scaled)
359
400
  self._engine.draw_text(
360
- text, x, y, int(r), int(g), int(b), int(a), font_id
401
+ text, sx, sy, int(r), int(g), int(b), int(a), font_id
361
402
  )
403
+ # self._engine.draw_text(
404
+ # text, x, y, int(r), int(g), int(b), int(a), font_id
405
+ # )
362
406
 
363
407
  # pylint: enable=too-many-arguments,too-many-positional-arguments
364
408
 
@@ -388,3 +432,73 @@ class NativeBackend(Backend):
388
432
  font_id = self._get_font_id(font_size)
389
433
  w, h = self._engine.measure_text(text, font_id)
390
434
  return int(w), int(h)
435
+
436
+ def init_audio(
437
+ self, frequency: int = 44100, channels: int = 2, chunk_size: int = 2048
438
+ ):
439
+ """Initialize SDL_mixer audio."""
440
+ self._engine.init_audio(int(frequency), int(channels), int(chunk_size))
441
+
442
+ def shutdown_audio(self):
443
+ """Shutdown SDL_mixer audio and free loaded sounds."""
444
+ self._engine.shutdown_audio()
445
+
446
+ def load_sound(self, sound_id: str, path: str):
447
+ """
448
+ Load a WAV sound and store it by ID.
449
+ Example: backend.load_sound("hit", "assets/sfx/hit.wav")
450
+ """
451
+ if not sound_id:
452
+ raise ValueError("sound_id cannot be empty")
453
+
454
+ p = Path(path)
455
+ if not p.exists():
456
+ raise FileNotFoundError(f"Sound file not found: {p}")
457
+
458
+ self._engine.load_sound(sound_id, str(p))
459
+
460
+ def play_sound(self, sound_id: str, loops: int = 0):
461
+ """
462
+ Play a loaded sound.
463
+ loops=0 => play once
464
+ loops=-1 => infinite loop
465
+ loops=1 => play twice (SDL convention)
466
+ """
467
+ self._engine.play_sound(sound_id, int(loops))
468
+
469
+ def set_master_volume(self, volume: int):
470
+ """
471
+ Master volume: 0..128
472
+ """
473
+ self._engine.set_master_volume(int(volume))
474
+
475
+ def set_sound_volume(self, sound_id: str, volume: int):
476
+ """
477
+ Per-sound volume: 0..128
478
+ """
479
+ self._engine.set_sound_volume(sound_id, int(volume))
480
+
481
+ def stop_all_sounds(self):
482
+ """Stop all channels."""
483
+ self._engine.stop_all_sounds()
484
+
485
+ def set_viewport_transform(
486
+ self, offset_x: int, offset_y: int, scale: float
487
+ ) -> None:
488
+ self._vp_offset_x = int(offset_x)
489
+ self._vp_offset_y = int(offset_y)
490
+ self._vp_scale = float(scale)
491
+
492
+ def clear_viewport_transform(self) -> None:
493
+ self._vp_offset_x = 0
494
+ self._vp_offset_y = 0
495
+ self._vp_scale = 1.0
496
+
497
+ def resize_window(self, width: int, height: int) -> None:
498
+ self._engine.resize_window(int(width), int(height))
499
+
500
+ def set_clip_rect(self, x: int, y: int, w: int, h: int) -> None:
501
+ self._engine.set_clip_rect(int(x), int(y), int(w), int(h))
502
+
503
+ def clear_clip_rect(self) -> None:
504
+ self._engine.clear_clip_rect()