dama 0.1.0-x86_64-linux

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 (96) 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/lib/dama/animation.rb +66 -0
  7. data/lib/dama/asset_cache.rb +56 -0
  8. data/lib/dama/audio.rb +47 -0
  9. data/lib/dama/auto_loader.rb +54 -0
  10. data/lib/dama/backend/base.rb +137 -0
  11. data/lib/dama/backend/native/ffi_bindings.rb +122 -0
  12. data/lib/dama/backend/native.rb +191 -0
  13. data/lib/dama/backend/web.rb +199 -0
  14. data/lib/dama/backend.rb +13 -0
  15. data/lib/dama/camera.rb +68 -0
  16. data/lib/dama/cli/new_project.rb +112 -0
  17. data/lib/dama/cli/release.rb +45 -0
  18. data/lib/dama/cli.rb +22 -0
  19. data/lib/dama/colors.rb +30 -0
  20. data/lib/dama/command_buffer.rb +83 -0
  21. data/lib/dama/component/attribute_definition.rb +13 -0
  22. data/lib/dama/component/attribute_set.rb +32 -0
  23. data/lib/dama/component.rb +28 -0
  24. data/lib/dama/configuration.rb +18 -0
  25. data/lib/dama/debug/frame_controller.rb +35 -0
  26. data/lib/dama/debug/screenshot_tool.rb +19 -0
  27. data/lib/dama/debug.rb +4 -0
  28. data/lib/dama/event_bus.rb +47 -0
  29. data/lib/dama/game/builder.rb +31 -0
  30. data/lib/dama/game/loop.rb +44 -0
  31. data/lib/dama/game.rb +88 -0
  32. data/lib/dama/geometry/circle.rb +28 -0
  33. data/lib/dama/geometry/rect.rb +16 -0
  34. data/lib/dama/geometry/sprite.rb +18 -0
  35. data/lib/dama/geometry/triangle.rb +13 -0
  36. data/lib/dama/geometry.rb +4 -0
  37. data/lib/dama/input/keyboard_state.rb +44 -0
  38. data/lib/dama/input/mouse_state.rb +45 -0
  39. data/lib/dama/input.rb +38 -0
  40. data/lib/dama/keys.rb +67 -0
  41. data/lib/dama/native/libdama_native.so +0 -0
  42. data/lib/dama/node/component_slot.rb +18 -0
  43. data/lib/dama/node/draw_context.rb +96 -0
  44. data/lib/dama/node.rb +139 -0
  45. data/lib/dama/physics/body.rb +57 -0
  46. data/lib/dama/physics/collider.rb +152 -0
  47. data/lib/dama/physics/collision.rb +15 -0
  48. data/lib/dama/physics/world.rb +125 -0
  49. data/lib/dama/physics.rb +4 -0
  50. data/lib/dama/registry/class_resolver.rb +48 -0
  51. data/lib/dama/registry.rb +21 -0
  52. data/lib/dama/release/archiver.rb +100 -0
  53. data/lib/dama/release/defaults/icon.icns +0 -0
  54. data/lib/dama/release/defaults/icon.ico +0 -0
  55. data/lib/dama/release/defaults/icon.png +0 -0
  56. data/lib/dama/release/dylib_relinker.rb +95 -0
  57. data/lib/dama/release/game_file_copier.rb +35 -0
  58. data/lib/dama/release/game_metadata.rb +61 -0
  59. data/lib/dama/release/icon_provider.rb +36 -0
  60. data/lib/dama/release/native_builder.rb +44 -0
  61. data/lib/dama/release/packager/linux.rb +62 -0
  62. data/lib/dama/release/packager/macos.rb +99 -0
  63. data/lib/dama/release/packager/web.rb +32 -0
  64. data/lib/dama/release/packager/windows.rb +61 -0
  65. data/lib/dama/release/packager.rb +9 -0
  66. data/lib/dama/release/platform_detector.rb +23 -0
  67. data/lib/dama/release/ruby_bundler.rb +163 -0
  68. data/lib/dama/release/stdlib_trimmer.rb +133 -0
  69. data/lib/dama/release/template_renderer.rb +40 -0
  70. data/lib/dama/release/templates/info_plist.xml.erb +19 -0
  71. data/lib/dama/release/templates/launcher_linux.sh.erb +10 -0
  72. data/lib/dama/release/templates/launcher_macos.sh.erb +10 -0
  73. data/lib/dama/release/templates/launcher_windows.bat.erb +11 -0
  74. data/lib/dama/release.rb +7 -0
  75. data/lib/dama/scene/composer.rb +65 -0
  76. data/lib/dama/scene.rb +233 -0
  77. data/lib/dama/scene_graph/class_index.rb +26 -0
  78. data/lib/dama/scene_graph/group_node.rb +27 -0
  79. data/lib/dama/scene_graph/instance_node.rb +30 -0
  80. data/lib/dama/scene_graph/path_selector.rb +25 -0
  81. data/lib/dama/scene_graph/query.rb +34 -0
  82. data/lib/dama/scene_graph/tag_index.rb +26 -0
  83. data/lib/dama/scene_graph/tree.rb +65 -0
  84. data/lib/dama/scene_graph.rb +4 -0
  85. data/lib/dama/sprite_sheet.rb +36 -0
  86. data/lib/dama/tween/easing.rb +31 -0
  87. data/lib/dama/tween/lerp.rb +35 -0
  88. data/lib/dama/tween/manager.rb +28 -0
  89. data/lib/dama/tween.rb +4 -0
  90. data/lib/dama/version.rb +3 -0
  91. data/lib/dama/vertex_batch.rb +35 -0
  92. data/lib/dama/web/entry.rb +79 -0
  93. data/lib/dama/web/static/index.html +142 -0
  94. data/lib/dama/web_builder.rb +232 -0
  95. data/lib/dama.rb +42 -0
  96. metadata +186 -0
