rgame 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 (161) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +26 -0
  3. data/README.md +406 -0
  4. data/docs/api/README.md +167 -0
  5. data/docs/api/app.md +192 -0
  6. data/docs/api/assets.md +426 -0
  7. data/docs/api/audio.md +208 -0
  8. data/docs/api/components.md +321 -0
  9. data/docs/api/drawing.md +330 -0
  10. data/docs/api/game.md +99 -0
  11. data/docs/api/images.md +118 -0
  12. data/docs/api/input.md +179 -0
  13. data/docs/api/internals.md +110 -0
  14. data/docs/api/scene_graph.md +159 -0
  15. data/docs/api/signals.md +142 -0
  16. data/docs/api/systems.md +98 -0
  17. data/docs/api/text.md +116 -0
  18. data/docs/api/toolbox.md +240 -0
  19. data/docs/api/values.md +101 -0
  20. data/ext/README.md +225 -0
  21. data/ext/rgame_core/app/app.c +721 -0
  22. data/ext/rgame_core/app/app_gl.h +64 -0
  23. data/ext/rgame_core/app/frame_loop.c +42 -0
  24. data/ext/rgame_core/app/frame_loop.h +54 -0
  25. data/ext/rgame_core/audio/audio.c +466 -0
  26. data/ext/rgame_core/audio/audio_internal.h +45 -0
  27. data/ext/rgame_core/audio/vorbis_decoder.c +282 -0
  28. data/ext/rgame_core/audio/vorbis_decoder.h +45 -0
  29. data/ext/rgame_core/example.rb +188 -0
  30. data/ext/rgame_core/extconf.rb +167 -0
  31. data/ext/rgame_core/graphics/backend.c +52 -0
  32. data/ext/rgame_core/graphics/backend.h +64 -0
  33. data/ext/rgame_core/graphics/canvas.c +247 -0
  34. data/ext/rgame_core/graphics/canvas.h +143 -0
  35. data/ext/rgame_core/graphics/clip.c +87 -0
  36. data/ext/rgame_core/graphics/clip.h +89 -0
  37. data/ext/rgame_core/graphics/draw_queue.c +216 -0
  38. data/ext/rgame_core/graphics/draw_queue.h +174 -0
  39. data/ext/rgame_core/graphics/gl_backend.c +122 -0
  40. data/ext/rgame_core/graphics/gl_backend.h +43 -0
  41. data/ext/rgame_core/graphics/image.c +304 -0
  42. data/ext/rgame_core/graphics/image_internal.h +30 -0
  43. data/ext/rgame_core/graphics/primitives.c +189 -0
  44. data/ext/rgame_core/graphics/primitives.h +111 -0
  45. data/ext/rgame_core/graphics/recording.c +119 -0
  46. data/ext/rgame_core/graphics/recording.h +88 -0
  47. data/ext/rgame_core/graphics/texture.c +181 -0
  48. data/ext/rgame_core/graphics/texture.h +165 -0
  49. data/ext/rgame_core/graphics/transform.c +128 -0
  50. data/ext/rgame_core/graphics/transform.h +106 -0
  51. data/ext/rgame_core/include/rgame/core.h +577 -0
  52. data/ext/rgame_core/input/device_slots.c +103 -0
  53. data/ext/rgame_core/input/device_slots.h +93 -0
  54. data/ext/rgame_core/input/gamepad.c +145 -0
  55. data/ext/rgame_core/input/gamepad.h +63 -0
  56. data/ext/rgame_core/input/input.c +109 -0
  57. data/ext/rgame_core/input/input.h +99 -0
  58. data/ext/rgame_core/ruby/audio_ext.c +321 -0
  59. data/ext/rgame_core/ruby/core_ext.c +513 -0
  60. data/ext/rgame_core/ruby/core_ext.h +51 -0
  61. data/ext/rgame_core/ruby/font_ext.c +168 -0
  62. data/ext/rgame_core/ruby/image_ext.c +230 -0
  63. data/ext/rgame_core/ruby/recording_ext.c +186 -0
  64. data/ext/rgame_core/ruby/renderer_ext.c +376 -0
  65. data/ext/rgame_core/text/atlas.c +59 -0
  66. data/ext/rgame_core/text/atlas.h +85 -0
  67. data/ext/rgame_core/text/font.c +281 -0
  68. data/ext/rgame_core/text/font.h +139 -0
  69. data/ext/rgame_core/text/font_atlas.c +385 -0
  70. data/ext/rgame_core/text/font_internal.h +47 -0
  71. data/ext/rgame_core/text/glyph_cache.c +142 -0
  72. data/ext/rgame_core/text/glyph_cache.h +89 -0
  73. data/ext/rgame_core/vendor/README.md +159 -0
  74. data/ext/rgame_core/vendor/miniaudio.h +95864 -0
  75. data/ext/rgame_core/vendor/miniaudio_impl.c +62 -0
  76. data/ext/rgame_core/vendor/stb_image.h +7988 -0
  77. data/ext/rgame_core/vendor/stb_image_impl.c +31 -0
  78. data/ext/rgame_core/vendor/stb_truetype.h +5079 -0
  79. data/ext/rgame_core/vendor/stb_truetype_impl.c +23 -0
  80. data/ext/rgame_core/vendor/stb_vorbis.c +5584 -0
  81. data/ext/rgame_core/vendor/stb_vorbis_impl.c +29 -0
  82. data/ext/rgame_util/color.c +19 -0
  83. data/ext/rgame_util/color.h +60 -0
  84. data/ext/rgame_util/color_ext.c +156 -0
  85. data/ext/rgame_util/extconf.rb +27 -0
  86. data/ext/rgame_util/tensor.c +186 -0
  87. data/ext/rgame_util/util_ext.c +27 -0
  88. data/ext/rgame_util/util_ext.h +16 -0
  89. data/lib/rgame/boot.rb +13 -0
  90. data/lib/rgame/core/app.rb +82 -0
  91. data/lib/rgame/core/asset_manager.rb +224 -0
  92. data/lib/rgame/core/audio.rb +124 -0
  93. data/lib/rgame/core/font.rb +49 -0
  94. data/lib/rgame/core/gamepad.rb +55 -0
  95. data/lib/rgame/core/image.rb +55 -0
  96. data/lib/rgame/core/input.rb +77 -0
  97. data/lib/rgame/core/nine_slice.rb +163 -0
  98. data/lib/rgame/core/recording.rb +52 -0
  99. data/lib/rgame/core/renderer.rb +363 -0
  100. data/lib/rgame/core/sprite_sheet.rb +108 -0
  101. data/lib/rgame/core/tile_map_renderer.rb +160 -0
  102. data/lib/rgame/core/ui_atlas.rb +86 -0
  103. data/lib/rgame/core.rb +24 -0
  104. data/lib/rgame/engine/actor.rb +53 -0
  105. data/lib/rgame/engine/animation_set.rb +49 -0
  106. data/lib/rgame/engine/animator.rb +44 -0
  107. data/lib/rgame/engine/audio_bus.rb +24 -0
  108. data/lib/rgame/engine/audio_director.rb +29 -0
  109. data/lib/rgame/engine/body.rb +49 -0
  110. data/lib/rgame/engine/cached_label.rb +33 -0
  111. data/lib/rgame/engine/camera.rb +33 -0
  112. data/lib/rgame/engine/camera_view.rb +28 -0
  113. data/lib/rgame/engine/circle_collider.rb +32 -0
  114. data/lib/rgame/engine/collision_box.rb +34 -0
  115. data/lib/rgame/engine/collision_system.rb +44 -0
  116. data/lib/rgame/engine/component.rb +30 -0
  117. data/lib/rgame/engine/components/action_trigger.rb +41 -0
  118. data/lib/rgame/engine/components/animated_sprite.rb +63 -0
  119. data/lib/rgame/engine/components/character_body.rb +70 -0
  120. data/lib/rgame/engine/components/circle_collider.rb +44 -0
  121. data/lib/rgame/engine/components/collision_world.rb +103 -0
  122. data/lib/rgame/engine/components/despawn_offscreen.rb +26 -0
  123. data/lib/rgame/engine/components/path_follow.rb +84 -0
  124. data/lib/rgame/engine/components/player_controller.rb +24 -0
  125. data/lib/rgame/engine/components/pool.rb +53 -0
  126. data/lib/rgame/engine/components/screen_wrap.rb +27 -0
  127. data/lib/rgame/engine/components/sprite.rb +31 -0
  128. data/lib/rgame/engine/components/targeting.rb +54 -0
  129. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  130. data/lib/rgame/engine/components/tile_world.rb +68 -0
  131. data/lib/rgame/engine/components/timer.rb +75 -0
  132. data/lib/rgame/engine/components/velocity.rb +27 -0
  133. data/lib/rgame/engine/components/wander_controller.rb +60 -0
  134. data/lib/rgame/engine/debug_overlay.rb +106 -0
  135. data/lib/rgame/engine/i18n.rb +97 -0
  136. data/lib/rgame/engine/input/action_mapper.rb +46 -0
  137. data/lib/rgame/engine/input/actions.rb +41 -0
  138. data/lib/rgame/engine/input/player_controller.rb +14 -0
  139. data/lib/rgame/engine/matrix.rb +32 -0
  140. data/lib/rgame/engine/node2d.rb +271 -0
  141. data/lib/rgame/engine/path.rb +78 -0
  142. data/lib/rgame/engine/pool.rb +51 -0
  143. data/lib/rgame/engine/resettable.rb +67 -0
  144. data/lib/rgame/engine/scene/scene_stack.rb +65 -0
  145. data/lib/rgame/engine/signal.rb +75 -0
  146. data/lib/rgame/engine/spatial_hash.rb +71 -0
  147. data/lib/rgame/engine/tile_collision.rb +78 -0
  148. data/lib/rgame/engine/tile_map.rb +149 -0
  149. data/lib/rgame/engine/tileset.rb +101 -0
  150. data/lib/rgame/engine/timer.rb +51 -0
  151. data/lib/rgame/engine.rb +68 -0
  152. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  153. data/lib/rgame/fonts/OFL.txt +102 -0
  154. data/lib/rgame/game.rb +129 -0
  155. data/lib/rgame/util/color.rb +27 -0
  156. data/lib/rgame/util/controls.rb +107 -0
  157. data/lib/rgame/util/tensor.rb +12 -0
  158. data/lib/rgame/util.rb +8 -0
  159. data/lib/rgame/version.rb +12 -0
  160. data/lib/rgame.rb +20 -0
  161. metadata +215 -0
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A fixed-size 2-D grid backed by a single flat (row-major) array, addressed as
6
+ # [x, y]. The flat backing keeps the whole grid in one contiguous allocation —
7
+ # cheaper than an array-of-arrays and a straightforward shape to later move into a
8
+ # C-level buffer. Pure; no bounds checking on the hot path (callers stay in range).
9
+ #
10
+ # This stays Ruby, unlike the engine's old `Tensor`, which was deleted in
11
+ # favour of the C `RGame::Util::Tensor`. The reason is only that there is no
12
+ # C `Util::Matrix` to swap to — `Util` holds `Tensor`, `Controls` and
13
+ # `Color`. If one is ever written, this is the caller to point at it.
14
+ class Matrix
15
+ attr_reader :width, :height
16
+
17
+ def initialize(width, height, initial: nil)
18
+ @width = width
19
+ @height = height
20
+ @data = Array.new(width * height, initial)
21
+ end
22
+
23
+ def [](x, y)
24
+ @data[(y * @width) + x]
25
+ end
26
+
27
+ def []=(x, y, value)
28
+ @data[(y * @width) + x] = value
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A node in a scene graph. We currently have only 2D nodes, but the
6
+ # name reflects this should there ever be a 3D space. Nodes are
7
+ # containers for both containers and nodes. They extend the signal
8
+ # DSL to allow for easy signal usage.
9
+ class Node2D
10
+ extend Engine::Signal::DSL
11
+
12
+ attr_accessor :x, :y, :z, :angle, :width, :height, :parent
13
+ attr_writer :scene, :context
14
+ attr_reader :children, :components, :abs_x, :abs_y, :abs_z, :abs_angle
15
+
16
+ def initialize(x: 0, y: 0, z: 0, angle: 0, width: 0, height: 0)
17
+ @x = x
18
+ @y = y
19
+ @z = z
20
+ @angle = angle
21
+ @width = width
22
+ @height = height
23
+ @children = []
24
+ @components = []
25
+ @component_slots = {} # slot (Class by default, or a Symbol name) => component
26
+ @parent = nil
27
+ @scene = nil
28
+ @in_tree = false
29
+ @freed = false
30
+ end
31
+
32
+ def add_node(node)
33
+ @children << node
34
+ node.parent = self
35
+ # Defer the entered-tree cascade until this node is itself live; otherwise it
36
+ # fires when an ancestor enters (see #enter_tree). This is the construct-vs-enter
37
+ # split — a node built inside another node's initialize is not yet in the tree.
38
+ node.enter_tree if @in_tree
39
+ node
40
+ end
41
+
42
+ def remove_node(node)
43
+ node.exit_tree if @in_tree
44
+ @children.delete(node)
45
+ node.parent = nil
46
+ node
47
+ end
48
+
49
+ # Look a component up by its slot. A Class/Module is matched by ancestry across every
50
+ # component (so a base class finds a subclass instance); a Symbol names a specific slot
51
+ # (see #add_component's `as:`). A class lookup raises when it's ambiguous — two
52
+ # components share that type — so the caller reaches for the name instead. The scan is
53
+ # allocation-free, so it's safe to call on the per-frame path.
54
+ def get_component(key)
55
+ return @component_slots[key] unless key.is_a?(Module)
56
+
57
+ found = nil
58
+ @components.each do |component|
59
+ next unless component.is_a?(key)
60
+ raise ArgumentError, "Multiple components match #{key}; look one up by name" if found
61
+
62
+ found = component
63
+ end
64
+ found
65
+ end
66
+
67
+ # Attach a component in a named slot. The slot defaults to the component's class, so a
68
+ # node still holds at most one component per class — until you give them distinct
69
+ # names: `add_component(Timer.new, as: :spawn)` / `add_component(Timer.new, as: :wave)`.
70
+ # A taken slot raises, so an accidental duplicate is still caught.
71
+ def add_component(component, as: nil)
72
+ slot = as || component.class
73
+ raise ArgumentError, "Node already has a component in slot #{slot.inspect}" if @component_slots.key?(slot)
74
+
75
+ @components << component
76
+ @component_slots[slot] = component
77
+ component.node = self
78
+ component.on_attach if @in_tree
79
+ component
80
+ end
81
+
82
+ def remove_component(key)
83
+ component = get_component(key)
84
+ return nil unless component
85
+
86
+ component.on_detach if @in_tree
87
+ @components.delete(component)
88
+ @component_slots.delete(@component_slots.key(component))
89
+ component.node = nil
90
+ component
91
+ end
92
+
93
+ # Anchors, resolved by walking parents so they can never go stale (a cached
94
+ # back-link set at add-time breaks when children are built before the node is
95
+ # in the tree). Shared systems live as components on an anchor node and are
96
+ # reached through these, not threaded through constructors.
97
+
98
+ # The top-most node — a node with no parent is its own root. Global,
99
+ # program-lifetime systems live here as components.
100
+ def root
101
+ @parent ? @parent.root : self
102
+ end
103
+
104
+ # The nearest enclosing scene node, marked as a boundary by SceneStack
105
+ # (#scene= self). Scene-lifetime systems live on it as components.
106
+ def scene
107
+ @scene || @parent&.scene
108
+ end
109
+
110
+ def context
111
+ @context ||= root.context
112
+ end
113
+
114
+ # Nearest system of a class: scene scope first, then the global root.
115
+ def system(klass)
116
+ scene&.get_component(klass) || root.get_component(klass)
117
+ end
118
+
119
+ # updates input, both from player (readings actions) as well as
120
+ # AI-driven node control. This run first in a game tick
121
+ # Each phase settles this node first (components, then the node's own
122
+ # hook), then descends into children. Self-before-subtree keeps the
123
+ # transform flowing downward: a component or hook that moves this node
124
+ # does so before children resolve their origin from it.
125
+
126
+ def control(actions)
127
+ resolve_origin
128
+ @components.each { it.control(actions) }
129
+ on_control(actions)
130
+ @children.each { it.control(actions) }
131
+ end
132
+
133
+ # update game logic and physics (might become two calls with
134
+ # time, but for now works in one step). This runs second in a
135
+ # game tick
136
+ def update(dt)
137
+ resolve_origin
138
+ @components.each { it.update(dt) }
139
+ on_update(dt)
140
+ @children.each { it.update(dt) }
141
+ end
142
+
143
+ # update visual game state, drawing the node. This runs last in
144
+ # a game tick
145
+ def draw(renderer)
146
+ resolve_origin
147
+ # Draw this node's own visuals oriented by its absolute angle, then descend.
148
+ # Children resolve their own world transform (resolve_origin already baked this
149
+ # node's rotation into their abs_x/abs_y), so they draw in flat world space and
150
+ # must NOT be nested inside this node's rotation — nesting would apply that
151
+ # rotation to them a second time. Unrotated nodes skip the wrapper entirely.
152
+ if abs_angle.zero?
153
+ draw_content(renderer)
154
+ else
155
+ renderer.rotated(abs_angle * 180.0 / Math::PI, abs_x, abs_y) { draw_content(renderer) }
156
+ end
157
+ draw_children(renderer)
158
+ end
159
+
160
+ def in_tree? = @in_tree
161
+
162
+ # Deferred removal (à la Godot's queue_free): mark this node for removal
163
+ # instead of detaching it now. A node that removes itself or a sibling
164
+ # mid-traversal would mutate the parent's @children while it's being iterated;
165
+ # marking instead and sweeping once after the tick (see #sweep_freed, flushed by
166
+ # the platform loop) keeps removal safe and allocation-free.
167
+ def queue_free = @freed = true
168
+ def freed? = @freed
169
+
170
+ # Detach every node marked by #queue_free, depth-first, from a point outside the
171
+ # update traversal. Components get a hook too, so a container-style component
172
+ # (e.g. SceneStack) can flush the subtree it owns off the normal child list.
173
+ def sweep_freed
174
+ @components.each(&:sweep_freed)
175
+ i = 0
176
+ while i < @children.size
177
+ child = @children[i]
178
+ if child.freed?
179
+ remove_node(child) # detaches + exit_tree; @children shrinks, so don't advance i
180
+ else
181
+ child.sweep_freed
182
+ i += 1
183
+ end
184
+ end
185
+ end
186
+
187
+ # Entered-tree cascade: anchors (root/scene) and sibling systems are now
188
+ # reachable, so components attach (register with systems) before this node's
189
+ # own on_add, and the whole subtree enters depth-first. The engine fires this
190
+ # — the user never calls it — so registration can't be forgotten. Idempotent.
191
+ def enter_tree
192
+ return if @in_tree
193
+
194
+ @in_tree = true
195
+ @freed = false # revive: a pooled node reacquired after death re-enters here
196
+ @components.each(&:on_attach)
197
+ on_add
198
+ @children.each(&:enter_tree)
199
+ end
200
+
201
+ # Leaving-tree cascade: mirror of #enter_tree (children first, then this
202
+ # node's on_remove, then component on_detach to release registrations).
203
+ def exit_tree
204
+ return unless @in_tree
205
+
206
+ @children.each(&:exit_tree)
207
+ on_remove
208
+ @components.each(&:on_detach)
209
+ @in_tree = false
210
+ end
211
+
212
+ # Lifecycle hooks: Subclasses should implement these instead of
213
+ # overwriting the public interface draw/update/add etc. on_add/on_remove
214
+ # fire when the node enters/leaves the live tree (see #enter_tree), not at
215
+ # construction — so anchors and systems are available inside them.
216
+
217
+ def on_control(actions); end
218
+ def on_update(dt); end
219
+ def on_draw(renderer); end
220
+ def on_add; end
221
+ def on_remove; end
222
+
223
+ private
224
+
225
+ # This node's own drawing: its components and its draw hook, in that order.
226
+ # Wrapped in renderer.rotated by #draw when the node carries an absolute angle.
227
+ def draw_content(renderer)
228
+ @components.each { it.draw(renderer) }
229
+ on_draw(renderer)
230
+ end
231
+
232
+ # Draw the child subtrees. Its own method so a node can wrap the whole subtree's
233
+ # draw in a transform — e.g. CameraView wraps it in renderer.translated to apply a
234
+ # camera offset, without each child knowing about the camera.
235
+ def draw_children(renderer)
236
+ @children.each { it.draw(renderer) }
237
+ end
238
+
239
+ # TODO: Calculate (and cache?) depth
240
+ # depth = z + highest z of children, maybe +1?
241
+
242
+ # Resolve this node's absolute transform from the parent origin passed down by the
243
+ # traversal. Relative x/y/z/angle accumulate, so a nested Node offsets, re-layers
244
+ # and rotates its whole subtree: a child's local (x, y) is rotated by the parent's
245
+ # accumulated angle before being added to the parent's origin.
246
+ # TODO: Do not recalculate every time, but use a @dirty flag
247
+ # TODO: Handle z better - offset not by Z of the parent, but their
248
+ # depth
249
+ def resolve_origin
250
+ if @parent.nil?
251
+ @abs_x = @abs_y = @abs_z = 0
252
+ @abs_angle = 0 # root pinned to identity, like its position
253
+ return
254
+ end
255
+
256
+ pa = @parent.abs_angle
257
+ if pa.zero? # fast path: parent unrotated -> plain translation, no trig
258
+ @abs_x = @parent.abs_x + @x
259
+ @abs_y = @parent.abs_y + @y
260
+ else
261
+ cos = Math.cos(pa)
262
+ sin = Math.sin(pa)
263
+ @abs_x = @parent.abs_x + (@x * cos) - (@y * sin)
264
+ @abs_y = @parent.abs_y + (@x * sin) + (@y * cos)
265
+ end
266
+ @abs_z = @parent.abs_z + @z
267
+ @abs_angle = pa + @angle
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # An ordered polyline of waypoints an entity walks along — the "road" of a tower
6
+ # defense level. Pure data: it holds the waypoints and the precomputed per-segment
7
+ # lengths, so a follower walking it at runtime allocates nothing.
8
+ #
9
+ # Waypoints are stored flat (x0, y0, x1, y1, …) in one contiguous array rather than a
10
+ # pair-object per point, and read back through scalar accessors (`x_at`/`y_at`), so
11
+ # neither construction shape nor traversal leaks per-waypoint Arrays onto the hot path.
12
+ # A follower (see Components::PathFollow) reads segments by index and interpolates
13
+ # itself; Path never returns a coordinate pair.
14
+ class Path
15
+ attr_reader :length, :count
16
+
17
+ # `points` is an Array of [x, y] waypoint pairs in walk order (construction-time, so
18
+ # the pair Arrays are fine here). At least two are required — a path with one point
19
+ # has nowhere to walk.
20
+ def initialize(points)
21
+ raise ArgumentError, 'a Path needs at least two waypoints' if points.length < 2
22
+
23
+ @coords = points.flatten.freeze
24
+ @count = points.length
25
+ @segment_lengths, @length = build_segments
26
+ end
27
+
28
+ # World coordinates of waypoint `i` (0-based), as scalars (no allocation).
29
+ def x_at(index) = @coords[index * 2]
30
+ def y_at(index) = @coords[(index * 2) + 1]
31
+
32
+ # Length of the segment from waypoint `i` to waypoint `i + 1`. There are
33
+ # `count - 1` segments, indexed 0..count-2.
34
+ def segment_length(index) = @segment_lengths[index]
35
+
36
+ # Shortest distance from the point (x, y) to the polyline — e.g. how far a spot is
37
+ # from the road, so a tower-defense level can mask placement cells that sit on it.
38
+ # Pure scalar maths, allocation-free.
39
+ def distance_to(x, y)
40
+ min = Float::INFINITY
41
+ (@count - 1).times do |i|
42
+ d = segment_distance(x, y, x_at(i), y_at(i), x_at(i + 1), y_at(i + 1))
43
+ min = d if d < min
44
+ end
45
+ min
46
+ end
47
+
48
+ private
49
+
50
+ # Distance from (px, py) to the segment (ax, ay)-(bx, by): project the point onto the
51
+ # segment, clamp the projection to the segment's ends, and measure to that foot.
52
+ def segment_distance(px, py, ax, ay, bx, by)
53
+ abx = bx - ax
54
+ aby = by - ay
55
+ len2 = (abx * abx) + (aby * aby)
56
+ t = len2.zero? ? 0.0 : (((px - ax) * abx) + ((py - ay) * aby)) / len2
57
+ t = 0.0 if t < 0.0
58
+ t = 1.0 if t > 1.0
59
+ dx = px - (ax + (t * abx))
60
+ dy = py - (ay + (t * aby))
61
+ Math.sqrt((dx * dx) + (dy * dy))
62
+ end
63
+
64
+ def build_segments
65
+ lengths = []
66
+ total = 0.0
67
+ (@count - 1).times do |i|
68
+ dx = x_at(i + 1) - x_at(i)
69
+ dy = y_at(i + 1) - y_at(i)
70
+ len = Math.sqrt((dx * dx) + (dy * dy))
71
+ lengths << len
72
+ total += len
73
+ end
74
+ [lengths.freeze, total]
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A reuse-don't-allocate pool for many short-lived, homogeneous objects —
6
+ # bullets, particles, transient enemies. Acquired objects come from a free list
7
+ # (or the factory when the list is empty) so steady-state spawning allocates
8
+ # nothing. Pure logic; no graphics.
9
+ #
10
+ # pool = Engine::Pool.new { Bullet.new }
11
+ # b = pool.acquire # recycled or freshly built
12
+ # pool.each { |b| b.update(dt) }
13
+ # pool.reclaim_if(&:dead?) # sweep dead → free list, once per frame
14
+ #
15
+ # The factory builds a blank object; callers re-initialise it after `acquire`.
16
+ class Pool
17
+ def initialize(&factory)
18
+ @factory = factory
19
+ @free = []
20
+ @active = []
21
+ end
22
+
23
+ attr_reader :active
24
+
25
+ def acquire
26
+ obj = @free.empty? ? @factory.call : @free.pop
27
+ @active.push(obj)
28
+ obj
29
+ end
30
+
31
+ def each(&) = @active.each(&)
32
+
33
+ def size = @active.size
34
+
35
+ # No live (acquired, not-yet-reclaimed) objects.
36
+ def empty? = @active.empty?
37
+
38
+ # Deferred removal: sweep the active list once, moving every object for which
39
+ # the block returns true onto the free list. Safe to call after iterating with
40
+ # `each` — never mutate the active list mid-iteration; mark objects dead during
41
+ # the frame and reclaim them here.
42
+ def reclaim_if
43
+ @active.reject! do |obj|
44
+ dead = yield(obj)
45
+ @free.push(obj) if dead
46
+ dead
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Builds value-object classes for pooling. Like `Data.define`, instances expose
6
+ # read-only accessors and carry their fields as a unit — but where a `Data` value
7
+ # is fully immutable (every change means a fresh allocation), these add exactly
8
+ # one mutation: `reset`, which overwrites all fields at once and returns self.
9
+ #
10
+ # That is the only mutability a pool needs: acquire a recycled instance and
11
+ # `reset` it, without exposing the per-field setters a `Struct` would. The methods
12
+ # are generated fixed-arity with direct ivar assignment (as Struct/Data do), so
13
+ # `reset` is allocation-free and recycling stays zero-allocation in steady state.
14
+ #
15
+ # Positional by default; pass `keyword_init: true` for keyword fields. Keywords
16
+ # are *also* allocation-free here because they are generated as named parameters
17
+ # (`reset(x:, y:)`), not a `**kwargs` splat — the splat is the one form that builds
18
+ # a Hash per call.
19
+ #
20
+ # Point = Engine::Resettable.define(:x, :y)
21
+ # p = Point.new(3, 4); p.x # => 3
22
+ # p.reset(5, 6) # same object, new values; returns p
23
+ #
24
+ # Event = Engine::Resettable.define(:type, :payload, keyword_init: true)
25
+ # e = Event.new(type: :hit, payload: 10)
26
+ # e.reset(type: :lose, payload: nil) # same object; returns e
27
+ # e.respond_to?(:type=) # => false (no setters)
28
+ #
29
+ # Field names are generated into real methods, so they must be plain identifiers.
30
+ module Resettable
31
+ IDENTIFIER = /\A[a-z_][a-zA-Z0-9_]*\z/
32
+
33
+ def self.define(*fields, keyword_init: false)
34
+ fields.each do |field|
35
+ raise ArgumentError, "invalid field name: #{field.inspect}" unless field.to_s.match?(IDENTIFIER)
36
+ end
37
+
38
+ params = fields.map { |f| keyword_init ? "#{f}:" : f.to_s }.join(', ')
39
+ assign = fields.map { |f| "@#{f} = #{f}" }.join("\n")
40
+
41
+ Class.new do
42
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
43
+ # For fields [:x, :y] this generates (keyword_init makes `x, y` -> `x:, y:`):
44
+ # attr_reader :x, :y
45
+ # def initialize(x, y)
46
+ # @x = x; @y = y
47
+ # end
48
+ # def reset(x, y)
49
+ # @x = x; @y = y
50
+ # self
51
+ # end
52
+ attr_reader #{fields.map { |f| ":#{f}" }.join(', ')}
53
+
54
+ def initialize(#{params})
55
+ #{assign}
56
+ end
57
+
58
+ def reset(#{params})
59
+ #{assign}
60
+ self
61
+ end
62
+ RUBY
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Scene
6
+ class SceneStack < Engine::Component
7
+ def initialize
8
+ super
9
+ @stack = []
10
+ end
11
+
12
+ def push(scene)
13
+ @stack.push(scene)
14
+ scene.parent = node # so scene.root resolves up to the host
15
+ scene.scene = scene # mark the scene as its subtree's scene boundary
16
+ scene.enter_tree # cascades on_attach/on_add through the scene
17
+ self
18
+ end
19
+
20
+ def pop
21
+ scene = @stack.pop
22
+ return self unless scene
23
+
24
+ scene.exit_tree # cascades on_remove/on_detach, releasing systems
25
+ scene.scene = nil
26
+ scene.parent = nil
27
+ self
28
+ end
29
+
30
+ def replace(scene)
31
+ pop
32
+ push(scene)
33
+ end
34
+
35
+ def current
36
+ @stack.last
37
+ end
38
+
39
+ def control(actions)
40
+ return unless (current_scene = current)
41
+
42
+ current_scene.control(actions)
43
+ end
44
+
45
+ def update(dt)
46
+ return unless (current_scene = current)
47
+
48
+ current_scene.update(dt)
49
+ end
50
+
51
+ def draw(renderer)
52
+ @stack.each do |scene|
53
+ scene.draw(renderer)
54
+ end
55
+ end
56
+
57
+ # Scenes live in @stack, off the host's child list, so the host's #sweep_freed
58
+ # can't reach them — forward the sweep into the active scene's subtree.
59
+ def sweep_freed
60
+ current&.sweep_freed
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Signal
6
+ IDENTIFIER = /\A[a-z_][a-zA-Z0-9_]*\z/
7
+
8
+ # Class-level DSL for declaring signal "slots" so a host class need not hand-write
9
+ # the connect-forwarding boilerplate. `extend Engine::Signal::DSL`, then:
10
+ #
11
+ # signal :on_clicked # a no-arg signal
12
+ # signal :on_changed, ChangeSignal # a typed signal (Signal.define(:index, :value))
13
+ #
14
+ # For :on_clicked this generates:
15
+ # def on_clicked(&block) = on_clicked_signal.connect(&block) # public: subscribe, returns handle
16
+ # def on_clicked_signal = (@on_clicked ||= type.new) # private: the Signal, to emit on
17
+ #
18
+ # The Signal is built lazily on first use, so the host wires nothing in #initialize;
19
+ # emit from inside the host via the private `<name>_signal` reader.
20
+ module DSL
21
+ def signal(name, type = Signal.define)
22
+ ivar = :"@#{name}"
23
+ reader = :"#{name}_signal"
24
+
25
+ define_method(reader) do
26
+ instance_variable_get(ivar) || instance_variable_set(ivar, type.new)
27
+ end
28
+ private reader
29
+
30
+ define_method(name) { |&block| send(reader).connect(&block) }
31
+ end
32
+ end
33
+
34
+ def self.define(*fields)
35
+ fields.each do |field|
36
+ raise ArgumentError, "invalid field name: #{field.inspect}" unless field.to_s.match?(IDENTIFIER)
37
+ end
38
+
39
+ params = fields.size == 1 ? fields.first : fields.map { |f| "#{f}:" }.join(', ')
40
+ args = fields.join(', ')
41
+
42
+ Class.new do
43
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
44
+ # For fields [:x, :y] this generates:
45
+ # def initialize
46
+ # @listeners = []
47
+ # end
48
+ # def connect(&callback)
49
+ # @listeners << callback
50
+ # callback
51
+ # end
52
+ # def disconnect(handle) = @listeners.delete(handle)
53
+ # def emit(x:, y:)
54
+ # @listeners.each { it.call(x, y) }
55
+ # end
56
+
57
+ def initialize
58
+ @listeners = []
59
+ end
60
+
61
+ def connect(&callback)
62
+ @listeners << callback
63
+ callback
64
+ end
65
+
66
+ def disconnect(handle) = @listeners.delete(handle)
67
+ def emit(#{params})
68
+ @listeners.each { it.call(#{args}) }
69
+ end
70
+ RUBY
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end