rgame 0.2.0 → 0.3.1

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 (253) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -1
  3. data/README.md +67 -65
  4. data/docs/api/README.md +86 -82
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -165
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1032 -265
  10. data/docs/api/drawing.md +160 -160
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +149 -50
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +226 -148
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +397 -244
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +186 -73
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +395 -146
  23. data/docs/api/ui.md +883 -54
  24. data/docs/api/values.md +216 -33
  25. data/examples/assets/README.md +322 -0
  26. data/examples/assets/blip.ogg +0 -0
  27. data/examples/assets/glyphs.json +5 -0
  28. data/examples/assets/glyphs.png +0 -0
  29. data/examples/assets/hero.json +12 -0
  30. data/examples/assets/hero.png +0 -0
  31. data/examples/assets/icons.json +13 -0
  32. data/examples/assets/icons.png +0 -0
  33. data/examples/assets/music.ogg +0 -0
  34. data/examples/assets/skills.json +10 -0
  35. data/examples/assets/skills.png +0 -0
  36. data/examples/assets/tileset.png +0 -0
  37. data/examples/assets/tileset.tsx +65 -0
  38. data/examples/assets/town.tmx +26 -0
  39. data/examples/assets/ui.json +11 -0
  40. data/examples/assets/ui.png +0 -0
  41. data/examples/collision/locales/en.yml +8 -0
  42. data/examples/collision/main.rb +316 -0
  43. data/examples/collision_tiles/locales/en.yml +9 -0
  44. data/examples/collision_tiles/main.rb +274 -0
  45. data/examples/fullscreen/locales/en.yml +10 -0
  46. data/examples/fullscreen/main.rb +216 -0
  47. data/examples/game_menu/locales/en.yml +8 -0
  48. data/examples/game_menu/main.rb +170 -0
  49. data/examples/input_glyphs/locales/en.yml +14 -0
  50. data/examples/input_glyphs/main.rb +213 -0
  51. data/examples/jump_topdown/locales/en.yml +9 -0
  52. data/examples/jump_topdown/main.rb +178 -0
  53. data/examples/localization/locales/de.yml +12 -0
  54. data/examples/localization/locales/en.yml +13 -0
  55. data/examples/localization/main.rb +158 -0
  56. data/examples/menu_navigation/locales/en.yml +23 -0
  57. data/examples/menu_navigation/main.rb +365 -0
  58. data/examples/music/locales/en.yml +7 -0
  59. data/examples/music/main.rb +134 -0
  60. data/examples/pathfinding/locales/en.yml +17 -0
  61. data/examples/pathfinding/main.rb +298 -0
  62. data/examples/pooling/locales/en.yml +7 -0
  63. data/examples/pooling/main.rb +259 -0
  64. data/examples/quick_wheel/locales/en.yml +16 -0
  65. data/examples/quick_wheel/main.rb +184 -0
  66. data/examples/radial_menu/locales/en.yml +16 -0
  67. data/examples/radial_menu/main.rb +184 -0
  68. data/examples/save_load/locales/en.yml +11 -0
  69. data/examples/save_load/main.rb +207 -0
  70. data/examples/save_load_ids/locales/en.yml +11 -0
  71. data/examples/save_load_ids/main.rb +322 -0
  72. data/examples/scroll_map/locales/en.yml +4 -0
  73. data/examples/scroll_map/main.rb +140 -0
  74. data/examples/signals/locales/en.yml +6 -0
  75. data/examples/signals/main.rb +278 -0
  76. data/examples/skill_bar/locales/en.yml +14 -0
  77. data/examples/skill_bar/main.rb +159 -0
  78. data/examples/sound/locales/en.yml +6 -0
  79. data/examples/sound/main.rb +122 -0
  80. data/examples/split_screen/locales/en.yml +9 -0
  81. data/examples/split_screen/main.rb +304 -0
  82. data/examples/sprite/locales/en.yml +8 -0
  83. data/examples/sprite/main.rb +180 -0
  84. data/examples/timer/locales/en.yml +12 -0
  85. data/examples/timer/main.rb +273 -0
  86. data/examples/velocity/locales/en.yml +6 -0
  87. data/examples/velocity/main.rb +196 -0
  88. data/examples/walk/locales/en.yml +4 -0
  89. data/examples/walk/main.rb +99 -0
  90. data/exe/rgame +9 -0
  91. data/ext/rgame_core/app/app.c +33 -3
  92. data/ext/rgame_core/app/locale.c +67 -0
  93. data/ext/rgame_core/app/locale.h +28 -0
  94. data/ext/rgame_core/audio/audio.c +39 -2
  95. data/ext/rgame_core/example.rb +0 -49
  96. data/ext/rgame_core/extconf.rb +0 -125
  97. data/ext/rgame_core/include/rgame/core.h +38 -2
  98. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  99. data/ext/rgame_core/ruby/core_ext.c +30 -7
  100. data/ext/rgame_core/ruby/core_ext.h +3 -0
  101. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  102. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  103. data/ext/rgame_core/ruby/renderer_ext.c +20 -20
  104. data/ext/rgame_util/extconf.rb +2 -20
  105. data/ext/rgame_util/route_search.c +305 -0
  106. data/ext/rgame_util/route_search.h +86 -0
  107. data/ext/rgame_util/route_search_ext.c +150 -0
  108. data/ext/rgame_util/solid_grid.c +58 -0
  109. data/ext/rgame_util/solid_grid.h +49 -0
  110. data/ext/rgame_util/solid_grid_ext.c +161 -0
  111. data/ext/rgame_util/tile_sweep.c +164 -0
  112. data/ext/rgame_util/tile_sweep.h +62 -0
  113. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  114. data/ext/rgame_util/util_ext.c +3 -0
  115. data/ext/rgame_util/util_ext.h +15 -0
  116. data/lib/rgame/boot.rb +0 -10
  117. data/lib/rgame/cli/new_project.rb +139 -0
  118. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  119. data/lib/rgame/cli/templates/README.md.tt +93 -0
  120. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  121. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  122. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  123. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  124. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  125. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  126. data/lib/rgame/cli/templates/rspec.tt +2 -0
  127. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  128. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  129. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  130. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  131. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  132. data/lib/rgame/cli.rb +66 -0
  133. data/lib/rgame/core/app.rb +6 -44
  134. data/lib/rgame/core/asset_manager.rb +13 -31
  135. data/lib/rgame/core/audio.rb +37 -16
  136. data/lib/rgame/core/font.rb +0 -3
  137. data/lib/rgame/core/locale.rb +22 -0
  138. data/lib/rgame/core/nine_slice.rb +0 -21
  139. data/lib/rgame/core/renderer.rb +6 -63
  140. data/lib/rgame/core/sprite_sheet.rb +0 -3
  141. data/lib/rgame/core/tile_map_renderer.rb +2 -19
  142. data/lib/rgame/core/ui_atlas.rb +28 -13
  143. data/lib/rgame/core.rb +1 -8
  144. data/lib/rgame/engine/actor_blockers.rb +131 -0
  145. data/lib/rgame/engine/animation_set.rb +1 -0
  146. data/lib/rgame/engine/audio_director.rb +36 -6
  147. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  148. data/lib/rgame/engine/camera.rb +3 -3
  149. data/lib/rgame/engine/circle_collider.rb +4 -2
  150. data/lib/rgame/engine/collision_box.rb +26 -1
  151. data/lib/rgame/engine/collision_system.rb +110 -22
  152. data/lib/rgame/engine/component.rb +34 -10
  153. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  154. data/lib/rgame/engine/components/animated_sprite.rb +27 -25
  155. data/lib/rgame/engine/components/box_collider.rb +99 -0
  156. data/lib/rgame/engine/components/camera_follow.rb +6 -5
  157. data/lib/rgame/engine/components/character_body.rb +20 -61
  158. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  159. data/lib/rgame/engine/components/collision_world.rb +159 -31
  160. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  161. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  162. data/lib/rgame/engine/components/hop.rb +76 -0
  163. data/lib/rgame/engine/components/identity.rb +73 -0
  164. data/lib/rgame/engine/components/mover.rb +285 -0
  165. data/lib/rgame/engine/components/navigator.rb +145 -0
  166. data/lib/rgame/engine/components/path_follow.rb +123 -31
  167. data/lib/rgame/engine/components/player_controller.rb +5 -2
  168. data/lib/rgame/engine/components/pool.rb +1 -1
  169. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  170. data/lib/rgame/engine/components/sprite.rb +12 -6
  171. data/lib/rgame/engine/components/targeting.rb +9 -11
  172. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  173. data/lib/rgame/engine/components/tile_world.rb +41 -13
  174. data/lib/rgame/engine/components/timer.rb +1 -1
  175. data/lib/rgame/engine/components/velocity.rb +23 -7
  176. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  177. data/lib/rgame/engine/components/world.rb +133 -0
  178. data/lib/rgame/engine/contact_set.rb +74 -0
  179. data/lib/rgame/engine/culling.rb +4 -6
  180. data/lib/rgame/engine/debug_overlay.rb +5 -11
  181. data/lib/rgame/engine/i18n/plural.rb +45 -0
  182. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  183. data/lib/rgame/engine/i18n/template.rb +59 -0
  184. data/lib/rgame/engine/i18n.rb +276 -51
  185. data/lib/rgame/engine/input/action_mapper.rb +0 -26
  186. data/lib/rgame/engine/input/actions.rb +2 -8
  187. data/lib/rgame/engine/input/input_map.rb +39 -21
  188. data/lib/rgame/engine/nav_grid.rb +87 -0
  189. data/lib/rgame/engine/node2d.rb +248 -133
  190. data/lib/rgame/engine/path.rb +4 -6
  191. data/lib/rgame/engine/players.rb +6 -13
  192. data/lib/rgame/engine/presentation.rb +171 -0
  193. data/lib/rgame/engine/scene/scene_stack.rb +4 -4
  194. data/lib/rgame/engine/sealed_privates.rb +54 -0
  195. data/lib/rgame/engine/spatial_hash.rb +51 -19
  196. data/lib/rgame/engine/text.rb +194 -0
  197. data/lib/rgame/engine/tile_blockers.rb +63 -0
  198. data/lib/rgame/engine/tile_map.rb +3 -5
  199. data/lib/rgame/engine/tile_map_layer.rb +0 -2
  200. data/lib/rgame/engine/tileset.rb +2 -4
  201. data/lib/rgame/engine/timer.rb +2 -2
  202. data/lib/rgame/engine/ui/button.rb +248 -0
  203. data/lib/rgame/engine/ui/column.rb +20 -0
  204. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  205. data/lib/rgame/engine/ui/menu.rb +246 -71
  206. data/lib/rgame/engine/ui/navigation.rb +57 -0
  207. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  208. data/lib/rgame/engine/ui/option_button.rb +163 -0
  209. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  210. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  211. data/lib/rgame/engine/ui/pointing.rb +146 -0
  212. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  213. data/lib/rgame/engine/ui/ring.rb +55 -0
  214. data/lib/rgame/engine/ui/row.rb +21 -0
  215. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  216. data/lib/rgame/engine/ui/stack.rb +58 -0
  217. data/lib/rgame/engine/ui/stepping.rb +93 -0
  218. data/lib/rgame/engine/ui/text_button.rb +59 -0
  219. data/lib/rgame/engine/viewports.rb +2 -5
  220. data/lib/rgame/engine/world_view.rb +5 -4
  221. data/lib/rgame/engine.rb +31 -25
  222. data/lib/rgame/game.rb +99 -27
  223. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  224. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  225. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  226. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  227. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  228. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  229. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  230. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  231. data/lib/rgame/rubocop/default.yml +39 -0
  232. data/lib/rgame/rubocop/plugin.rb +45 -0
  233. data/lib/rgame/rubocop.rb +11 -0
  234. data/lib/rgame/util/color.rb +20 -24
  235. data/lib/rgame/util/controls.rb +14 -28
  236. data/lib/rgame/util/route_search.rb +27 -0
  237. data/lib/rgame/util/save_file.rb +107 -0
  238. data/lib/rgame/util/solid_grid.rb +37 -0
  239. data/lib/rgame/util/tensor.rb +0 -9
  240. data/lib/rgame/util/tile_sweep.rb +36 -0
  241. data/lib/rgame/util/z.rb +4 -14
  242. data/lib/rgame/util.rb +4 -3
  243. data/lib/rgame/version.rb +1 -1
  244. data/lib/rgame.rb +0 -15
  245. metadata +167 -12
  246. data/lib/rgame/engine/actor.rb +0 -53
  247. data/lib/rgame/engine/body.rb +0 -49
  248. data/lib/rgame/engine/cached_label.rb +0 -33
  249. data/lib/rgame/engine/input/player_controller.rb +0 -14
  250. data/lib/rgame/engine/matrix.rb +0 -32
  251. data/lib/rgame/engine/resettable.rb +0 -67
  252. data/lib/rgame/engine/tile_collision.rb +0 -78
  253. data/lib/rgame/engine/ui/menu_item.rb +0 -84
