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,45 @@
1
+ module Dama
2
+ class Input
3
+ # Tracks mouse position and button state with edge detection.
4
+ # Call #update once per frame before querying.
5
+ class MouseState
6
+ BUTTON_CODES = {
7
+ left: 0,
8
+ right: 1,
9
+ middle: 2,
10
+ }.freeze
11
+
12
+ def initialize(backend:)
13
+ @backend = backend
14
+ @previous_pressed = Hash.new(false)
15
+ @current_pressed = Hash.new(false)
16
+ end
17
+
18
+ def x = backend.mouse_x
19
+ def y = backend.mouse_y
20
+
21
+ def pressed?(button:)
22
+ code = BUTTON_CODES.fetch(button)
23
+ backend.mouse_button_pressed?(button: code)
24
+ end
25
+
26
+ # Returns true only on the frame the button transitions
27
+ # from released to pressed (edge detection).
28
+ def just_pressed?(button:)
29
+ current_pressed.fetch(button, false) && !previous_pressed.fetch(button, false)
30
+ end
31
+
32
+ # Must be called once per frame to track state transitions.
33
+ def update
34
+ BUTTON_CODES.each_key do |button|
35
+ previous_pressed[button] = current_pressed[button]
36
+ current_pressed[button] = pressed?(button:)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :backend, :previous_pressed, :current_pressed
43
+ end
44
+ end
45
+ end
data/lib/dama/input.rb ADDED
@@ -0,0 +1,38 @@
1
+ module Dama
2
+ # Snapshot of input state for a single frame.
3
+ # Provides convenience methods for common input queries.
4
+ class Input
5
+ def initialize(backend:)
6
+ @keyboard = Input::KeyboardState.new(backend:)
7
+ @mouse = Input::MouseState.new(backend:)
8
+ end
9
+
10
+ # Generic key queries — works for any named key.
11
+ def key_pressed?(key) = keyboard.pressed?(key:)
12
+ def key_just_pressed?(key) = keyboard.just_pressed?(key:)
13
+
14
+ # Keyboard convenience methods.
15
+ def left? = keyboard.pressed?(key: :left)
16
+ def right? = keyboard.pressed?(key: :right)
17
+ def up? = keyboard.pressed?(key: :up)
18
+ def down? = keyboard.pressed?(key: :down)
19
+ def space? = keyboard.pressed?(key: :space)
20
+ def escape? = keyboard.pressed?(key: :escape)
21
+
22
+ # Mouse convenience methods.
23
+ def mouse_x = mouse.x
24
+ def mouse_y = mouse.y
25
+ def mouse_pressed?(button) = mouse.pressed?(button:)
26
+ def mouse_just_pressed?(button) = mouse.just_pressed?(button:)
27
+ def mouse_clicked? = mouse.just_pressed?(button: :left)
28
+
29
+ # Must be called once per frame to track mouse button transitions.
30
+ def update
31
+ mouse.update
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :keyboard, :mouse
37
+ end
38
+ end
data/lib/dama/keys.rb ADDED
@@ -0,0 +1,67 @@
1
+ module Dama
2
+ # Named constants for keyboard key codes matching winit's KeyCode enum.
3
+ # Use these instead of raw numeric values when querying input state.
4
+ #
5
+ # Values are the Rust enum discriminant indices for winit::keyboard::KeyCode.
6
+ module Keys
7
+ ARROW_LEFT = 80
8
+ ARROW_RIGHT = 81
9
+ ARROW_UP = 82
10
+ ARROW_DOWN = 79
11
+
12
+ SPACE = 62
13
+ ENTER = 57
14
+ ESCAPE = 114
15
+
16
+ BACKSPACE = 52
17
+ TAB = 63
18
+
19
+ LEFT_SHIFT = 60
20
+ LEFT_CTRL = 55
21
+ LEFT_ALT = 50
22
+ RIGHT_SHIFT = 61
23
+ RIGHT_CTRL = 56
24
+ RIGHT_ALT = 51
25
+
26
+ KEY_A = 19
27
+ KEY_B = 20
28
+ KEY_C = 21
29
+ KEY_D = 22
30
+ KEY_E = 23
31
+ KEY_F = 24
32
+ KEY_G = 25
33
+ KEY_H = 26
34
+ KEY_I = 27
35
+ KEY_J = 28
36
+ KEY_K = 29
37
+ KEY_L = 30
38
+ KEY_M = 31
39
+ KEY_N = 32
40
+ KEY_O = 33
41
+ KEY_P = 34
42
+ KEY_Q = 35
43
+ KEY_R = 36
44
+ KEY_S = 37
45
+ KEY_T = 38
46
+ KEY_U = 39
47
+ KEY_V = 40
48
+ KEY_W = 41
49
+ KEY_X = 42
50
+ KEY_Y = 43
51
+ KEY_Z = 44
52
+
53
+ EQUAL = 15
54
+ MINUS = 45
55
+
56
+ DIGIT_0 = 5
57
+ DIGIT_1 = 6
58
+ DIGIT_2 = 7
59
+ DIGIT_3 = 8
60
+ DIGIT_4 = 9
61
+ DIGIT_5 = 10
62
+ DIGIT_6 = 11
63
+ DIGIT_7 = 12
64
+ DIGIT_8 = 13
65
+ DIGIT_9 = 14
66
+ end
67
+ end
@@ -0,0 +1,18 @@
1
+ module Dama
2
+ class Node
3
+ # Binds a Component class to its default attribute values.
4
+ # When a Node is instantiated, each slot builds a component instance.
5
+ class ComponentSlot
6
+ attr_reader :component_class, :defaults
7
+
8
+ def initialize(component_class:, defaults:)
9
+ @component_class = component_class
10
+ @defaults = defaults
11
+ end
12
+
13
+ def build(**overrides)
14
+ component_class.new(**defaults, **overrides)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,96 @@
1
+ module Dama
2
+ class Node
3
+ # Evaluation context for a Node's draw block. Provides drawing
4
+ # primitives (rect, triangle, circle) and exposes all node
5
+ # attributes and component accessors as direct methods.
6
+ #
7
+ # When a camera is present, all coordinates are automatically
8
+ # transformed from world space to screen space.
9
+ #
10
+ # Custom shaders can be applied to any shape via the `shader:` keyword:
11
+ # rect(x, y, w, h, color: Dama::Colors::RED, shader: glow)
12
+ class DrawContext
13
+ def initialize(node:, backend:, camera: nil)
14
+ @node = node
15
+ @backend = backend
16
+ @camera = camera
17
+ end
18
+
19
+ def rect(x, y, w, h, color: Dama::Colors::WHITE, r: color.r, g: color.g, b: color.b, a: color.a, shader: nil)
20
+ sx, sy = apply_camera(x, y)
21
+ sw = w * zoom_factor
22
+ sh = h * zoom_factor
23
+ with_shader(shader) { backend.draw_rect(x: sx, y: sy, w: sw, h: sh, r:, g:, b:, a:) }
24
+ end
25
+
26
+ def triangle(x1, y1, x2, y2, x3, y3, color: Dama::Colors::WHITE,
27
+ r: color.r, g: color.g, b: color.b, a: color.a, shader: nil)
28
+ sx1, sy1 = apply_camera(x1, y1)
29
+ sx2, sy2 = apply_camera(x2, y2)
30
+ sx3, sy3 = apply_camera(x3, y3)
31
+ with_shader(shader) do
32
+ backend.draw_triangle(x1: sx1, y1: sy1, x2: sx2, y2: sy2, x3: sx3, y3: sy3, r:, g:, b:, a:)
33
+ end
34
+ end
35
+
36
+ def circle(cx, cy, radius, color: Dama::Colors::WHITE,
37
+ r: color.r, g: color.g, b: color.b, a: color.a, segments: 32, shader: nil)
38
+ sx, sy = apply_camera(cx, cy)
39
+ with_shader(shader) do
40
+ backend.draw_circle(cx: sx, cy: sy, radius: radius * zoom_factor, r:, g:, b:, a:, segments:)
41
+ end
42
+ end
43
+
44
+ def text(content, x, y, size: 24.0, color: Dama::Colors::WHITE,
45
+ r: color.r, g: color.g, b: color.b, a: color.a, font: nil)
46
+ sx, sy = apply_camera(x, y)
47
+ backend.draw_text(text: content.to_s, x: sx, y: sy, size: size * zoom_factor, r:, g:, b:, a:, font:)
48
+ end
49
+
50
+ def sprite(texture_handle, x, y, w, h, color: Dama::Colors::WHITE,
51
+ r: color.r, g: color.g, b: color.b, a: color.a, shader: nil)
52
+ sx, sy = apply_camera(x, y)
53
+ sw = w * zoom_factor
54
+ sh = h * zoom_factor
55
+ with_shader(shader) do
56
+ backend.draw_sprite(texture_handle:, x: sx, y: sy, w: sw, h: sh, r:, g:, b:, a:)
57
+ end
58
+ end
59
+
60
+ def method_missing(method_name, ...)
61
+ return super unless node.respond_to?(method_name)
62
+
63
+ node.public_send(method_name, ...)
64
+ end
65
+
66
+ def respond_to_missing?(method_name, include_private = false)
67
+ node.respond_to?(method_name, include_private) || super
68
+ end
69
+
70
+ private
71
+
72
+ attr_reader :node, :backend, :camera
73
+
74
+ def apply_camera(world_x, world_y)
75
+ return [world_x, world_y] unless camera
76
+
77
+ result = camera.world_to_screen(world_x:, world_y:)
78
+ [result.fetch(:screen_x), result.fetch(:screen_y)]
79
+ end
80
+
81
+ def zoom_factor
82
+ camera ? camera.zoom : 1.0
83
+ end
84
+
85
+ # Wraps a draw call with shader activation/deactivation.
86
+ # If no shader is specified, the block executes unchanged.
87
+ def with_shader(shader_handle)
88
+ return yield unless shader_handle
89
+
90
+ backend.set_shader(handle: shader_handle)
91
+ yield
92
+ backend.set_shader(handle: 0)
93
+ end
94
+ end
95
+ end
96
+ end
data/lib/dama/node.rb ADDED
@@ -0,0 +1,139 @@
1
+ module Dama
2
+ # Base class for game entities. Nodes compose Components via named
3
+ # accessors, declare attributes, textures, and define draw behavior.
4
+ #
5
+ # Example:
6
+ # class Player < Dama::Node
7
+ # component Transform, as: :transform, x: 50.0, y: 50.0
8
+ # texture :sprite, path: "assets/player.png"
9
+ # attribute :name, default: "Player"
10
+ #
11
+ # draw do
12
+ # sprite(sprite, transform.x, transform.y, 32, 32)
13
+ # end
14
+ # end
15
+ class Node
16
+ class << self
17
+ def component(component_class, as:, **defaults)
18
+ component_slots[as] = ComponentSlot.new(component_class:, defaults:)
19
+ define_method(as) { components.fetch(as) }
20
+ end
21
+
22
+ def attribute(name, default: nil)
23
+ attribute_definitions[name] = default
24
+ attr_accessor name
25
+ end
26
+
27
+ # Declare a texture asset with a named accessor.
28
+ # The texture is loaded via AssetCache during scene composition
29
+ # and the GPU handle is accessible as a method on the node.
30
+ def texture(name, path:)
31
+ texture_declarations[name] = path
32
+ define_method(name) { texture_handles.fetch(name) }
33
+ end
34
+
35
+ # Declare a custom WGSL fragment shader with a named accessor.
36
+ # The shader is loaded via the backend during scene composition
37
+ # and the handle is accessible as a method on the node.
38
+ #
39
+ # Example:
40
+ # shader :glow, path: "assets/shaders/glow.wgsl"
41
+ # shader :invert, source: "@fragment\nfn fs_main(...) { ... }"
42
+ def shader(name, path: nil, source: nil)
43
+ shader_declarations[name] = { path:, source: }
44
+ define_method(name) { shader_handles.fetch(name) }
45
+ end
46
+
47
+ def draw(&block)
48
+ @draw_block = block
49
+ end
50
+
51
+ def component_slots
52
+ @component_slots ||= {}
53
+ end
54
+
55
+ def attribute_definitions
56
+ @attribute_definitions ||= {}
57
+ end
58
+
59
+ def texture_declarations
60
+ @texture_declarations ||= {}
61
+ end
62
+
63
+ def shader_declarations
64
+ @shader_declarations ||= {}
65
+ end
66
+
67
+ # Declare a physics body for this node.
68
+ # The body is created during scene composition if the scene has physics enabled.
69
+ def physics_body(**options)
70
+ @physics_body_options = options
71
+ end
72
+
73
+ attr_reader :physics_body_options, :draw_block
74
+ end
75
+
76
+ attr_accessor :physics
77
+
78
+ def initialize(**values)
79
+ @components = {}
80
+ @texture_handles = {}
81
+ @shader_handles = {}
82
+ @physics = nil
83
+ initialize_components(values)
84
+ initialize_attributes(values)
85
+ end
86
+
87
+ # Load all declared textures via the AssetCache.
88
+ def load_textures(asset_cache:)
89
+ self.class.texture_declarations.each do |name, path|
90
+ texture_handles[name] = asset_cache.acquire(path:)
91
+ end
92
+ end
93
+
94
+ # Release all declared textures from the AssetCache.
95
+ def unload_textures(asset_cache:)
96
+ self.class.texture_declarations.each_value do |path|
97
+ asset_cache.release(path:)
98
+ end
99
+ texture_handles.clear
100
+ end
101
+
102
+ # Load all declared shaders via the backend.
103
+ def load_shaders(backend:)
104
+ self.class.shader_declarations.each do |name, declaration|
105
+ source = declaration[:source] || File.read(declaration.fetch(:path))
106
+ shader_handles[name] = backend.load_shader(source:)
107
+ end
108
+ end
109
+
110
+ # Unload all declared shaders via the backend.
111
+ def unload_shaders(backend:)
112
+ shader_handles.each_value do |handle|
113
+ backend.unload_shader(handle:)
114
+ end
115
+ shader_handles.clear
116
+ end
117
+
118
+ private
119
+
120
+ attr_reader :components, :texture_handles, :shader_handles
121
+
122
+ def initialize_components(values)
123
+ self.class.component_slots.each do |name, slot|
124
+ # Allow constructor values to override component defaults.
125
+ # e.g., PieceNode.new(x: 100.0) overrides Transform's default x.
126
+ component_attrs = slot.component_class.attribute_set.map(&:name)
127
+ overrides = values.slice(*component_attrs)
128
+ components[name] = slot.build(**overrides)
129
+ end
130
+ end
131
+
132
+ def initialize_attributes(values)
133
+ self.class.attribute_definitions.each do |name, default|
134
+ value = values.fetch(name, default)
135
+ public_send(:"#{name}=", value)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,57 @@
1
+ module Dama
2
+ module Physics
3
+ # A physics body attached to a node. Tracks velocity, acceleration,
4
+ # and collider shape. Updated by Physics::World each frame.
5
+ class Body
6
+ BODY_TYPES = %i[dynamic static kinematic].freeze
7
+
8
+ attr_reader :type, :mass, :collider, :node
9
+ attr_accessor :velocity_x, :velocity_y, :acceleration_x, :acceleration_y,
10
+ :restitution
11
+
12
+ def initialize(type:, collider:, mass: 1.0, node: nil, restitution: 0.0)
13
+ @type = type
14
+ @mass = mass.to_f
15
+ @collider = collider
16
+ @node = node
17
+ @velocity_x = 0.0
18
+ @velocity_y = 0.0
19
+ @acceleration_x = 0.0
20
+ @acceleration_y = 0.0
21
+ @restitution = restitution.to_f
22
+ end
23
+
24
+ def dynamic? = type == :dynamic
25
+ def static? = type == :static
26
+ def kinematic? = type == :kinematic
27
+
28
+ # Current position from the node's transform component.
29
+ def x
30
+ node.transform.x
31
+ end
32
+
33
+ def y
34
+ node.transform.y
35
+ end
36
+
37
+ def x=(value)
38
+ node.transform.x = value
39
+ end
40
+
41
+ def y=(value)
42
+ node.transform.y = value
43
+ end
44
+
45
+ # Integrate velocity and acceleration over delta_time.
46
+ def integrate(delta_time:, gravity_x: 0.0, gravity_y: 0.0)
47
+ return unless dynamic?
48
+
49
+ self.velocity_x += (acceleration_x + gravity_x) * delta_time
50
+ self.velocity_y += (acceleration_y + gravity_y) * delta_time
51
+
52
+ self.x = x + (velocity_x * delta_time)
53
+ self.y = y + (velocity_y * delta_time)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,152 @@
1
+ module Dama
2
+ module Physics
3
+ # Collision shape attached to a physics body.
4
+ # Supports AABB rectangles and circles.
5
+ # Positions (ax, ay, bx, by) are the top-left corner for rects
6
+ # and center for circles.
7
+ class Collider
8
+ attr_reader :shape, :width, :height, :radius
9
+
10
+ OVERLAP_DISPATCH = {
11
+ %i[rect rect] => :overlap_rect_rect?,
12
+ %i[circle circle] => :overlap_circle_circle?,
13
+ %i[rect circle] => :overlap_rect_circle?,
14
+ %i[circle rect] => :overlap_circle_rect?,
15
+ }.freeze
16
+
17
+ SEPARATION_DISPATCH = {
18
+ %i[rect rect] => :separate_rect_rect,
19
+ %i[circle circle] => :separate_circle_circle,
20
+ %i[rect circle] => :separate_rect_circle,
21
+ %i[circle rect] => :separate_circle_rect,
22
+ }.freeze
23
+
24
+ def self.rect(width:, height:)
25
+ new(shape: :rect, width:, height:, radius: 0.0)
26
+ end
27
+
28
+ def self.circle(radius:)
29
+ new(shape: :circle, width: 0.0, height: 0.0, radius:)
30
+ end
31
+
32
+ def initialize(shape:, width:, height:, radius:)
33
+ @shape = shape
34
+ @width = width.to_f
35
+ @height = height.to_f
36
+ @radius = radius.to_f
37
+ end
38
+
39
+ def overlap?(other:, ax:, ay:, bx:, by:)
40
+ key = [shape, other.shape]
41
+ method_name = OVERLAP_DISPATCH.fetch(key)
42
+ send(method_name, other, ax, ay, bx, by)
43
+ end
44
+
45
+ def separation(other:, ax:, ay:, bx:, by:)
46
+ key = [shape, other.shape]
47
+ method_name = SEPARATION_DISPATCH.fetch(key, nil)
48
+ return nil unless method_name
49
+
50
+ send(method_name, other, ax, ay, bx, by)
51
+ end
52
+
53
+ private
54
+
55
+ # AABB vs AABB overlap check.
56
+ def overlap_rect_rect?(other, ax, ay, bx, by)
57
+ ax + width > bx && ax < bx + other.width &&
58
+ ay + height > by && ay < by + other.height
59
+ end
60
+
61
+ # Circle vs Circle overlap check.
62
+ def overlap_circle_circle?(other, ax, ay, bx, by)
63
+ dx = bx - ax
64
+ dy = by - ay
65
+ dist_sq = (dx * dx) + (dy * dy)
66
+ max_dist = radius + other.radius
67
+ dist_sq < max_dist * max_dist
68
+ end
69
+
70
+ # Rect vs Circle overlap: find closest point on rect to circle center.
71
+ def overlap_rect_circle?(other, ax, ay, bx, by)
72
+ closest_x = bx.clamp(ax, ax + width)
73
+ closest_y = by.clamp(ay, ay + height)
74
+ dx = bx - closest_x
75
+ dy = by - closest_y
76
+ (dx * dx) + (dy * dy) < other.radius * other.radius
77
+ end
78
+
79
+ # Circle vs Rect: delegate with swapped args.
80
+ def overlap_circle_rect?(other, ax, ay, bx, by)
81
+ other.overlap?(other: self, ax: bx, ay: by, bx: ax, by: ay)
82
+ end
83
+
84
+ # Circle vs Circle separation: push along the line connecting centers.
85
+ def separate_circle_circle(other, ax, ay, bx, by)
86
+ return nil unless overlap_circle_circle?(other, ax, ay, bx, by)
87
+
88
+ dx = bx - ax
89
+ dy = by - ay
90
+ dist = Math.sqrt((dx * dx) + (dy * dy))
91
+
92
+ # If centers coincide, push along arbitrary axis.
93
+ return { dx: radius + other.radius, dy: 0.0 } if dist < 0.0001
94
+
95
+ overlap = (radius + other.radius) - dist
96
+ nx = dx / dist
97
+ ny = dy / dist
98
+ { dx: overlap * nx, dy: overlap * ny }
99
+ end
100
+
101
+ # Rect vs Circle separation.
102
+ def separate_rect_circle(other, ax, ay, bx, by)
103
+ return nil unless overlap_rect_circle?(other, ax, ay, bx, by)
104
+
105
+ closest_x = bx.clamp(ax, ax + width)
106
+ closest_y = by.clamp(ay, ay + height)
107
+ dx = bx - closest_x
108
+ dy = by - closest_y
109
+ dist = Math.sqrt((dx * dx) + (dy * dy))
110
+
111
+ return { dx: other.radius, dy: 0.0 } if dist < 0.0001
112
+
113
+ overlap = other.radius - dist
114
+ nx = dx / dist
115
+ ny = dy / dist
116
+ { dx: overlap * nx, dy: overlap * ny }
117
+ end
118
+
119
+ # Circle vs Rect: delegate and flip.
120
+ def separate_circle_rect(other, ax, ay, bx, by)
121
+ result = other.separation(other: self, ax: bx, ay: by, bx: ax, by: ay)
122
+ return nil unless result
123
+
124
+ { dx: -result.fetch(:dx), dy: -result.fetch(:dy) }
125
+ end
126
+
127
+ # Minimum translation vector to push b out of a (rect vs rect).
128
+ # Returns { dx:, dy: } or nil if no overlap.
129
+ def separate_rect_rect(other, ax, ay, bx, by) # rubocop:disable Metrics/AbcSize
130
+ return nil unless overlap_rect_rect?(other, ax, ay, bx, by)
131
+
132
+ # Calculate overlap on each axis.
133
+ overlap_x = (ax + width) < (bx + other.width) ? (ax + width - bx) : (bx + other.width - ax)
134
+ overlap_y = (ay + height) < (by + other.height) ? (ay + height - by) : (by + other.height - ay)
135
+
136
+ # Determine separation direction (sign).
137
+ center_ax = ax + (width / 2.0)
138
+ center_bx = bx + (other.width / 2.0)
139
+ center_ay = ay + (height / 2.0)
140
+ center_by = by + (other.height / 2.0)
141
+
142
+ sign_x = center_bx >= center_ax ? 1.0 : -1.0
143
+ sign_y = center_by >= center_ay ? 1.0 : -1.0
144
+
145
+ # Separate along the axis of least penetration.
146
+ return { dx: overlap_x * sign_x, dy: 0.0 } if overlap_x.abs <= overlap_y.abs
147
+
148
+ { dx: 0.0, dy: overlap_y * sign_y }
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,15 @@
1
+ module Dama
2
+ module Physics
3
+ # Value object representing a collision between two physics bodies.
4
+ class Collision
5
+ attr_reader :body_a, :body_b, :separation_x, :separation_y
6
+
7
+ def initialize(body_a:, body_b:, separation_x:, separation_y:)
8
+ @body_a = body_a
9
+ @body_b = body_b
10
+ @separation_x = separation_x
11
+ @separation_y = separation_y
12
+ end
13
+ end
14
+ end
15
+ end