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,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
@@ -0,0 +1,36 @@
1
+ module Dama
2
+ module Release
3
+ # Resolves the icon file for a release build.
4
+ # Checks for a user-provided icon in assets/ first,
5
+ # then falls back to the default icon shipped with the gem.
6
+ class IconProvider
7
+ ICON_EXTENSIONS = {
8
+ macos: "icns",
9
+ linux: "png",
10
+ windows: "ico",
11
+ }.freeze
12
+
13
+ DEFAULT_ICONS_PATH = ->(ext) { File.expand_path("defaults/icon.#{ext}", __dir__) }
14
+
15
+ def initialize(project_root:, platform:)
16
+ @project_root = project_root
17
+ @platform = platform
18
+ end
19
+
20
+ def icon_path
21
+ user_icon_path = File.join(project_root, "assets", "icon.#{extension}")
22
+ return user_icon_path if File.exist?(user_icon_path)
23
+
24
+ DEFAULT_ICONS_PATH.call(extension)
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :project_root, :platform
30
+
31
+ def extension
32
+ ICON_EXTENSIONS.fetch(platform)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,44 @@
1
+ module Dama
2
+ module Release
3
+ # Compiles the Rust native library in release mode.
4
+ # Returns the path to the compiled shared library,
5
+ # using the platform-appropriate extension from FfiBindings.
6
+ class NativeBuilder
7
+ RUST_CRATE_PATH = File.expand_path("../../../ext/dama_native", __dir__)
8
+
9
+ def build
10
+ puts "=== Building native library (release) ==="
11
+ run_command("cargo build --release", dir: RUST_CRATE_PATH)
12
+ library_path
13
+ end
14
+
15
+ def library_path
16
+ platform_key = Backend::Native::FfiBindings::LIBRARY_EXTENSIONS.keys.detect do |k|
17
+ RUBY_PLATFORM.include?(k)
18
+ end
19
+ raise PlatformDetector::UnsupportedPlatformError, "Unsupported platform: #{RUBY_PLATFORM}" unless platform_key
20
+
21
+ extension = Backend::Native::FfiBindings::LIBRARY_EXTENSIONS.fetch(platform_key)
22
+ File.join(RUST_CRATE_PATH, "target", "release", "libdama_native.#{extension}")
23
+ end
24
+
25
+ private
26
+
27
+ def run_command(cmd, dir:)
28
+ full_env = ENV.to_h
29
+ full_env["PATH"] = rust_enhanced_path(full_env.fetch("PATH", ""))
30
+ success = system(full_env, cmd, chdir: dir)
31
+ raise "Command failed: #{cmd}" unless success
32
+ end
33
+
34
+ def rust_enhanced_path(existing_path)
35
+ home = Dir.home
36
+ rust_dirs = [
37
+ File.join(home, ".cargo", "bin"),
38
+ ].select { |d| File.directory?(d) }
39
+
40
+ (rust_dirs + existing_path.split(File::PATH_SEPARATOR)).join(File::PATH_SEPARATOR)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,62 @@
1
+ require "fileutils"
2
+
3
+ module Dama
4
+ module Release
5
+ module Packager
6
+ # Creates a self-contained Linux directory with a launcher script,
7
+ # bundled Ruby, native .so, game files, and assets.
8
+ class Linux
9
+ def initialize(project_root:)
10
+ @project_root = project_root
11
+ @metadata = GameMetadata.new(project_root:)
12
+ @icon_provider = IconProvider.new(project_root:, platform: :linux)
13
+ end
14
+
15
+ def package
16
+ native_library_path = NativeBuilder.new.build
17
+ prepare_structure
18
+ RubyBundler.new(destination: release_path, project_root:).bundle
19
+ FileUtils.cp(native_library_path, release_path)
20
+ GameFileCopier.new(project_root:, destination: release_path).copy
21
+ copy_icon
22
+ write_launcher_script(native_library_path:)
23
+
24
+ archive_path = Archiver.new(source_path: release_path).create_tar_gz
25
+ puts "Linux release created: #{archive_path}"
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :project_root, :metadata, :icon_provider
31
+
32
+ def release_path
33
+ File.join(project_root, "release", metadata.release_name)
34
+ end
35
+
36
+ def prepare_structure
37
+ FileUtils.rm_rf(release_path)
38
+ FileUtils.mkdir_p(release_path)
39
+ end
40
+
41
+ def copy_icon
42
+ icon_source = icon_provider.icon_path
43
+ FileUtils.cp(icon_source, File.join(release_path, "icon.png")) if File.exist?(icon_source)
44
+ end
45
+
46
+ def write_launcher_script(native_library_path:)
47
+ launcher_path = File.join(release_path, metadata.release_name.downcase.gsub(/\s+/, "-"))
48
+ content = TemplateRenderer.new(
49
+ template_name: "launcher_linux.sh.erb",
50
+ variables: {
51
+ native_lib_name: File.basename(native_library_path),
52
+ ruby_version: RbConfig::CONFIG.fetch("ruby_version"),
53
+ ruby_arch: RbConfig::CONFIG.fetch("arch"),
54
+ },
55
+ ).render
56
+ File.write(launcher_path, content)
57
+ FileUtils.chmod(0o755, launcher_path)
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end