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
data/docs/api/audio.md ADDED
@@ -0,0 +1,208 @@
1
+ # Audio
2
+
3
+ ```ruby
4
+ require 'rgame/core'
5
+
6
+ class MyGame < RGame::Core::App
7
+ def initialize
8
+ super(width: 800, height: 600, caption: 'demo')
9
+ @audio = RGame::Core::Audio.new
10
+ @hit = @audio.sample('assets/hit.ogg')
11
+ @music = @audio.song('assets/theme.ogg')
12
+ @music.play(looping: true)
13
+ end
14
+
15
+ def button_down(id)
16
+ @hit.play if id == RGame::Util::Controls::KEY_SPACE
17
+ end
18
+ end
19
+ ```
20
+
21
+ Three classes. `Audio` is the sound device; `Sample` is a short sound played
22
+ over itself; `Song` is a long one streamed from disk.
23
+
24
+ Ogg Vorbis and WAV are the formats. Nothing else — see [What is not
25
+ here](#what-is-not-here).
26
+
27
+ ## The device
28
+
29
+ ```ruby
30
+ audio = RGame::Core::Audio.new
31
+ audio.backend # => "PulseAudio"
32
+ audio.volume # => 1.0
33
+ audio.volume = 0.8
34
+ ```
35
+
36
+ **It takes no app.** Sound is not tied to a window: it survives one being
37
+ resized or recreated, and there is no GL context involved. One device for the
38
+ program is the normal arrangement.
39
+
40
+ **A machine with no sound hardware still gets a working device.** It opens a
41
+ null backend and plays silently rather than raising, and `#backend` returns
42
+ `"Null"`. That is deliberate — a game should run on a CI runner, in a container,
43
+ or on a laptop with the sound card switched off, and crashing at startup over
44
+ something nobody asked for is the worse failure. If your game wants to know, ask
45
+ `#backend`; nothing else changes.
46
+
47
+ `#volume` is the master volume, multiplied into everything the device plays.
48
+
49
+ ## Samples
50
+
51
+ ```ruby
52
+ hit = audio.sample('assets/hit.ogg')
53
+ hit.volume = 0.5
54
+ hit.play
55
+ hit.play # a second voice, over the first
56
+ ```
57
+
58
+ A sample is decoded once, into memory, and played as often as you like.
59
+ **Playing one that is already sounding layers another voice over it** rather
60
+ than restarting it — which is what makes a fast run of footsteps sound like
61
+ footsteps instead of one stuttering step.
62
+
63
+ There is no handle for an individual play, and no way to stop one: a sample is
64
+ fire-and-forget. Volume belongs to the sample and reaches every voice it has
65
+ out, including the ones already sounding.
66
+
67
+ Keep samples for short sounds. A sample holds its whole decoded length in
68
+ memory — measured at roughly 10 MB per minute of CD-quality stereo — so a music
69
+ track belongs in a `Song`.
70
+
71
+ Two samples loaded from the **same path** share one decoded copy, so loading a
72
+ file twice costs nothing the second time.
73
+
74
+ ## Songs
75
+
76
+ ```ruby
77
+ music = audio.song('assets/theme.ogg')
78
+ music.play(looping: true)
79
+ music.playing? # => true
80
+ music.volume = 0.6
81
+ music.stop
82
+ ```
83
+
84
+ A song is streamed from the file as it plays, so a three-minute track costs a
85
+ buffer rather than forty megabytes.
86
+
87
+ **A song is one voice.** Playing one that is already playing restarts it from
88
+ the beginning; so does playing it after `stop`. There is no pause — `stop` then
89
+ `play` is "from the top", not "resume".
90
+
91
+ `#playing?` and `#looping?` report what the song was last told to do.
92
+ `#looping?` is the flag, not a count.
93
+
94
+ **"One song at a time" is your rule, not the engine's.** Two songs can play at
95
+ once, which is what a crossfade is; if a game wants only one, it stops the old
96
+ one before starting the new one.
97
+
98
+ ## Playing by id
99
+
100
+ The same boundary drawing has: gameplay emits a fact and names the sound,
101
+ because a scene may not hold a `Sample`.
102
+
103
+ ```ruby
104
+ audio.register_sound(:hit, app.assets.sound('example 09/hurt.ogg'))
105
+ audio.register_music(:theme, app.assets.song('example 09/theme.ogg'))
106
+
107
+ audio.play_sound(:hit)
108
+ audio.play_music(:theme) # loops
109
+ audio.stop_music
110
+ ```
111
+
112
+ Registration only, unlike the renderer's draw-by-id: a sound id is whatever a
113
+ game wants to call it, and there is no per-frame path here to make resolving a
114
+ path worth caching. An unknown id is a `KeyError`.
115
+
116
+ **`play_music` is idempotent.** Asking for the track that is already playing
117
+ does nothing, so a scene that re-emits the request every time it is entered
118
+ never restarts the music mid-loop.
119
+
120
+ **`stop_music` stops the song *this registry* started.** Gosu had a
121
+ process-wide "current song"; there is no such global here, because one-song-at-
122
+ a-time is a game's policy rather than the engine's. A `Song` you started by hand
123
+ is yours to stop, and `stop_music` with nothing playing is a no-op.
124
+
125
+ ## Loading and failure
126
+
127
+ ```ruby
128
+ audio.sample(path) # => RGame::Core::Sample
129
+ audio.song(path) # => RGame::Core::Song
130
+
131
+ RGame::Core::Sample.new(audio, path) # the same thing
132
+ RGame::Core::Song.new(audio, path)
133
+ ```
134
+
135
+ Both forms exist. Prefer `audio.sample` — it reads in the direction the objects
136
+ depend, and a stand-in device can offer it while `Sample.new` cannot (see
137
+ [Testing](#testing)).
138
+
139
+ A file that cannot be read, or that is not a format the engine decodes, raises
140
+ `RGame::Core::Sample::LoadError` or `RGame::Core::Song::LoadError`, naming the
141
+ file. Both are `StandardError`, so an ordinary `rescue` catches them:
142
+
143
+ ```ruby
144
+ @music = begin
145
+ audio.song('assets/theme.ogg')
146
+ rescue RGame::Core::Song::LoadError => e
147
+ warn "no music: #{e.message}"
148
+ nil
149
+ end
150
+ ```
151
+
152
+ The decision is made by **content, not by extension** — a text file named
153
+ `.ogg` is refused.
154
+
155
+ ## Volume
156
+
157
+ Every volume — the device's, a sample's, a song's — behaves the same way:
158
+
159
+ | Value | Effect |
160
+ |---|---|
161
+ | `1.0` | unchanged, the default |
162
+ | `0.0` … `1.0` | quieter |
163
+ | above `1.0` | amplified; clipping is yours to avoid |
164
+ | below `0.0` | clamped to `0.0` |
165
+
166
+ Negative is clamped rather than refused because a fader driven by a slider or an
167
+ easing curve undershoots constantly, and silence is the meaningful answer — a
168
+ negative volume would phase-invert the samples, which is *louder*.
169
+
170
+ Volumes are 32-bit floats inside the mixer, so `0.8` reads back as
171
+ `0.800000011920929`. Compare with a tolerance, not with `==`.
172
+
173
+ ## What it costs
174
+
175
+ Nothing needs freeing. A sample or a song releases what it holds when it is
176
+ collected, and each one keeps its device alive for as long as it exists — so
177
+ dropping your reference to the `Audio` while a sound is still around is safe,
178
+ in either order.
179
+
180
+ `RGame::Core::Audio.debug_live_sounds` reports how many samples and songs exist.
181
+ It is there for tests, not for gameplay.
182
+
183
+ ## Testing
184
+
185
+ Audio follows the same pattern as drawing: the engine layer is handed a device
186
+ and calls it by method name, never by class, so a headless spec can substitute
187
+ one that makes no sound and records everything.
188
+
189
+ `spec/support/fake_audio.rb` is that stand-in, and
190
+ `spec/support/shared_examples/an_audio_server.rb` is the interface both it and
191
+ the real device are run against — so the fake cannot drift into describing an
192
+ engine that no longer exists.
193
+
194
+ ```ruby
195
+ audio = FakeAudio.new
196
+ audio.sample('hit.ogg').play
197
+
198
+ expect(audio.played?('hit.ogg')).to be(true)
199
+ expect(audio.calls.map(&:name)).to eq(%i[sample sample_play])
200
+ ```
201
+
202
+ Nothing there loads a file, opens a device, or needs a sound card.
203
+
204
+ ## What is not here
205
+
206
+ MP3 and FLAC (Vorbis and WAV only, to keep the gem small), positional and 3D
207
+ audio, effects and filters, fades, pausing, seeking, per-play handles, playback
208
+ position, and recording.
@@ -0,0 +1,321 @@
1
+ # Components
2
+
3
+ A **component** is a reusable piece of behaviour attached to a `Node2D`, instead of
4
+ baked into a node subclass. A node composes several of them; each knows its owning
5
+ `node` and is driven by the node's tick. Components live in `engine/components/` under
6
+ `RGame::Engine::Components` and subclass `RGame::Engine::Component`. See [Scene graph](scene_graph.md)
7
+ for how nodes drive components, and [Systems & shared resources](systems.md) for
8
+ components that act as shared, scene- or program-scoped services.
9
+
10
+ ## The `Component` base
11
+
12
+ `RGame::Engine::Component` (`rgame/engine/component`) gives every component a `node` back-link and
13
+ a set of hooks the node calls — override the ones you need; the rest are no-ops. Like
14
+ nodes, it extends the signal DSL, so a component can declare and emit signals.
15
+
16
+ Per-tick hooks (a node runs its components in each phase, before its own hook and
17
+ before its children):
18
+
19
+ - `control(actions)` — read intent from the per-frame action snapshot.
20
+ - `update(dt)` — advance state over the timestep.
21
+ - `draw(renderer)` — render against the renderer interface.
22
+
23
+ Tree-lifecycle hooks (fired by the engine when the node enters/leaves the live tree —
24
+ this is where anchors and sibling systems are reachable, so do cross-node wiring here,
25
+ not in `initialize`):
26
+
27
+ - `on_attach` — the node entered the tree; pull and register with shared systems.
28
+ - `on_detach` — the node is leaving; release those registrations.
29
+
30
+ `sweep_freed` exists for container components that hold nodes off the normal child
31
+ list; the default is a no-op (see [deferred free](scene_graph.md#deferred-free)).
32
+
33
+ A node holds **at most one component per slot**. The slot defaults to the component's
34
+ class, so by default that's one per class (`add_component` raises on a taken slot) — but
35
+ pass `as: :name` to keep several of one type (a spawn timer and a wave timer). Look a
36
+ component up with `get_component(key)`, where `key` is a class (matched by ancestry; it
37
+ raises if several share the type) or a Symbol name.
38
+
39
+ ## Available components
40
+
41
+ ### `Velocity`
42
+
43
+ Integrates linear and angular velocity into the node's transform each step.
44
+
45
+ - **Construct:** `Velocity.new(vx: 0.0, vy: 0.0, spin: 0.0)`.
46
+ - **State:** `vx`, `vy`, `spin` are read/write accessors — a controller (or the node's
47
+ own `control` hook) writes them as movement intent.
48
+ - **Phase:** `update(dt)` adds `vx*dt`/`vy*dt` to `node.x`/`node.y` and `spin*dt` to
49
+ `node.angle`.
50
+
51
+ A free-moving entity can use `Velocity` alone; pair it with a controller for input.
52
+
53
+ ### `PathFollow`
54
+
55
+ Walks the owning node along an [`RGame::Engine::Path`](toolbox.md#path--a-walkable-polyline)
56
+ at a constant speed and emits `on_finished` when it reaches the last waypoint — the seam a
57
+ tower-defense game uses to leak a life when an enemy reaches the base.
58
+
59
+ - **Construct:** `PathFollow.new(path:, speed:)`.
60
+ - **Lifecycle:** `on_attach` (re)starts the walk — back to the first waypoint with progress
61
+ cleared — so a pooled follower reacquired and re-added begins a fresh walk.
62
+ - **Signal:** `on_finished` fires once (no payload) at the end of the path —
63
+ `follow.on_finished { node.queue_free }`.
64
+ - **Phase:** `update(dt)` advances `speed * dt`, crossing as many segments as one step
65
+ spans and interpolating the node's position; allocation-free.
66
+
67
+ ### `Timer`
68
+
69
+ A node-driven interval timer: it rides the node's update tick (so nothing can forget to
70
+ advance it) and emits `on_timeout` each time a whole interval elapses — a spawn cadence, a
71
+ tower's fire rate, a wave clock. Wraps the pure [`RGame::Engine::Timer`](toolbox.md#timer--paced-periodic-events),
72
+ reusing its drift-free carry-forward.
73
+
74
+ - **Construct:** `Timer.new(interval, repeating: true)` (seconds). Add it named when a node
75
+ needs several: `node.add_component(Timer.new(0.8), as: :spawn)`. `repeating: false` makes
76
+ it a **one-shot** — it fires `on_timeout` exactly once, then goes inert. The one-shot
77
+ replaces a dedicated "lifetime" component: a projectile that should vanish after N seconds
78
+ on a fixed board is `Timer.new(2.0, repeating: false)` + `on_timeout { node.queue_free }`
79
+ (use `DespawnOffscreen` instead when the board scrolls and the entity leaves the screen).
80
+ - **Signal:** `on_timeout` fires once per whole interval — `timer.on_timeout { spawn_enemy }`.
81
+ - **Lifecycle:** `on_attach` restarts the countdown (and re-arms a spent one-shot), so a
82
+ pooled node reacquired and re-added starts fresh rather than inheriting its previous
83
+ life's elapsed time.
84
+ - **Phase:** `update(dt)` advances and emits; a repeating timer emits once per interval
85
+ crossed in a single long step (catch-up, not drift), a one-shot at most once. Allocation-free.
86
+ - **Reset:** `reset` drops accumulated time and re-arms a one-shot — a fresh timer.
87
+
88
+ ### `Pool`
89
+
90
+ Wraps an [`RGame::Engine::Pool`](toolbox.md#pool--reuse-dont-allocate) of nodes and folds the
91
+ tree bookkeeping into the frame tick, so a scene that recycles entities (enemies, projectiles)
92
+ writes no acquire/add/reclaim bridge of its own — just `spawn` and the ordinary `queue_free`.
93
+ Pooled nodes are **normal children** of the owner, so the scene's usual traversal updates and
94
+ draws them; this component only manages their pool membership.
95
+
96
+ - **Construct:** `Pool.new { Enemy.new(...) }` — the factory builds a blank node. Add it named
97
+ (`as:`) when a node needs more than one pool.
98
+ - **Spawn:** `pool.spawn` takes a node (recycled or freshly built) and adds it as a child;
99
+ `pool.spawn { |n| n.reset(...) }` runs the block to re-initialise it *before* it enters the
100
+ tree (so `on_attach` sees the reset state — the order projectiles need).
101
+ - **Reclaim:** `update(dt)` returns every freed pooled node to the free list, detaching any
102
+ still attached. So despawning is just `node.queue_free` anywhere; the pool recycles it with
103
+ no game-side wiring. Allocation-free in steady state.
104
+
105
+ ### `Clickable`
106
+
107
+ Makes the owning node a world-space click target: on the click-down edge it hit-tests the
108
+ pointer against a circle of `radius` about the node's absolute origin and emits `on_clicked`
109
+ (no payload — the node identifies the click, like a button). It reads the pointer from the
110
+ Actions snapshot, so it assumes screen == world (a fixed, unscrolled board; a scrolling
111
+ camera would need the pointer unprojected first).
112
+
113
+ - **Construct:** `Clickable.new(radius:, action: :ui_click)`. The action must be bound to the
114
+ pointer button — `action_map: { ui_click: { button: %i[pointer] } }`.
115
+ - **Signal:** `on_clicked` fires once per press inside the radius —
116
+ `spot.on_clicked { build_tower }`.
117
+ - **Phase:** `control(actions)` hit-tests and emits; allocation-free. Add it named (`as:`)
118
+ when a node needs more than one click region.
119
+
120
+ ### `ScreenWrap`
121
+
122
+ Wraps the node's position toroidally within a rectangle, so an entity leaving one edge
123
+ reappears on the opposite one.
124
+
125
+ - **Construct:** `ScreenWrap.new(width:, height:, margin: 0.0)` — `margin` lets a
126
+ sprite pass fully off one edge before reappearing on the other.
127
+ - **Phase:** `update(dt)` clamps-and-wraps `node.x`/`node.y` against the bounds.
128
+
129
+ ### `DespawnOffscreen`
130
+
131
+ Removes the node once it has fully left the bounds (plus margin) — for short-lived
132
+ entities like projectiles.
133
+
134
+ - **Construct:** `DespawnOffscreen.new(width:, height:, margin: 0.0)`.
135
+ - **Phase:** `update(dt)` calls `node.queue_free` when the node is past every edge.
136
+ Removal is *deferred* (see [deferred free](scene_graph.md#deferred-free)), so it is
137
+ safe to trigger from inside the update traversal. For a *fixed* board (an entity that
138
+ never leaves the screen, e.g. a projectile that should vanish after N seconds), use a
139
+ one-shot [`Timer`](#timer) (`repeating: false`) with `on_timeout { node.queue_free }`
140
+ instead.
141
+
142
+ ### `CircleCollider`
143
+
144
+ A circular collision shape that participates in a scene's
145
+ [`CollisionWorld`](#collisionworld). It registers itself when the node enters the tree
146
+ and unregisters when it leaves, so a spawned or despawned entity never leaks a
147
+ registration.
148
+
149
+ - **Construct:** `CircleCollider.new(radius:, layer: :default)`. `layer` is an opaque
150
+ tag the *owner* reads to decide what a contact means; the collision system itself is
151
+ layer-agnostic.
152
+ - **Lifecycle:** `on_attach` registers with `node.system(CollisionWorld)`; `on_detach`
153
+ unregisters.
154
+ - **Geometry:** `cx`/`cy` are the node's resolved absolute origin; `radius` is a
155
+ read/write accessor (so a pooled entity can retune its shape on reset — see
156
+ `ScreenWrap`/pooling), `layer` is a reader; `overlap?(other)` is the circle-vs-circle
157
+ test.
158
+ - **Signal:** `on_hit` fires with the other collider on each contact —
159
+ `collider.on_hit { |other| ... }`. The system triggers it via `emit_hit(other)`.
160
+
161
+ ### `CollisionWorld`
162
+
163
+ A scene-scoped broadphase collision **system**: a component that lives on the scene
164
+ node, holds the registered colliders in a `SpatialHash`, and each step reports every
165
+ overlapping pair. Because it is a normal component it rides the `update` traversal and
166
+ is torn down with the scene. See [Systems & shared resources](systems.md).
167
+
168
+ - **Construct:** `CollisionWorld.new(cell_size:)` — the spatial-hash cell size (tune to
169
+ the typical collider size).
170
+ - **Registration:** `register(collider)` / `unregister(collider)`; colliders call these
171
+ through their own lifecycle, so nodes never wire this by hand.
172
+ - **Phase:** `update(dt)` rebuilds the spatial index and, for each overlapping pair,
173
+ fires both colliders' `on_hit`. It is **layer-agnostic** — it reports contacts and
174
+ lets each collider's owner decide meaning by reading the other's `layer`. Colliders
175
+ whose node is queued for removal are skipped.
176
+ - **Range queries (targeting):** the same index answers point-radius lookups against the
177
+ most recent `update`, so a tower can find enemies without a contact:
178
+ - `query_circle(x, y, r) { |collider| }` yields every registered collider whose centre
179
+ is within `r` of `(x, y)` (centre distance — the collider's own radius isn't added,
180
+ so it reads like a range ring); freed-node colliders are skipped, and a collider may
181
+ be yielded more than once (broadphase dedup contract — fine for selecting). Filter by
182
+ `collider.layer` in the block.
183
+ - `nearest(x, y, r, layer: nil)` returns the closest such collider (optionally limited
184
+ to one `layer`), or `nil`. Both are allocation-free, so a targeting component can call
185
+ them every frame.
186
+
187
+ ### `Targeting`
188
+
189
+ Picks an enemy for the owning node (a tower) to aim at: each `update` it queries the
190
+ scene's [`CollisionWorld`](#collisionworld) around the node's world origin and exposes the
191
+ chosen target. It only *selects* — it never moves or fires; the owner reads `target` and
192
+ acts. Because enemies already register with the broadphase through their
193
+ [`CircleCollider`](#circlecollider), targeting keeps no entity list of its own.
194
+
195
+ - **Construct:** `Targeting.new(range:, policy: :nearest, layer: nil)`. `range` is the
196
+ reach in pixels; `layer` restricts candidates (a tower passes `:enemy`, so it ignores
197
+ other towers/projectiles); an unknown `policy` raises at construction.
198
+ - **Policies** (how to choose among the in-range candidates):
199
+ - `:nearest` — the closest enemy (the default; one broadphase nearest-lookup).
200
+ - **State:** `target` is the chosen enemy **node** (or `nil` when nothing is in range),
201
+ refreshed every `update` — so a freed/out-of-range target clears on its own. It's a node
202
+ (not a collider) so the owner can read its position and components.
203
+ - **Lifecycle:** `on_attach` pulls the scene's `CollisionWorld`.
204
+ - **Phase:** `update(dt)` re-selects the target; allocation-free, so it runs every frame.
205
+
206
+ ### `Sprite`
207
+
208
+ Draws a single registered image centered on the node's absolute origin.
209
+
210
+ - **Construct:** `Sprite.new(id:, scale: 1.0, z: 0)` — `id` is a renderer image id; `z`
211
+ is the render layer (distinct from the node's transform `z`/`abs_z`).
212
+ - **State:** `scale` is a read/write accessor (a pooled entity can retune it).
213
+ - **Phase:** `draw(renderer)` draws the image with **no angle** — `Node2D#draw` already
214
+ wraps a node's own draws in `renderer.rotated(abs_angle, …)`, so the node's rotation
215
+ orients the sprite; passing an angle here would rotate it twice.
216
+
217
+ ### `ThrustController`
218
+
219
+ Inertial "ship" flight on top of a `Velocity` sibling: a turn axis rotates the node and
220
+ a thrust axis accelerates it along its heading.
221
+
222
+ - **Construct:** `ThrustController.new(turn_speed:, accel:, max_speed:, drag: 0.0,
223
+ turn_action: :turn, thrust_action: :thrust)`.
224
+ - **Lifecycle:** `on_attach` pulls the node's `Velocity` component.
225
+ - **Phase:** `control(actions)` reads intent (turn → `velocity.spin`, thrust stored);
226
+ `update(dt)` accelerates along the heading (angle 0 = up, so forward is
227
+ `(sin θ, −cos θ)`), applies drag, and clamps to `max_speed`. Firing is intentionally
228
+ not here.
229
+
230
+ ### `ActionTrigger`
231
+
232
+ Maps held input actions to an `on_triggered(action)` signal, rate-limited by a per-action
233
+ cooldown. One instance covers several actions (the engine allows one component per class
234
+ per node), so it emits the action name and lets listeners filter — reusable for "fire"
235
+ here, or "jump"/"fire" in a platformer.
236
+
237
+ - **Construct:** `ActionTrigger.new(cooldowns)` where `cooldowns` is `{ action => seconds }`,
238
+ e.g. `ActionTrigger.new(fire: 0.22)`.
239
+ - **Signal:** `on_triggered` fires with the action name — `trigger.on_triggered { |a| … }`.
240
+ - **Phase:** `update(dt)` ticks the per-action cooldowns; `control(actions)` emits when an
241
+ action is held and its cooldown has elapsed (held + cooldown = auto-repeat).
242
+
243
+ ### `AnimatedSprite`
244
+
245
+ Draws a sprite-sheet animation and picks the animation from a [`CharacterBody`](#characterbody)
246
+ sibling's movement: `walk_left`/`walk_right`/`walk_up`/`walk_down` while moving (horizontal wins
247
+ on a diagonal), `stand` when still. Owns an `RGame::Engine::Animator` over the pure `AnimationSet` built
248
+ from the sheet's animation table.
249
+
250
+ - **Construct:** `AnimatedSprite.new(sheet:, z: 0)` — `sheet` is the asset's relative path; `z` the
251
+ render layer.
252
+ - **Lifecycle:** `on_attach` resolves the sheet from the game's asset manager
253
+ (`node.root.context.assets.sheet(sheet)`), builds its animation set, **sizes the node** to the
254
+ sheet's frame (`node.width`/`height`, so a `CharacterBody` sibling can read them), and pulls that
255
+ sibling (the facing source). The renderer resolves the same path when drawing, so nothing is
256
+ registered or passed in by hand.
257
+ - **Phase:** `update(dt)` selects + advances the animation; `draw` renders the current frame via
258
+ `renderer.sprite` at the node's **world** origin (`abs_x`/`abs_y`) with no angle — a
259
+ [`CameraView`](scene_graph.md) ancestor applies the camera offset, so the component never touches
260
+ the camera. (`Sprite` above is the single-image counterpart.)
261
+
262
+ ### `CharacterBody`
263
+
264
+ Collision-checked walking for a tile-bound actor. A controller writes a per-step movement intent
265
+ (each axis −1..1); the body turns it into a real move each `update`, resolved against the scene's
266
+ [`TileWorld`](#tileworld) so the actor slides along walls and stays in the map. Unlike `Velocity`
267
+ (which integrates blindly), every step here is collision-checked.
268
+
269
+ - **Construct:** `CharacterBody.new(feet_width:, feet_height:, speed:)` — the feet box size (in px)
270
+ and walk speed (px/s). No sprite size is passed: `collision_box` is built lazily from the node's
271
+ `width`/`height` (which `AnimatedSprite` sets), centred horizontally and bottom-anchored. A body
272
+ with no sprite must set the node's dimensions itself.
273
+ - **State:** `set_intent(x, y)` writes the step's intent; `move_x`/`move_y` read it back (the facing
274
+ for `AnimatedSprite`).
275
+ - **Lifecycle:** `on_attach` caches the scene's `TileWorld`.
276
+ - **Phase:** `update(dt)` moves `intent * speed * dt` through the tile world (nothing when the
277
+ intent is zero).
278
+
279
+ ### `PlayerController`
280
+
281
+ Drives a `CharacterBody` sibling from two input axes — direct 8-way walking, no inertia (unlike
282
+ `ThrustController`).
283
+
284
+ - **Construct:** `PlayerController.new(x_axis: :move_x, y_axis: :move_y)`.
285
+ - **Phase:** `control(actions)` copies the two axes into the body's intent.
286
+
287
+ ### `WanderController`
288
+
289
+ A simple AI driver for a `CharacterBody`: every so often it rolls a new direction (one of eight, or
290
+ idle) and holds it, re-rolling early when a wall blocks it. The RNG is injected, so behaviour is
291
+ deterministic in tests.
292
+
293
+ - **Construct:** `WanderController.new(rng: Random.new, change_interval: 1.0..3.0, idle_chance: 0.25)`.
294
+ - **Phase:** `update(dt)` counts down the timer and re-rolls on timeout or when blocked.
295
+
296
+ ### `TileWorld`
297
+
298
+ The scene-scoped tile **system** (see [Systems](systems.md)): it holds the parsed `RGame::Engine::TileMap`
299
+ and answers everything an actor needs from it — collision against the solid tiles (reusing
300
+ `RGame::Engine::CollisionSystem`), the world bounds, and drawing the map through the scene's camera. Found
301
+ with `node.system(TileWorld)`.
302
+
303
+ - **Construct:** `TileWorld.new(map:, tilemap_id:, camera:)`.
304
+ - **Queries:** `move(actor, dx, dy)` slides an actor (anything responding to `x`/`y`/`collision_box`)
305
+ along solids and clamps it to the world; `solid?(col, row)`; `world_width`/`world_height`.
306
+ - **Phase:** `draw(renderer)` draws the below band at `GROUND_Z` and the above band at `OVERLAY_Z`
307
+ (canopies/roofs). Actors draw at a z in between, so the renderer's z-sort composites ground < actors <
308
+ canopy regardless of draw-call order.
309
+
310
+ ```ruby
311
+ # A node composing components, with collision meaning decided by the owner:
312
+ class Bullet < RGame::Engine::Node2D
313
+ def initialize(x:, y:, vx:, vy:, bounds:)
314
+ super(x: x, y: y)
315
+ add_component(RGame::Engine::Components::Velocity.new(vx: vx, vy: vy))
316
+ add_component(RGame::Engine::Components::DespawnOffscreen.new(**bounds))
317
+ collider = add_component(RGame::Engine::Components::CircleCollider.new(radius: 3, layer: :bullet))
318
+ collider.on_hit { |other| queue_free if other.layer == :rock }
319
+ end
320
+ end
321
+ ```