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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A character's collision rectangle, expressed as an offset + size relative to
6
+ # the actor's top-left origin (the sprite's top-left). Decoupled from sprite
7
+ # size: a 32x32 sprite can have a small 16x16 box at its feet.
8
+ class CollisionBox
9
+ attr_reader :offset_x, :offset_y, :width, :height
10
+
11
+ # Centre the box horizontally and anchor it to the bottom of the sprite (feet).
12
+ def self.bottom_anchored(sprite_width:, sprite_height:, width:, height:)
13
+ new(
14
+ offset_x: (sprite_width - width) / 2,
15
+ offset_y: sprite_height - height,
16
+ width: width,
17
+ height: height
18
+ )
19
+ end
20
+
21
+ def initialize(offset_x:, offset_y:, width:, height:)
22
+ @offset_x = offset_x
23
+ @offset_y = offset_y
24
+ @width = width
25
+ @height = height
26
+ end
27
+
28
+ # World-space AABB [x, y, w, h] for an actor whose origin is at (x, y).
29
+ def aabb(x, y)
30
+ [x + @offset_x, y + @offset_y, @width, @height]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The actor-facing collision system: moves any actor by a delta, resolving its
6
+ # *collision box* (not its sprite) against the tiles via TileCollision, then
7
+ # clamping the box inside the world as a backstop. Reusable by the player and
8
+ # any NPC.
9
+ #
10
+ class CollisionSystem
11
+ def initialize(tile_collision:, world_width:, world_height:)
12
+ @tiles = tile_collision
13
+ @world_width = world_width
14
+ @world_height = world_height
15
+ end
16
+
17
+ # Move `actor` by (dx, dy), writing the resolved position back to it.
18
+ def move(actor, dx, dy)
19
+ box = actor.collision_box
20
+ # Read the box AABB into locals directly rather than via box.aabb, which would
21
+ # allocate an Array on this per-frame path.
22
+ bx = actor.x + box.offset_x
23
+ by = actor.y + box.offset_y
24
+ bw = box.width
25
+ bh = box.height
26
+
27
+ bx = @tiles.resolve_x(bx, by, bw, bh, dx)
28
+ by = @tiles.resolve_y(bx, by, bw, bh, dy)
29
+
30
+ # Clamp the box inside the world. Floor each upper bound at 0 without a [span, 0]
31
+ # array (this runs per actor per frame).
32
+ max_x = @world_width - bw
33
+ max_x = 0 if max_x.negative?
34
+ max_y = @world_height - bh
35
+ max_y = 0 if max_y.negative?
36
+ bx = bx.clamp(0.0, max_x.to_f)
37
+ by = by.clamp(0.0, max_y.to_f)
38
+
39
+ actor.x = bx - box.offset_x
40
+ actor.y = by - box.offset_y
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ class Component
6
+ extend Signal::DSL
7
+
8
+ attr_accessor :node
9
+
10
+ def context
11
+ @context ||= node.context
12
+ end
13
+
14
+ # Tree-lifecycle hooks. on_attach fires once the node is in the live tree, so
15
+ # node.root / node.scene and sibling systems are reachable — pull and register
16
+ # shared systems here, not in initialize (where the node has no anchors yet).
17
+ # on_detach mirrors it: release those registrations.
18
+ def on_attach; end
19
+ def on_detach; end
20
+
21
+ def control(actions); end
22
+ def update(dt); end
23
+ def draw(renderer); end
24
+
25
+ # Container components (e.g. SceneStack) that hold nodes off the normal child
26
+ # list override this to forward the deferred-free sweep into them.
27
+ def sweep_freed; end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Maps held input actions to an `on_triggered(action)` signal, rate-limited by a
7
+ # per-action cooldown. One instance covers several actions (the engine allows
8
+ # only one component of a class per node), so it emits the action name and lets
9
+ # listeners filter — reusable for "fire" here, or "jump"/"fire" in a platformer.
10
+ #
11
+ # trigger = node.add_component(ActionTrigger.new(fire: 0.22, dash: 0.5))
12
+ # trigger.on_triggered { |action| fire if action == :fire }
13
+ #
14
+ # Semantics: while an action is held and its cooldown has elapsed, it fires and
15
+ # the cooldown restarts — i.e. auto-repeat at the cooldown rate.
16
+ class ActionTrigger < Engine::Component
17
+ signal :on_triggered, Engine::Signal.define(:action)
18
+
19
+ def initialize(cooldowns)
20
+ super()
21
+ @cooldowns = cooldowns
22
+ # action => seconds remaining until it may fire again (0 = ready).
23
+ @timers = cooldowns.transform_values { 0.0 }
24
+ end
25
+
26
+ def update(dt)
27
+ @timers.each { |action, remaining| @timers[action] = remaining - dt if remaining.positive? }
28
+ end
29
+
30
+ def control(actions)
31
+ @cooldowns.each_key do |action|
32
+ next unless actions.held?(action) && @timers[action] <= 0.0
33
+
34
+ @timers[action] = @cooldowns[action]
35
+ on_triggered_signal.emit(action)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Draws a sprite-sheet animation for a walking actor, picking the animation from a
7
+ # CharacterBody sibling's movement intent: walk_left/right/up/down while moving
8
+ # (horizontal wins on a diagonal), stand when still. Owns its Animator + the pure
9
+ # AnimationSet built from the sheet's animation table.
10
+ #
11
+ # Like Sprite, it passes NO angle and draws at the node's *world* origin
12
+ # (node.abs_x/abs_y): a CameraView ancestor wraps the draw in renderer.translated to
13
+ # map world → screen, so this component never touches the camera. `z` is the render
14
+ # layer (kept as @layer, distinct from the node's transform z); it must sit between
15
+ # the tile world's ground band and its overlay band so canopies draw in front.
16
+ #
17
+ # `sheet` is the asset's relative path. The component resolves it from the game's
18
+ # asset manager on attach — via node.root.context.assets (the platform seam) — to
19
+ # build its animation table and to size the node to the sprite's frame, so siblings
20
+ # like CharacterBody can read node.width/height. The renderer resolves the same
21
+ # symbol when drawing, so nothing is registered or passed in by hand.
22
+ class AnimatedSprite < Engine::Component
23
+ def initialize(sheet:, z: 0)
24
+ super()
25
+ @sheet = sheet
26
+ @layer = z
27
+ end
28
+
29
+ def on_attach
30
+ sheet = context.assets.sheet(@sheet)
31
+ @animator = Engine::Animator.new(Engine::AnimationSet.new(sheet.animations))
32
+ node.width = sheet.frame_width
33
+ node.height = sheet.frame_height
34
+ @body = node.get_component(CharacterBody)
35
+ end
36
+
37
+ def update(dt)
38
+ @animator.play(walk_animation(@body.move_x, @body.move_y))
39
+ @animator.update(dt)
40
+ end
41
+
42
+ def draw(renderer)
43
+ # Read row/col/flip_x separately (not @animator.frame, which allocates an Array
44
+ # every call) to keep the draw path allocation-free.
45
+ renderer.sprite(@sheet, @animator.row, @animator.col, node.abs_x, node.abs_y,
46
+ flip_x: @animator.flip_x, z: @layer)
47
+ end
48
+
49
+ private
50
+
51
+ # Horizontal movement wins over vertical, so a diagonal walk faces left/right.
52
+ def walk_animation(move_x, move_y)
53
+ if move_x.negative? then :walk_left
54
+ elsif move_x.positive? then :walk_right
55
+ elsif move_y.negative? then :walk_up
56
+ elsif move_y.positive? then :walk_down
57
+ else :stand
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Walking movement for a tile-bound actor (player or NPC). A controller writes a
7
+ # per-step movement intent — each axis in -1..1 — and this component turns it into
8
+ # a real move each update, resolved against the scene's TileWorld so the actor
9
+ # slides along walls and stays inside the map. Distinct from Velocity (which
10
+ # integrates blindly): here every step is collision-checked.
11
+ #
12
+ # The intent doubles as the facing for AnimatedSprite (move_x / move_y readers), so
13
+ # a character is just CharacterBody + a controller + AnimatedSprite.
14
+ #
15
+ # The feet box is built from the node's dimensions (which AnimatedSprite sets from the
16
+ # sprite frame), not a passed-in sprite size — `feet_width`/`feet_height` are the box,
17
+ # centred horizontally and anchored to the node's bottom. It's built lazily on first
18
+ # use (the first update, after every on_attach has run), so the order components are
19
+ # added in doesn't matter. A body with no sprite must set the node's width/height.
20
+ class CharacterBody < Engine::Component
21
+ attr_reader :move_x, :move_y
22
+
23
+ def initialize(feet_width:, feet_height:, speed:)
24
+ super()
25
+ @feet_width = feet_width
26
+ @feet_height = feet_height
27
+ @speed = speed
28
+ @move_x = 0.0
29
+ @move_y = 0.0
30
+ @collision_box = nil
31
+ end
32
+
33
+ def collision_box
34
+ @collision_box ||= Engine::CollisionBox.bottom_anchored(
35
+ sprite_width: node.width, sprite_height: node.height,
36
+ width: @feet_width, height: @feet_height
37
+ )
38
+ end
39
+
40
+ # The TileWorld is a scene-scoped system, reachable once we're in the tree.
41
+ def on_attach = @world = node.system(TileWorld)
42
+
43
+ # Set this step's movement intent; each axis is in -1..1.
44
+ def set_intent(intent_x, intent_y)
45
+ @move_x = intent_x
46
+ @move_y = intent_y
47
+ end
48
+
49
+ def update(dt)
50
+ return if @move_x.zero? && @move_y.zero?
51
+
52
+ @world.move(self, @move_x * @speed * dt, @move_y * @speed * dt)
53
+ end
54
+
55
+ # CollisionSystem#move drives an "actor" through x/y/collision_box and writes the
56
+ # resolved position back — back those by the owning node's transform.
57
+ def x = node.x
58
+ def y = node.y
59
+
60
+ def x=(value)
61
+ node.x = value
62
+ end
63
+
64
+ def y=(value)
65
+ node.y = value
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A circular collision shape on a node. It registers itself with the scene's
7
+ # CollisionWorld system when it enters the tree and unregisters on leaving —
8
+ # the engine fires both hooks, so a spawned/despawned entity can't leak a
9
+ # registration. Its world centre is the node's resolved absolute origin; the
10
+ # `layer` is an opaque tag the game reads in its on_hit handler to decide what a
11
+ # contact means (bullet-vs-rock, etc.). See docs/api/systems.md.
12
+ class CircleCollider < Engine::Component
13
+ # Fired by CollisionWorld for each overlapping collider; the listener gets the
14
+ # other collider and reads its #layer / #node to react.
15
+ signal :on_hit, Engine::Signal.define(:other)
16
+
17
+ # radius is writable so a pooled entity (e.g. a multi-tier rock) can retune its
18
+ # shape on reset; CollisionWorld reads it fresh each frame, so no re-registration.
19
+ attr_accessor :radius
20
+ attr_reader :layer
21
+
22
+ def initialize(radius:, layer: :default)
23
+ super()
24
+ @radius = radius
25
+ @layer = layer
26
+ end
27
+
28
+ def on_attach = node.system(CollisionWorld).register(self)
29
+ def on_detach = node.system(CollisionWorld)&.unregister(self)
30
+
31
+ # World-space centre — the node's resolved absolute origin.
32
+ def cx = node.abs_x
33
+ def cy = node.abs_y
34
+
35
+ def overlap?(other)
36
+ Engine::CircleCollider.overlap?(cx, cy, @radius, other.cx, other.cy, other.radius)
37
+ end
38
+
39
+ # Called by CollisionWorld on contact (the signal's emit is otherwise private).
40
+ def emit_hit(other) = on_hit_signal.emit(other)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Scene-scoped broadphase collision system: a Component that lives on the scene
7
+ # node (so it is born and torn down with the scene, and rides the normal update
8
+ # traversal). CircleColliders register/unregister with it via their tree
9
+ # lifecycle; each update it buckets them in a SpatialHash and fires on_hit on
10
+ # every overlapping pair. It is layer-agnostic — it reports contacts and lets
11
+ # the colliders' owners decide meaning. See docs/api/systems.md.
12
+ class CollisionWorld < Engine::Component
13
+ def initialize(cell_size:)
14
+ super()
15
+ @hash = Engine::SpatialHash.new(cell_size: cell_size)
16
+ @colliders = []
17
+ end
18
+
19
+ def register(collider) = @colliders << collider
20
+ def unregister(collider) = @colliders.delete(collider)
21
+
22
+ # Yield every registered collider whose centre lies within `r` of (x, y), using
23
+ # the spatial index built by the most recent #update. The narrowphase is a
24
+ # centre-distance test — the query is a point + range (a tower's range ring), so
25
+ # the collider's own radius isn't added in. Colliders whose node is queued for
26
+ # removal are skipped. As with SpatialHash#query a collider spanning several cells
27
+ # may be yielded more than once, so callers that *select* (e.g. #nearest) are
28
+ # written dup-insensitively. Layer-agnostic — filter by `collider.layer` in the
29
+ # block. Allocation-free.
30
+ def query_circle(x, y, r)
31
+ r2 = r * r
32
+ @hash.query_circle(x, y, r) do |collider|
33
+ next if collider.node.freed?
34
+
35
+ dx = collider.cx - x
36
+ dy = collider.cy - y
37
+ yield collider if (dx * dx) + (dy * dy) <= r2
38
+ end
39
+ end
40
+
41
+ # The registered collider nearest to (x, y) within range `r`, or nil when none
42
+ # qualifies. Restrict to a single `layer:` (the common case: a tower targeting only
43
+ # :enemy). Dup-safe — it keeps the running minimum, so #query_circle's possible
44
+ # multi-cell repeats don't matter. Allocation-free.
45
+ def nearest(x, y, r, layer: nil)
46
+ best = nil
47
+ best_d2 = nil
48
+ query_circle(x, y, r) do |collider|
49
+ next if layer && collider.layer != layer
50
+
51
+ dx = collider.cx - x
52
+ dy = collider.cy - y
53
+ d2 = (dx * dx) + (dy * dy)
54
+ next unless best_d2.nil? || d2 < best_d2
55
+
56
+ best = collider
57
+ best_d2 = d2
58
+ end
59
+ best
60
+ end
61
+
62
+ def update(_dt)
63
+ @hash.clear
64
+ @colliders.each { |c| insert(c) }
65
+
66
+ # Index-bounded over the count at frame start: an on_hit handler may spawn
67
+ # entities (a rock splitting), which `register`s new colliders mid-loop; those
68
+ # appended ones are skipped this frame (processed next) rather than mutating
69
+ # the array being iterated. The hash was built before the loop, so they're
70
+ # absent from queries too — consistent.
71
+ count = @colliders.size
72
+ i = 0
73
+ while i < count
74
+ a = @colliders[i]
75
+ i += 1
76
+ next if a.node.freed?
77
+
78
+ r = a.radius
79
+ d = r * 2
80
+ @hash.query(a.cx - r, a.cy - r, d, d) do |b|
81
+ # object_id ordering visits each unordered pair once (and skips self);
82
+ # the freed? guards skip nodes already queued for removal, so a dead
83
+ # entity stops colliding and duplicate (multi-cell) yields are ignored.
84
+ next if a.node.freed? || b.node.freed? || a.object_id >= b.object_id
85
+ next unless a.overlap?(b)
86
+
87
+ a.emit_hit(b)
88
+ b.emit_hit(a)
89
+ end
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def insert(collider)
96
+ r = collider.radius
97
+ d = r * 2
98
+ @hash.insert(collider, collider.cx - r, collider.cy - r, d, d)
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Queues the node for removal once it has fully left the bounds (plus margin).
7
+ # From Body#offscreen?. Used by short-lived projectiles; removal is deferred via
8
+ # queue_free so it is safe to trigger from inside the update traversal.
9
+ class DespawnOffscreen < Engine::Component
10
+ def initialize(width:, height:, margin: 0.0)
11
+ super()
12
+ @width = width
13
+ @height = height
14
+ @margin = margin
15
+ end
16
+
17
+ def update(_dt)
18
+ x = node.x
19
+ y = node.y
20
+ offscreen = x < -@margin || x > @width + @margin || y < -@margin || y > @height + @margin
21
+ node.queue_free if offscreen
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Walks the owning node along an Engine::Path at a constant speed, segment by
7
+ # segment, and emits `on_finished` once it reaches the final waypoint — the seam a
8
+ # tower defense game uses to leak a life when an enemy reaches the base.
9
+ #
10
+ # The walk is allocation-free: it tracks the current segment and the distance into
11
+ # it, advancing through as many segments as one step crosses (so a fast mover over
12
+ # short segments still lands correctly), then interpolates the node's position from
13
+ # the segment endpoints. Movement is driven purely by `speed * dt`; this is not a
14
+ # Velocity integrator and ignores the node's angle.
15
+ class PathFollow < Engine::Component
16
+ signal :on_finished # emits no payload; the owning node identifies which follower finished
17
+
18
+ attr_accessor :speed
19
+
20
+ def initialize(path:, speed:)
21
+ super()
22
+ @path = path
23
+ @speed = speed
24
+ @segment = 0 # walking from waypoint @segment to @segment + 1
25
+ @distance = 0.0 # distance travelled into the current segment
26
+ @finished = false
27
+ end
28
+
29
+ def finished? = @finished
30
+
31
+ # Restart the walk as the node enters the tree — back to the first waypoint, with
32
+ # progress cleared — so a pooled follower reacquired and re-added begins a fresh walk
33
+ # rather than resuming (or staying finished) where its previous life ended.
34
+ def on_attach
35
+ @segment = 0
36
+ @distance = 0.0
37
+ @finished = false
38
+ place_at(0)
39
+ end
40
+
41
+ def update(dt)
42
+ return if @finished
43
+
44
+ remaining = @speed * dt
45
+ while remaining.positive?
46
+ left = @path.segment_length(@segment) - @distance
47
+ if remaining < left
48
+ @distance += remaining
49
+ break
50
+ end
51
+ # Consume the rest of this segment and step onto the next waypoint.
52
+ remaining -= left
53
+ @segment += 1
54
+ @distance = 0.0
55
+ return finish if @segment >= @path.count - 1
56
+ end
57
+ place_along_segment
58
+ end
59
+
60
+ private
61
+
62
+ def finish
63
+ place_at(@path.count - 1)
64
+ @finished = true
65
+ on_finished_signal.emit
66
+ end
67
+
68
+ def place_at(waypoint)
69
+ node.x = @path.x_at(waypoint)
70
+ node.y = @path.y_at(waypoint)
71
+ end
72
+
73
+ def place_along_segment
74
+ seg_len = @path.segment_length(@segment)
75
+ t = seg_len.zero? ? 0.0 : @distance / seg_len
76
+ sx = @path.x_at(@segment)
77
+ sy = @path.y_at(@segment)
78
+ node.x = sx + ((@path.x_at(@segment + 1) - sx) * t)
79
+ node.y = sy + ((@path.y_at(@segment + 1) - sy) * t)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Drives a CharacterBody sibling from two input axes — direct 8-way walking, no
7
+ # inertia (unlike ThrustController). Each frame it copies the axis snapshot into
8
+ # the body's movement intent; the body does the collision-checked move.
9
+ class PlayerController < Engine::Component
10
+ def initialize(x_axis: :move_x, y_axis: :move_y)
11
+ super()
12
+ @x_axis = x_axis
13
+ @y_axis = y_axis
14
+ end
15
+
16
+ def on_attach = @body = node.get_component(CharacterBody)
17
+
18
+ def control(actions)
19
+ @body.set_intent(actions.axis(@x_axis), actions.axis(@y_axis))
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Wraps an Engine::Pool of nodes and folds the tree bookkeeping into the frame tick:
7
+ # `spawn` takes a node from the pool (recycled or freshly built) and adds it as a
8
+ # child of the owner, and `update` returns freed nodes to the pool — so a game only
9
+ # ever writes `pool.spawn` and the ordinary `node.queue_free`, never a hand-written
10
+ # acquire/add/reclaim bridge. Pooled nodes are normal children, so the scene's usual
11
+ # traversal updates and draws them; this component only manages their pool membership.
12
+ #
13
+ # pool = add_component(Engine::Components::Pool.new { Enemy.new(path: @path) })
14
+ # pool.spawn # acquire + add as a child
15
+ # pool.spawn { |b| b.reset(x, y) } # reset before it enters the tree (projectiles)
16
+ # enemy.queue_free # → reclaimed back to the pool automatically
17
+ #
18
+ # Add it named (`as:`) like any component when a node needs more than one pool.
19
+ class Pool < Engine::Component
20
+ def initialize(&)
21
+ super()
22
+ @pool = Engine::Pool.new(&)
23
+ end
24
+
25
+ # Take a node from the pool, let the caller re-initialise it before it goes live
26
+ # (optional block — runs before on_attach), and add it as a child of the owner.
27
+ def spawn
28
+ child = @pool.acquire
29
+ yield child if block_given?
30
+ node.add_node(child)
31
+ child
32
+ end
33
+
34
+ # Ride the tick: return every freed pooled node to the free list, detaching any that
35
+ # are still in the tree — so a queue_free elsewhere recycles with no game-side wiring.
36
+ def update(_dt)
37
+ @pool.reclaim_if do |child|
38
+ next false unless child.freed?
39
+
40
+ node.remove_node(child) # idempotent if the deferred-free sweep already detached it
41
+ true
42
+ end
43
+ end
44
+
45
+ def size = @pool.size
46
+
47
+ # True when no spawned node is currently live (all reclaimed) — a scene reads this to
48
+ # tell when a wave/round has been fully cleared.
49
+ def empty? = @pool.empty?
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Wraps the node's position toroidally within a rectangle (plus margin), so an
7
+ # entity leaving one edge reappears on the opposite one. From Body#wrap!. The
8
+ # bounds are passed in; in a scrolling game they'd come from a scene-scoped
9
+ # World system instead.
10
+ class ScreenWrap < Engine::Component
11
+ def initialize(width:, height:, margin: 0.0)
12
+ super()
13
+ @width = width
14
+ @height = height
15
+ @margin = margin
16
+ end
17
+
18
+ def update(_dt)
19
+ node.x = @width + @margin if node.x < -@margin
20
+ node.x = -@margin if node.x > @width + @margin
21
+ node.y = @height + @margin if node.y < -@margin
22
+ node.y = -@margin if node.y > @height + @margin
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Draws a single registered image centered on the node's absolute origin. It
7
+ # passes NO angle to the renderer: Node2D#draw already wraps a node's own draws
8
+ # in renderer.rotated(abs_angle, abs_x, abs_y), so the node's rotation orients
9
+ # the sprite — passing an angle here too would rotate it twice.
10
+ #
11
+ # `scale` is writable so a pooled entity (e.g. a multi-tier rock) can retune it on
12
+ # reset. `z` is the render layer (NOT the node's transform z / abs_z) — kept under
13
+ # @layer to say so. For an animated sprite (sheet + frame index driving
14
+ # renderer.sprite), add a sibling AnimatedSprite component later.
15
+ class Sprite < Engine::Component
16
+ attr_accessor :scale
17
+
18
+ def initialize(id:, scale: 1.0, z: 0)
19
+ super()
20
+ @id = id
21
+ @scale = scale
22
+ @layer = z
23
+ end
24
+
25
+ def draw(renderer)
26
+ renderer.image(@id, node.abs_x, node.abs_y, scale: @scale, z: @layer)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end