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,122 @@
1
+ require "ffi"
2
+
3
+ module Dama
4
+ module Backend
5
+ class Native
6
+ # Raw FFI bindings to the Rust cdylib. This is the only place in the
7
+ # Ruby codebase where FFI types appear. All other code interacts
8
+ # through the Backend::Native adapter.
9
+ module FfiBindings
10
+ extend FFI::Library
11
+
12
+ # Platform-specific shared library extension, resolved via hash
13
+ # lookup to avoid conditionals (per project coding guidelines).
14
+ LIBRARY_EXTENSIONS = {
15
+ "darwin" => "dylib",
16
+ "linux" => "so",
17
+ "mingw" => "dll",
18
+ "mswin" => "dll",
19
+ }.freeze
20
+
21
+ # Rust omits the "lib" prefix on Windows cdylibs (dama_native.dll
22
+ # instead of libdama_native.dll), so we need platform-aware names.
23
+ LIBRARY_PREFIXES = {
24
+ "darwin" => "lib",
25
+ "linux" => "lib",
26
+ "mingw" => "",
27
+ "mswin" => "",
28
+ }.freeze
29
+
30
+ def self.library_filename
31
+ platform_key = LIBRARY_EXTENSIONS.keys.detect { |k| RUBY_PLATFORM.include?(k) }
32
+ prefix = LIBRARY_PREFIXES.fetch(platform_key)
33
+ extension = LIBRARY_EXTENSIONS.fetch(platform_key)
34
+ "#{prefix}dama_native.#{extension}"
35
+ end
36
+
37
+ # Library resolution order:
38
+ # 1. DAMA_NATIVE_LIB env var — packaged games set this to their bundled copy
39
+ # 2. lib/dama/native/ — pre-compiled platform gems and source gem extconf.rb
40
+ # install the shared library here
41
+ # 3. ext/dama_native/target/release/ — local development with cargo build
42
+ LIBRARY_PATH_RESOLVERS = [
43
+ lambda {
44
+ path = ENV.fetch("DAMA_NATIVE_LIB", nil)
45
+ path if path && File.exist?(path)
46
+ },
47
+ lambda {
48
+ path = File.expand_path("../../native/#{library_filename}", __dir__)
49
+ path if File.exist?(path)
50
+ },
51
+ lambda {
52
+ path = File.expand_path("../../../../ext/dama_native/target/release/#{library_filename}", __dir__)
53
+ path if File.exist?(path)
54
+ },
55
+ ].freeze
56
+
57
+ def self.library_path
58
+ LIBRARY_PATH_RESOLVERS.each do |resolver|
59
+ path = resolver.call
60
+ return path if path
61
+ end
62
+
63
+ raise "dama native library not found. Run `cargo build --release` in ext/dama_native/ " \
64
+ "or install a platform-specific gem."
65
+ end
66
+
67
+ ffi_lib library_path
68
+
69
+ # --- Lifecycle ---
70
+ attach_function :dama_engine_init_headless, %i[uint32 uint32], :int32
71
+ attach_function :dama_engine_init, %i[uint32 uint32 string], :int32
72
+ attach_function :dama_engine_shutdown, [], :int32
73
+ attach_function :dama_engine_poll_events, [], :int32
74
+ attach_function :dama_engine_begin_frame, [], :int32
75
+ attach_function :dama_engine_end_frame, [], :int32
76
+ attach_function :dama_engine_delta_time, [], :double
77
+ attach_function :dama_engine_frame_count, [], :uint64
78
+ attach_function :dama_engine_last_error, [], :string
79
+
80
+ # --- Rendering ---
81
+ attach_function :dama_render_clear, %i[float float float float], :int32
82
+ attach_function :dama_render_vertices, %i[pointer uint32], :int32
83
+ attach_function :dama_render_set_texture, [:uint64], :int32
84
+ attach_function :dama_render_text,
85
+ %i[string float float float
86
+ float float float float], :int32
87
+
88
+ # --- Assets ---
89
+ attach_function :dama_asset_load_texture, %i[pointer uint32], :uint64
90
+ attach_function :dama_asset_unload_texture, [:uint64], :int32
91
+
92
+ # --- Input ---
93
+ attach_function :dama_input_key_pressed, [:uint32], :int32
94
+ attach_function :dama_input_key_just_pressed, [:uint32], :int32
95
+ attach_function :dama_input_key_just_released, [:uint32], :int32
96
+ attach_function :dama_input_mouse_x, [], :float
97
+ attach_function :dama_input_mouse_y, [], :float
98
+ attach_function :dama_input_mouse_button_pressed, [:uint32], :int32
99
+
100
+ # --- Debug ---
101
+ attach_function :dama_debug_screenshot, [:string], :int32
102
+
103
+ # --- Fonts ---
104
+ attach_function :dama_font_load, [:string], :int32
105
+ attach_function :dama_render_text_with_font,
106
+ %i[string float float float
107
+ float float float float string], :int32
108
+
109
+ # --- Audio ---
110
+ attach_function :dama_audio_load_sound, [:string], :uint64
111
+ attach_function :dama_audio_play_sound, %i[uint64 float int32], :int32
112
+ attach_function :dama_audio_stop_all, [], :int32
113
+ attach_function :dama_audio_unload_sound, [:uint64], :int32
114
+
115
+ # --- Shaders ---
116
+ attach_function :dama_shader_load, [:string], :uint64
117
+ attach_function :dama_shader_unload, [:uint64], :int32
118
+ attach_function :dama_render_set_shader, [:uint64], :int32
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,191 @@
1
+ module Dama
2
+ module Backend
3
+ # Native backend: calls the Rust cdylib through ruby-ffi.
4
+ # Shapes are decomposed into vertices in Ruby and submitted
5
+ # as a single batch per frame via dama_render_vertices.
6
+ class Native < Base
7
+ HEADLESS_INIT = lambda { |bindings, config|
8
+ bindings.dama_engine_init_headless(config.width, config.height)
9
+ }
10
+
11
+ # :nocov:
12
+ WINDOWED_INIT = ->(bindings, config) { bindings.dama_engine_init(config.width, config.height, config.title) }
13
+ # :nocov:
14
+
15
+ INIT_STRATEGIES = { true => HEADLESS_INIT, false => WINDOWED_INIT }.freeze
16
+
17
+ def initialize
18
+ @bindings = Native::FfiBindings
19
+ @vertex_batch = VertexBatch.new
20
+ end
21
+
22
+ def initialize_engine(configuration:)
23
+ strategy = INIT_STRATEGIES.fetch(configuration.headless)
24
+ result = strategy.call(bindings, configuration)
25
+ check_result(result:)
26
+ end
27
+
28
+ def shutdown
29
+ check_result(result: bindings.dama_engine_shutdown)
30
+ end
31
+
32
+ def poll_events
33
+ result = bindings.dama_engine_poll_events
34
+ result == 1
35
+ end
36
+
37
+ def begin_frame
38
+ check_result(result: bindings.dama_engine_begin_frame)
39
+ end
40
+
41
+ def end_frame
42
+ # Flush accumulated vertices to the GPU in one FFI call.
43
+ vertex_batch.flush(bindings:)
44
+ check_result(result: bindings.dama_engine_end_frame)
45
+ end
46
+
47
+ def delta_time
48
+ bindings.dama_engine_delta_time
49
+ end
50
+
51
+ def frame_count
52
+ bindings.dama_engine_frame_count
53
+ end
54
+
55
+ def clear(color: Dama::Colors::BLACK, r: color.r, g: color.g, b: color.b, a: color.a)
56
+ check_result(result: bindings.dama_render_clear(r, g, b, a))
57
+ end
58
+
59
+ def draw_triangle(x1:, y1:, x2:, y2:, x3:, y3:, color: Dama::Colors::WHITE,
60
+ r: color.r, g: color.g, b: color.b, a: color.a, filled: true)
61
+ vertex_batch.push(Geometry::Triangle.vertices(x1:, y1:, x2:, y2:, x3:, y3:, r:, g:, b:, a:))
62
+ end
63
+
64
+ def draw_rect(x:, y:, w:, h:, color: Dama::Colors::WHITE,
65
+ r: color.r, g: color.g, b: color.b, a: color.a, filled: true)
66
+ vertex_batch.push(Geometry::Rect.vertices(x:, y:, w:, h:, r:, g:, b:, a:))
67
+ end
68
+
69
+ def draw_circle(cx:, cy:, radius:, color: Dama::Colors::WHITE,
70
+ r: color.r, g: color.g, b: color.b, a: color.a, filled: true, segments: 32)
71
+ vertex_batch.push(Geometry::Circle.vertices(cx:, cy:, radius:, r:, g:, b:, a:, segments:))
72
+ end
73
+
74
+ def draw_text(text:, x:, y:, size:, color: Dama::Colors::WHITE,
75
+ r: color.r, g: color.g, b: color.b, a: color.a, font: nil)
76
+ vertex_batch.flush(bindings:)
77
+ result = if font
78
+ bindings.dama_render_text_with_font(text, x, y, size, r, g, b, a, font)
79
+ else
80
+ bindings.dama_render_text(text, x, y, size, r, g, b, a)
81
+ end
82
+ check_result(result:)
83
+ end
84
+
85
+ def load_font(path:)
86
+ check_result(result: bindings.dama_font_load(path))
87
+ end
88
+
89
+ def draw_sprite(texture_handle:, x:, y:, w:, h:, color: Dama::Colors::WHITE,
90
+ r: color.r, g: color.g, b: color.b, a: color.a)
91
+ # Flush any untextured vertices, switch texture, push sprite, flush, reset.
92
+ vertex_batch.flush(bindings:)
93
+ check_result(result: bindings.dama_render_set_texture(texture_handle))
94
+ vertex_batch.push(Geometry::Sprite.vertices(x:, y:, w:, h:, r:, g:, b:, a:))
95
+ vertex_batch.flush(bindings:)
96
+ check_result(result: bindings.dama_render_set_texture(0))
97
+ end
98
+
99
+ def load_texture(bytes:)
100
+ ptr = FFI::MemoryPointer.new(:uint8, bytes.bytesize)
101
+ ptr.put_bytes(0, bytes)
102
+ handle = bindings.dama_asset_load_texture(ptr, bytes.bytesize)
103
+ raise "Failed to load texture" if handle.zero?
104
+
105
+ handle
106
+ end
107
+
108
+ def load_texture_file(path:)
109
+ load_texture(bytes: File.binread(path))
110
+ end
111
+
112
+ def unload_texture(handle:)
113
+ check_result(result: bindings.dama_asset_unload_texture(handle))
114
+ end
115
+
116
+ def screenshot(output_path:)
117
+ check_result(result: bindings.dama_debug_screenshot(output_path))
118
+ end
119
+
120
+ def key_pressed?(key_code:)
121
+ bindings.dama_input_key_pressed(key_code) == 1
122
+ end
123
+
124
+ def key_just_pressed?(key_code:)
125
+ bindings.dama_input_key_just_pressed(key_code) == 1
126
+ end
127
+
128
+ def key_just_released?(key_code:)
129
+ bindings.dama_input_key_just_released(key_code) == 1
130
+ end
131
+
132
+ def mouse_x
133
+ bindings.dama_input_mouse_x
134
+ end
135
+
136
+ def mouse_y
137
+ bindings.dama_input_mouse_y
138
+ end
139
+
140
+ def mouse_button_pressed?(button:)
141
+ bindings.dama_input_mouse_button_pressed(button) == 1
142
+ end
143
+
144
+ def load_sound(path:)
145
+ handle = bindings.dama_audio_load_sound(path)
146
+ raise "Failed to load sound: #{bindings.dama_engine_last_error}" if handle.zero?
147
+
148
+ handle
149
+ end
150
+
151
+ def play_sound(handle:, volume: 1.0, loop: false)
152
+ looping = loop ? 1 : 0
153
+ check_result(result: bindings.dama_audio_play_sound(handle, volume, looping))
154
+ end
155
+
156
+ def stop_all_sounds
157
+ check_result(result: bindings.dama_audio_stop_all)
158
+ end
159
+
160
+ def unload_sound(handle:)
161
+ check_result(result: bindings.dama_audio_unload_sound(handle))
162
+ end
163
+
164
+ def load_shader(source:)
165
+ bindings.dama_shader_load(source)
166
+ end
167
+
168
+ def unload_shader(handle:)
169
+ check_result(result: bindings.dama_shader_unload(handle))
170
+ end
171
+
172
+ def set_shader(handle:)
173
+ # Flush pending vertices before changing shader to ensure
174
+ # they render with the current shader, not the new one.
175
+ vertex_batch.flush(bindings:)
176
+ check_result(result: bindings.dama_render_set_shader(handle))
177
+ end
178
+
179
+ private
180
+
181
+ attr_reader :bindings, :vertex_batch
182
+
183
+ def check_result(result:)
184
+ return if result >= 0
185
+
186
+ error_msg = bindings.dama_engine_last_error
187
+ raise error_msg || "Unknown native engine error"
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,199 @@
1
+ module Dama
2
+ module Backend
3
+ # Web backend: runs in ruby.wasm, sends high-level draw commands
4
+ # to the Rust wgpu wasm renderer via JavaScript bridge.
5
+ #
6
+ # Instead of decomposing shapes into triangles in Ruby (expensive in wasm),
7
+ # we send compact commands (9-14 floats each) and let Rust decompose them
8
+ # at native speed via dama_render_commands.
9
+ class Web < Base
10
+ def initialize
11
+ @frame_count = 0
12
+ @command_buffer = CommandBuffer.new
13
+ end
14
+
15
+ def initialize_engine(configuration:)
16
+ # On web, the engine is already initialized by index.html.
17
+ # Only call dama_init if not yet ready (avoids double-init replacing shaders).
18
+ w = configuration.width
19
+ h = configuration.height
20
+ ::JS.eval("if (!window.__damaReady) { window.damaWgpu.dama_init('game', #{w}, #{h}); }")
21
+ end
22
+
23
+ def shutdown; end
24
+ def poll_events = false
25
+
26
+ def begin_frame
27
+ command_buffer.clear
28
+ js_renderer.call(:dama_begin_frame)
29
+ end
30
+
31
+ def end_frame
32
+ flush_commands
33
+ js_renderer.call(:dama_end_frame)
34
+ @frame_count += 1
35
+ end
36
+
37
+ def delta_time
38
+ js_time[:delta].to_f
39
+ end
40
+
41
+ attr_reader :frame_count
42
+
43
+ def clear(color: Dama::Colors::BLACK, r: color.r, g: color.g, b: color.b, a: color.a)
44
+ js_renderer.call(:dama_clear, r, g, b, a)
45
+ end
46
+
47
+ def draw_triangle(x1:, y1:, x2:, y2:, x3:, y3:, color: Dama::Colors::WHITE,
48
+ r: color.r, g: color.g, b: color.b, a: color.a, filled: true)
49
+ command_buffer.push_triangle(x1:, y1:, x2:, y2:, x3:, y3:, r:, g:, b:, a:)
50
+ end
51
+
52
+ def draw_rect(x:, y:, w:, h:, color: Dama::Colors::WHITE,
53
+ r: color.r, g: color.g, b: color.b, a: color.a, filled: true)
54
+ command_buffer.push_rect(x:, y:, w:, h:, r:, g:, b:, a:)
55
+ end
56
+
57
+ def draw_circle(cx:, cy:, radius:, color: Dama::Colors::WHITE,
58
+ r: color.r, g: color.g, b: color.b, a: color.a, filled: true, segments: 32)
59
+ command_buffer.push_circle(cx:, cy:, radius:, r:, g:, b:, a:, segments:)
60
+ end
61
+
62
+ def draw_text(text:, x:, y:, size:, color: Dama::Colors::WHITE,
63
+ r: color.r, g: color.g, b: color.b, a: color.a, font: nil)
64
+ flush_commands
65
+ js_renderer.call(:dama_render_text, text, x, y, size, r, g, b, a)
66
+ end
67
+
68
+ def draw_sprite(texture_handle:, x:, y:, w:, h:, color: Dama::Colors::WHITE,
69
+ r: color.r, g: color.g, b: color.b, a: color.a)
70
+ command_buffer.push_sprite(
71
+ texture_handle:, x:, y:, w:, h:, r:, g:, b:, a:,
72
+ u_min: 0.0, v_min: 0.0, u_max: 1.0, v_max: 1.0
73
+ )
74
+ end
75
+
76
+ def screenshot(output_path:); end
77
+
78
+ def key_pressed?(key_code:)
79
+ js_renderer.call(:dama_key_pressed, key_code).to_s == "true"
80
+ end
81
+
82
+ def key_just_pressed?(key_code:)
83
+ js_renderer.call(:dama_key_just_pressed, key_code).to_s == "true"
84
+ end
85
+
86
+ def key_just_released?(key_code:)
87
+ false
88
+ end
89
+
90
+ def mouse_x
91
+ js_renderer.call(:dama_mouse_x).to_f
92
+ end
93
+
94
+ def mouse_y
95
+ js_renderer.call(:dama_mouse_y).to_f
96
+ end
97
+
98
+ def mouse_button_pressed?(button:)
99
+ ::JS.eval("return !!window.damaMouseButtons[#{button}]").to_s == "true"
100
+ end
101
+
102
+ def load_texture(bytes:)
103
+ b64 = [bytes].pack("m0")
104
+ js_array = ::JS.eval("return Uint8Array.from(atob('#{b64}'), c => c.charCodeAt(0))")
105
+ from_bigint(js_renderer.call(:dama_load_texture, js_array))
106
+ end
107
+
108
+ def load_texture_file(path:)
109
+ load_texture(bytes: File.binread(path))
110
+ end
111
+
112
+ def unload_texture(handle:)
113
+ js_renderer.call(:dama_unload_texture, handle)
114
+ end
115
+
116
+ def load_sound(path:)
117
+ @next_sound_handle ||= 0
118
+ @next_sound_handle += 1
119
+
120
+ data = File.binread(path)
121
+ b64 = [data].pack("m0")
122
+ ::JS.eval("window.damaSounds = window.damaSounds || {}; " \
123
+ "window.damaSounds[#{@next_sound_handle}] = 'data:audio/wav;base64,#{b64}'")
124
+ @next_sound_handle
125
+ end
126
+
127
+ def play_sound(handle:, volume: 1.0, loop: false)
128
+ loop_js = loop ? "a.loop = true;" : ""
129
+ ::JS.eval("(() => { const a = new Audio(window.damaSounds[#{handle}]); " \
130
+ "a.volume = #{volume}; #{loop_js} a.play().catch(() => {}); })()")
131
+ end
132
+
133
+ def stop_all_sounds
134
+ ::JS.eval("document.querySelectorAll('audio').forEach(a => { a.pause(); a.currentTime = 0; })")
135
+ end
136
+
137
+ def unload_sound(handle:)
138
+ ::JS.eval("delete window.damaSounds[#{handle}]")
139
+ end
140
+
141
+ def load_font(path:); end
142
+
143
+ def load_shader(source:)
144
+ # Pass shader source via JS template literal to avoid ruby.wasm
145
+ # JsValue.call data corruption. Escape backticks and backslashes.
146
+ escaped = source.gsub("\\", "\\\\\\\\").gsub("`", "\\`")
147
+ result = ::JS.eval("return String(window.damaWgpu.dama_shader_load(`#{escaped}`))")
148
+ result.to_s.to_i
149
+ end
150
+
151
+ def unload_shader(handle:)
152
+ js_renderer.call(:dama_shader_unload, to_bigint(handle))
153
+ end
154
+
155
+ def set_shader(handle:)
156
+ # Merge shader switch INTO the next flush — don't send it separately.
157
+ # This avoids the ruby.wasm state persistence issue between JS.eval calls.
158
+ command_buffer.push_set_shader(shader_handle: handle)
159
+ end
160
+
161
+ private
162
+
163
+ attr_reader :command_buffer
164
+
165
+ def js_renderer
166
+ ::JS.global[:damaWgpu]
167
+ end
168
+
169
+ def js_time
170
+ ::JS.global[:damaTime]
171
+ end
172
+
173
+ # wasm-bindgen maps Rust u64 to JS BigInt.
174
+ # Ruby integers must be converted before passing to wasm.
175
+ def to_bigint(value)
176
+ ::JS.eval("return BigInt(#{value})")
177
+ end
178
+
179
+ # Convert a JS BigInt (from wasm u64 return) to Ruby integer.
180
+ # Uses BigInt.toString() → Ruby String#to_i for reliable conversion.
181
+ def from_bigint(js_bigint)
182
+ js_bigint.call(:toString).to_s.to_i
183
+ end
184
+
185
+ # Flush accumulated commands to Rust wasm via a single JS.eval call.
186
+ # We pass the data as a JSON array string and construct the Float32Array
187
+ # entirely in JS, because ruby.wasm's JsValue.call doesn't reliably pass
188
+ # typed arrays to wasm-bindgen functions.
189
+ def flush_commands
190
+ return if command_buffer.empty?
191
+
192
+ floats = command_buffer.to_a
193
+ json = floats.map { |f| f.to_f.to_s }.join(",")
194
+ ::JS.eval("window.damaWgpu.dama_render_commands(new Float32Array([#{json}]), #{floats.length})")
195
+ command_buffer.clear
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,13 @@
1
+ module Dama
2
+ module Backend
3
+ PLATFORMS = {
4
+ web: -> { Backend::Web.new },
5
+ native: -> { Backend::Native.new },
6
+ }.freeze
7
+
8
+ def self.for
9
+ platform = defined?(JS) ? :web : :native
10
+ PLATFORMS.fetch(platform).call
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,68 @@
1
+ module Dama
2
+ # 2D camera with position, zoom, follow, and viewport culling.
3
+ # All draw coordinates in a scene are translated/scaled through
4
+ # the camera before reaching the backend.
5
+ class Camera
6
+ MIN_ZOOM = 0.1
7
+ MAX_ZOOM = 10.0
8
+
9
+ attr_reader :x, :y, :zoom, :viewport_width, :viewport_height
10
+
11
+ def initialize(viewport_width:, viewport_height:, x: 0.0, y: 0.0, zoom: 1.0)
12
+ @viewport_width = viewport_width.to_f
13
+ @viewport_height = viewport_height.to_f
14
+ @x = x.to_f
15
+ @y = y.to_f
16
+ @zoom = zoom.to_f.clamp(MIN_ZOOM, MAX_ZOOM)
17
+ end
18
+
19
+ def move_to(x:, y:)
20
+ @x = x.to_f
21
+ @y = y.to_f
22
+ end
23
+
24
+ def move_by(dx:, dy:)
25
+ @x += dx.to_f
26
+ @y += dy.to_f
27
+ end
28
+
29
+ def zoom_to(level:)
30
+ @zoom = level.to_f.clamp(MIN_ZOOM, MAX_ZOOM)
31
+ end
32
+
33
+ # Converts world coordinates to screen pixel coordinates.
34
+ def world_to_screen(world_x:, world_y:)
35
+ {
36
+ screen_x: (world_x - x) * zoom,
37
+ screen_y: (world_y - y) * zoom,
38
+ }
39
+ end
40
+
41
+ # Converts screen pixel coordinates to world coordinates.
42
+ def screen_to_world(screen_x:, screen_y:)
43
+ {
44
+ world_x: (screen_x / zoom) + x,
45
+ world_y: (screen_y / zoom) + y,
46
+ }
47
+ end
48
+
49
+ # Returns true if a world-space rectangle overlaps the camera viewport.
50
+ def visible?(x:, y:, width:, height:)
51
+ screen = world_to_screen(world_x: x, world_y: y)
52
+ screen_w = width * zoom
53
+ screen_h = height * zoom
54
+
55
+ (screen.fetch(:screen_x) + screen_w).positive? &&
56
+ screen.fetch(:screen_x) < viewport_width &&
57
+ (screen.fetch(:screen_y) + screen_h).positive? &&
58
+ screen.fetch(:screen_y) < viewport_height
59
+ end
60
+
61
+ # Centers the camera on a target object (must respond to #x and #y).
62
+ # Use lerp < 1.0 for smooth following.
63
+ def follow(target:, lerp: 1.0)
64
+ @x += (target.x - x) * lerp
65
+ @y += (target.y - y) * lerp
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,112 @@
1
+ require "fileutils"
2
+
3
+ module Dama
4
+ class Cli
5
+ # Generates a new game project in the current directory.
6
+ # Creates the standard directory structure with starter files
7
+ # for a playable game: a red circle moveable with arrow keys.
8
+ class NewProject
9
+ FILE_PERMISSIONS = {
10
+ true => 0o755,
11
+ false => 0o644,
12
+ }.freeze
13
+
14
+ def self.run
15
+ new.generate
16
+ end
17
+
18
+ def generate
19
+ puts "Creating new dama game project..."
20
+
21
+ TEMPLATES.each do |path, template|
22
+ write_template(path:, template:)
23
+ end
24
+
25
+ create_directory("assets")
26
+
27
+ puts "\nDone! Run bin/dama to start your game."
28
+ end
29
+
30
+ private
31
+
32
+ def write_template(path:, template:)
33
+ full_path = File.join(Dir.pwd, path)
34
+ return puts(" exists #{path}") if File.exist?(full_path)
35
+
36
+ FileUtils.mkdir_p(File.dirname(full_path))
37
+ File.write(full_path, template.fetch(:content))
38
+ FileUtils.chmod(FILE_PERMISSIONS.fetch(template.fetch(:executable)), full_path)
39
+ puts " create #{path}"
40
+ end
41
+
42
+ def create_directory(name)
43
+ return puts(" exists #{name}/") if File.directory?(name)
44
+
45
+ FileUtils.mkdir_p(name)
46
+ puts " create #{name}/"
47
+ end
48
+
49
+ TEMPLATES = {
50
+ "config.rb" => {
51
+ content: <<~RUBY,
52
+ GAME = Dama::Game.new do
53
+ settings resolution: [800, 600], title: "My Game"
54
+ start_scene MainScene
55
+ end
56
+ RUBY
57
+ executable: false,
58
+ },
59
+ "bin/dama" => {
60
+ content: <<~RUBY,
61
+ #!/usr/bin/env ruby
62
+ require "bundler/setup"
63
+ require "dama"
64
+
65
+ Dama::Cli.run(args: ARGV, root: File.expand_path("..", __dir__))
66
+ RUBY
67
+ executable: true,
68
+ },
69
+ "game/components/transform.rb" => {
70
+ content: <<~RUBY,
71
+ class Transform < Dama::Component
72
+ attribute :x, default: 0.0
73
+ attribute :y, default: 0.0
74
+ end
75
+ RUBY
76
+ executable: false,
77
+ },
78
+ "game/nodes/player.rb" => {
79
+ content: <<~RUBY,
80
+ class Player < Dama::Node
81
+ component Transform, as: :transform, x: 400.0, y: 300.0
82
+
83
+ draw do
84
+ circle(transform.x, transform.y, 20.0, color: Dama::Colors::RED)
85
+ end
86
+ end
87
+ RUBY
88
+ executable: false,
89
+ },
90
+ "game/scenes/main_scene.rb" => {
91
+ content: <<~RUBY,
92
+ class MainScene < Dama::Scene
93
+ compose do
94
+ add Player, as: :hero
95
+ end
96
+
97
+ update do |dt, input|
98
+ speed = 200.0
99
+
100
+ hero.transform.x += speed * dt if input.right?
101
+ hero.transform.x -= speed * dt if input.left?
102
+ hero.transform.y += speed * dt if input.down?
103
+ hero.transform.y -= speed * dt if input.up?
104
+ end
105
+ end
106
+ RUBY
107
+ executable: false,
108
+ },
109
+ }.freeze
110
+ end
111
+ end
112
+ end