dama 0.1.0

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 (107) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +227 -0
  4. data/dama-logo.svg +91 -0
  5. data/exe/dama +4 -0
  6. data/ext/dama_native/.cargo/config.toml +3 -0
  7. data/ext/dama_native/Cargo.lock +3575 -0
  8. data/ext/dama_native/Cargo.toml +39 -0
  9. data/ext/dama_native/extconf.rb +72 -0
  10. data/ext/dama_native/src/audio.rs +134 -0
  11. data/ext/dama_native/src/engine.rs +339 -0
  12. data/ext/dama_native/src/lib.rs +396 -0
  13. data/ext/dama_native/src/renderer/screenshot.rs +84 -0
  14. data/ext/dama_native/src/renderer/shape_renderer.rs +507 -0
  15. data/ext/dama_native/src/renderer/text_renderer.rs +192 -0
  16. data/ext/dama_native/src/renderer.rs +563 -0
  17. data/ext/dama_native/src/window.rs +255 -0
  18. data/lib/dama/animation.rb +66 -0
  19. data/lib/dama/asset_cache.rb +56 -0
  20. data/lib/dama/audio.rb +47 -0
  21. data/lib/dama/auto_loader.rb +54 -0
  22. data/lib/dama/backend/base.rb +137 -0
  23. data/lib/dama/backend/native/ffi_bindings.rb +122 -0
  24. data/lib/dama/backend/native.rb +191 -0
  25. data/lib/dama/backend/web.rb +199 -0
  26. data/lib/dama/backend.rb +13 -0
  27. data/lib/dama/camera.rb +68 -0
  28. data/lib/dama/cli/new_project.rb +112 -0
  29. data/lib/dama/cli/release.rb +45 -0
  30. data/lib/dama/cli.rb +22 -0
  31. data/lib/dama/colors.rb +30 -0
  32. data/lib/dama/command_buffer.rb +83 -0
  33. data/lib/dama/component/attribute_definition.rb +13 -0
  34. data/lib/dama/component/attribute_set.rb +32 -0
  35. data/lib/dama/component.rb +28 -0
  36. data/lib/dama/configuration.rb +18 -0
  37. data/lib/dama/debug/frame_controller.rb +35 -0
  38. data/lib/dama/debug/screenshot_tool.rb +19 -0
  39. data/lib/dama/debug.rb +4 -0
  40. data/lib/dama/event_bus.rb +47 -0
  41. data/lib/dama/game/builder.rb +31 -0
  42. data/lib/dama/game/loop.rb +44 -0
  43. data/lib/dama/game.rb +88 -0
  44. data/lib/dama/geometry/circle.rb +28 -0
  45. data/lib/dama/geometry/rect.rb +16 -0
  46. data/lib/dama/geometry/sprite.rb +18 -0
  47. data/lib/dama/geometry/triangle.rb +13 -0
  48. data/lib/dama/geometry.rb +4 -0
  49. data/lib/dama/input/keyboard_state.rb +44 -0
  50. data/lib/dama/input/mouse_state.rb +45 -0
  51. data/lib/dama/input.rb +38 -0
  52. data/lib/dama/keys.rb +67 -0
  53. data/lib/dama/node/component_slot.rb +18 -0
  54. data/lib/dama/node/draw_context.rb +96 -0
  55. data/lib/dama/node.rb +139 -0
  56. data/lib/dama/physics/body.rb +57 -0
  57. data/lib/dama/physics/collider.rb +152 -0
  58. data/lib/dama/physics/collision.rb +15 -0
  59. data/lib/dama/physics/world.rb +125 -0
  60. data/lib/dama/physics.rb +4 -0
  61. data/lib/dama/registry/class_resolver.rb +48 -0
  62. data/lib/dama/registry.rb +21 -0
  63. data/lib/dama/release/archiver.rb +100 -0
  64. data/lib/dama/release/defaults/icon.icns +0 -0
  65. data/lib/dama/release/defaults/icon.ico +0 -0
  66. data/lib/dama/release/defaults/icon.png +0 -0
  67. data/lib/dama/release/dylib_relinker.rb +95 -0
  68. data/lib/dama/release/game_file_copier.rb +35 -0
  69. data/lib/dama/release/game_metadata.rb +61 -0
  70. data/lib/dama/release/icon_provider.rb +36 -0
  71. data/lib/dama/release/native_builder.rb +44 -0
  72. data/lib/dama/release/packager/linux.rb +62 -0
  73. data/lib/dama/release/packager/macos.rb +99 -0
  74. data/lib/dama/release/packager/web.rb +32 -0
  75. data/lib/dama/release/packager/windows.rb +61 -0
  76. data/lib/dama/release/packager.rb +9 -0
  77. data/lib/dama/release/platform_detector.rb +23 -0
  78. data/lib/dama/release/ruby_bundler.rb +163 -0
  79. data/lib/dama/release/stdlib_trimmer.rb +133 -0
  80. data/lib/dama/release/template_renderer.rb +40 -0
  81. data/lib/dama/release/templates/info_plist.xml.erb +19 -0
  82. data/lib/dama/release/templates/launcher_linux.sh.erb +10 -0
  83. data/lib/dama/release/templates/launcher_macos.sh.erb +10 -0
  84. data/lib/dama/release/templates/launcher_windows.bat.erb +11 -0
  85. data/lib/dama/release.rb +7 -0
  86. data/lib/dama/scene/composer.rb +65 -0
  87. data/lib/dama/scene.rb +233 -0
  88. data/lib/dama/scene_graph/class_index.rb +26 -0
  89. data/lib/dama/scene_graph/group_node.rb +27 -0
  90. data/lib/dama/scene_graph/instance_node.rb +30 -0
  91. data/lib/dama/scene_graph/path_selector.rb +25 -0
  92. data/lib/dama/scene_graph/query.rb +34 -0
  93. data/lib/dama/scene_graph/tag_index.rb +26 -0
  94. data/lib/dama/scene_graph/tree.rb +65 -0
  95. data/lib/dama/scene_graph.rb +4 -0
  96. data/lib/dama/sprite_sheet.rb +36 -0
  97. data/lib/dama/tween/easing.rb +31 -0
  98. data/lib/dama/tween/lerp.rb +35 -0
  99. data/lib/dama/tween/manager.rb +28 -0
  100. data/lib/dama/tween.rb +4 -0
  101. data/lib/dama/version.rb +3 -0
  102. data/lib/dama/vertex_batch.rb +35 -0
  103. data/lib/dama/web/entry.rb +79 -0
  104. data/lib/dama/web/static/index.html +142 -0
  105. data/lib/dama/web_builder.rb +232 -0
  106. data/lib/dama.rb +42 -0
  107. metadata +198 -0
