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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +227 -0
- data/dama-logo.svg +91 -0
- data/exe/dama +4 -0
- data/ext/dama_native/.cargo/config.toml +3 -0
- data/ext/dama_native/Cargo.lock +3575 -0
- data/ext/dama_native/Cargo.toml +39 -0
- data/ext/dama_native/extconf.rb +72 -0
- data/ext/dama_native/src/audio.rs +134 -0
- data/ext/dama_native/src/engine.rs +339 -0
- data/ext/dama_native/src/lib.rs +396 -0
- data/ext/dama_native/src/renderer/screenshot.rs +84 -0
- data/ext/dama_native/src/renderer/shape_renderer.rs +507 -0
- data/ext/dama_native/src/renderer/text_renderer.rs +192 -0
- data/ext/dama_native/src/renderer.rs +563 -0
- data/ext/dama_native/src/window.rs +255 -0
- data/lib/dama/animation.rb +66 -0
- data/lib/dama/asset_cache.rb +56 -0
- data/lib/dama/audio.rb +47 -0
- data/lib/dama/auto_loader.rb +54 -0
- data/lib/dama/backend/base.rb +137 -0
- data/lib/dama/backend/native/ffi_bindings.rb +122 -0
- data/lib/dama/backend/native.rb +191 -0
- data/lib/dama/backend/web.rb +199 -0
- data/lib/dama/backend.rb +13 -0
- data/lib/dama/camera.rb +68 -0
- data/lib/dama/cli/new_project.rb +112 -0
- data/lib/dama/cli/release.rb +45 -0
- data/lib/dama/cli.rb +22 -0
- data/lib/dama/colors.rb +30 -0
- data/lib/dama/command_buffer.rb +83 -0
- data/lib/dama/component/attribute_definition.rb +13 -0
- data/lib/dama/component/attribute_set.rb +32 -0
- data/lib/dama/component.rb +28 -0
- data/lib/dama/configuration.rb +18 -0
- data/lib/dama/debug/frame_controller.rb +35 -0
- data/lib/dama/debug/screenshot_tool.rb +19 -0
- data/lib/dama/debug.rb +4 -0
- data/lib/dama/event_bus.rb +47 -0
- data/lib/dama/game/builder.rb +31 -0
- data/lib/dama/game/loop.rb +44 -0
- data/lib/dama/game.rb +88 -0
- data/lib/dama/geometry/circle.rb +28 -0
- data/lib/dama/geometry/rect.rb +16 -0
- data/lib/dama/geometry/sprite.rb +18 -0
- data/lib/dama/geometry/triangle.rb +13 -0
- data/lib/dama/geometry.rb +4 -0
- data/lib/dama/input/keyboard_state.rb +44 -0
- data/lib/dama/input/mouse_state.rb +45 -0
- data/lib/dama/input.rb +38 -0
- data/lib/dama/keys.rb +67 -0
- data/lib/dama/node/component_slot.rb +18 -0
- data/lib/dama/node/draw_context.rb +96 -0
- data/lib/dama/node.rb +139 -0
- data/lib/dama/physics/body.rb +57 -0
- data/lib/dama/physics/collider.rb +152 -0
- data/lib/dama/physics/collision.rb +15 -0
- data/lib/dama/physics/world.rb +125 -0
- data/lib/dama/physics.rb +4 -0
- data/lib/dama/registry/class_resolver.rb +48 -0
- data/lib/dama/registry.rb +21 -0
- data/lib/dama/release/archiver.rb +100 -0
- data/lib/dama/release/defaults/icon.icns +0 -0
- data/lib/dama/release/defaults/icon.ico +0 -0
- data/lib/dama/release/defaults/icon.png +0 -0
- data/lib/dama/release/dylib_relinker.rb +95 -0
- data/lib/dama/release/game_file_copier.rb +35 -0
- data/lib/dama/release/game_metadata.rb +61 -0
- data/lib/dama/release/icon_provider.rb +36 -0
- data/lib/dama/release/native_builder.rb +44 -0
- data/lib/dama/release/packager/linux.rb +62 -0
- data/lib/dama/release/packager/macos.rb +99 -0
- data/lib/dama/release/packager/web.rb +32 -0
- data/lib/dama/release/packager/windows.rb +61 -0
- data/lib/dama/release/packager.rb +9 -0
- data/lib/dama/release/platform_detector.rb +23 -0
- data/lib/dama/release/ruby_bundler.rb +163 -0
- data/lib/dama/release/stdlib_trimmer.rb +133 -0
- data/lib/dama/release/template_renderer.rb +40 -0
- data/lib/dama/release/templates/info_plist.xml.erb +19 -0
- data/lib/dama/release/templates/launcher_linux.sh.erb +10 -0
- data/lib/dama/release/templates/launcher_macos.sh.erb +10 -0
- data/lib/dama/release/templates/launcher_windows.bat.erb +11 -0
- data/lib/dama/release.rb +7 -0
- data/lib/dama/scene/composer.rb +65 -0
- data/lib/dama/scene.rb +233 -0
- data/lib/dama/scene_graph/class_index.rb +26 -0
- data/lib/dama/scene_graph/group_node.rb +27 -0
- data/lib/dama/scene_graph/instance_node.rb +30 -0
- data/lib/dama/scene_graph/path_selector.rb +25 -0
- data/lib/dama/scene_graph/query.rb +34 -0
- data/lib/dama/scene_graph/tag_index.rb +26 -0
- data/lib/dama/scene_graph/tree.rb +65 -0
- data/lib/dama/scene_graph.rb +4 -0
- data/lib/dama/sprite_sheet.rb +36 -0
- data/lib/dama/tween/easing.rb +31 -0
- data/lib/dama/tween/lerp.rb +35 -0
- data/lib/dama/tween/manager.rb +28 -0
- data/lib/dama/tween.rb +4 -0
- data/lib/dama/version.rb +3 -0
- data/lib/dama/vertex_batch.rb +35 -0
- data/lib/dama/web/entry.rb +79 -0
- data/lib/dama/web/static/index.html +142 -0
- data/lib/dama/web_builder.rb +232 -0
- data/lib/dama.rb +42 -0
- metadata +198 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
class Scene
|
|
3
|
+
# Evaluates compose blocks to build the scene graph.
|
|
4
|
+
# Supports multiple forms of `add`:
|
|
5
|
+
# add PlayerClass, as: :hero # by Class
|
|
6
|
+
# add :player, as: :hero # by Symbol (auto-discover)
|
|
7
|
+
# add "player", as: :hero # by String (auto-discover)
|
|
8
|
+
# add Player.new, as: :hero # by Instance
|
|
9
|
+
# add Player, as: :hero, tags: [:enemy] # with explicit tags
|
|
10
|
+
class Composer
|
|
11
|
+
RESOLVE_STRATEGIES = {
|
|
12
|
+
Symbol => ->(name, registry) { registry.resolve(name:, category: :node) },
|
|
13
|
+
String => ->(name, registry) { registry.resolve(name: name.to_sym, category: :node) },
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(scene_graph:, registry:, scene:)
|
|
17
|
+
@scene_graph = scene_graph
|
|
18
|
+
@registry = registry
|
|
19
|
+
@scene = scene
|
|
20
|
+
@current_group = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def add(class_or_name_or_instance, as:, tags: [], **props, &block)
|
|
24
|
+
node_instance = resolve_and_build(class_or_name_or_instance, props)
|
|
25
|
+
instance_node = SceneGraph::InstanceNode.new(id: as, node: node_instance, tags:)
|
|
26
|
+
scene_graph.add(instance_node:, parent_group: current_group)
|
|
27
|
+
|
|
28
|
+
# Register the named accessor on the scene so `hero` works in update/enter.
|
|
29
|
+
scene.register_named_node(name: as, instance_node:)
|
|
30
|
+
|
|
31
|
+
instance_node.instance_eval(&block) if block
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def camera(**)
|
|
35
|
+
scene.enable_camera(**)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def physics(gravity: [0.0, 0.0])
|
|
39
|
+
scene.enable_physics(gravity_x: gravity[0], gravity_y: gravity[1])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def group(name, &)
|
|
43
|
+
scene_graph.add_group(name:)
|
|
44
|
+
previous_group = current_group
|
|
45
|
+
@current_group = name
|
|
46
|
+
instance_eval(&)
|
|
47
|
+
@current_group = previous_group
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
attr_reader :scene_graph, :registry, :scene, :current_group
|
|
53
|
+
|
|
54
|
+
def resolve_and_build(class_or_name_or_instance, props)
|
|
55
|
+
return class_or_name_or_instance if class_or_name_or_instance.is_a?(Dama::Node)
|
|
56
|
+
|
|
57
|
+
return class_or_name_or_instance.new(**props) if class_or_name_or_instance.is_a?(Class)
|
|
58
|
+
|
|
59
|
+
strategy = RESOLVE_STRATEGIES.fetch(class_or_name_or_instance.class)
|
|
60
|
+
klass = strategy.call(class_or_name_or_instance, registry)
|
|
61
|
+
klass.new(**props)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/dama/scene.rb
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
# Base class for game scenes. Scenes have a compose/enter/update
|
|
3
|
+
# lifecycle and manage a scene graph of nodes.
|
|
4
|
+
#
|
|
5
|
+
# Example:
|
|
6
|
+
# class Level1 < Dama::Scene
|
|
7
|
+
# sound :jump, path: "assets/sfx/jump.wav"
|
|
8
|
+
#
|
|
9
|
+
# compose do
|
|
10
|
+
# camera viewport_width: 800, viewport_height: 600
|
|
11
|
+
# add Player, as: :hero
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# update do |dt, input|
|
|
15
|
+
# hero.transform.x += 100 * dt if input.right?
|
|
16
|
+
# play(:jump) if input.space?
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
class Scene # rubocop:disable Metrics/ClassLength
|
|
20
|
+
class << self
|
|
21
|
+
def compose(&block)
|
|
22
|
+
@compose_block = block
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def enter(&block)
|
|
26
|
+
@enter_block = block
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def update(&block)
|
|
30
|
+
@update_block = block
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Declare a sound asset at the class level.
|
|
34
|
+
# Loaded automatically during scene composition.
|
|
35
|
+
def sound(name, path:)
|
|
36
|
+
sound_declarations[name] = path
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sound_declarations
|
|
40
|
+
@sound_declarations ||= {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
attr_reader :compose_block, :enter_block, :update_block
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
attr_reader :camera
|
|
47
|
+
|
|
48
|
+
def initialize(registry:, asset_cache: nil, scene_switcher: nil, backend: nil)
|
|
49
|
+
@registry = registry
|
|
50
|
+
@asset_cache = asset_cache
|
|
51
|
+
@scene_switcher = scene_switcher
|
|
52
|
+
@backend = backend
|
|
53
|
+
@scene_graph = SceneGraph::Tree.new
|
|
54
|
+
@named_nodes = {}
|
|
55
|
+
@camera = nil
|
|
56
|
+
@audio = nil
|
|
57
|
+
@physics_world = nil
|
|
58
|
+
@collision_handlers = {}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Play a sound declared via the `sound` class DSL.
|
|
62
|
+
def play(name)
|
|
63
|
+
audio&.play(name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def enable_camera(viewport_width:, viewport_height:, **)
|
|
67
|
+
@camera = Camera.new(viewport_width:, viewport_height:, **)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Enable physics for this scene. Called by `physics` DSL in composer.
|
|
71
|
+
def enable_physics(gravity_x: 0.0, gravity_y: 0.0)
|
|
72
|
+
event_bus = EventBus.new
|
|
73
|
+
@physics_world = Physics::World.new(gravity_x:, gravity_y:, event_bus:)
|
|
74
|
+
|
|
75
|
+
# Wire collision events to named handlers.
|
|
76
|
+
event_bus.on(:collision) do |collision:|
|
|
77
|
+
dispatch_collision(collision)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Register a collision handler between two named nodes.
|
|
82
|
+
def on_collision(name_a, name_b, &block)
|
|
83
|
+
key = [name_a, name_b].sort
|
|
84
|
+
collision_handlers[key] = block
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Request a scene transition. The game applies it between frames.
|
|
88
|
+
def switch_to(scene_class)
|
|
89
|
+
scene_switcher&.call(scene_class)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def perform_compose
|
|
93
|
+
load_sounds
|
|
94
|
+
return unless self.class.compose_block
|
|
95
|
+
|
|
96
|
+
composer = Scene::Composer.new(scene_graph:, registry:, scene: self)
|
|
97
|
+
composer.instance_eval(&self.class.compose_block)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def perform_enter
|
|
101
|
+
return unless self.class.enter_block
|
|
102
|
+
|
|
103
|
+
instance_eval(&self.class.enter_block)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def perform_update(delta_time:, input:)
|
|
107
|
+
instance_exec(delta_time, input, &self.class.update_block) if self.class.update_block
|
|
108
|
+
physics_world&.step(delta_time:)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def perform_draw(backend:)
|
|
112
|
+
scene_graph.each_node do |instance_node|
|
|
113
|
+
draw_block = instance_node.node.class.draw_block
|
|
114
|
+
next unless draw_block
|
|
115
|
+
|
|
116
|
+
context = Node::DrawContext.new(node: instance_node.node, backend:, camera:)
|
|
117
|
+
context.instance_eval(&draw_block)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Register a named node, load textures/shaders, and create physics body.
|
|
122
|
+
def register_named_node(name:, instance_node:)
|
|
123
|
+
named_nodes[name] = instance_node
|
|
124
|
+
define_singleton_method(name) { named_nodes.fetch(name) }
|
|
125
|
+
|
|
126
|
+
instance_node.node.load_textures(asset_cache:) if asset_cache
|
|
127
|
+
instance_node.node.load_shaders(backend:) if backend
|
|
128
|
+
create_physics_body(name:, node: instance_node.node)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- Query methods ---
|
|
132
|
+
|
|
133
|
+
def ref!(path)
|
|
134
|
+
scene_graph.query.by_path(path:)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def all(klass)
|
|
138
|
+
scene_graph.query.by_class(klass:)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def by_tag(tag)
|
|
142
|
+
scene_graph.query.by_tag(tag:)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def each(klass, &)
|
|
146
|
+
all(klass).each(&)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Add a node dynamically at runtime (during update or enter).
|
|
150
|
+
def add(class_or_instance, as:, tags: [], group: nil, **props)
|
|
151
|
+
node_instance = build_node(class_or_instance, props)
|
|
152
|
+
instance_node = SceneGraph::InstanceNode.new(id: as, node: node_instance, tags: Array(tags))
|
|
153
|
+
scene_graph.add(instance_node:, parent_group: group)
|
|
154
|
+
register_named_node(name: as, instance_node:)
|
|
155
|
+
instance_node
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Remove a node, its physics body, and release its textures/shaders.
|
|
159
|
+
def remove(name)
|
|
160
|
+
instance_node = named_nodes.delete(name)
|
|
161
|
+
return unless instance_node
|
|
162
|
+
|
|
163
|
+
node = instance_node.node
|
|
164
|
+
node.unload_textures(asset_cache:) if asset_cache
|
|
165
|
+
node.unload_shaders(backend:) if backend
|
|
166
|
+
physics_world&.remove(node.physics) if node.physics
|
|
167
|
+
|
|
168
|
+
scene_graph.remove(id: name)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
attr_reader :registry, :scene_graph, :named_nodes, :asset_cache,
|
|
174
|
+
:scene_switcher, :backend, :audio, :physics_world,
|
|
175
|
+
:collision_handlers
|
|
176
|
+
|
|
177
|
+
NODE_BUILDERS = {
|
|
178
|
+
Class => ->(klass, props) { klass.new(**props) },
|
|
179
|
+
:instance => ->(instance, _props) { instance },
|
|
180
|
+
}.freeze
|
|
181
|
+
|
|
182
|
+
def build_node(class_or_instance, props)
|
|
183
|
+
builder_key = class_or_instance.is_a?(Class) ? Class : :instance
|
|
184
|
+
NODE_BUILDERS.fetch(builder_key).call(class_or_instance, props)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Load all class-level sound declarations via Audio.
|
|
188
|
+
def load_sounds
|
|
189
|
+
return if self.class.sound_declarations.empty?
|
|
190
|
+
return unless backend
|
|
191
|
+
|
|
192
|
+
@audio = Audio.new(backend:)
|
|
193
|
+
self.class.sound_declarations.each do |name, path|
|
|
194
|
+
audio.load(name:, path:)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
COLLIDER_FACTORIES = {
|
|
199
|
+
rect: ->(opts) { Physics::Collider.rect(width: opts.fetch(:width), height: opts.fetch(:height)) },
|
|
200
|
+
circle: ->(opts) { Physics::Collider.circle(radius: opts.fetch(:radius)) },
|
|
201
|
+
}.freeze
|
|
202
|
+
|
|
203
|
+
# Create a physics body for a node if it declares `physics_body`.
|
|
204
|
+
def create_physics_body(name:, node:)
|
|
205
|
+
opts = node.class.physics_body_options
|
|
206
|
+
return unless opts && physics_world
|
|
207
|
+
|
|
208
|
+
collider_shape = opts.fetch(:collider, :rect)
|
|
209
|
+
collider = COLLIDER_FACTORIES.fetch(collider_shape).call(opts)
|
|
210
|
+
body = Physics::Body.new(
|
|
211
|
+
type: opts.fetch(:type, :dynamic),
|
|
212
|
+
mass: opts.fetch(:mass, 1.0),
|
|
213
|
+
collider:,
|
|
214
|
+
node:,
|
|
215
|
+
restitution: opts.fetch(:restitution, 0.0),
|
|
216
|
+
)
|
|
217
|
+
node.physics = body
|
|
218
|
+
physics_world.add(body)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Route a collision event to the appropriate named handler.
|
|
222
|
+
def dispatch_collision(collision)
|
|
223
|
+
# Find node names for the two bodies.
|
|
224
|
+
name_a = named_nodes.key(named_nodes.values.find { |n| n.node == collision.body_a.node })
|
|
225
|
+
name_b = named_nodes.key(named_nodes.values.find { |n| n.node == collision.body_b.node })
|
|
226
|
+
return unless name_a && name_b
|
|
227
|
+
|
|
228
|
+
key = [name_a, name_b].sort
|
|
229
|
+
handler = collision_handlers[key]
|
|
230
|
+
handler&.call
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
module SceneGraph
|
|
3
|
+
# Indexes scene graph nodes by their Node class for fast lookup.
|
|
4
|
+
class ClassIndex
|
|
5
|
+
def initialize
|
|
6
|
+
@index = Hash.new { |h, k| h[k] = [] }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def register(node:)
|
|
10
|
+
index[node.node.class] << node
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def unregister(node:)
|
|
14
|
+
index[node.node.class].delete(node)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def by_class(klass:)
|
|
18
|
+
index.fetch(klass, [])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :index
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
module SceneGraph
|
|
3
|
+
# A named container in the scene graph. Holds child nodes
|
|
4
|
+
# and supports polymorphic traversal.
|
|
5
|
+
class GroupNode
|
|
6
|
+
attr_reader :name, :children
|
|
7
|
+
|
|
8
|
+
def initialize(name:)
|
|
9
|
+
@name = name
|
|
10
|
+
@children = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def <<(node)
|
|
14
|
+
children << node
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def [](id)
|
|
18
|
+
children.detect { |child| child.id == id }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Polymorphic traversal: delegates to each child.
|
|
22
|
+
def traverse(&)
|
|
23
|
+
children.each { |child| child.traverse(&) }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
module SceneGraph
|
|
3
|
+
# Wraps a live Node instance within the scene graph.
|
|
4
|
+
# Holds the id, tags, and delegates to the underlying Node.
|
|
5
|
+
class InstanceNode
|
|
6
|
+
attr_reader :id, :node, :tags
|
|
7
|
+
|
|
8
|
+
def initialize(id:, node:, tags: [])
|
|
9
|
+
@id = id
|
|
10
|
+
@node = node
|
|
11
|
+
@tags = tags
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Polymorphic traversal: yields self to the block.
|
|
15
|
+
def traverse(&block)
|
|
16
|
+
block.call(self)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def method_missing(method_name, ...)
|
|
20
|
+
return super unless node.respond_to?(method_name)
|
|
21
|
+
|
|
22
|
+
node.public_send(method_name, ...)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def respond_to_missing?(method_name, include_private = false)
|
|
26
|
+
node.respond_to?(method_name, include_private) || super
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
module SceneGraph
|
|
3
|
+
# Resolves "group/node_id" path strings to nodes in the scene graph.
|
|
4
|
+
# Uses regex with named groups to parse the path (no split per CLAUDE.md).
|
|
5
|
+
class PathSelector
|
|
6
|
+
SEGMENT_PATTERN = %r{\A(?<group>[^/]+)/(?<node_id>[^/]+)\z}
|
|
7
|
+
|
|
8
|
+
def initialize(groups:)
|
|
9
|
+
@groups = groups
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def resolve(path:)
|
|
13
|
+
match = path.match(SEGMENT_PATTERN)
|
|
14
|
+
raise ArgumentError, "Invalid path format: #{path}" unless match
|
|
15
|
+
|
|
16
|
+
group = groups.fetch(match[:group].to_sym)
|
|
17
|
+
group[match[:node_id].to_sym]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :groups
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
module SceneGraph
|
|
3
|
+
# Unified query API for the scene graph. Provides by_id, by_class,
|
|
4
|
+
# by_tag, and by_path lookups.
|
|
5
|
+
class Query
|
|
6
|
+
def initialize(id_index:, tag_index:, class_index:, groups:)
|
|
7
|
+
@id_index = id_index
|
|
8
|
+
@tag_index = tag_index
|
|
9
|
+
@class_index = class_index
|
|
10
|
+
@path_selector = PathSelector.new(groups:)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def by_id(id:)
|
|
14
|
+
id_index.fetch(id)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def by_class(klass:)
|
|
18
|
+
class_index.by_class(klass:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def by_tag(tag:)
|
|
22
|
+
tag_index.by_tag(tag:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def by_path(path:)
|
|
26
|
+
path_selector.resolve(path:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :id_index, :tag_index, :class_index, :path_selector
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
module SceneGraph
|
|
3
|
+
# Indexes scene graph nodes by their tags for fast lookup.
|
|
4
|
+
class TagIndex
|
|
5
|
+
def initialize
|
|
6
|
+
@index = Hash.new { |h, k| h[k] = [] }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def register(node:)
|
|
10
|
+
node.tags.each { |tag| index[tag] << node }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def unregister(node:)
|
|
14
|
+
node.tags.each { |tag| index[tag].delete(node) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def by_tag(tag:)
|
|
18
|
+
index.fetch(tag, [])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :index
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
module SceneGraph
|
|
3
|
+
# Root container of the scene graph. Maintains flat indexes
|
|
4
|
+
# (by id, tag, class) and a hierarchy of groups for traversal.
|
|
5
|
+
class Tree
|
|
6
|
+
def initialize
|
|
7
|
+
@root_children = []
|
|
8
|
+
@id_index = {}
|
|
9
|
+
@tag_index = TagIndex.new
|
|
10
|
+
@class_index = ClassIndex.new
|
|
11
|
+
@groups = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add(instance_node:, parent_group: nil)
|
|
15
|
+
target = parent_group ? groups.fetch(parent_group) : self
|
|
16
|
+
target << instance_node
|
|
17
|
+
id_index[instance_node.id] = instance_node
|
|
18
|
+
tag_index.register(node: instance_node)
|
|
19
|
+
class_index.register(node: instance_node)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def add_group(name:)
|
|
23
|
+
group = GroupNode.new(name:)
|
|
24
|
+
groups[name] = group
|
|
25
|
+
root_children << group
|
|
26
|
+
group
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def <<(node)
|
|
30
|
+
root_children << node
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def remove(id:)
|
|
34
|
+
node = id_index.delete(id)
|
|
35
|
+
return unless node
|
|
36
|
+
|
|
37
|
+
tag_index.unregister(node:)
|
|
38
|
+
class_index.unregister(node:)
|
|
39
|
+
remove_from_children(node:)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def query
|
|
43
|
+
@query ||= Query.new(id_index:, tag_index:, class_index:, groups:)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def each_node(&)
|
|
47
|
+
root_children.each { |child| child.traverse(&) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# InstanceNode-like traversal protocol so Tree can be a container.
|
|
51
|
+
def traverse(&)
|
|
52
|
+
each_node(&)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
attr_reader :root_children, :id_index, :tag_index, :class_index, :groups
|
|
58
|
+
|
|
59
|
+
def remove_from_children(node:)
|
|
60
|
+
root_children.delete(node)
|
|
61
|
+
groups.each_value { |group| group.children.delete(node) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
# Calculates UV coordinates for individual frames within a
|
|
3
|
+
# sprite sheet / texture atlas.
|
|
4
|
+
class SpriteSheet
|
|
5
|
+
attr_reader :frame_width, :frame_height, :columns, :rows, :frame_count
|
|
6
|
+
|
|
7
|
+
def initialize(texture_width:, texture_height:, frame_width:, frame_height:)
|
|
8
|
+
@frame_width = frame_width.to_f
|
|
9
|
+
@frame_height = frame_height.to_f
|
|
10
|
+
@texture_width = texture_width.to_f
|
|
11
|
+
@texture_height = texture_height.to_f
|
|
12
|
+
@columns = (texture_width / frame_width).to_i
|
|
13
|
+
@rows = (texture_height / frame_height).to_i
|
|
14
|
+
@frame_count = columns * rows
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns normalized UV coordinates for a given frame index.
|
|
18
|
+
# frame: 0-based index, left-to-right then top-to-bottom.
|
|
19
|
+
def frame_uv(frame:)
|
|
20
|
+
clamped = frame.clamp(0, frame_count - 1)
|
|
21
|
+
col = clamped % columns
|
|
22
|
+
row = clamped / columns
|
|
23
|
+
|
|
24
|
+
u = (col * frame_width) / texture_width
|
|
25
|
+
v = (row * frame_height) / texture_height
|
|
26
|
+
u2 = ((col + 1) * frame_width) / texture_width
|
|
27
|
+
v2 = ((row + 1) * frame_height) / texture_height
|
|
28
|
+
|
|
29
|
+
{ u:, v:, u2:, v2: }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
attr_reader :texture_width, :texture_height
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
class Tween
|
|
3
|
+
# Standard easing functions for use with Tween::Lerp.
|
|
4
|
+
# Each function maps a progress value t (0.0..1.0) to an eased value.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Tween::Lerp.new(target:, attribute:, from:, to:, duration:,
|
|
8
|
+
# easing: :ease_in_out_quad)
|
|
9
|
+
module Easing
|
|
10
|
+
FUNCTIONS = {
|
|
11
|
+
linear: ->(t) { t },
|
|
12
|
+
|
|
13
|
+
ease_in_quad: ->(t) { t * t },
|
|
14
|
+
ease_out_quad: ->(t) { t * (2.0 - t) },
|
|
15
|
+
ease_in_out_quad: ->(t) { t < 0.5 ? 2.0 * t * t : -1.0 + ((4.0 - (2.0 * t)) * t) },
|
|
16
|
+
|
|
17
|
+
ease_in_cubic: ->(t) { t * t * t },
|
|
18
|
+
ease_out_cubic: ->(t) { ((t - 1.0)**3) + 1.0 },
|
|
19
|
+
ease_in_out_cubic: lambda { |t|
|
|
20
|
+
t < 0.5 ? 4.0 * t * t * t : ((t - 1.0) * ((2.0 * t) - 2.0) * ((2.0 * t) - 2.0)) + 1.0
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
ease_in_out_sine: ->(t) { -(Math.cos(Math::PI * t) - 1.0) / 2.0 },
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def self.fetch(name:)
|
|
27
|
+
FUNCTIONS.fetch(name)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
class Tween
|
|
3
|
+
# Interpolates a single attribute on a target object from a start value
|
|
4
|
+
# to an end value over a given duration, with optional easing.
|
|
5
|
+
class Lerp
|
|
6
|
+
def initialize(target:, attribute:, from:, to:, duration:, easing: :linear, on_complete: nil)
|
|
7
|
+
@target = target
|
|
8
|
+
@attribute = attribute
|
|
9
|
+
@from = from.to_f
|
|
10
|
+
@to = to.to_f
|
|
11
|
+
@duration = duration.to_f
|
|
12
|
+
@elapsed = 0.0
|
|
13
|
+
@easing_fn = Easing.fetch(name: easing)
|
|
14
|
+
@on_complete = on_complete
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update(delta_time:)
|
|
18
|
+
@elapsed += delta_time
|
|
19
|
+
linear_progress = [elapsed / duration, 1.0].min
|
|
20
|
+
eased_progress = easing_fn.call(linear_progress)
|
|
21
|
+
value = from + ((to - from) * eased_progress)
|
|
22
|
+
target.public_send(:"#{attribute}=", value)
|
|
23
|
+
on_complete&.call if complete?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def complete?
|
|
27
|
+
elapsed >= duration
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :target, :attribute, :from, :to, :duration, :elapsed, :easing_fn, :on_complete
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Dama
|
|
2
|
+
class Tween
|
|
3
|
+
# Manages a collection of active tweens, updating them each
|
|
4
|
+
# frame and automatically removing completed ones.
|
|
5
|
+
class Manager
|
|
6
|
+
def initialize
|
|
7
|
+
@active_tweens = []
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def add(tween:)
|
|
11
|
+
active_tweens << tween
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def update(delta_time:)
|
|
15
|
+
active_tweens.each { |tween| tween.update(delta_time:) }
|
|
16
|
+
active_tweens.reject!(&:complete?)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def active?
|
|
20
|
+
active_tweens.any?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
attr_reader :active_tweens
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/dama/tween.rb
ADDED
data/lib/dama/version.rb
ADDED