@@ -1,325 +1,1005 @@
1
1
  # Components
2
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.
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.
9
41
 
10
42
  ## The `Component` base
11
43
 
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.
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.
15
48
 
16
- Per-tick hooks (a node runs its components in each phase, before its own hook and
17
- before its children):
49
+ **Per-tick hooks.** In each phase a node runs its components before its own hook
50
+ and before its children.
18
51
 
19
- - `control(actions)` read intent from the per-frame action snapshot. It is the
20
- actions of whoever [owns the node](scene_graph.md#who-a-node-answers-to), so a
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
21
54
  component never learns there is more than one player.
22
- - `update(dt)` advance state over the timestep.
23
- - `draw(renderer, view)` render against the renderer interface, into the
24
- [viewport being drawn](scene_graph.md#viewports-and-views). Most components ignore the
25
- view; it is there for laying out against the region's edges and for culling.
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
+ ```
26
123
 
27
- Tree-lifecycle hooks (fired by the engine when the node enters/leaves the live tree
28
- this is where anchors and sibling systems are reachable, so do cross-node wiring here,
29
- not in `initialize`):
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.
30
128
 
31
- - `on_attach` the node entered the tree; pull and register with shared systems.
32
- - `on_detach` — the node is leaving; release those registrations.
129
+ ### Adding from `on_add`, and when you must
33
130
 
34
- `sweep_freed` exists for container components that hold nodes off the normal child
35
- list; the default is a no-op (see [deferred free](scene_graph.md#deferred-free)).
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:
36
136
 
37
- A node holds **at most one component per slot**. The slot defaults to the component's
38
- class, so by default that's one per class (`add_component` raises on a taken slot) — but
39
- pass `as: :name` to keep several of one type (a spawn timer and a wave timer). Look a
40
- component up with `get_component(key)`, where `key` is a class (matched by ancestry; it
41
- raises if several share the type) or a Symbol name.
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
+ ```
42
148
 
43
- ## Available components
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.
44
154
 
45
- ### `Velocity`
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.
46
159
 
47
- Integrates linear and angular velocity into the node's transform each step.
160
+ ## Available components
48
161
 
49
- - **Construct:** `Velocity.new(vx: 0.0, vy: 0.0, spin: 0.0)`.
50
- - **State:** `vx`, `vy`, `spin` are read/write accessors — a controller (or the node's
51
- own `control` hook) writes them as movement intent.
52
- - **Phase:** `update(dt)` adds `vx*dt`/`vy*dt` to `node.x`/`node.y` and `spin*dt` to
53
- `node.angle`.
162
+ ### `ActionTrigger`
54
163
 
55
- A free-moving entity can use `Velocity` alone; pair it with a controller for input.
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.
56
168
 
57
- ### `PathFollow`
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.
58
176
 
59
- Walks the owning node along an [`RGame::Engine::Path`](toolbox.md#path--a-walkable-polyline)
60
- at a constant speed and emits `on_finished` when it reaches the last waypoint — the seam a
61
- tower-defense game uses to leak a life when an enemy reaches the base.
177
+ ### `AnimatedSprite`
62
178
 
63
- - **Construct:** `PathFollow.new(path:, speed:)`.
64
- - **Lifecycle:** `on_attach` (re)starts the walk back to the first waypoint with progress
65
- cleared so a pooled follower reacquired and re-added begins a fresh walk.
66
- - **Signal:** `on_finished` fires once (no payload) at the end of the path —
67
- `follow.on_finished { node.queue_free }`.
68
- - **Phase:** `update(dt)` advances `speed * dt`, crossing as many segments as one step
69
- spans and interpolating the node's position; allocation-free.
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).
70
252
 
71
- ### `Timer`
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
+ ```
72
259
 
73
- A node-driven interval timer: it rides the node's update tick (so nothing can forget to
74
- advance it) and emits `on_timeout` each time a whole interval elapses — a spawn cadence, a
75
- tower's fire rate, a wave clock. Wraps the pure [`RGame::Engine::Timer`](toolbox.md#timer--paced-periodic-events),
76
- reusing its drift-free carry-forward.
77
-
78
- - **Construct:** `Timer.new(interval, repeating: true)` (seconds). Add it named when a node
79
- needs several: `node.add_component(Timer.new(0.8), as: :spawn)`. `repeating: false` makes
80
- it a **one-shot** — it fires `on_timeout` exactly once, then goes inert. The one-shot
81
- replaces a dedicated "lifetime" component: a projectile that should vanish after N seconds
82
- on a fixed board is `Timer.new(2.0, repeating: false)` + `on_timeout { node.queue_free }`
83
- (use `DespawnOffscreen` instead when the board scrolls and the entity leaves the screen).
84
- - **Signal:** `on_timeout` fires once per whole interval — `timer.on_timeout { spawn_enemy }`.
85
- - **Lifecycle:** `on_attach` restarts the countdown (and re-arms a spent one-shot), so a
86
- pooled node reacquired and re-added starts fresh rather than inheriting its previous
87
- life's elapsed time.
88
- - **Phase:** `update(dt)` advances and emits; a repeating timer emits once per interval
89
- crossed in a single long step (catch-up, not drift), a one-shot at most once. Allocation-free.
90
- - **Reset:** `reset` drops accumulated time and re-arms a one-shot — a fresh timer.
260
+ ### `CameraFollow`
91
261
 
92
- ### `Pool`
262
+ **Points a camera at the node it is attached to.**
93
263
 
94
- Wraps an [`RGame::Engine::Pool`](toolbox.md#pool--reuse-dont-allocate) of nodes and folds the
95
- tree bookkeeping into the frame tick, so a scene that recycles entities (enemies, projectiles)
96
- writes no acquire/add/reclaim bridge of its own just `spawn` and the ordinary `queue_free`.
97
- Pooled nodes are **normal children** of the owner, so the scene's usual traversal updates and
98
- draws them; this component only manages their pool membership.
99
-
100
- - **Construct:** `Pool.new { Enemy.new(...) }` the factory builds a blank node. Add it named
101
- (`as:`) when a node needs more than one pool.
102
- - **Spawn:** `pool.spawn` takes a node (recycled or freshly built) and adds it as a child;
103
- `pool.spawn { |n| n.reset(...) }` runs the block to re-initialise it *before* it enters the
104
- tree (so `on_attach` sees the reset state — the order projectiles need).
105
- - **Reclaim:** `update(dt)` returns every freed pooled node to the free list, detaching any
106
- still attached. So despawning is just `node.queue_free` anywhere; the pool recycles it with
107
- no game-side wiring. Allocation-free in steady state.
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.
108
272
 
109
- ### `ScreenWrap`
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.
110
277
 
111
- Wraps the node's position toroidally within a rectangle, so an entity leaving one edge
112
- reappears on the opposite one.
278
+ ### `CharacterBody`
113
279
 
114
- - **Construct:** `ScreenWrap.new(width:, height:, margin: 0.0)` `margin` lets a
115
- sprite pass fully off one edge before reappearing on the other.
116
- - **Phase:** `update(dt)` clamps-and-wraps `node.x`/`node.y` against the bounds.
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.
117
284
 
118
- ### `DespawnOffscreen`
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.
119
289
 
120
- Removes the node once it has fully left the bounds (plus margin) — for short-lived
121
- entities like projectiles.
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
+ ```
122
296
 
123
- - **Construct:** `DespawnOffscreen.new(width:, height:, margin: 0.0)`.
124
- - **Phase:** `update(dt)` calls `node.queue_free` when the node is past every edge.
125
- Removal is *deferred* (see [deferred free](scene_graph.md#deferred-free)), so it is
126
- safe to trigger from inside the update traversal. For a *fixed* board (an entity that
127
- never leaves the screen, e.g. a projectile that should vanish after N seconds), use a
128
- one-shot [`Timer`](#timer) (`repeating: false`) with `on_timeout { node.queue_free }`
129
- instead.
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.
130
313
 
131
314
  ### `CircleCollider`
132
315
 
133
- A circular collision shape that participates in a scene's
134
- [`CollisionWorld`](#collisionworld). It registers itself when the node enters the tree
135
- and unregisters when it leaves, so a spawned or despawned entity never leaks a
136
- registration.
137
-
138
- - **Construct:** `CircleCollider.new(radius:, layer: :default)`. `layer` is an opaque
139
- tag the *owner* reads to decide what a contact means; the collision system itself is
140
- layer-agnostic.
141
- - **Lifecycle:** `on_attach` registers with `node.system(CollisionWorld)`; `on_detach`
142
- unregisters.
143
- - **Geometry:** `cx`/`cy` are the node's resolved absolute origin; `radius` is a
144
- read/write accessor (so a pooled entity can retune its shape on reset — see
145
- `ScreenWrap`/pooling), `layer` is a reader; `overlap?(other)` is the circle-vs-circle
146
- test.
147
- - **Signal:** `on_hit` fires with the other collider on each contact —
148
- `collider.on_hit { |other| ... }`. The system triggers it via `emit_hit(other)`.
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).
149
344
 
150
345
  ### `CollisionWorld`
151
346
 
152
- A scene-scoped broadphase collision **system**: a component that lives on the scene
153
- node, holds the registered colliders in a `SpatialHash`, and each step reports every
154
- overlapping pair. Because it is a normal component it rides the `update` traversal and
155
- is torn down with the scene. See [Systems & shared resources](systems.md).
156
-
157
- - **Construct:** `CollisionWorld.new(cell_size:)` — the spatial-hash cell size (tune to
158
- the typical collider size).
159
- - **Registration:** `register(collider)` / `unregister(collider)`; colliders call these
160
- through their own lifecycle, so nodes never wire this by hand.
161
- - **Phase:** `update(dt)` rebuilds the spatial index and, for each overlapping pair,
162
- fires both colliders' `on_hit`. It is **layer-agnostic** — it reports contacts and
163
- lets each collider's owner decide meaning by reading the other's `layer`. Colliders
164
- whose node is queued for removal are skipped.
165
- - **Range queries (targeting):** the same index answers point-radius lookups against the
166
- most recent `update`, so a tower can find enemies without a contact:
167
- - `query_circle(x, y, r) { |collider| }` yields every registered collider whose centre
168
- is within `r` of `(x, y)` (centre distance the collider's own radius isn't added,
169
- so it reads like a range ring); freed-node colliders are skipped, and a collider may
170
- be yielded more than once (broadphase dedup contract — fine for selecting). Filter by
171
- `collider.layer` in the block.
172
- - `nearest(x, y, r, layer: nil)` returns the closest such collider (optionally limited
173
- to one `layer`), or `nil`. Both are allocation-free, so a targeting component can call
174
- them every frame.
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
+ ```
175
457
 
176
- ### `Targeting`
458
+ ### `DespawnOffscreen`
177
459
 
178
- Picks an enemy for the owning node (a tower) to aim at: each `update` it queries the
179
- scene's [`CollisionWorld`](#collisionworld) around the node's world origin and exposes the
180
- chosen target. It only *selects* it never moves or fires; the owner reads `target` and
181
- acts. Because enemies already register with the broadphase through their
182
- [`CircleCollider`](#circlecollider), targeting keeps no entity list of its own.
183
-
184
- - **Construct:** `Targeting.new(range:, policy: :nearest, layer: nil)`. `range` is the
185
- reach in pixels; `layer` restricts candidates (a tower passes `:enemy`, so it ignores
186
- other towers/projectiles); an unknown `policy` raises at construction.
187
- - **Policies** (how to choose among the in-range candidates):
188
- - `:nearest` the closest enemy (the default; one broadphase nearest-lookup).
189
- - **State:** `target` is the chosen enemy **node** (or `nil` when nothing is in range),
190
- refreshed every `update` so a freed/out-of-range target clears on its own. It's a node
191
- (not a collider) so the owner can read its position and components.
192
- - **Lifecycle:** `on_attach` pulls the scene's `CollisionWorld`.
193
- - **Phase:** `update(dt)` re-selects the target; allocation-free, so it runs every frame.
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.
194
508
 
195
- ### `Sprite`
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
+ ```
196
516
 
197
- Draws a single registered image centered on the node's absolute origin.
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.
198
542
 
199
- - **Construct:** `Sprite.new(id:, scale: 1.0, z: 0)` — `id` is a renderer image id; `z`
200
- orders this component against the node's *other* drawing (a shadow under a sprite),
201
- inside the node's own slot. It is not the node's `z`, which orders the node against
202
- its siblings. See [Drawing](drawing.md#draw-order).
203
- - **State:** `scale` is a read/write accessor (a pooled entity can retune it).
204
- - **Phase:** `draw(renderer, view)` draws the image with **no angle** — `Node2D#draw`
205
- already wraps a node's own draws in `renderer.rotated(abs_angle, …)`, so the node's
206
- rotation orients the sprite; passing an angle here would rotate it twice. It skips the
207
- draw entirely when the view cannot show it, measuring the node's box scaled — a node
208
- that never set a size is never culled.
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
+ ```
209
547
 
210
- ### `CameraFollow`
548
+ `examples/jump_topdown` draws the shadow and the feet box that stay on the ground
549
+ under the picture.
211
550
 
212
- Points a camera at the node it is attached to.
551
+ ### `Identity`
213
552
 
214
- - **Construct:** `CameraFollow.new(camera:, offset_x: 0.0, offset_y: 0.0)` the offsets
215
- shift the point being centred on, for a node whose origin is not what should be in the
216
- middle of the screen (a bottom-anchored sprite usually wants its feet).
217
- - **Phase:** `update(dt)` calls `camera.center_on` with the node's resolved absolute
218
- origin. The camera trails the node's own movement by one step, uniformly.
553
+ **A stable name for one node**, so something outside the tree can refer to it.
219
554
 
220
- The camera belongs to a [player](input.md#players-seats-and-joining), not to this
221
- component or to the scene — a scene may have any number of viewers. Ownership and
222
- behaviour are different questions: the player owns the camera, and this moves it. So
223
- "player two's camera follows player two" is this component with their camera in it.
555
+ ```ruby
556
+ sheep.add_component(RGame::Engine::Components::Identity.new(id: 7))
557
+ RGame::Engine::Components::Identity.of(sheep) # => 7
558
+ ```
224
559
 
225
- ### `ThrustController`
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.
226
564
 
227
- Inertial "ship" flight on top of a `Velocity` sibling: a turn axis rotates the node and
228
- a thrust axis accelerates it along its heading.
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.
229
570
 
230
- - **Construct:** `ThrustController.new(turn_speed:, accel:, max_speed:, drag: 0.0,
231
- turn_action: :turn, thrust_action: :thrust)`.
232
- - **Lifecycle:** `on_attach` pulls the node's `Velocity` component.
233
- - **Phase:** `control(actions)` reads intent (turn → `velocity.spin`, thrust stored);
234
- `update(dt)` accelerates along the heading (angle 0 = up, so forward is
235
- `(sin θ, −cos θ)`), applies drag, and clamps to `max_speed`. Firing is intentionally
236
- not here.
571
+ `Identity` covers two cases the others miss:
237
572
 
238
- ### `ActionTrigger`
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.
239
580
 
240
- Maps held input actions to an `on_triggered(action)` signal, rate-limited by a per-action
241
- cooldown. One instance covers several actions (the engine allows one component per class
242
- per node), so it emits the action name and lets listeners filter — reusable for "fire"
243
- here, or "jump"/"fire" in a platformer.
581
+ The game must get two things right; this component does not check them:
244
582
 
245
- - **Construct:** `ActionTrigger.new(cooldowns)` where `cooldowns` is `{ action => seconds }`,
246
- e.g. `ActionTrigger.new(fire: 0.22)`.
247
- - **Signal:** `on_triggered` fires with the action name `trigger.on_triggered { |a| }`.
248
- - **Phase:** `update(dt)` ticks the per-action cooldowns; `control(actions)` emits when an
249
- action is held and its cooldown has elapsed (held + cooldown = auto-repeat).
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.
250
587
 
251
- ### `AnimatedSprite`
588
+ ### `Mover`
252
589
 
253
- Draws a sprite-sheet animation and picks the animation from a [`CharacterBody`](#characterbody)
254
- sibling's movement: `walk_left`/`walk_right`/`walk_up`/`walk_down` while moving (horizontal wins
255
- on a diagonal), `stand` when still. Owns an `RGame::Engine::Animator` over the pure `AnimationSet` built
256
- from the sheet's animation table.
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.
257
596
 
258
- - **Construct:** `AnimatedSprite.new(sheet:, z: 0)` `sheet` is the asset's relative path; `z`
259
- orders this component against the node's other drawing, inside the node's own slot (as
260
- for [`Sprite`](#sprite)).
261
- - **Lifecycle:** `on_attach` resolves the sheet from the game's asset manager
262
- (`node.root.context.assets.sheet(sheet)`), builds its animation set, **sizes the node** to the
263
- sheet's frame (`node.width`/`height`, so a `CharacterBody` sibling can read them), and pulls that
264
- sibling (the facing source). The renderer resolves the same path when drawing, so nothing is
265
- registered or passed in by hand.
266
- - **Phase:** `update(dt)` selects + advances the animation; `draw(renderer, view)` renders the
267
- current frame via `renderer.sprite` at the node's **world** origin (`abs_x`/`abs_y`) with no
268
- angle — a [`WorldView`](scene_graph.md#view-transforms-and-the-camera) ancestor applies the
269
- camera offset, so the component never touches the camera. It skips the draw when the view
270
- cannot show it, measuring the node's box. (`Sprite` above is the single-image counterpart.)
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**.
271
601
 
272
- ### `CharacterBody`
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
+ ```
273
630
 
274
- Collision-checked walking for a tile-bound actor. A controller writes a per-step movement intent
275
- (each axis −1..1); the body turns it into a real move each `update`, resolved against the scene's
276
- [`TileWorld`](#tileworld) so the actor slides along walls and stays in the map. Unlike `Velocity`
277
- (which integrates blindly), every step here is collision-checked.
278
-
279
- - **Construct:** `CharacterBody.new(feet_width:, feet_height:, speed:)` — the feet box size (in px)
280
- and walk speed (px/s). No sprite size is passed: `collision_box` is built lazily from the node's
281
- `width`/`height` (which `AnimatedSprite` sets), centred horizontally and bottom-anchored. A body
282
- with no sprite must set the node's dimensions itself.
283
- - **State:** `set_intent(x, y)` writes the step's intent; `move_x`/`move_y` read it back (the facing
284
- for `AnimatedSprite`).
285
- - **Lifecycle:** `on_attach` caches the scene's `TileWorld`.
286
- - **Phase:** `update(dt)` moves `intent * speed * dt` through the tile world (nothing when the
287
- intent is zero).
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.
288
837
 
289
838
  ### `PlayerController`
290
839
 
291
- Drives a `CharacterBody` sibling from two input axes direct 8-way walking, no inertia (unlike
292
- `ThrustController`).
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.
293
843
 
294
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`.
295
847
  - **Phase:** `control(actions)` copies the two axes into the body's intent.
848
+ - **Example:** `examples/walk`.
296
849
 
297
- ### `WanderController`
850
+ ### `Pool`
298
851
 
299
- A simple AI driver for a `CharacterBody`: every so often it rolls a new direction (one of eight, or
300
- idle) and holds it, re-rolling early when a wall blocks it. The RNG is injected, so behaviour is
301
- deterministic in tests.
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.
302
871
 
303
- - **Construct:** `WanderController.new(rng: Random.new, change_interval: 1.0..3.0, idle_chance: 0.25)`.
304
- - **Phase:** `update(dt)` counts down the timer and re-rolls on timeout or when blocked.
872
+ ### `ScreenWrap`
305
873
 
306
- ### `TileWorld`
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).
307
895
 
308
- The scene-scoped tile **system** (see [Systems](systems.md)): it holds the parsed `RGame::Engine::TileMap`
309
- and answers everything an actor needs from it — collision against the solid tiles (reusing
310
- `RGame::Engine::CollisionSystem`) and the world bounds. Found with `node.system(TileWorld)`.
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.
311
913
 
312
- **It does not draw.** `RGame::Engine::TileMapLayer` does — one node per Tiled layer, mounted
313
- inside a `WorldView`, so the map is drawn once per viewport like the rest of world space.
314
- This stays the thing actors ask questions of.
914
+ ### `Targeting`
315
915
 
316
- - **Construct:** `TileWorld.new(map:, tilemap_id:, cameras: [])` it clamps each camera it is given to
317
- the map's edges, and `bound(camera)` does the same for one that arrives later (a player joining).
318
- - **Queries:** `move(actor, dx, dy)` slides an actor (anything responding to `x`/`y`/`collision_box`)
319
- along solids and clamps it to the world; `solid?(col, row)`; `world_width`/`world_height`;
320
- `tilemap_id` and `elapsed`, which the layers read; `layer_count` and `first_above_layer`,
321
- which `TileMapLayer.mount` reads to decide where the actors go.
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.
322
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.
323
1003
 
324
1004
  ```ruby
325
1005
  world = scene.add_node(RGame::Engine::WorldView.new)
@@ -327,19 +1007,106 @@ actors = RGame::Engine::TileMapLayer.mount(world) # a node per Tiled layer
327
1007
  actors.add_node(player) # in the gap between them
328
1008
  ```
329
1009
 
330
- `mount` returns the node the actors go in. It sits below the first layer Tiled flags
331
- `above` trunks under the walker, canopies over — and `mount(world, under: index)`
332
- overrides that for a map with a different arrangement. Nothing here picks a `z`.
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.
333
1057
 
334
1058
  ```ruby
335
- # A node composing components, with collision meaning decided by the owner:
336
- class Bullet < RGame::Engine::Node2D
337
- def initialize(x:, y:, vx:, vy:, bounds:)
338
- super(x: x, y: y)
339
- add_component(RGame::Engine::Components::Velocity.new(vx: vx, vy: vy))
340
- add_component(RGame::Engine::Components::DespawnOffscreen.new(**bounds))
341
- collider = add_component(RGame::Engine::Components::CircleCollider.new(radius: 3, layer: :bullet))
342
- collider.on_hit { |other| queue_free if other.layer == :rock }
343
- end
344
- end
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 }
345
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.