@@ -0,0 +1,39 @@
1
+ [package]
2
+ name = "dama_native"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ description = "GPU rendering backend for the dama-rb 2D game engine"
6
+ license = "MIT"
7
+ repository = "https://github.com/caiubi/dama-rb"
8
+
9
+ [lib]
10
+ crate-type = ["cdylib", "rlib"]
11
+
12
+ [dependencies]
13
+ wgpu = "28"
14
+ glyphon = "0.10"
15
+ bytemuck = { version = "1", features = ["derive"] }
16
+ image = { version = "0.25", default-features = false, features = ["png"] }
17
+ log = "0.4"
18
+
19
+ # Native-only dependencies
20
+ [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
21
+ winit = "0.30"
22
+ pollster = "0.4"
23
+ env_logger = "0.11"
24
+ rodio = { version = "0.20", default-features = false, features = ["wav", "vorbis", "mp3"] }
25
+
26
+ # Web-only dependencies
27
+ [target.'cfg(target_arch = "wasm32")'.dependencies]
28
+ wasm-bindgen = "0.2"
29
+ wasm-bindgen-futures = "0.4"
30
+ web-sys = { version = "0.3", features = [
31
+ "Window", "Document", "HtmlCanvasElement", "Element",
32
+ ] }
33
+ js-sys = "0.3"
34
+ console_log = "1"
35
+ console_error_panic_hook = "0.1"
36
+ web-time = "1"
37
+
38
+ [dev-dependencies]
39
+ tempfile = "3"
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Compiles the Rust cdylib native extension during `gem install`.
5
+ # The compiled shared library is placed in lib/dama/native/ where
6
+ # FfiBindings can discover it at runtime.
7
+
8
+ require "fileutils"
9
+
10
+ CARGO = ENV.fetch("CARGO", "cargo")
11
+
12
+ def verify_rust_toolchain!
13
+ return if system(CARGO, "--version", out: File::NULL, err: File::NULL)
14
+
15
+ abort <<~MSG
16
+
17
+ ┌─────────────────────────────────────────────────────┐
18
+ │ Rust toolchain not found. │
19
+ │ │
20
+ │ dama requires Rust to compile its native renderer. │
21
+ │ Install Rust: https://rustup.rs │
22
+ └─────────────────────────────────────────────────────┘
23
+
24
+ MSG
25
+ end
26
+
27
+ LIBRARY_NAMES = {
28
+ "darwin" => "libdama_native.dylib",
29
+ "linux" => "libdama_native.so",
30
+ "mingw" => "dama_native.dll",
31
+ "mswin" => "dama_native.dll",
32
+ }.freeze
33
+
34
+ def library_filename
35
+ platform_key = LIBRARY_NAMES.keys.detect { |k| RUBY_PLATFORM.include?(k) }
36
+ LIBRARY_NAMES.fetch(platform_key) do
37
+ abort "Unsupported platform: #{RUBY_PLATFORM}"
38
+ end
39
+ end
40
+
41
+ def cargo_build!
42
+ puts "=== Compiling dama native extension (this may take a few minutes) ==="
43
+ Dir.chdir(__dir__) do
44
+ success = system(CARGO, "build", "--release")
45
+ abort "cargo build failed" unless success
46
+ end
47
+ end
48
+
49
+ def install_library!
50
+ filename = library_filename
51
+ source = File.join(__dir__, "target", "release", filename)
52
+ dest_dir = File.expand_path("../../lib/dama/native", __dir__)
53
+ FileUtils.mkdir_p(dest_dir)
54
+ FileUtils.cp(source, dest_dir)
55
+ puts "=== Installed #{filename} to lib/dama/native/ ==="
56
+ end
57
+
58
+ def write_dummy_makefile!
59
+ File.write(File.join(__dir__, "Makefile"), <<~MAKEFILE)
60
+ all:
61
+ \t@echo "dama native extension already compiled"
62
+ install:
63
+ \t@echo "dama native extension already installed"
64
+ clean:
65
+ \t@echo "nothing to clean"
66
+ MAKEFILE
67
+ end
68
+
69
+ verify_rust_toolchain!
70
+ cargo_build!
71
+ install_library!
72
+ write_dummy_makefile!
@@ -0,0 +1,134 @@
1
+ /// Audio playback via rodio. Native-only (not available on wasm).
2
+ ///
3
+ /// Gracefully degrades when no audio device is available: sounds are still
4
+ /// loaded and tracked (load_sound returns valid handles), but playback is
5
+ /// silently skipped. This allows headless / CI environments to run without
6
+ /// a physical audio output.
7
+ ///
8
+ /// Uses thread_local storage because rodio's OutputStream is not Send/Sync.
9
+ /// All audio functions must be called from the main thread.
10
+ use std::cell::RefCell;
11
+ use std::collections::HashMap;
12
+ use std::sync::atomic::{AtomicU64, Ordering};
13
+
14
+ use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink, Source};
15
+
16
+ static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1);
17
+
18
+ struct AudioState {
19
+ /// None when no audio output device is available (headless / CI).
20
+ output: Option<AudioOutput>,
21
+ sounds: HashMap<u64, Vec<u8>>,
22
+ }
23
+
24
+ struct AudioOutput {
25
+ _stream: OutputStream,
26
+ stream_handle: OutputStreamHandle,
27
+ sinks: Vec<Sink>,
28
+ }
29
+
30
+ thread_local! {
31
+ static AUDIO: RefCell<Option<AudioState>> = const { RefCell::new(None) };
32
+ }
33
+
34
+ /// Initialize the audio system. Called automatically on first use.
35
+ /// When no audio device is present the state is still created (with
36
+ /// output = None) so that load_sound can store data.
37
+ fn ensure_init() {
38
+ AUDIO.with(|cell| {
39
+ if cell.borrow().is_some() {
40
+ return;
41
+ }
42
+ let output = OutputStream::try_default()
43
+ .ok()
44
+ .map(|(stream, handle)| AudioOutput {
45
+ _stream: stream,
46
+ stream_handle: handle,
47
+ sinks: Vec::new(),
48
+ });
49
+ *cell.borrow_mut() = Some(AudioState {
50
+ output,
51
+ sounds: HashMap::new(),
52
+ });
53
+ });
54
+ }
55
+
56
+ fn with_state<F, T>(f: F) -> Result<T, String>
57
+ where
58
+ F: FnOnce(&mut AudioState) -> Result<T, String>,
59
+ {
60
+ ensure_init();
61
+ AUDIO.with(|cell| {
62
+ let mut borrow = cell.borrow_mut();
63
+ let state = borrow
64
+ .as_mut()
65
+ .ok_or_else(|| "Audio state not initialized".to_string())?;
66
+ f(state)
67
+ })
68
+ }
69
+
70
+ /// Load a sound file into memory and return a handle.
71
+ /// Works even without an audio device — the data is stored for later use.
72
+ pub fn load_sound(path: &str) -> Result<u64, String> {
73
+ let data = std::fs::read(path).map_err(|e| format!("Failed to read {path}: {e}"))?;
74
+ with_state(|state| {
75
+ let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed);
76
+ state.sounds.insert(handle, data.clone());
77
+ Ok(handle)
78
+ })
79
+ }
80
+
81
+ /// Play a loaded sound with the given volume (0.0..1.0) and optional looping.
82
+ /// Silently succeeds when no audio device is available.
83
+ pub fn play_sound(handle: u64, volume: f32, looping: bool) -> Result<(), String> {
84
+ with_state(|state| {
85
+ let data = state
86
+ .sounds
87
+ .get(&handle)
88
+ .ok_or_else(|| format!("Unknown sound handle: {handle}"))?;
89
+
90
+ let output = match state.output.as_mut() {
91
+ Some(o) => o,
92
+ None => return Ok(()),
93
+ };
94
+
95
+ let cursor = std::io::Cursor::new(data.clone());
96
+ let source = Decoder::new(std::io::BufReader::new(cursor))
97
+ .map_err(|e| format!("Failed to decode sound: {e}"))?;
98
+
99
+ let sink = Sink::try_new(&output.stream_handle)
100
+ .map_err(|e| format!("Failed to create sink: {e}"))?;
101
+ sink.set_volume(volume);
102
+
103
+ if looping {
104
+ sink.append(source.repeat_infinite());
105
+ } else {
106
+ sink.append(source);
107
+ }
108
+
109
+ output.sinks.retain(|s| !s.empty());
110
+ output.sinks.push(sink);
111
+ Ok(())
112
+ })
113
+ }
114
+
115
+ /// Stop all currently playing sounds.
116
+ pub fn stop_all() {
117
+ let _ = with_state(|state| {
118
+ if let Some(output) = state.output.as_mut() {
119
+ for sink in &output.sinks {
120
+ sink.stop();
121
+ }
122
+ output.sinks.clear();
123
+ }
124
+ Ok(())
125
+ });
126
+ }
127
+
128
+ /// Unload a sound and free its memory.
129
+ pub fn unload_sound(handle: u64) {
130
+ let _ = with_state(|state| {
131
+ state.sounds.remove(&handle);
132
+ Ok(())
133
+ });
134
+ }
@@ -0,0 +1,339 @@
1
+ #[cfg(not(target_arch = "wasm32"))]
2
+ use std::time::Instant;
3
+ #[cfg(target_arch = "wasm32")]
4
+ use web_time::Instant;
5
+
6
+ use crate::renderer::Renderer;
7
+
8
+ // Native: Mutex for thread safety.
9
+ // Web: RefCell in thread-local (single-threaded, avoids Sync requirement on glyphon types).
10
+ #[cfg(not(target_arch = "wasm32"))]
11
+ static ENGINE: std::sync::Mutex<Option<Engine>> = std::sync::Mutex::new(None);
12
+
13
+ #[cfg(target_arch = "wasm32")]
14
+ thread_local! {
15
+ static ENGINE_CELL: std::cell::RefCell<Option<Engine>> = const { std::cell::RefCell::new(None) };
16
+ }
17
+
18
+ // Platform-agnostic helpers to access the global Engine.
19
+ #[cfg(not(target_arch = "wasm32"))]
20
+ fn engine_set(engine: Option<Engine>) {
21
+ let mut guard = ENGINE.lock().unwrap();
22
+ *guard = engine;
23
+ }
24
+
25
+ #[cfg(target_arch = "wasm32")]
26
+ fn engine_set(engine: Option<Engine>) {
27
+ ENGINE_CELL.with(|cell| { *cell.borrow_mut() = engine; });
28
+ }
29
+
30
+ #[cfg(not(target_arch = "wasm32"))]
31
+ fn engine_with<F, T>(f: F) -> Result<T, String>
32
+ where F: FnOnce(&mut Engine) -> Result<T, String> {
33
+ let mut guard = ENGINE.lock().map_err(|e| format!("Mutex poisoned: {e}"))?;
34
+ let engine = guard.as_mut().ok_or("Engine not initialized")?;
35
+ f(engine)
36
+ }
37
+
38
+ #[cfg(target_arch = "wasm32")]
39
+ fn engine_with<F, T>(f: F) -> Result<T, String>
40
+ where F: FnOnce(&mut Engine) -> Result<T, String> {
41
+ ENGINE_CELL.with(|cell| {
42
+ let mut borrow = cell.borrow_mut();
43
+ let engine = borrow.as_mut().ok_or("Engine not initialized".to_string())?;
44
+ f(engine)
45
+ })
46
+ }
47
+
48
+ // Native-only: winit event loop in thread-local storage.
49
+ #[cfg(not(target_arch = "wasm32"))]
50
+ use std::cell::RefCell;
51
+ #[cfg(not(target_arch = "wasm32"))]
52
+ thread_local! {
53
+ static EVENT_LOOP: RefCell<Option<winit::event_loop::EventLoop<()>>> = const { RefCell::new(None) };
54
+ }
55
+
56
+ pub struct Engine {
57
+ renderer: Renderer,
58
+ #[cfg(not(target_arch = "wasm32"))]
59
+ window_state: Option<crate::window::WindowState>,
60
+ frame_count: u64,
61
+ last_frame_time: Instant,
62
+ delta_time: f64,
63
+ last_error: Option<String>,
64
+ #[cfg(not(target_arch = "wasm32"))]
65
+ wgpu_instance: Option<wgpu::Instance>,
66
+ #[cfg(not(target_arch = "wasm32"))]
67
+ wgpu_adapter: Option<wgpu::Adapter>,
68
+ }
69
+
70
+ impl Engine {
71
+ #[cfg(not(target_arch = "wasm32"))]
72
+ pub fn init_headless(width: u32, height: u32) -> Result<(), String> {
73
+ let renderer = Renderer::new_headless(width, height)?;
74
+ let engine = Engine {
75
+ renderer,
76
+ window_state: None,
77
+ frame_count: 0,
78
+ last_frame_time: Instant::now(),
79
+ delta_time: 0.0,
80
+ last_error: None,
81
+ wgpu_instance: None,
82
+ wgpu_adapter: None,
83
+ };
84
+ engine_set(Some(engine));
85
+ Ok(())
86
+ }
87
+
88
+ #[cfg(not(target_arch = "wasm32"))]
89
+ pub fn init_windowed(width: u32, height: u32, title: &str) -> Result<(), String> {
90
+ let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
91
+ let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
92
+ power_preference: wgpu::PowerPreference::default(),
93
+ compatible_surface: None,
94
+ force_fallback_adapter: false,
95
+ }))
96
+ .map_err(|e| format!("Failed to find GPU adapter: {e}"))?;
97
+
98
+ let (device, queue) = pollster::block_on(adapter.request_device(
99
+ &wgpu::DeviceDescriptor { label: Some("dama_device"), ..Default::default() },
100
+ ))
101
+ .map_err(|e| format!("Failed to create device: {e}"))?;
102
+
103
+ let renderer = Renderer::new_windowed(device, queue, width, height);
104
+ let window_state = crate::window::WindowState::new(title.to_string(), width, height);
105
+
106
+ let engine = Engine {
107
+ renderer,
108
+ window_state: Some(window_state),
109
+ frame_count: 0,
110
+ last_frame_time: Instant::now(),
111
+ delta_time: 0.0,
112
+ last_error: None,
113
+ wgpu_instance: Some(instance),
114
+ wgpu_adapter: Some(adapter),
115
+ };
116
+
117
+ engine_set(Some(engine));
118
+
119
+ let event_loop = winit::event_loop::EventLoop::new()
120
+ .map_err(|e| format!("Failed to create event loop: {e}"))?;
121
+ EVENT_LOOP.with(|cell| { *cell.borrow_mut() = Some(event_loop); });
122
+
123
+ Engine::pump_events()?;
124
+
125
+ Engine::with(|engine| {
126
+ let instance = engine.wgpu_instance.as_ref().ok_or("No wgpu instance")?;
127
+ let adapter = engine.wgpu_adapter.as_ref().ok_or("No wgpu adapter")?;
128
+ let device = engine.renderer.device();
129
+ let ws = engine.window_state.as_mut().ok_or("No window state")?;
130
+ ws.create_surface(instance, device, adapter)?;
131
+ if let Some(config) = ws.surface_config() {
132
+ // Update physical dimensions BEFORE creating renderers so
133
+ // text renderer gets the correct viewport size.
134
+ engine.renderer.set_physical_size(config.width, config.height);
135
+ engine.renderer.set_surface_format(config.format);
136
+ }
137
+ Ok(())
138
+ })?;
139
+
140
+ Ok(())
141
+ }
142
+
143
+ // Web init: canvas-based surface via wgpu's WebGPU backend.
144
+ #[cfg(target_arch = "wasm32")]
145
+ pub fn init_web(canvas_id: &str, width: u32, height: u32) -> Result<(), String> {
146
+ use wasm_bindgen::JsCast;
147
+
148
+ console_error_panic_hook::set_once();
149
+ let _ = console_log::init_with_level(log::Level::Warn);
150
+
151
+ crate::window::InputState::init();
152
+
153
+ let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
154
+
155
+ let document = web_sys::window()
156
+ .ok_or("No window")?
157
+ .document()
158
+ .ok_or("No document")?;
159
+ let canvas = document
160
+ .get_element_by_id(canvas_id)
161
+ .ok_or(format!("Canvas '{canvas_id}' not found"))?
162
+ .dyn_into::<web_sys::HtmlCanvasElement>()
163
+ .map_err(|_| "Element is not a canvas")?;
164
+
165
+ // Read the canvas's physical backing dimensions (set by JS to logical × DPR).
166
+ let physical_width = canvas.width();
167
+ let physical_height = canvas.height();
168
+
169
+ let surface = instance
170
+ .create_surface(wgpu::SurfaceTarget::Canvas(canvas))
171
+ .map_err(|e| format!("Failed to create surface: {e}"))?;
172
+
173
+ // wasm: adapter/device requests are async. Use spawn_local.
174
+ let logical_width = width;
175
+ let logical_height = height;
176
+ wasm_bindgen_futures::spawn_local(async move {
177
+ let adapter = instance
178
+ .request_adapter(&wgpu::RequestAdapterOptions {
179
+ power_preference: wgpu::PowerPreference::default(),
180
+ compatible_surface: Some(&surface),
181
+ force_fallback_adapter: false,
182
+ })
183
+ .await
184
+ .expect("Failed to find GPU adapter");
185
+
186
+ let (device, queue) = adapter
187
+ .request_device(&wgpu::DeviceDescriptor {
188
+ label: Some("dama_device"),
189
+ ..Default::default()
190
+ })
191
+ .await
192
+ .expect("Failed to create device");
193
+
194
+ let caps = surface.get_capabilities(&adapter);
195
+ // Prefer non-sRGB for consistent colors with native.
196
+ let format = caps.formats.iter()
197
+ .find(|f| !f.is_srgb())
198
+ .copied()
199
+ .unwrap_or(caps.formats[0]);
200
+ let config = wgpu::SurfaceConfiguration {
201
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
202
+ format,
203
+ width: physical_width,
204
+ height: physical_height,
205
+ present_mode: wgpu::PresentMode::AutoVsync,
206
+ alpha_mode: caps.alpha_modes[0],
207
+ view_formats: vec![],
208
+ desired_maximum_frame_latency: 2,
209
+ };
210
+ surface.configure(&device, &config);
211
+
212
+ // Renderer uses physical dims for GPU but logical for coordinate mapping.
213
+ let mut renderer = Renderer::new_windowed(device, queue, physical_width, physical_height);
214
+ renderer.set_logical_size(logical_width, logical_height);
215
+ renderer.set_surface_format(format);
216
+ renderer.set_web_surface(surface);
217
+
218
+ let engine = Engine {
219
+ renderer,
220
+ frame_count: 0,
221
+ last_frame_time: Instant::now(),
222
+ delta_time: 0.0,
223
+ last_error: None,
224
+ };
225
+ engine_set(Some(engine));
226
+
227
+ // Signal to JS that the engine is ready.
228
+ let window = web_sys::window().unwrap();
229
+ js_sys::Reflect::set(
230
+ &window, &"__damaReady".into(), &true.into()
231
+ ).unwrap();
232
+ });
233
+
234
+ Ok(())
235
+ }
236
+
237
+ pub fn shutdown() -> Result<(), String> {
238
+ // Catch panics during engine drop (e.g., surface already invalidated).
239
+ let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
240
+ engine_set(None);
241
+ }));
242
+ #[cfg(not(target_arch = "wasm32"))]
243
+ EVENT_LOOP.with(|cell| { *cell.borrow_mut() = None; });
244
+ Ok(())
245
+ }
246
+
247
+ #[cfg(not(target_arch = "wasm32"))]
248
+ pub fn pump_events() -> Result<bool, String> {
249
+ use winit::platform::pump_events::EventLoopExtPumpEvents;
250
+
251
+ let mut event_loop_opt = EVENT_LOOP.with(|cell| cell.borrow_mut().take());
252
+ let event_loop = event_loop_opt.as_mut().ok_or("No event loop (headless mode?)")?;
253
+
254
+ let mut ws = engine_with(|engine| {
255
+ engine.window_state.take().ok_or("No window state".to_string())
256
+ })?;
257
+
258
+ ws.begin_input_frame();
259
+ {
260
+ let mut handler = crate::window::DamaAppHandler {
261
+ window_state: &mut ws,
262
+ on_window_created: None,
263
+ };
264
+ let _status = event_loop.pump_app_events(Some(std::time::Duration::ZERO), &mut handler);
265
+ }
266
+ let quit = ws.quit_requested();
267
+
268
+ let _ = engine_with(|engine| { engine.window_state = Some(ws); Ok(()) });
269
+
270
+ EVENT_LOOP.with(|cell| { *cell.borrow_mut() = event_loop_opt; });
271
+ Ok(quit)
272
+ }
273
+
274
+ #[cfg(target_arch = "wasm32")]
275
+ pub fn pump_events() -> Result<bool, String> {
276
+ crate::window::InputState::begin_frame();
277
+ Ok(false)
278
+ }
279
+
280
+ pub fn with<F, T>(f: F) -> Result<T, String>
281
+ where F: FnOnce(&mut Engine) -> Result<T, String>,
282
+ {
283
+ engine_with(|engine| {
284
+ let result = f(engine);
285
+ if let Err(ref e) = result { engine.last_error = Some(e.clone()); }
286
+ result
287
+ })
288
+ }
289
+
290
+ pub fn renderer(&mut self) -> &mut Renderer { &mut self.renderer }
291
+
292
+ #[cfg(not(target_arch = "wasm32"))]
293
+ pub fn window_state(&self) -> Option<&crate::window::WindowState> { self.window_state.as_ref() }
294
+
295
+ pub fn begin_frame(&mut self) -> Result<(), String> {
296
+ // Compute delta time from Instant (works on both native and web via web-time crate).
297
+ let now = Instant::now();
298
+ self.delta_time = now.duration_since(self.last_frame_time).as_secs_f64();
299
+ self.last_frame_time = now;
300
+
301
+ #[cfg(not(target_arch = "wasm32"))]
302
+ if let Some(ref mut ws) = self.window_state {
303
+ let texture = ws.acquire_texture()?;
304
+ let view = texture.texture.create_view(&wgpu::TextureViewDescriptor::default());
305
+ self.renderer.set_surface_view(Some(view));
306
+ }
307
+
308
+ // Web: acquire surface texture from stored surface.
309
+ #[cfg(target_arch = "wasm32")]
310
+ self.renderer.acquire_web_surface()?;
311
+
312
+ self.renderer.begin_frame(self.delta_time as f32)
313
+ }
314
+
315
+ pub fn end_frame(&mut self) -> Result<(), String> {
316
+ self.renderer.end_frame()?;
317
+ self.renderer.set_surface_view(None);
318
+
319
+ #[cfg(not(target_arch = "wasm32"))]
320
+ if let Some(ref mut ws) = self.window_state {
321
+ ws.present();
322
+ }
323
+
324
+ #[cfg(target_arch = "wasm32")]
325
+ self.renderer.present_web_surface();
326
+
327
+ self.frame_count += 1;
328
+ Ok(())
329
+ }
330
+
331
+ pub fn delta_time(&self) -> f64 { self.delta_time }
332
+ pub fn frame_count(&self) -> u64 { self.frame_count }
333
+ pub fn last_error(&self) -> Option<&str> { self.last_error.as_deref() }
334
+
335
+ pub fn screenshot(&self, path: &str) -> Result<(), String> {
336
+ self.renderer.screenshot(path)
337
+ }
338
+
339
+ }