@@ -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
@@ -0,0 +1,125 @@
1
+ module Dama
2
+ module Physics
3
+ # Manages physics bodies and steps the simulation each frame.
4
+ # Integrates velocity, detects AABB/circle collisions, resolves overlaps,
5
+ # and emits collision events via EventBus.
6
+ class World
7
+ attr_reader :gravity_x, :gravity_y
8
+
9
+ def initialize(gravity_x: 0.0, gravity_y: 0.0, event_bus: nil)
10
+ @gravity_x = gravity_x.to_f
11
+ @gravity_y = gravity_y.to_f
12
+ @event_bus = event_bus
13
+ @bodies = []
14
+ end
15
+
16
+ def add(body)
17
+ bodies << body
18
+ end
19
+
20
+ def remove(body)
21
+ bodies.delete(body)
22
+ end
23
+
24
+ # Step the simulation: integrate velocities, detect and resolve collisions.
25
+ def step(delta_time:)
26
+ integrate_bodies(delta_time:)
27
+ detect_and_resolve_collisions
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :bodies, :event_bus
33
+
34
+ def integrate_bodies(delta_time:)
35
+ bodies.each do |body|
36
+ body.integrate(delta_time:, gravity_x:, gravity_y:)
37
+ end
38
+ end
39
+
40
+ def detect_and_resolve_collisions
41
+ bodies.combination(2).each do |body_a, body_b|
42
+ next if body_a.static? && body_b.static?
43
+
44
+ sep = body_a.collider.separation(
45
+ other: body_b.collider,
46
+ ax: body_a.x, ay: body_a.y,
47
+ bx: body_b.x, by: body_b.y
48
+ )
49
+ next unless sep
50
+
51
+ resolve_collision(body_a, body_b, sep)
52
+ emit_collision(body_a, body_b, sep)
53
+ end
54
+ end
55
+
56
+ def resolve_collision(body_a, body_b, sep)
57
+ dx = sep.fetch(:dx)
58
+ dy = sep.fetch(:dy)
59
+
60
+ resolve_positions(body_a, body_b, dx, dy)
61
+ resolve_velocities(body_a, body_b, dx, dy)
62
+ end
63
+
64
+ POSITION_RESOLVERS = {
65
+ # dynamic vs static: push dynamic body away (opposite of separation)
66
+ %i[dynamic static] => lambda { |a, _b, dx, dy|
67
+ a.x -= dx
68
+ a.y -= dy
69
+ },
70
+ # static vs dynamic: push dynamic body along separation
71
+ %i[static dynamic] => lambda { |_a, b, dx, dy|
72
+ b.x += dx
73
+ b.y += dy
74
+ },
75
+ # dynamic vs dynamic: split the separation equally
76
+ %i[dynamic dynamic] => lambda { |a, b, dx, dy|
77
+ half_dx = dx / 2.0
78
+ half_dy = dy / 2.0
79
+ a.x -= half_dx
80
+ a.y -= half_dy
81
+ b.x += half_dx
82
+ b.y += half_dy
83
+ },
84
+ }.freeze
85
+
86
+ def resolve_positions(body_a, body_b, dx, dy)
87
+ key = [body_a.type, body_b.type]
88
+ resolver = POSITION_RESOLVERS.fetch(key, nil)
89
+ resolver&.call(body_a, body_b, dx, dy)
90
+ end
91
+
92
+ def resolve_velocities(body_a, body_b, dx, dy)
93
+ # Separation points from a toward b. Normal for a points away (negative).
94
+ normal_x = dx.zero? ? 0.0 : (dx / dx.abs)
95
+ normal_y = dy.zero? ? 0.0 : (dy / dy.abs)
96
+
97
+ # body_a bounces against normal pointing away from b (negative).
98
+ bounce_body(body_a, -normal_x, -normal_y) if body_a.dynamic?
99
+ # body_b bounces against normal pointing away from a (positive).
100
+ bounce_body(body_b, normal_x, normal_y) if body_b.dynamic?
101
+ end
102
+
103
+ def bounce_body(body, normal_x, normal_y)
104
+ restitution = body.restitution
105
+ # Reflect velocity along the collision normal.
106
+ dot = (body.velocity_x * normal_x) + (body.velocity_y * normal_y)
107
+ return unless dot.negative? # Only bounce if moving toward the surface.
108
+
109
+ body.velocity_x -= (1.0 + restitution) * dot * normal_x
110
+ body.velocity_y -= (1.0 + restitution) * dot * normal_y
111
+ end
112
+
113
+ def emit_collision(body_a, body_b, sep)
114
+ return unless event_bus
115
+
116
+ collision = Collision.new(
117
+ body_a:, body_b:,
118
+ separation_x: sep.fetch(:dx),
119
+ separation_y: sep.fetch(:dy)
120
+ )
121
+ event_bus.emit(:collision, collision:)
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,4 @@
1
+ module Dama
2
+ module Physics
3
+ end
4
+ end
@@ -0,0 +1,48 @@
1
+ module Dama
2
+ class Registry
3
+ # Resolves symbol/string names to registered classes.
4
+ # Classes are registered by category (:component, :node, :scene)
5
+ # and looked up via a snake_case key derived from the class name.
6
+ class ClassResolver
7
+ CATEGORIES = %i[component node scene].freeze
8
+
9
+ def initialize
10
+ @registrations = CATEGORIES.to_h { |cat| [cat, {}] }
11
+ end
12
+
13
+ def register(klass:, category:)
14
+ key = derive_key(klass:)
15
+ registrations.fetch(category)[key] = klass
16
+ end
17
+
18
+ def resolve(name:, category:)
19
+ key = normalize_name(name:)
20
+ registrations.fetch(category).fetch(key)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :registrations
26
+
27
+ # Derives a snake_case symbol key from a class name.
28
+ # Only uses the last segment (after ::) to avoid namespace prefixes.
29
+ # Uses a regex with named groups to split CamelCase boundaries.
30
+ CAMEL_BOUNDARY = /(?<upper>[A-Z]+)(?<next_upper>[A-Z][a-z])/
31
+ CAMEL_LOWER = /(?<lower>[a-z\d])(?<upper>[A-Z])/
32
+
33
+ def derive_key(klass:)
34
+ # Extract the class basename (last segment after ::).
35
+ basename = klass.name&.match(/(?<basename>[^:]+)\z/)&.[](:basename) || klass.to_s
36
+ basename
37
+ .gsub(CAMEL_BOUNDARY, '\k<upper>_\k<next_upper>')
38
+ .gsub(CAMEL_LOWER, '\k<lower>_\k<upper>')
39
+ .downcase
40
+ .to_sym
41
+ end
42
+
43
+ def normalize_name(name:)
44
+ name.to_s.downcase.to_sym
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,21 @@
1
+ module Dama
2
+ # Global class registry for auto-discovery of Component, Node, and Scene
3
+ # subclasses. Supports symbol/string-to-class resolution for the Composer DSL.
4
+ class Registry
5
+ def initialize
6
+ @resolver = Registry::ClassResolver.new
7
+ end
8
+
9
+ def register(klass:, category:)
10
+ resolver.register(klass:, category:)
11
+ end
12
+
13
+ def resolve(name:, category:)
14
+ resolver.resolve(name:, category:)
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :resolver
20
+ end
21
+ end
@@ -0,0 +1,100 @@
1
+ require "fileutils"
2
+ require "pathname"
3
+ require "zlib"
4
+
5
+ begin
6
+ require "zip"
7
+ rescue LoadError
8
+ # :nocov:
9
+ raise LoadError,
10
+ "rubyzip gem is required for release packaging. Install it with: gem install rubyzip"
11
+ # :nocov:
12
+ end
13
+
14
+ module Dama
15
+ module Release
16
+ # Creates distributable archives from release directories.
17
+ # Uses pure Ruby for tar.gz and zip formats. The macOS .app
18
+ # variant uses ditto to preserve extended attributes and
19
+ # resource forks -- no Ruby equivalent exists for this.
20
+ class Archiver
21
+ def initialize(source_path:)
22
+ @source_path = source_path
23
+ end
24
+
25
+ # Creates a .zip using ditto, which preserves macOS extended
26
+ # attributes and resource forks required by .app bundles.
27
+ def create_macos_zip
28
+ archive_path = "#{source_path}.zip"
29
+ FileUtils.rm_f(archive_path)
30
+ success = system("ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", source_path, archive_path)
31
+ raise "ditto failed creating #{archive_path}" unless success
32
+
33
+ archive_path
34
+ end
35
+
36
+ def create_tar_gz
37
+ require "rubygems/package"
38
+
39
+ archive_path = "#{source_path}.tar.gz"
40
+ FileUtils.rm_f(archive_path)
41
+
42
+ File.open(archive_path, "wb") do |file|
43
+ Zlib::GzipWriter.wrap(file) do |gzip|
44
+ Gem::Package::TarWriter.new(gzip) do |tar|
45
+ write_directory_to_tar(tar:, dir: source_path, prefix: source_name)
46
+ end
47
+ end
48
+ end
49
+
50
+ archive_path
51
+ end
52
+
53
+ def create_zip
54
+ archive_path = "#{source_path}.zip"
55
+ FileUtils.rm_f(archive_path)
56
+
57
+ source_pathname = Pathname.new(source_path)
58
+ Zip::File.open(archive_path, create: true) do |zipfile|
59
+ collect_files.each do |file_path|
60
+ relative = Pathname.new(file_path).relative_path_from(source_pathname.parent).to_s
61
+ zipfile.add(relative, file_path)
62
+ end
63
+ end
64
+
65
+ archive_path
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :source_path
71
+
72
+ def source_name
73
+ File.basename(source_path)
74
+ end
75
+
76
+ def collect_files
77
+ Dir.glob(File.join(source_path, "**", "*"))
78
+ .reject { |f| File.directory?(f) }
79
+ .sort
80
+ end
81
+
82
+ def write_directory_to_tar(tar:, dir:, prefix:)
83
+ dir_pathname = Pathname.new(dir)
84
+
85
+ Dir.glob(File.join(dir, "**", "*"), File::FNM_DOTMATCH).sort.each do |entry|
86
+ next if File.basename(entry).match?(/\A\.\.?\z/)
87
+
88
+ relative = "#{prefix}/#{Pathname.new(entry).relative_path_from(dir_pathname)}"
89
+ stat = File.stat(entry)
90
+
91
+ next if stat.directory?
92
+
93
+ tar.add_file_simple(relative, stat.mode, stat.size) do |io|
94
+ File.open(entry, "rb") { |f| IO.copy_stream(f, io) }
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
Binary file
Binary file
Binary file
@@ -0,0 +1,95 @@
1
+ require "fileutils"
2
+ require "pathname"
3
+
4
+ begin
5
+ require "macho"
6
+ rescue LoadError
7
+ # :nocov:
8
+ raise LoadError,
9
+ "ruby-macho gem is required for macOS release packaging. Install it with: gem install ruby-macho"
10
+ # :nocov:
11
+ end
12
+
13
+ module Dama
14
+ module Release
15
+ # Rewrites dynamic library load paths in macOS Mach-O binaries so they
16
+ # reference bundled copies via @loader_path instead of absolute paths
17
+ # from the build machine. This makes the app bundle portable —
18
+ # it can run on any Mac without the original build environment.
19
+ #
20
+ # Uses the ruby-macho gem (maintained by Homebrew) for pure-Ruby
21
+ # Mach-O manipulation, removing the dependency on Xcode command-line tools.
22
+ class DylibRelinker
23
+ SYSTEM_LIB_PATTERN = %r{\A(/usr/lib/|/System/)}
24
+
25
+ def initialize(binary_path:, lib_destination:)
26
+ @binary_path = binary_path
27
+ @lib_destination = lib_destination
28
+ @processed = Set.new
29
+ end
30
+
31
+ def relink
32
+ FileUtils.mkdir_p(lib_destination)
33
+ relink_binary(path: binary_path)
34
+ # ruby-macho modifies Mach-O data directly, invalidating the
35
+ # original code signature. Re-sign every modified binary with
36
+ # an ad-hoc signature so macOS doesn't kill the process.
37
+ codesign_modified_binaries
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :binary_path, :lib_destination, :processed
43
+
44
+ def relink_binary(path:)
45
+ return if processed.include?(path)
46
+
47
+ processed.add(path)
48
+
49
+ non_system_libraries(path:).each do |original_path|
50
+ lib_name = File.basename(original_path)
51
+ dest_lib = File.join(lib_destination, lib_name)
52
+
53
+ copy_library(source: original_path, destination: dest_lib)
54
+ change_load_path(binary: path, old_path: original_path, lib_name:)
55
+ relink_binary(path: dest_lib)
56
+ end
57
+ end
58
+
59
+ def non_system_libraries(path:)
60
+ binary_name = File.basename(path)
61
+
62
+ linked_dylibs(path:).reject do |lib_path|
63
+ SYSTEM_LIB_PATTERN.match?(lib_path) || File.basename(lib_path) == binary_name
64
+ end
65
+ end
66
+
67
+ def linked_dylibs(path:)
68
+ MachO::Tools.dylibs(path)
69
+ end
70
+
71
+ def copy_library(source:, destination:)
72
+ return if File.exist?(destination)
73
+
74
+ FileUtils.cp(source, destination)
75
+ end
76
+
77
+ def change_load_path(binary:, old_path:, lib_name:)
78
+ binary_dir = Pathname.new(File.dirname(binary))
79
+ lib_dir = Pathname.new(lib_destination)
80
+ relative = lib_dir.relative_path_from(binary_dir)
81
+ new_path = "@loader_path/#{relative}/#{lib_name}"
82
+
83
+ MachO::Tools.change_install_name(binary, old_path, new_path)
84
+ end
85
+
86
+ def codesign_modified_binaries
87
+ processed.each { |path| codesign(path:) }
88
+ end
89
+
90
+ def codesign(path:)
91
+ system("codesign", "--sign", "-", "--force", path)
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,35 @@
1
+ require "fileutils"
2
+
3
+ module Dama
4
+ module Release
5
+ # Copies the game's source files (game/, config.rb, assets/) from
6
+ # the project directory into the release destination. Shared across
7
+ # all native packagers to avoid duplicating the same copy logic.
8
+ class GameFileCopier
9
+ def initialize(project_root:, destination:)
10
+ @project_root = project_root
11
+ @destination = destination
12
+ end
13
+
14
+ def copy
15
+ copy_directory("game")
16
+ copy_file("config.rb")
17
+ copy_directory("assets")
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :project_root, :destination
23
+
24
+ def copy_directory(name)
25
+ source = File.join(project_root, name)
26
+ FileUtils.cp_r(source, File.join(destination, name)) if File.directory?(source)
27
+ end
28
+
29
+ def copy_file(name)
30
+ source = File.join(project_root, name)
31
+ FileUtils.cp(source, destination) if File.exist?(source)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,61 @@
1
+ module Dama
2
+ module Release
3
+ # Extracts game title and resolution from config.rb
4
+ # without loading the engine. Uses regex with named groups
5
+ # to parse the DSL statically.
6
+ class GameMetadata
7
+ TITLE_PATTERN = /title:\s*"(?<title>[^"]+)"/
8
+ RESOLUTION_PATTERN = /resolution:\s*\[(?<width>\d+),\s*(?<height>\d+)\]/
9
+
10
+ # Characters that are unsafe in filenames across macOS, Linux, and Windows.
11
+ UNSAFE_FILENAME_CHARS = %r{[/\\:*?"<>|]}
12
+
13
+ DEFAULT_RESOLUTION = [800, 600].freeze
14
+
15
+ def initialize(project_root:)
16
+ @project_root = project_root
17
+ end
18
+
19
+ def title
20
+ extracted_title || directory_name
21
+ end
22
+
23
+ # Filesystem-safe version of title for use in release
24
+ # directory names and .app bundle names.
25
+ def release_name
26
+ title.gsub(UNSAFE_FILENAME_CHARS, " ").squeeze(" ").strip
27
+ end
28
+
29
+ def resolution
30
+ match = config_content.match(RESOLUTION_PATTERN)
31
+ return DEFAULT_RESOLUTION unless match
32
+
33
+ [Integer(match[:width]), Integer(match[:height])]
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :project_root
39
+
40
+ def extracted_title
41
+ match = config_content.match(TITLE_PATTERN)
42
+ match&.[](:title)
43
+ end
44
+
45
+ def directory_name
46
+ File.basename(project_root)
47
+ end
48
+
49
+ def config_content
50
+ @config_content ||= read_config
51
+ end
52
+
53
+ def read_config
54
+ config_path = File.join(project_root, "config.rb")
55
+ return "" unless File.exist?(config_path)
56
+
57
+ File.read(config_path)
58
+ end
59
+ end
60
+ end
61
+ end