rgame 0.4.0-x86_64-linux-gnu

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 (239) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +251 -0
  3. data/LICENSE +26 -0
  4. data/README.md +325 -0
  5. data/docs/api/README.md +224 -0
  6. data/docs/api/app.md +249 -0
  7. data/docs/api/assets.md +463 -0
  8. data/docs/api/audio.md +249 -0
  9. data/docs/api/cli.md +259 -0
  10. data/docs/api/components.md +1112 -0
  11. data/docs/api/drawing.md +398 -0
  12. data/docs/api/examples.md +263 -0
  13. data/docs/api/game.md +219 -0
  14. data/docs/api/images.md +118 -0
  15. data/docs/api/input.md +476 -0
  16. data/docs/api/internals.md +289 -0
  17. data/docs/api/localization.md +285 -0
  18. data/docs/api/scene_graph.md +539 -0
  19. data/docs/api/signals.md +147 -0
  20. data/docs/api/systems.md +231 -0
  21. data/docs/api/text.md +133 -0
  22. data/docs/api/tile_maps.md +237 -0
  23. data/docs/api/toolbox.md +493 -0
  24. data/docs/api/ui.md +927 -0
  25. data/docs/api/values.md +316 -0
  26. data/examples/assets/README.md +322 -0
  27. data/examples/assets/blip.ogg +0 -0
  28. data/examples/assets/glyphs.json +5 -0
  29. data/examples/assets/glyphs.png +0 -0
  30. data/examples/assets/hero.json +12 -0
  31. data/examples/assets/hero.png +0 -0
  32. data/examples/assets/icons.json +13 -0
  33. data/examples/assets/icons.png +0 -0
  34. data/examples/assets/music.ogg +0 -0
  35. data/examples/assets/skills.json +10 -0
  36. data/examples/assets/skills.png +0 -0
  37. data/examples/assets/tileset.png +0 -0
  38. data/examples/assets/tileset.tsx +65 -0
  39. data/examples/assets/town.tmx +26 -0
  40. data/examples/assets/ui.json +11 -0
  41. data/examples/assets/ui.png +0 -0
  42. data/examples/collision/locales/en.yml +8 -0
  43. data/examples/collision/main.rb +316 -0
  44. data/examples/collision_tiles/locales/en.yml +9 -0
  45. data/examples/collision_tiles/main.rb +274 -0
  46. data/examples/fullscreen/locales/en.yml +10 -0
  47. data/examples/fullscreen/main.rb +216 -0
  48. data/examples/game_menu/locales/en.yml +8 -0
  49. data/examples/game_menu/main.rb +170 -0
  50. data/examples/input_glyphs/locales/en.yml +14 -0
  51. data/examples/input_glyphs/main.rb +213 -0
  52. data/examples/jump_topdown/locales/en.yml +9 -0
  53. data/examples/jump_topdown/main.rb +178 -0
  54. data/examples/localization/locales/de.yml +12 -0
  55. data/examples/localization/locales/en.yml +13 -0
  56. data/examples/localization/main.rb +158 -0
  57. data/examples/menu_navigation/locales/en.yml +23 -0
  58. data/examples/menu_navigation/main.rb +365 -0
  59. data/examples/music/locales/en.yml +7 -0
  60. data/examples/music/main.rb +134 -0
  61. data/examples/pathfinding/locales/en.yml +17 -0
  62. data/examples/pathfinding/main.rb +298 -0
  63. data/examples/pooling/locales/en.yml +7 -0
  64. data/examples/pooling/main.rb +259 -0
  65. data/examples/quick_wheel/locales/en.yml +16 -0
  66. data/examples/quick_wheel/main.rb +184 -0
  67. data/examples/radial_menu/locales/en.yml +16 -0
  68. data/examples/radial_menu/main.rb +184 -0
  69. data/examples/save_load/locales/en.yml +11 -0
  70. data/examples/save_load/main.rb +207 -0
  71. data/examples/save_load_ids/locales/en.yml +11 -0
  72. data/examples/save_load_ids/main.rb +322 -0
  73. data/examples/scroll_map/locales/en.yml +4 -0
  74. data/examples/scroll_map/main.rb +140 -0
  75. data/examples/signals/locales/en.yml +6 -0
  76. data/examples/signals/main.rb +278 -0
  77. data/examples/skill_bar/locales/en.yml +14 -0
  78. data/examples/skill_bar/main.rb +159 -0
  79. data/examples/sound/locales/en.yml +6 -0
  80. data/examples/sound/main.rb +122 -0
  81. data/examples/split_screen/locales/en.yml +9 -0
  82. data/examples/split_screen/main.rb +304 -0
  83. data/examples/sprite/locales/en.yml +8 -0
  84. data/examples/sprite/main.rb +180 -0
  85. data/examples/timer/locales/en.yml +12 -0
  86. data/examples/timer/main.rb +273 -0
  87. data/examples/velocity/locales/en.yml +6 -0
  88. data/examples/velocity/main.rb +196 -0
  89. data/examples/walk/locales/en.yml +4 -0
  90. data/examples/walk/main.rb +99 -0
  91. data/exe/rgame +9 -0
  92. data/lib/rgame/boot.rb +3 -0
  93. data/lib/rgame/cli/new_project.rb +139 -0
  94. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  95. data/lib/rgame/cli/templates/README.md.tt +93 -0
  96. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  97. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  98. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  99. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  100. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  101. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  102. data/lib/rgame/cli/templates/rspec.tt +2 -0
  103. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  104. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  105. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  106. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  107. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  108. data/lib/rgame/cli.rb +66 -0
  109. data/lib/rgame/core/app.rb +46 -0
  110. data/lib/rgame/core/asset_manager.rb +206 -0
  111. data/lib/rgame/core/audio.rb +145 -0
  112. data/lib/rgame/core/font.rb +46 -0
  113. data/lib/rgame/core/gamepad.rb +55 -0
  114. data/lib/rgame/core/image.rb +55 -0
  115. data/lib/rgame/core/input.rb +71 -0
  116. data/lib/rgame/core/locale.rb +22 -0
  117. data/lib/rgame/core/nine_slice.rb +142 -0
  118. data/lib/rgame/core/recording.rb +54 -0
  119. data/lib/rgame/core/renderer.rb +354 -0
  120. data/lib/rgame/core/sprite_sheet.rb +105 -0
  121. data/lib/rgame/core/tile_map_renderer.rb +172 -0
  122. data/lib/rgame/core/ui_atlas.rb +101 -0
  123. data/lib/rgame/core/virtual_gamepad.rb +26 -0
  124. data/lib/rgame/core.rb +18 -0
  125. data/lib/rgame/core_ext.so +0 -0
  126. data/lib/rgame/engine/actor_blockers.rb +131 -0
  127. data/lib/rgame/engine/animation_set.rb +50 -0
  128. data/lib/rgame/engine/animator.rb +44 -0
  129. data/lib/rgame/engine/audio_bus.rb +24 -0
  130. data/lib/rgame/engine/audio_director.rb +59 -0
  131. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  132. data/lib/rgame/engine/camera.rb +78 -0
  133. data/lib/rgame/engine/circle_collider.rb +34 -0
  134. data/lib/rgame/engine/collision_box.rb +59 -0
  135. data/lib/rgame/engine/collision_system.rb +132 -0
  136. data/lib/rgame/engine/component.rb +64 -0
  137. data/lib/rgame/engine/components/action_trigger.rb +40 -0
  138. data/lib/rgame/engine/components/animated_sprite.rb +71 -0
  139. data/lib/rgame/engine/components/box_collider.rb +99 -0
  140. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  141. data/lib/rgame/engine/components/character_body.rb +50 -0
  142. data/lib/rgame/engine/components/circle_collider.rb +80 -0
  143. data/lib/rgame/engine/components/collision_world.rb +231 -0
  144. data/lib/rgame/engine/components/despawn_offscreen.rb +42 -0
  145. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  146. data/lib/rgame/engine/components/hop.rb +76 -0
  147. data/lib/rgame/engine/components/identity.rb +73 -0
  148. data/lib/rgame/engine/components/mover.rb +285 -0
  149. data/lib/rgame/engine/components/navigator.rb +145 -0
  150. data/lib/rgame/engine/components/path_follow.rb +176 -0
  151. data/lib/rgame/engine/components/player_controller.rb +27 -0
  152. data/lib/rgame/engine/components/pool.rb +53 -0
  153. data/lib/rgame/engine/components/screen_wrap.rb +49 -0
  154. data/lib/rgame/engine/components/sprite.rb +47 -0
  155. data/lib/rgame/engine/components/targeting.rb +52 -0
  156. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  157. data/lib/rgame/engine/components/tile_world.rb +109 -0
  158. data/lib/rgame/engine/components/timer.rb +75 -0
  159. data/lib/rgame/engine/components/velocity.rb +43 -0
  160. data/lib/rgame/engine/components/wander_controller.rb +64 -0
  161. data/lib/rgame/engine/components/world.rb +133 -0
  162. data/lib/rgame/engine/contact_set.rb +74 -0
  163. data/lib/rgame/engine/culling.rb +45 -0
  164. data/lib/rgame/engine/debug_overlay.rb +111 -0
  165. data/lib/rgame/engine/i18n/plural.rb +45 -0
  166. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  167. data/lib/rgame/engine/i18n/template.rb +59 -0
  168. data/lib/rgame/engine/i18n.rb +322 -0
  169. data/lib/rgame/engine/input/action_mapper.rb +100 -0
  170. data/lib/rgame/engine/input/actions.rb +92 -0
  171. data/lib/rgame/engine/input/input_map.rb +196 -0
  172. data/lib/rgame/engine/layout.rb +82 -0
  173. data/lib/rgame/engine/nav_grid.rb +87 -0
  174. data/lib/rgame/engine/node2d.rb +555 -0
  175. data/lib/rgame/engine/path.rb +76 -0
  176. data/lib/rgame/engine/player.rb +69 -0
  177. data/lib/rgame/engine/player_layer.rb +70 -0
  178. data/lib/rgame/engine/players.rb +205 -0
  179. data/lib/rgame/engine/pool.rb +51 -0
  180. data/lib/rgame/engine/presentation.rb +171 -0
  181. data/lib/rgame/engine/scene/scene_stack.rb +87 -0
  182. data/lib/rgame/engine/sealed_privates.rb +54 -0
  183. data/lib/rgame/engine/signal.rb +75 -0
  184. data/lib/rgame/engine/spatial_hash.rb +116 -0
  185. data/lib/rgame/engine/text.rb +194 -0
  186. data/lib/rgame/engine/tile_blockers.rb +63 -0
  187. data/lib/rgame/engine/tile_map.rb +147 -0
  188. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  189. data/lib/rgame/engine/tileset.rb +99 -0
  190. data/lib/rgame/engine/timer.rb +51 -0
  191. data/lib/rgame/engine/ui/button.rb +248 -0
  192. data/lib/rgame/engine/ui/column.rb +20 -0
  193. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  194. data/lib/rgame/engine/ui/menu.rb +290 -0
  195. data/lib/rgame/engine/ui/navigation.rb +57 -0
  196. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  197. data/lib/rgame/engine/ui/option_button.rb +163 -0
  198. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  199. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  200. data/lib/rgame/engine/ui/pointing.rb +146 -0
  201. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  202. data/lib/rgame/engine/ui/ring.rb +55 -0
  203. data/lib/rgame/engine/ui/row.rb +21 -0
  204. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  205. data/lib/rgame/engine/ui/stack.rb +58 -0
  206. data/lib/rgame/engine/ui/stepping.rb +93 -0
  207. data/lib/rgame/engine/ui/text_button.rb +59 -0
  208. data/lib/rgame/engine/view.rb +76 -0
  209. data/lib/rgame/engine/viewports.rb +171 -0
  210. data/lib/rgame/engine/world_view.rb +71 -0
  211. data/lib/rgame/engine.rb +86 -0
  212. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  213. data/lib/rgame/fonts/OFL.txt +102 -0
  214. data/lib/rgame/game.rb +271 -0
  215. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  216. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  217. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  218. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  219. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  220. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  221. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  222. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  223. data/lib/rgame/rubocop/default.yml +39 -0
  224. data/lib/rgame/rubocop/plugin.rb +45 -0
  225. data/lib/rgame/rubocop.rb +11 -0
  226. data/lib/rgame/util/color.rb +23 -0
  227. data/lib/rgame/util/controls.rb +169 -0
  228. data/lib/rgame/util/route_search.rb +27 -0
  229. data/lib/rgame/util/save_file.rb +107 -0
  230. data/lib/rgame/util/solid_grid.rb +37 -0
  231. data/lib/rgame/util/tensor.rb +3 -0
  232. data/lib/rgame/util/tile_sweep.rb +36 -0
  233. data/lib/rgame/util/z.rb +123 -0
  234. data/lib/rgame/util.rb +10 -0
  235. data/lib/rgame/util_ext.so +0 -0
  236. data/lib/rgame/version.rb +12 -0
  237. data/lib/rgame.rb +5 -0
  238. data/licenses/SDL2/LICENSE.txt +18 -0
  239. metadata +309 -0
@@ -0,0 +1,1112 @@
1
+ # Components
2
+
3
+ **A component is a reusable piece of behaviour attached to a `Node2D`**, instead
4
+ of being built into a node subclass. A node composes several components. Each
5
+ knows its owning `node`, and the node's tick drives it. Components live in
6
+ `engine/components/`, under `RGame::Engine::Components`, and subclass
7
+ `RGame::Engine::Component`. [Scene graph](scene_graph.md) explains how nodes drive
8
+ components. [Systems & shared resources](systems.md) covers components that serve
9
+ a whole scene or program.
10
+
11
+ `examples/walk` shows the smallest character in one file: `AnimatedSprite`,
12
+ `CharacterBody` and `PlayerController`.
13
+
14
+ ## When what you want is not a component
15
+
16
+ **A component is behaviour on the node's tick.** It overrides `control`, `update`
17
+ or `draw`, and the node drives it. Much of what a game needs is not that. Searching
18
+ this page for it leads to worse, hand-made answers.
19
+
20
+ Those helpers live in the [Toolbox](toolbox.md). A game constructs them directly,
21
+ attached to nothing, and that is ordinary use. `examples/sound` emits on `AudioBus`
22
+ and reads a `Text` within ten lines.
23
+
24
+ | Looking for | Reach for | |
25
+ |---|---|---|
26
+ | a label from a value that changes, with no `String` per frame | `Text` | [→](toolbox.md#text--the-string-a-node-draws) |
27
+ | to say *what happened* without naming a sound device | `AudioBus` | [→](toolbox.md#audiobus--decoupled-audio-facts) |
28
+ | a point to follow, clamped to the world | `Camera` | [→](toolbox.md#camera--follow-a-point-clamp-to-the-world) |
29
+ | text in the player's language | `I18n` | [→](localization.md) |
30
+ | an ordered route to walk | `Path` | [→](toolbox.md#path--a-walkable-polyline) |
31
+ | the cheapest route between two tiles | `NavGrid`, from `TileWorld#nav_grid` | [→](toolbox.md#navgrid--routes-over-a-tile-grid) |
32
+ | a node that walks itself to a point, around the map | `Components::Navigator` | [→](#navigator) |
33
+
34
+ **Per-frame work earns a component.** Two toolbox classes have component wrappers,
35
+ for that reason only. A timer must advance every tick, and a pool must reclaim
36
+ freed nodes every tick. `Components::Timer` and `Components::Pool` put that work in
37
+ the traversal, where nothing can forget it. Without per-frame work there is no
38
+ wrapper. A `Text` is read when something draws it. A component that
39
+ overrode no hook would be a component in name only. The one-per-slot rule would
40
+ even make a second label on one node harder.
41
+
42
+ ## The `Component` base
43
+
44
+ `RGame::Engine::Component` (`rgame/engine/component`) gives every component a
45
+ `node` back-link and hooks the node calls. Override the hooks you need; the rest
46
+ do nothing. Like nodes, components extend the signal DSL, so they can declare and
47
+ emit signals.
48
+
49
+ **Per-tick hooks.** In each phase a node runs its components before its own hook
50
+ and before its children.
51
+
52
+ - `control(actions)` reads intent from the per-tick action snapshot. The snapshot
53
+ belongs to whoever [owns the node](scene_graph.md#who-a-node-answers-to), so a
54
+ component never learns there is more than one player.
55
+ - `update(dt)` advances state over the timestep.
56
+ - `draw(renderer, view)` renders through the renderer interface into the
57
+ [viewport being drawn](scene_graph.md#viewports-and-views). Most components
58
+ ignore `view`. It serves layout against the region's edges, and culling.
59
+
60
+ **Tree-lifecycle hooks.** The engine fires these when the node enters or leaves
61
+ the live tree. Anchors and sibling systems are reachable here, so wire across
62
+ nodes here, not in `initialize`.
63
+
64
+ - `on_attach`: the node entered the tree. Look up shared systems and register
65
+ with them.
66
+ - `on_detach`: the node is leaving. Release those registrations.
67
+
68
+ `sweep_freed` serves container components that hold nodes outside the normal
69
+ child list. The default does nothing; see
70
+ [deferred free](scene_graph.md#deferred-free).
71
+
72
+ **`require_sibling(klass)` opens the `on_attach` of a component that drives a
73
+ sibling**: `@body = require_sibling(CharacterBody)`. It returns the component, or
74
+ raises naming both. A plain `nil` would stay silent until the first frame called a
75
+ method on it. When it raises, the cause is nearly always add order; see
76
+ [Where to add a component](#where-to-add-a-component) below. It also raises,
77
+ naming each one, when *several* siblings match `klass`, such as two
78
+ [`Mover`](#mover)s under one [`AnimatedSprite`](#animatedsprite). It never quietly
79
+ takes whichever came first.
80
+
81
+ **A node holds at most one component per slot.** The slot defaults to the
82
+ component's class, so by default a node holds one per class, and `add_component`
83
+ raises on a taken slot. Pass `as: :name` to keep several of one type, such as a
84
+ spawn timer and a wave timer. Look a component up with `get_component(key)`. The
85
+ key is a class, matched by ancestry and raising if several share the type, or a
86
+ Symbol name.
87
+
88
+ ## Where to add a component
89
+
90
+ **Assemble a node before it enters the tree.** Two shapes do that. Which one fits
91
+ depends on whether the node has a class of its own:
92
+
93
+ - **A `Node2D` subclass adds components in `initialize`.** This default covers most
94
+ entities.
95
+
96
+ ```ruby
97
+ class Bullet < RGame::Engine::Node2D
98
+ def initialize(x:, y:, vx:, vy:)
99
+ super(x: x, y: y)
100
+ add_component(RGame::Engine::Components::Velocity.new(vx: vx, vy: vy))
101
+ add_component(RGame::Engine::Components::DespawnOffscreen.new)
102
+ end
103
+ end
104
+ ```
105
+
106
+ - **A plain `Node2D` built from components gets a builder method** that returns
107
+ the assembled node. Use it when the node is nothing but its components, and a
108
+ subclass would add no behaviour. `examples/save_load` builds its walkers this
109
+ way.
110
+
111
+ ```ruby
112
+ def build_player
113
+ node = RGame::Engine::Node2D.new(x: spawn_x, y: spawn_y)
114
+ node.add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: PLAYER_SHEET))
115
+ node.add_component(RGame::Engine::Components::FeetCollider.new(width: 10, height: 8))
116
+ node.add_component(RGame::Engine::Components::CharacterBody.new(
117
+ speed: PLAYER_SPEED, blocked_by: [:tiles]
118
+ ))
119
+ node.add_component(RGame::Engine::Components::PlayerController.new)
120
+ node
121
+ end
122
+ ```
123
+
124
+ **Both shapes work for the same reason.** The node is not in the tree yet, so
125
+ `add_component` only appends. No `on_attach` fires until the whole set is present
126
+ and the node enters. **Add order is therefore free.** `build_player` above adds an
127
+ `AnimatedSprite` *before* the `CharacterBody` it faces by, and that works.
128
+
129
+ ### Adding from `on_add`, and when you must
130
+
131
+ **A node running `on_add` is already in the tree.** Each `add_component` attaches
132
+ at once and sees only the components added before it. The same two lines in the
133
+ other order raise (see [`require_sibling`](#the-component-base) above). Prefer
134
+ `initialize` or a builder. Use `on_add` only when the component cannot be built
135
+ earlier, because its constructor needs something only the tree can answer:
136
+
137
+ ```ruby
138
+ def on_add
139
+ # Both arguments are cross-tree lookups: the asset manager hangs off the root's
140
+ # context, and the player registry is a system. Neither exists at construction.
141
+ add_component(RGame::Engine::Components::TileWorld.new(
142
+ map: root.context.assets.tilemap(MAP_KEY).map,
143
+ tilemap_id: MAP_KEY,
144
+ cameras: root.system(RGame::Engine::Players).map(&:camera)
145
+ ))
146
+ end
147
+ ```
148
+
149
+ **Apply one test: does the constructor need the tree?** A `World` built from
150
+ numbers the scene already has does not. Nor does a `CollisionWorld` built from a
151
+ constant. `examples/collision` mounts both in `initialize`. There they are
152
+ guaranteed to precede every entity the scene spawns later. A `TileWorld` parsed
153
+ from the asset manager needs the tree, so it waits.
154
+
155
+ The opposite exception is a component added **deliberately** after entry, because
156
+ it depends on state that exists only once the node is live. An example is a
157
+ `CameraFollow` whose offset comes from the sibling body's resolved
158
+ `collision_box`. That is a later decision, not assembly, and `on_add` suits it.
159
+
160
+ ## Available components
161
+
162
+ ### `ActionTrigger`
163
+
164
+ **Maps held input actions to an `on_triggered(action)` signal, limited by a
165
+ per-action cooldown.** One instance covers several actions and emits the action
166
+ name, so listeners filter. The same component serves "fire" in a shooter or
167
+ "jump" and "fire" in a platformer.
168
+
169
+ - **Construct:** `ActionTrigger.new(cooldowns)`, where `cooldowns` is
170
+ `{ action => seconds }`, e.g. `ActionTrigger.new(fire: 0.22)`.
171
+ - **Signal:** `on_triggered` fires with the action name:
172
+ `trigger.on_triggered { |a| … }`.
173
+ - **Phase:** `update(dt)` ticks the per-action cooldowns. `control(actions)` emits
174
+ when an action is held and its cooldown has elapsed. Held plus cooldown gives
175
+ auto-repeat.
176
+
177
+ ### `AnimatedSprite`
178
+
179
+ **Draws a sprite-sheet animation, chosen from its [`Mover`](#mover) sibling's
180
+ [heading](#mover).** It plays `walk_left`, `walk_right`, `walk_up` or `walk_down`
181
+ while moving, and `stand` when still. The heading's larger axis picks the
182
+ direction, and a tie goes horizontal. A keyboard diagonal therefore walks sideways,
183
+ while a stick held mostly down walks down, as does a route segment running mostly
184
+ down. Any mover works. A [`CharacterBody`](#characterbody) faces its intent. A
185
+ [`PathFollow`](#pathfollow) or [`Navigator`](#navigator) faces the segment it
186
+ walks. A [`Velocity`](#velocity) faces where it flies. The component owns an
187
+ `RGame::Engine::Animator` over the pure `AnimationSet` built from the sheet's
188
+ animation table.
189
+
190
+ - **Construct:** `AnimatedSprite.new(sheet:, z: 0)`. `sheet` is the asset's
191
+ relative path. `z` orders this component against the node's other drawing,
192
+ inside the node's own slot, as for [`Sprite`](#sprite).
193
+ - **Lifecycle:** `on_attach` resolves the sheet from the game's asset manager
194
+ (`node.root.context.assets.sheet(sheet)`) and builds its animation set. It
195
+ **sizes the node** to the sheet's frame (`node.width` and `height`), so a
196
+ [`FeetCollider`](#feetcollider) can read them. It then looks up the mover sibling
197
+ it faces by. A node with **two** movers raises here, naming both: both write the
198
+ position, so no facing is defined. The renderer resolves the same path when
199
+ drawing, so nothing is registered or passed in by hand.
200
+ - **Phase:** `update(dt)` selects and advances the animation.
201
+ `draw(renderer, view)` renders the current frame via `renderer.sprite` at
202
+ **`0, 0`** with no angle. The traversal already placed the renderer on the node,
203
+ and a [`WorldView`](scene_graph.md#view-transforms-and-the-camera) ancestor
204
+ already applied the camera. The frame is lifted by
205
+ [`node.elevation`](scene_graph.md#elevation), so it draws at `0, -elevation`.
206
+ The component skips the draw when the view cannot show it. It measures the
207
+ node's box, raised by the same elevation, against `node.world_x` and `world_y`.
208
+ Culling uses world coordinates because it compares against the camera.
209
+ [`Sprite`](#sprite) is the single-image counterpart.
210
+
211
+ ### `BoxCollider`
212
+
213
+ **A rectangular collision shape in a scene's
214
+ [`CollisionWorld`](#collisionworld).** It is the sibling of
215
+ [`CircleCollider`](#circlecollider), for box-shaped entities: a crate, a platform,
216
+ a wall segment. It registers when the node enters the tree and unregisters when it
217
+ leaves, so spawning and despawning never leak a registration.
218
+
219
+ - **Construct:** `BoxCollider.new(width:, height:, offset_x: 0, offset_y: 0, layer: :default)`.
220
+ The offsets are relative to the node's origin, so a 32×32 sprite can carry a
221
+ small box at its feet. `layer` is an opaque tag. The *owner* reads it to decide
222
+ what a contact means; the collision system ignores it.
223
+ - **Lifecycle:** `on_attach` registers with `node.system(CollisionWorld)`, and
224
+ `on_detach` unregisters. In a scene with **no** world, it stays a bare shape and
225
+ does not raise. A collider is a shape; a world turns shapes into contacts. A
226
+ tile-only game can therefore carry a feet box for
227
+ [`CharacterBody(blocked_by:)`](#characterbody), or any other [`Mover`](#mover),
228
+ alone. The cost: an `on_hit` handler in such a scene never fires, and nothing
229
+ reports it.
230
+ - **Geometry:** the rectangle is an
231
+ [`RGame::Engine::CollisionBox`](toolbox.md#collisionbox--an-actors-feet-box),
232
+ exposed as the read/write `box` accessor. Assign a new one, such as
233
+ `CollisionBox.bottom_anchored(...)`, to retune a pooled entity's shape on reset.
234
+ No re-registration is needed. `aabb_x`, `aabb_y`, `aabb_w` and `aabb_h` give the
235
+ world-space box. `cx` and `cy` give the **box's** centre, which the world's range
236
+ queries measure from. A circle's centre, by contrast, is the node origin.
237
+ - **Rotation:** the box stays axis-aligned in world space and does not turn with
238
+ the node. A spinning entity wants a `CircleCollider`, which rotation does not
239
+ affect, instead of a box recomputed every frame.
240
+ - **Contacts:** `overlap?(other)` works against a box *or* a circle. The two
241
+ colliders settle the test between themselves, so both shapes mix freely in one
242
+ world.
243
+ - **Blocking:** a box on a layer that some [`Mover`](#mover) named in
244
+ `blocked_by:` also *stops* that mover's steps, flush against this box's edge. The
245
+ box needs no opt-in; the other body's layer declaration is the whole setup.
246
+ - **Signals:** `on_hit` fires with the other collider on the step a contact
247
+ **starts**, and `on_separated` on the step it **ends**:
248
+ `collider.on_hit { |other| ... }`. The system triggers them through
249
+ `emit_hit(other)` and `emit_separated(other)`. Each fires once per pair, so a
250
+ handler may count, play a sound or spend a life; see
251
+ [`CollisionWorld`](#collisionworld).
252
+
253
+ ```ruby
254
+ collider = add_component(RGame::Engine::Components::BoxCollider.new(
255
+ width: 32, height: 32, layer: :pickup
256
+ ))
257
+ collider.on_hit { |other| collect if other.layer == :player }
258
+ ```
259
+
260
+ ### `CameraFollow`
261
+
262
+ **Points a camera at the node it is attached to.**
263
+
264
+ - **Construct:** `CameraFollow.new(camera:, offset_x: 0.0, offset_y: 0.0)`. The
265
+ offsets shift the point the camera centres on, for a node whose origin should not
266
+ sit mid-screen. A bottom-anchored sprite usually wants its feet centred.
267
+ - **Phase:** `update(dt)` calls `camera.center_on` with the node's world origin.
268
+ The camera trails the node's movement by one step, uniformly.
269
+ - **Example:** `examples/scroll_map`. The followed node is an invisible rig with a
270
+ `CharacterBody` and a `PlayerController`. That is all "scroll the map with the
271
+ arrow keys" takes.
272
+
273
+ **The camera belongs to a [player](input.md#players-seats-and-joining)**, not to
274
+ this component or the scene, because a scene may have any number of viewers. The
275
+ player owns the camera; this component moves it. "Player two's camera follows
276
+ player two" is this component holding player two's camera.
277
+
278
+ ### `CharacterBody`
279
+
280
+ **Direct, per-step walking for an actor.** A controller writes a movement intent,
281
+ each axis −1..1. The body turns it into a move each `update`, at a fixed speed with
282
+ no inertia. `Velocity`, by contrast, integrates a velocity the controller sets, and
283
+ `ThrustController` accelerates one.
284
+
285
+ A character is a `CharacterBody` plus a controller. A character the map stops adds
286
+ a feet box and a declaration. **[`Mover`](#mover) decides what may stop a step**,
287
+ for `Velocity` and `PathFollow` too. `blocked_by:`, `on_blocked`, `on_unblocked`
288
+ and the `apply_move` seam are documented there, and work the same way here.
289
+
290
+ ```ruby
291
+ add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: 'hero.json'))
292
+ add_component(RGame::Engine::Components::FeetCollider.new(width: 12, height: 6))
293
+ add_component(RGame::Engine::Components::CharacterBody.new(speed: 80, blocked_by: [:tiles]))
294
+ add_component(RGame::Engine::Components::PlayerController.new)
295
+ ```
296
+
297
+ - **Construct:** `CharacterBody.new(speed:, blocked_by: [])`: walk speed in px/s,
298
+ and what may stop a step (see [`Mover`](#mover)).
299
+ - **State:** `set_intent(x, y)` writes the step's intent. `move_x` and `move_y`
300
+ read it back, and so do `heading_x` and `heading_y`. A body pressed into a wall
301
+ still heads into it.
302
+ - **Phase:** `update(dt)` applies `intent * speed * dt` through `apply_move`, and
303
+ moves nothing when the intent is zero. That still counts as a step, so a body
304
+ that stops pushing into a wall reports `on_unblocked`.
305
+ - **Seam:** a body that resolves a step differently, such as a platformer's with
306
+ gravity and a jump, overrides [`apply_move`](#mover). It inherits the intent, the
307
+ speed and the standing-still check.
308
+ - **Examples:** `examples/walk` uses this, a `PlayerController` and an
309
+ `AnimatedSprite`, and nothing else. `examples/collision_tiles` adds a feet box and
310
+ `blocked_by: [:tiles]`, and draws the box over the sprite so you can see what
311
+ collides. A crowd adds more names: walkers that each declare
312
+ `%i[tiles hero npc]` are stopped by the map and by one another.
313
+
314
+ ### `CircleCollider`
315
+
316
+ **A circular collision shape in a scene's [`CollisionWorld`](#collisionworld).** It
317
+ registers when the node enters the tree and unregisters when it leaves, so spawning
318
+ and despawning never leak a registration.
319
+
320
+ - **Construct:** `CircleCollider.new(radius:, layer: :default)`. `layer` is an
321
+ opaque tag. The *owner* reads it to decide what a contact means; the collision
322
+ system ignores it.
323
+ - **Lifecycle:** `on_attach` registers with `node.system(CollisionWorld)`, and
324
+ `on_detach` unregisters. As with [`BoxCollider`](#boxcollider), a scene with no
325
+ world leaves it a bare shape, without raising.
326
+ - **Geometry:** `cx` and `cy` are the node's world origin (`node.world_x`,
327
+ `world_y`). `radius` is read/write, so a pooled entity can retune its shape on
328
+ reset. `layer` is a reader. `aabb_x`, `aabb_y`, `aabb_w` and `aabb_h` give the
329
+ bounding box the world buckets on.
330
+ - **Contacts:** `overlap?(other)` works against a circle *or* a
331
+ [`BoxCollider`](#boxcollider). The two colliders settle the test between
332
+ themselves, so both shapes mix freely in one world.
333
+ - **It never blocks.** Blocking is box against box. A circle on a layer that a
334
+ [`Mover`](#mover) named in `blocked_by:` reports contacts as usual and stops
335
+ nobody. Nor can a circle *be* stopped. A mover that declares anything needs a
336
+ `BoxCollider` of its own, and raises at attach without one. The first rule is a
337
+ limit, not a check: layer membership is a runtime fact, so a raise at attach
338
+ would catch only circles that already existed.
339
+ - **Signals:** `on_hit` fires with the other collider on the step a contact
340
+ **starts**, and `on_separated` on the step it **ends**:
341
+ `collider.on_hit { |other| ... }`. The system triggers them through
342
+ `emit_hit(other)` and `emit_separated(other)`. Each fires once per pair; see
343
+ [`CollisionWorld`](#collisionworld).
344
+
345
+ ### `CollisionWorld`
346
+
347
+ **A scene-scoped broadphase collision system.** The component lives on the scene
348
+ node, holds the registered colliders in a `SpatialHash`, and reports each step
349
+ where overlapping pairs begin and end. As a normal component, it runs in the
350
+ `update` traversal and goes away with the scene. See
351
+ [Systems & shared resources](systems.md).
352
+
353
+ **It ignores shape.** It buckets each collider by its reported bounding box
354
+ (`aabb_x`, `aabb_y`, `aabb_w`, `aabb_h`) and leaves the exact test to the pair's own
355
+ `overlap?`. [`CircleCollider`](#circlecollider) and [`BoxCollider`](#boxcollider)
356
+ therefore share one world and collide with each other. A game can add its own
357
+ shape by answering the same few methods.
358
+
359
+ - **Construct:** `CollisionWorld.new(cell_size:)`, the spatial hash's cell size.
360
+ **Size it to the colliders**, and nothing else. A tile map in the same scene is
361
+ no guide. A 64px collider in 16px cells lands in twenty-five cells and is queried
362
+ from all of them. That measured 2.8× the cost of a cell sized to the collider. A
363
+ 12×6 feet box in the same 16px cells costs only 10–20% over its own optimum. A
364
+ wrong value costs frame time, never correctness, so pick it deliberately.
365
+ - **Registration:** `register(collider)` and `unregister(collider)`. Colliders call
366
+ these through their own lifecycle, so nodes never wire them.
367
+ - **Queries:** `query_box(x, y, w, h)` yields every registered collider bucketed in
368
+ a cell the region covers. It skips nodes queued for removal. It is the
369
+ rectangular form of `query_circle`, and a blocked [`Mover`](#mover) asks it each
370
+ step. `nearest(x, y, r, layer:)` and `cell_empty?(x, y)` complete the set. All of
371
+ them read the index the most recent `update` built, and allocate nothing.
372
+ - **Staying current mid-step:** `reindex(collider, from_x, from_y, from_w, from_h)`
373
+ re-buckets a collider that moved after the index was built, given the box it
374
+ *was* bucketed at. Buckets fill once per step, so a query over cells a mover left
375
+ would miss it. With two hundred actors, that measured 116 misses in 60,000
376
+ queries, and none once each mover re-bucketed itself. A [`Mover`](#mover) that
377
+ declares a collider layer does this through its resolver. Anything else that
378
+ moves a collider mid-step may call it directly: a mover that declared nothing, or
379
+ an ancestor.
380
+ - **Phase:** `update(dt)` rebuilds the spatial index. It fires both colliders'
381
+ `on_hit` for each pair that *started* overlapping, then both colliders'
382
+ `on_separated` for each pair that *stopped*. It **ignores layers**: it reports
383
+ contacts, and each owner decides their meaning by reading the other's `layer`. It
384
+ skips colliders whose node is queued for removal.
385
+ - **Contacts arrive one step late.** This component sits on the scene node, and a
386
+ node runs its components before its children. The world builds the index and
387
+ reports every pair *before* any actor moves this step. Contacts therefore
388
+ describe where things stood at the end of the previous step. The delay is
389
+ consistent, so nothing jitters, but a pair that starts overlapping during step N
390
+ is reported at the start of step N+1. A blocked [`Mover`](#mover) does not read
391
+ the index this way and is unaffected. It queries mid-step and re-buckets itself,
392
+ through `reindex` above.
393
+ - **A contact is two edges, not a state.** Each signal fires **once per pair**.
394
+ Nothing fires on the steps between, however long the overlap lasts, and a pair
395
+ spanning several cells reports once. A handler may therefore do what must happen
396
+ exactly once, such as `score += 100`, playing a sound or spending a life, with
397
+ nothing to guard. `examples/collision`'s crate counts arrivals in one line thanks
398
+ to this.
399
+
400
+ The world keeps an
401
+ [`Engine::ContactSet`](internals.md#contactset--the-two-edges-of-a-contact) per
402
+ collider for this. The set also absorbs the
403
+ [`SpatialHash`](internals.md#spatialhash--uniform-grid-broadphase) may-yield-twice
404
+ contract: a pair spanning three cells is offered three times and recorded once.
405
+ - **`on_separated` also fires when the partner goes.** A contact ends when the
406
+ partner is destroyed (`queue_free`) or leaves the tree, not only when the two move
407
+ apart. The survivor hears about it on its next step. Otherwise a ship could stay
408
+ "in contact" with a rock that no longer exists. The departing collider hears
409
+ nothing, because it is leaving.
410
+
411
+ **Remember that last point when pooling.** The world clears a collider's own
412
+ contacts when it registers, so a recycled entity never reports a separation from
413
+ its previous life. But state a *node* derived from those contacts, such as a "how
414
+ many things am I touching" count, never unwinds. The edge that would unwind it
415
+ never arrived. Zero that state in the node's `reset`, with the rest.
416
+ - **Example:** `examples/collision` puts this system on the scene, circles and boxes
417
+ on the nodes, two layers, and one line that ignores same-layer pairs. A circle
418
+ stays lit while inside a crate (`on_hit` on, `on_separated` off), and the crate
419
+ blinks once and counts it. The backdrop draws the broadphase grid, so you can see
420
+ `cell_size`.
421
+ - **Range queries (targeting):** the same index answers point-radius lookups
422
+ against the most recent `update`. A turret can find enemies without a contact:
423
+ - `query_circle(x, y, r) { |collider| }` yields every registered collider whose
424
+ centre lies within `r` of `(x, y)`. It measures centre distance and does not add
425
+ the collider's size, so it reads like a range ring. It skips freed nodes'
426
+ colliders, and may yield a collider more than once, which is fine for selecting.
427
+ Filter by `collider.layer` in the block.
428
+ - `nearest(x, y, r, layer: nil)` returns the closest such collider, optionally
429
+ limited to one `layer`, or `nil`. Both allocate nothing, so a targeting
430
+ component can call them every frame.
431
+ - **Cell occupancy (grid games):** `cell_empty?(x, y)` answers whether the cell
432
+ containing the **world** point `(x, y)` is free, for questions like "may a pickup
433
+ spawn on this square?". It takes a point, not a region; pass any coordinate inside
434
+ the square you mean. Set `cell_size` to the game's own square so the two grids
435
+ line up. The cells form the hash's own lattice, anchored at the world origin. A
436
+ board should therefore put its origin on a multiple of `cell_size`. Off that
437
+ lattice, each square straddles two cells and both read occupied. Like the queries
438
+ above, it reads the index the last `update` built. It allocates nothing, even on a
439
+ miss, which is what a board scan asks most.
440
+
441
+ A collider whose node is queued for removal does not occupy a cell, so a corpse
442
+ cannot reserve a square. The rest is
443
+ [`SpatialHash#cell_empty?`](internals.md#spatialhash--uniform-grid-broadphase).
444
+ Its cell walk is half-open on the far edge, so a piece filling one square leaves
445
+ its neighbours free.
446
+
447
+ It takes *world* coordinates, because colliders report those. A node whose grid
448
+ starts elsewhere adds its origin first:
449
+
450
+ ```ruby
451
+ # in a Grid node whose cells are cell_size across
452
+ def free?(col, row)
453
+ collisions = system(RGame::Engine::Components::CollisionWorld)
454
+ collisions.cell_empty?(world_x + (col * cell_size), world_y + (row * cell_size))
455
+ end
456
+ ```
457
+
458
+ ### `DespawnOffscreen`
459
+
460
+ **Removes the node once its origin passes an edge of the world bounds by more than
461
+ `margin`.** Use it for short-lived entities like projectiles. The margin stands in
462
+ for the node's size: a node drawn centred on its origin has fully left once the
463
+ margin reaches half its extent.
464
+
465
+ - **Construct:** `DespawnOffscreen.new(margin: 0.0)`, with the same optional
466
+ `width:` and `height:` override as `ScreenWrap`.
467
+ - **Lifecycle:** `on_attach` resolves the bounds, exactly as `ScreenWrap` does.
468
+ - **Phase:** `update(dt)` calls `node.queue_free` once the node's **world**
469
+ position passes an edge. A projectile spawned as a child of an offset emitter
470
+ leaves at the world's edge, not at an edge shifted by the emitter's position.
471
+ Removal is *deferred* (see [deferred free](scene_graph.md#deferred-free)), so
472
+ triggering it inside the update traversal is safe. For an entity that never
473
+ leaves a *fixed* board, such as a projectile that should vanish after N seconds,
474
+ use a one-shot [`Timer`](#timer) (`repeating: false`) with
475
+ `on_timeout { node.queue_free }` instead.
476
+ - **One response to the edge per node.** It raises at attach beside a `ScreenWrap`
477
+ or a mover declaring `blocked_by: [:bounds]`; see
478
+ [`WorldBounds.one_response!`](#world).
479
+
480
+ ### `FeetCollider`
481
+
482
+ **A [`BoxCollider`](#boxcollider) whose rectangle is the node's feet**: centred
483
+ horizontally in the node's dimensions and anchored to their bottom. A top-down
484
+ character should collide with this shape. A 16×22 hero occupies the 12×6 patch
485
+ under them, not the whole sprite, so their head does not bump a wall a tile away. In
486
+ every other respect it is a `BoxCollider`: same registration, same signals, same
487
+ mixing with circles. `get_component(BoxCollider)` finds it.
488
+
489
+ - **Construct:** `FeetCollider.new(width:, height:, layer: :default)`, the feet box
490
+ size in px. It takes no sprite size and no offsets. Those come from `node.width`
491
+ and `node.height`, which [`AnimatedSprite`](#animatedsprite) sets from the sprite
492
+ frame, so box and sprite never disagree. A node without a sprite sets its own
493
+ dimensions.
494
+ - **Geometry:** the collider builds `box` on **first read** and keeps it, not at
495
+ construction. A node has no size until its sprite attaches, so add order never
496
+ matters. Reading `box` from a 0×0 node **raises**, naming the size. The
497
+ alternative would be a box anchored to nothing, and an actor walking through walls
498
+ far from the cause. Assigning `box =` still takes priority, which lets a pooled
499
+ entity retune its shape on reset.
500
+ - **Everything else:** as [`BoxCollider`](#boxcollider): `aabb_*`, `cx` and `cy`,
501
+ `overlap?`, `on_hit` and `on_separated`, and registration with the scene's
502
+ [`CollisionWorld`](#collisionworld) when one exists.
503
+
504
+ A blocked [`CharacterBody`](#characterbody) resolves its steps against this same
505
+ shape, so one component carries the feet box for both jobs.
506
+ `examples/collision_tiles` mounts no `CollisionWorld` at all, and uses the collider
507
+ only as the rectangle a step may not push past.
508
+
509
+ ```ruby
510
+ add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: 'hero.json'))
511
+ feet = add_component(RGame::Engine::Components::FeetCollider.new(
512
+ width: 12, height: 6, layer: :hero
513
+ ))
514
+ feet.on_hit { |other| take_damage if other.layer == :spike }
515
+ ```
516
+
517
+ ### `Hop`
518
+
519
+ **A jump in a top-down view.** The node's picture rises along a parabola and comes
520
+ back down. The node itself stays on the ground, and so does every collider, camera
521
+ and child that reads its position.
522
+
523
+ - **Construct:** `Hop.new(peak:, duration:, action: :jump)`. `peak` is the highest
524
+ the picture rises, in px, reached halfway through `duration` seconds. Both must be
525
+ positive, or the constructor raises `ArgumentError`. `action` names the action
526
+ whose **press edge** starts a hop, so holding it hops once. `action: nil` reads no
527
+ input.
528
+ - **State:** `height` (px above the ground now), `airborne?`, `peak`, `duration`.
529
+ - **Starting one:** press `action` during `control`, or call `jump`. `jump` does
530
+ nothing while a hop is under way. NPCs and scripts call it.
531
+ - **Phase:** `update(dt)` advances the arc and writes the height to
532
+ [`node.elevation`](scene_graph.md#elevation). [`AnimatedSprite`](#animatedsprite)
533
+ and [`Sprite`](#sprite) draw lifted by it. The arc depends on the time accumulated
534
+ in `update`, never on a clock, so a paused node hangs in the air.
535
+ - **Lifecycle:** `on_attach` lands the node, so a pooled node reused mid-hop starts
536
+ on the ground.
537
+
538
+ **The game decides what a hop crosses.** `Hop` knows nothing about tiles or
539
+ colliders. A [`CharacterBody`](#characterbody) blocked by a wall stays blocked while
540
+ its node is in the air. A game whose chasm tiles should be passable mid-hop reads
541
+ `airborne?` where it decides what is solid.
542
+
543
+ ```ruby
544
+ hop = add_component(RGame::Engine::Components::Hop.new(peak: 18, duration: 0.5))
545
+ hop.airborne? # => false — until the :jump action is pressed
546
+ ```
547
+
548
+ `examples/jump_topdown` draws the shadow and the feet box that stay on the ground
549
+ under the picture.
550
+
551
+ ### `Identity`
552
+
553
+ **A stable name for one node**, so something outside the tree can refer to it.
554
+
555
+ ```ruby
556
+ sheep.add_component(RGame::Engine::Components::Identity.new(id: 7))
557
+ RGame::Engine::Components::Identity.of(sheep) # => 7
558
+ ```
559
+
560
+ - **Construct:** `Identity.new(id:)`, with any object; `nil` raises.
561
+ - **Read:** `#id`, or `Identity.of(node)`. `of` returns `nil` for a node without an
562
+ identity, and for no node at all.
563
+ - **Phase:** none. It holds a value and does nothing per frame.
564
+
565
+ **Most saving needs no identity.** A scene is a recipe and a save file is state. A
566
+ *singular* thing is named by the variable that holds it. *Interchangeable* things
567
+ are named by their order in an array. `examples/save_load` restores a dog and a
568
+ flock with exactly those two and nothing else. `examples/save_load_ids` needs this
569
+ component.
570
+
571
+ `Identity` covers two cases the others miss:
572
+
573
+ - **A collection whose members can die.** An array index names nothing once a
574
+ middle member is gone.
575
+ - **A reference from one saved thing to another**, such as a dog chasing one
576
+ particular sheep. This case truly forces ids. A collection can be respawned from
577
+ its own records, but a reference into it needs a name for its target.
578
+ `Targeting#target` shows the problem: it holds a *node*, and `Identity.of` turns
579
+ that into something a file can hold.
580
+
581
+ The game must get two things right; this component does not check them:
582
+
583
+ - **Ids must be unique** among things that can refer to each other. A duplicate
584
+ restores the wrong object, silently.
585
+ - **The id allocator belongs in the save.** A counter that restarts at 1 on load
586
+ reissues ids the restored objects already hold. Save the next id with them.
587
+
588
+ ### `Mover`
589
+
590
+ **The base class of every component that moves its node**:
591
+ [`CharacterBody`](#characterbody), [`Velocity`](#velocity) and
592
+ [`PathFollow`](#pathfollow). Walking an intent, integrating a velocity and following
593
+ a path are three different jobs, so they are three classes. They share what happens
594
+ *after* a step is computed, and `Mover` holds that part. You never add a `Mover` on
595
+ its own.
596
+
597
+ **A mover declares what stops a step; it does not subclass for it.**
598
+ `blocked_by:` lists what a step may not pass through. The default is nothing. Then
599
+ the mover writes the node's position directly, and needs **no sprite, no
600
+ dimensions, no collider and no system on the scene**.
601
+
602
+ ```ruby
603
+ add_component(RGame::Engine::Components::BoxCollider.new(width: 12, height: 12, layer: :crate))
604
+ add_component(RGame::Engine::Components::Velocity.new(vx: 90, vy: 40, blocked_by: [:wall]))
605
+ ```
606
+
607
+ Two names are reserved, and every other name is a collider layer:
608
+
609
+ | Name | Resolved against | Stops the step at |
610
+ |---|---|---|
611
+ | `:tiles` | the scene's [`TileWorld`](#tileworld) | the edge of a solid tile |
612
+ | `:bounds` | the scene's [`WorldBounds`](#world) | the edge of the world |
613
+ | anything else | the scene's [`CollisionWorld`](#collisionworld) | the edge of any `BoxCollider` wearing that layer |
614
+
615
+ **Each step resolves one axis at a time and takes the most restrictive answer.** A
616
+ diagonal held against a wall keeps its free half, so the mover slides. It slides
617
+ off a villager exactly as off a fence. A character wants that; a bullet does not,
618
+ but needs no separate resolver. `on_blocked` fires on the step the bullet hits, and
619
+ a bullet that frees itself there is gone before it slides. A mover that keeps
620
+ pushing also keeps its intent: a blocked `Velocity` does not zero its `vx`. A bullet
621
+ that bounces reads which axis stopped, and reverses that half of its velocity:
622
+
623
+ ```ruby
624
+ velocity = RGame::Engine::Components::Velocity.new(vx: 120, vy: 80, blocked_by: %i[wall bounds])
625
+ velocity.on_blocked do |_by, axis|
626
+ velocity.vx = -velocity.vx unless axis == :y
627
+ velocity.vy = -velocity.vy unless axis == :x
628
+ end
629
+ ```
630
+
631
+ ```ruby
632
+ add_component(RGame::Engine::Components::FeetCollider.new(width: 12, height: 6, layer: :hero))
633
+ add_component(RGame::Engine::Components::CharacterBody.new(speed: 80, blocked_by: %i[tiles npc]))
634
+ ```
635
+
636
+ Three rules apply to layer names:
637
+
638
+ - **An empty layer is not an error.** The declaration says what *may* stop this
639
+ mover, not what does.
640
+ - **A mover is never stopped by its own collider.** A crowd of villagers can all
641
+ declare `blocked_by: [:npc]` while each wears `:npc`.
642
+ - **Blocking is box against box.** A [`CircleCollider`](#circlecollider) on a
643
+ declared layer reports contacts as usual and stops nothing.
644
+
645
+ `blocked_by` and [`on_hit`](#boxcollider) answer different questions: what may I
646
+ walk through, and what am I touching? They are not alternatives. A blocked pair
647
+ ends up *touching*, and `CollisionBox.overlap?` is half-open, so a blocked step
648
+ reports no contact. An entity that must both stop and react needs both.
649
+
650
+ **The mover reports what stopped a step.** `on_blocked` fires on the step something
651
+ starts stopping the mover, and `on_unblocked` on the step it stops doing so. Each
652
+ fires once per blocker. `BoxCollider` reports contacts with the same pair of edges,
653
+ here aimed at what a step could not pass. A spiky ball that both stops the player and
654
+ hurts them is thus two ordinary components, not a hand-written `on_hit`.
655
+
656
+ ```ruby
657
+ mover.on_blocked { |by| take_damage if by.layer == :spike }
658
+ ```
659
+
660
+ The listener receives the blocker and reads `by.layer` and `by.node`, whatever kind
661
+ stopped the step. A collider answers its own layer and owning node. The map's solid
662
+ tiles answer `:tiles` and `nil`. The world's edge answers `:bounds` and `nil`. The
663
+ second argument is the stopped axis: `:x`, `:y` or `:both`. A listener that names
664
+ only the blocker never sees it. Three details matter:
665
+
666
+ - **Standing still counts as unblocking.** The set of blockers advances once per
667
+ `update`. A mover that stops pushing records nothing that step, so `on_unblocked`
668
+ fires. The mover has not moved; it has stopped *being stopped*.
669
+ - **Once per blocker, not once per axis.** A step stopped on both axes by one thing
670
+ fires once, with `:both`. Only the map produces that, with a diagonal into an
671
+ inside corner of solid tiles. A step resolves X first, and once X is flush, a
672
+ single collider no longer overlaps on Y. A step stopped on X by the map and on Y
673
+ by a villager fires twice, each with its own axis. Starting edges come before
674
+ ending ones, the order `CollisionWorld` uses for contacts. A blocker that stops X
675
+ on one step and Y on the next stood in the way throughout. It fires once, with the
676
+ axis it first stopped. `on_unblocked` carries no axis for the same reason.
677
+ - **A blocked pair is not a contact**, as the paragraph above explains. The spiky
678
+ ball listens to `on_blocked`; a trigger area listens to `on_hit`.
679
+
680
+ **`:bounds` must be declared.** A mover that does not name it leaves the world.
681
+ Stopping at the edge is one of three responses to it, beside
682
+ [`ScreenWrap`](#screenwrap) and [`DespawnOffscreen`](#despawnoffscreen), and a node
683
+ may carry only one. Declaring `:bounds` beside either raises at attach. A game whose
684
+ entities wrap or despawn at the edge gives their movers no `:bounds`.
685
+ `blocked_by?(name)` answers whether a mover declared a name.
686
+
687
+ **The shape has one owner, and it is not the mover.** A blocked step resolves
688
+ against the sibling [`BoxCollider`](#boxcollider)'s rectangle;
689
+ [`FeetCollider`](#feetcollider) suits a walking character. You give the box once,
690
+ to the component that *is* a shape. The same rectangle stops the step and reports
691
+ contacts. Reassigning `collider.box` retunes both, and no component hands data to
692
+ another.
693
+
694
+ - **Construct:** every mover takes `blocked_by: []`. A bare Symbol also works
695
+ (`blocked_by: :tiles`).
696
+ - **Lifecycle:** `on_attach` resolves the declarations and builds the mover's own
697
+ [`CollisionSystem`](internals.md#collisionsystem--move-an-actor-against-its-blockers)
698
+ from the sources it finds. It **raises** for anything missing. It checks the
699
+ node's collider first, then the scene's `TileWorld` for `:tiles`, its `WorldBounds`
700
+ for `:bounds`, and its `CollisionWorld` for any layer name. Falling back to free
701
+ movement would look like a collision bug, caused by a scene three files away that
702
+ never mounted the system.
703
+ - **Signals:** `on_blocked` fires with what stopped the step and the stopped axis.
704
+ `on_unblocked` fires with what stopped stopping it:
705
+ `mover.on_blocked { |by, axis| ... }`, `mover.on_unblocked { |by| ... }`.
706
+ - **Phase:** `update(dt)` opens the step, calls the subclass's private
707
+ `take_step(dt)`, and reports the edges. Do not override it; it guarantees that no
708
+ mover forgets an edge.
709
+ - **Heading:** `heading_x` and `heading_y` give the step's direction, each axis in
710
+ -1..1, and `0, 0` when the mover is not trying to move.
711
+ [`AnimatedSprite`](#animatedsprite) faces by it. It is a facing, not a velocity: a
712
+ mover pressed into a wall still heads into it. A [`CharacterBody`](#characterbody)
713
+ answers its intent. A [`Velocity`](#velocity) answers its velocity, scaled so the
714
+ larger axis is ±1. A [`PathFollow`](#pathfollow) answers the unit direction of its
715
+ current segment. Reading it allocates nothing.
716
+ - **Seam:** `apply_move(dx, dy)` lands a step. With nothing declared, it writes
717
+ straight onto the node. With declarations, it goes through the resolver. A mover
718
+ may call it several times in one step, and still reports the edges once.
719
+ - **Actor adapter:** when blocked, the mover passes *itself* to
720
+ [`CollisionSystem#move`](internals.md#collisionsystem--move-an-actor-against-its-blockers).
721
+ It answers `collision_box` from the collider, and `x`, `y`, `x=` and `y=` from the
722
+ node **in world space**, the frame the tile grid and broadphase use. Writing back
723
+ translates the node's local position. That is exact under an unrotated ancestor
724
+ chain and approximate under a rotated one. A spinning thing wants a circle anyway.
725
+
726
+ ### `Navigator`
727
+
728
+ **A [`PathFollow`](#pathfollow) that plans its own paths.** `go_to(world_x, world_y)`
729
+ finds a route over the scene's [`TileWorld`](#tileworld) to the tile containing that
730
+ point. It smooths the route into as few straight segments as the node's collider can
731
+ travel, and walks it.
732
+
733
+ ```ruby
734
+ # `actors` is the node TileMapLayer.mount returned, in a scene with a TileWorld mounted.
735
+ hero = RGame::Engine::Node2D.new(x: 40, y: 40)
736
+ hero.add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: 'hero.json'))
737
+ hero.add_component(RGame::Engine::Components::FeetCollider.new(width: 12, height: 6))
738
+ navigator = hero.add_component(RGame::Engine::Components::Navigator.new(speed: 80, blocked_by: [:tiles]))
739
+ actors.add_node(hero)
740
+
741
+ navigator.go_to(200.0, 360.0) # => true — the hero sets off; false when there is no route
742
+ ```
743
+
744
+ - **Construct:** `Navigator.new(speed:, blocked_by: [])`. It takes no `path:` and
745
+ stays idle until the first `go_to`. [`Mover`](#mover) decides what may stop it. A
746
+ navigator that should stay off solid tiles while walking declares `:tiles`, like
747
+ any mover.
748
+ - **Lifecycle:** `on_attach` raises when the scene has no `TileWorld` to plan over.
749
+ It also looks up the node's `BoxCollider`, if any. Calling `go_to` before the
750
+ node is in the tree raises too.
751
+ - **`go_to(world_x, world_y)`** plans from where the node stands and starts walking
752
+ at once, from exactly there. A navigator halfway along one route turns onto the
753
+ next without a jump. It returns `true`, or `false` when no route exists: the
754
+ target is solid, outside the map, or in a part the node cannot reach. On `false`
755
+ nothing changes, and a walk under way continues. `on_finished` never fires inside
756
+ `go_to`, even for a target in the node's own cell; it fires on the next step.
757
+ - **The anchor.** The centre of the collider's box reaches the target: the feet,
758
+ for a [`FeetCollider`](#feetcollider). On a node without a collider, the origin
759
+ does. The walk ends with the anchor on the centre of the target tile.
760
+ - **Readers:** `cells` returns the route as the search found it,
761
+ `[[col, row], ...]` from start tile to target, or `nil` before the first `go_to`.
762
+ Use it to draw the route. `path` returns the smoothed
763
+ [`Path`](toolbox.md#path--a-walkable-polyline) the node walks, in the node's
764
+ coordinates, starting where the node stood.
765
+ - **It can walk every route it plans.** Smoothing keeps a straight segment only if
766
+ the map's own blocker source says the collider's box can travel it. That source is
767
+ `TileWorld#blockers`, the one that stops a mover declaring `:tiles`, queried
768
+ through [`TileBlockers#travel?`](internals.md#tileblockers--the-tile-grid-as-a-blocker-source).
769
+ A test on tiles alone would keep a diagonal past a tree's corner that a point
770
+ clears but a feet box clips. A `PathFollow` held on a corner does not slide off;
771
+ it would stand there. `travel?` holds for a walker taking steps under a quarter
772
+ tile: 240 px/s at 60 ticks a second on 16 px tiles.
773
+ - **One component, not a search beside a walker.** A navigator *is* a `PathFollow`,
774
+ so no component hands a route to a sibling. There is no wiring to forget, and the
775
+ order you add components in does not matter.
776
+ - **Colliders up to one tile only.** Pathfinding for a collider wider or taller than
777
+ a tile is unsupported. Smoothing assumes the box fits the cells the search found.
778
+ `go_to` raises `ArgumentError`, naming the box and the tile size, instead of
779
+ planning a route such a walker could stall on. A collider of exactly one tile
780
+ works.
781
+ - **It waits; it does not replan.** It plans against the map and knows only the
782
+ map. A navigator declaring other collider layers waits behind anything standing on
783
+ its route, as a `PathFollow` does, and resumes when the way clears. To go around
784
+ instead, call `go_to` again.
785
+ - **Cost.** Planning runs when `go_to` is called, never per frame, and allocates. It
786
+ takes about 0.2 ms for a 65-tile route across a 60x40 map. A 117-tile route across
787
+ 120x90 takes 1.3 ms, 1 ms of it in the search. The walk itself is `PathFollow`'s,
788
+ and allocates nothing.
789
+ - **Spaces:** it plans in world space and walks in the parent's. The two agree under
790
+ an unrotated ancestor chain, the same limit a blocked [`Mover`](#mover) has.
791
+ - **Example:** in `examples/pathfinding`, a tile cursor picks the target. The scene
792
+ draws `cells` as a dot per tile and `path` as lines. It adds the feet box's centre
793
+ back to each waypoint so the lines sit on the dots.
794
+
795
+ ### `PathFollow`
796
+
797
+ **Walks the owning node along an
798
+ [`RGame::Engine::Path`](toolbox.md#path--a-walkable-polyline) at constant speed**,
799
+ and emits `on_finished` at the last waypoint. Hook whatever should happen on arrival
800
+ to that signal.
801
+
802
+ - **Construct:** `PathFollow.new(speed:, path: nil, blocked_by: [])`.
803
+ [`Mover`](#mover) decides what may stop it. Without a path, the follower is idle:
804
+ it moves nothing, never finishes, and heads nowhere until it receives one.
805
+ - **Lifecycle:** `on_attach` restarts the walk. It returns to the first waypoint,
806
+ clears progress, and *places* the node there regardless of declarations. A pooled
807
+ follower acquired and added again starts a fresh walk.
808
+ - **A new route:** `follow(path)` restarts with a different path, at any time. A
809
+ finished follower walks it and emits `on_finished` again. A follower halfway along
810
+ another route drops that route at once and is placed on the new first waypoint. You
811
+ may call it from an `on_finished` handler. `follow(nil)` stops the walk where it
812
+ stands. `path` returns the route being walked.
813
+
814
+ ```ruby
815
+ out = RGame::Engine::Path.new([[40.0, 100.0], [200.0, 100.0]])
816
+ back = RGame::Engine::Path.new([[200.0, 100.0], [40.0, 100.0]])
817
+ patrol = RGame::Engine::Components::PathFollow.new(path: out, speed: 40)
818
+ patrol.on_finished { patrol.follow(patrol.path.equal?(out) ? back : out) }
819
+ ```
820
+ - **Heading:** the unit direction of the current segment, computed as the walk
821
+ enters it. It is `0, 0` while idle, after finishing, and along a zero-length
822
+ segment.
823
+ - **Signal:** `on_finished` fires once, without payload, at the path's end:
824
+ `follow.on_finished { node.queue_free }`. `finished?` reports the same state.
825
+ - **Phase:** `update(dt)` advances `speed * dt`, crosses as many segments as one step
826
+ spans, and interpolates the node's position. It allocates nothing. With nothing
827
+ declared, it places the node on that point.
828
+ - **When blocked, the walk waits.** With declarations, the follower moves the node
829
+ to that point through `apply_move`. A step stopped short **does not advance the
830
+ walk**: progress returns to where the step began. A follower held for a second
831
+ arrives a second late instead of racing ahead once free. `on_finished` never fires
832
+ for a walker still standing in front of its blocker.
833
+ - **A held follower does not slide.** The step's free axis still moves, but each
834
+ step aims at the same path point again. A follower pressed diagonally against a
835
+ wall comes to rest, unlike a [`Velocity`](#velocity), which slides. To get around
836
+ an obstacle, replan the path.
837
+
838
+ ### `PlayerController`
839
+
840
+ **Drives a `CharacterBody` sibling from two input axes**: direct 8-way walking, no
841
+ inertia (unlike `ThrustController`). It neither knows nor cares whether the body is
842
+ blocked.
843
+
844
+ - **Construct:** `PlayerController.new(x_axis: :move_x, y_axis: :move_y)`.
845
+ - **Lifecycle:** `on_attach` looks up the node's `CharacterBody` with
846
+ `require_sibling`.
847
+ - **Phase:** `control(actions)` copies the two axes into the body's intent.
848
+ - **Example:** `examples/walk`.
849
+
850
+ ### `Pool`
851
+
852
+ **Wraps an [`RGame::Engine::Pool`](toolbox.md#pool--reuse-dont-allocate) of nodes
853
+ and runs its tree bookkeeping in the frame tick.** A scene that recycles entities,
854
+ such as enemies or projectiles, writes no acquire/add/reclaim code. It calls `spawn`
855
+ and the ordinary `queue_free`. Pooled nodes are **normal children** of the owner, so
856
+ the scene's traversal updates and draws them. This component manages only pool
857
+ membership.
858
+
859
+ - **Construct:** `Pool.new { Enemy.new(...) }`. The factory builds a blank node. Add
860
+ the component with a name (`as:`) when a node needs several pools.
861
+ - **Spawn:** `pool.spawn` takes a node, recycled or newly built, and adds it as a
862
+ child. `pool.spawn { |n| n.reset(...) }` runs the block to re-initialise the node
863
+ *before* it enters the tree, so `on_attach` sees the reset state. Projectiles need
864
+ that order.
865
+ - **Reclaim:** `update(dt)` returns every freed pooled node to the free list, and
866
+ detaches any still attached. Despawning is thus `node.queue_free` from anywhere;
867
+ the pool recycles the node with no game-side wiring. It allocates nothing in steady
868
+ state.
869
+ - **State:** `size` counts the live pooled nodes, and `empty?` is true once all are
870
+ reclaimed. A scene reads it to tell when a wave is cleared.
871
+
872
+ ### `ScreenWrap`
873
+
874
+ **Wraps the node's position within the world bounds**, so an entity leaving one edge
875
+ reappears at the opposite one.
876
+
877
+ - **Construct:** `ScreenWrap.new(margin: 0.0)`. `margin` lets a sprite pass fully off
878
+ one edge before reappearing on the other. Bounds come from the scene's world
879
+ system. `ScreenWrap.new(width:, height:, margin:)` overrides them for a node whose
880
+ wrap region is not the whole world.
881
+ - **Lifecycle:** `on_attach` resolves the bounds, which is why you can omit them. A
882
+ pooled entity is built long before it enters a tree, and has nothing to ask yet. It
883
+ resolves again on every entry, so a recycled node follows the scene it lands in.
884
+ Attaching without bounds and without a world system in scope **raises**.
885
+ - **Phase:** `update(dt)` wraps the node's **world** position against the bounds, and
886
+ writes it back through [`Node2D#world_x=`](scene_graph.md#the-two-spaces). A node
887
+ under an offset container wraps at the world's edge, not at an edge shifted by the
888
+ container.
889
+ - **A wrap is a placement, not a step.** It does not consult a sibling mover's
890
+ `blocked_by` about the far side. A node wrapped onto something that blocks it stays
891
+ pressed against it.
892
+ - **One response to the edge per node.** It raises at attach beside a
893
+ `DespawnOffscreen`, or beside a mover declaring `blocked_by: [:bounds]`. A wrapping
894
+ entity's mover declares no `:bounds`; see [`WorldBounds.one_response!`](#world).
895
+
896
+ ### `Sprite`
897
+
898
+ **Draws one registered image centred on the node's origin**, at `0, 0`, where the
899
+ traversal already placed and rotated the renderer.
900
+
901
+ - **Construct:** `Sprite.new(id:, scale: 1.0, z: 0)`. `id` is a renderer image id.
902
+ `z` orders this component against the node's *other* drawing, such as a shadow
903
+ under a sprite, inside the node's own slot. It is not the node's `z`, which orders
904
+ the node among its siblings. See [Drawing](drawing.md#draw-order).
905
+ - **State:** `scale` is read/write, so a pooled entity can retune it.
906
+ - **Phase:** `draw(renderer, view)` draws the image at **`0, 0`** with **no angle**.
907
+ `Node2D#draw` already pushed the node's transform, so the origin and rotation
908
+ already apply. Passing either would apply it twice. The image is lifted by
909
+ [`node.elevation`](scene_graph.md#elevation), in the node's local space. The
910
+ component skips the draw entirely when the view cannot show it. It measures the
911
+ node's box, scaled and lifted, against `node.world_x` and `world_y`. A node that
912
+ never set a size is never culled.
913
+
914
+ ### `Targeting`
915
+
916
+ **Picks a node for the owner to aim at.** Each `update`, it queries the scene's
917
+ [`CollisionWorld`](#collisionworld) around the node's world origin and exposes the
918
+ chosen target. It only *selects*; it never moves or fires. The owner reads `target`
919
+ and acts. Every candidate already registers with the broadphase through its
920
+ collider, so targeting keeps no entity list.
921
+
922
+ - **Construct:** `Targeting.new(range:, policy: :nearest, layer: nil)`. `range` is
923
+ the reach in pixels. `layer` restricts candidates, so `:enemy` ignores allies and
924
+ projectiles. An unknown `policy` raises at construction.
925
+ - **Policies:** `:nearest`, the default and only policy, picks the closest candidate
926
+ in range with one broadphase lookup.
927
+ - **State:** `target` is the chosen **node**, or `nil` when nothing is in range. It
928
+ refreshes every `update`, so a freed or out-of-range target clears itself. It is a
929
+ node, not a collider, so the owner can read its position and components.
930
+ - **Lifecycle:** `on_attach` looks up the scene's `CollisionWorld`.
931
+ - **Phase:** `update(dt)` selects the target again. It allocates nothing, so it runs
932
+ every frame.
933
+
934
+ ### `ThrustController`
935
+
936
+ **Inertial ship flight on top of a `Velocity` sibling.** A turn axis rotates the
937
+ node, and a thrust axis accelerates it along its heading.
938
+
939
+ - **Construct:** `ThrustController.new(turn_speed:, accel:, max_speed:, drag: 0.0,
940
+ turn_action: :turn, thrust_action: :thrust)`.
941
+ - **Lifecycle:** `on_attach` looks up the node's `Velocity` with `require_sibling`,
942
+ so a missing one raises at once instead of surfacing later as a `nil`.
943
+ - **Phase:** `control(actions)` reads intent: turn sets `velocity.spin`, and thrust
944
+ is stored. `update(dt)` accelerates along the heading, applies drag, and clamps to
945
+ `max_speed`. Angle 0 points up, so forward is `(sin θ, −cos θ)`. Firing is not part
946
+ of this component.
947
+
948
+ ### `TileWorld`
949
+
950
+ **The scene-scoped tile system** (see [Systems](systems.md)). It holds the parsed
951
+ [`RGame::Engine::TileMap`](tile_maps.md) and answers what an actor needs from it: where the solid
952
+ tiles are, and how big the world is. Find it with `node.system(TileWorld)`. It
953
+ includes `WorldBounds` (see [`World`](#world)), so `ScreenWrap` and
954
+ `DespawnOffscreen` work in a tile scene with no arguments.
955
+
956
+ **It does not draw.** `RGame::Engine::TileMapLayer` draws, one node per Tiled layer,
957
+ mounted inside a `WorldView`. The map is therefore drawn once per viewport, like the
958
+ rest of world space. `TileWorld` stays the thing actors ask.
959
+
960
+ **Each piece of the tile stack is the kind of object its job requires:**
961
+
962
+ | Piece | Kind | Because |
963
+ |---|---|---|
964
+ | `TileMap`, `Tileset` | values | parsed data with no handle; the gid rows are a `Util::Tensor` |
965
+ | `TileWorld` | component, mounted as a system | it is a scene-scoped answer — solid tiles, world size — that actors look up |
966
+ | `TileMapLayer` | node | it draws in world space, and draw order is tree order |
967
+
968
+ None of them duplicates state the node owns. None needs a hand-written hook to pass
969
+ data to another, depends on a sibling's add order, or names a layer it may not name.
970
+
971
+ - **Construct:** `TileWorld.new(map:, tilemap_id:, cameras: [])`. It clamps each
972
+ camera it receives to the map's edges. `bound(camera)` does the same for a camera
973
+ that arrives later, when a player joins.
974
+ - **Queries:**
975
+ - `blockers` returns the map's solid tiles as an
976
+ [`Engine::TileBlockers`](internals.md#tileblockers--the-tile-grid-as-a-blocker-source),
977
+ the same object every time. A [`Mover`](#mover) declaring `:tiles` borrows it
978
+ and resolves its own steps against it.
979
+ - `nav_grid` returns the same solidity as an
980
+ [`Engine::NavGrid`](toolbox.md#navgrid--routes-over-a-tile-grid), for planning a
981
+ route instead of resolving a step. It is built on first request and reused.
982
+ - `tile_width` and `tile_height` turn a world position into a cell.
983
+ - `solid?(col, row)`, `world_width` and `world_height`.
984
+ - `tilemap_id` and `elapsed`, which the layers read.
985
+ - `layer_count` and `first_above_layer`, which `TileMapLayer.mount` reads to
986
+ decide where actors go.
987
+ - **Solidity is read from the map once.** On the first request, `TileWorld` reads
988
+ the map's `solid_tile?` once per cell into one
989
+ [`Util::SolidGrid`](values.md#rgameutilsolidgrid). From then on, `blockers`,
990
+ `nav_grid` and `solid?` all read that store, never the map. They cannot disagree
991
+ about a cell. A resolve on the per-frame path becomes a byte lookup instead of a
992
+ walk through the map's layers and tileset. Everything past the map's edges is open.
993
+ `TileWorld` does not hand the store out, so a game cannot change a tile's solidity
994
+ at runtime.
995
+ - **It does not resolve a step.** Tiles, other actors, the world's edge, or any
996
+ combination may stop a mover, and only the mover knows which. The resolver
997
+ therefore belongs to the mover, and the grid to this system.
998
+ - **Phase:** `update(dt)` advances the map's animation clock.
999
+ - **Examples:** `examples/scroll_map` loads a `.tmx` through the asset manager and
1000
+ uses this system, `TileMapLayer.mount`, and a camera clamped to the map's edges.
1001
+ `examples/collision_tiles` adds an actor that collides, and shows the solid half of
1002
+ this system.
1003
+
1004
+ ```ruby
1005
+ world = scene.add_node(RGame::Engine::WorldView.new)
1006
+ actors = RGame::Engine::TileMapLayer.mount(world) # a node per Tiled layer
1007
+ actors.add_node(player) # in the gap between them
1008
+ ```
1009
+
1010
+ **`mount` returns the node the actors go in.** That node sits below the first layer
1011
+ Tiled flags `above`, so trunks draw under the walker and canopies over it.
1012
+ `mount(world, under: index)` overrides the position for a map arranged differently.
1013
+ Nothing here picks a `z`.
1014
+
1015
+ ### `Timer`
1016
+
1017
+ **A node-driven interval timer.** It runs in the node's update tick, so nothing can
1018
+ forget to advance it. It emits `on_timeout` each time a whole interval elapses: a
1019
+ spawn cadence, a turret's fire rate, a wave clock. It wraps the pure
1020
+ [`RGame::Engine::Timer`](toolbox.md#timer--paced-periodic-events) and reuses its
1021
+ drift-free carry-forward.
1022
+
1023
+ - **Construct:** `Timer.new(interval, repeating: true)`, in seconds. Add it with a
1024
+ name when a node needs several: `node.add_component(Timer.new(0.8), as: :spawn)`.
1025
+ `repeating: false` makes a **one-shot** that fires `on_timeout` exactly once, then
1026
+ goes inert. A projectile that should vanish after N seconds on a fixed board is
1027
+ `Timer.new(2.0, repeating: false)` plus `on_timeout { node.queue_free }`. When the
1028
+ board scrolls and the entity leaves the screen, use `DespawnOffscreen` instead.
1029
+ - **Signal:** `on_timeout` fires once per whole interval:
1030
+ `timer.on_timeout { spawn_enemy }`.
1031
+ - **Lifecycle:** `on_attach` restarts the countdown and re-arms a spent one-shot. A
1032
+ pooled node acquired and added again starts fresh, without its previous life's
1033
+ elapsed time.
1034
+ - **Phase:** `update(dt)` advances and emits. In one long step, a repeating timer
1035
+ emits once per interval crossed, catching up without drift. A one-shot emits at
1036
+ most once. It allocates nothing.
1037
+ - **Reset:** `reset` drops accumulated time and re-arms a one-shot, giving a fresh
1038
+ timer.
1039
+
1040
+ ### `Velocity`
1041
+
1042
+ **Integrates linear and angular velocity into the node's transform each step.**
1043
+
1044
+ - **Construct:** `Velocity.new(vx: 0.0, vy: 0.0, spin: 0.0, blocked_by: [])`.
1045
+ [`Mover`](#mover) decides what may stop it, as for a `CharacterBody`.
1046
+ - **State:** `vx`, `vy` and `spin` are read/write. A controller, or the node's own
1047
+ `control` hook, writes them as movement intent.
1048
+ - **Phase:** `update(dt)` moves the node by `vx*dt` and `vy*dt` through `apply_move`.
1049
+ It adds `spin*dt` to `node.angle` directly: a collision box does not turn with its
1050
+ node, so nothing can block a rotation.
1051
+ - **Blocked:** a stopped step leaves `vx` and `vy` unchanged. They are the intent.
1052
+ The game decides what a stop does to them, whether nothing, zero or a bounce, in an
1053
+ `on_blocked` handler. To block a `ThrustController` ship, declare `blocked_by:` on
1054
+ the `Velocity` it drives.
1055
+
1056
+ A free-moving entity can use `Velocity` alone. Pair it with a controller for input.
1057
+
1058
+ ```ruby
1059
+ add_component(RGame::Engine::Components::BoxCollider.new(width: 8, height: 8, layer: :bullet))
1060
+ velocity = add_component(RGame::Engine::Components::Velocity.new(vx: 400, blocked_by: %i[tiles]))
1061
+ velocity.on_blocked { queue_free }
1062
+ ```
1063
+
1064
+ ### `WanderController`
1065
+
1066
+ **A simple AI driver for a `CharacterBody`.** At intervals it rolls a new direction,
1067
+ one of eight or idle, and holds it. A wall that blocks it triggers an early re-roll.
1068
+ The RNG is injected, so tests get deterministic behaviour.
1069
+
1070
+ - **Construct:** `WanderController.new(rng: Random.new, change_interval: 1.0..3.0, idle_chance: 0.25)`.
1071
+ - **Lifecycle:** `on_attach` looks up the node's `CharacterBody` with
1072
+ `require_sibling`.
1073
+ - **Phase:** `update(dt)` counts down and re-rolls on timeout or when blocked.
1074
+ "Blocked" means *the node did not move while intending to*. The controller measures
1075
+ that instead of asking a collision world. It therefore works over a plain
1076
+ `CharacterBody` too, and never re-rolls early for a body whose steps always land.
1077
+ - **Example:** `examples/save_load`.
1078
+
1079
+ ### `World`
1080
+
1081
+ **The scene-scoped world system for a rectangular world**: it knows how big the
1082
+ world is, and nothing more. Mount one on a scene with a plain rectangular world. The
1083
+ components that need bounds find it, so no constructor between scene and entity has
1084
+ to pass the numbers along.
1085
+
1086
+ - **Construct:** `World.new(width:, height:)`.
1087
+ - **Queries:** `world_width` and `world_height`. They never change; a world of a
1088
+ different size is a new scene.
1089
+ - **Contract:** it includes `WorldBounds`, and so does [`TileWorld`](#tileworld),
1090
+ which answers the same two questions from its map's pixel size. Ask for the
1091
+ contract, `node.system(RGame::Engine::Components::WorldBounds)`, and either kind of
1092
+ world answers. `get_component` matches an included module the same way it matches a
1093
+ class.
1094
+ - **Frame:** the bounds run from (0, 0) to (`world_width`, `world_height`) in
1095
+ **world** coordinates. Everything that compares a node against them reads
1096
+ `world_x` and `world_y`. An entity under an offset container is inside the world
1097
+ exactly when its world position is.
1098
+ - **One response to the edge per node:** `WorldBounds.one_response!(node)` raises,
1099
+ naming both, if the node carries more than one of [`ScreenWrap`](#screenwrap),
1100
+ [`DespawnOffscreen`](#despawnoffscreen) and a [`Mover`](#mover) declaring
1101
+ `blocked_by: [:bounds]`. Each of the three calls it from `on_attach`, so whichever
1102
+ attaches second raises, in any add order. No pair makes sense. Stopping and
1103
+ wrapping disagree about where the node ends up. Wrapping and despawning race on
1104
+ which margin is reached first. Stopping tests the collision box, while the other
1105
+ two test the node's origin. The engine therefore refuses every combination.
1106
+
1107
+ **The world is not the window.** The two coincide in a single-screen game, which
1108
+ makes the mistake easy to make and hard to see. Bind wrapping to the viewport, and
1109
+ the world changes shape without warning when the window resizes or the screen
1110
+ splits into viewports. Ask [`Viewports`](scene_graph.md#viewports-and-views), or the
1111
+ `View` a `draw` receives, how big the *window* is. Ask `World` how big the *world*
1112
+ is.