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
data/docs/api/signals.md CHANGED
@@ -1,17 +1,16 @@
1
1
  # Signals
2
2
 
3
- Signals are the engine's typed take on the observer pattern: a tiny object that
4
- holds a list of listener blocks and `emit`s to them. They are how decoupled parts
5
- of the engine talk to each other a `Button` tells a `Menu` it was clicked, a
6
- `Selector` announces its value changed, gameplay asks the audio layer to play a
7
- sound — without the emitter knowing who (if anyone) is listening.
8
-
9
- Signals replace the earlier global event bus / `Node#on` observer API. There is no
10
- central dispatcher and no string/symbol event types to match on: a signal *is* the
11
- channel, named by the attribute that exposes it, and its arity is fixed when it is
12
- defined.
13
-
14
- `RGame::Engine::Signal` is pure Ruby — no graphics — and lives in
3
+ **A signal is the engine's typed observer.** It holds a list of listener blocks
4
+ and `emit`s to them. Decoupled parts of the engine talk through signals. A
5
+ `UI::Button` reports that it was activated. A collider reports a hit. Gameplay
6
+ asks the audio layer to play a sound. The emitter never knows who listens, or
7
+ whether anyone does.
8
+
9
+ No central dispatcher exists, and no string or symbol event types need matching.
10
+ A signal *is* the channel. The attribute that exposes it names it, and its arity
11
+ is fixed when you define it.
12
+
13
+ `RGame::Engine::Signal` is pure Ruby, with no graphics, in
15
14
  `lib/rgame/engine/signal.rb`.
16
15
 
17
16
  ## The Signal class: `Signal.define`
@@ -19,28 +18,29 @@ defined.
19
18
  `Signal.define(*fields)` builds a signal **class**. Each instance is one channel:
20
19
 
21
20
  ```ruby
22
- ClickSignal = Signal.define # carries no payload
23
- ChangeSignal = Signal.define(:index, :value) # carries two values
21
+ require 'rgame'
22
+
23
+ ClickSignal = RGame::Engine::Signal.define # carries no payload
24
+ ChangeSignal = RGame::Engine::Signal.define(:index, :value) # carries two values
24
25
 
25
26
  sig = ChangeSignal.new
26
27
  handle = sig.connect { |index, value| puts "#{index} -> #{value}" }
27
- sig.emit(index: 2, value: :hard) # prints "2 -> hard"
28
- sig.disconnect(handle) # stops that listener
28
+ sig.emit(index: 2, value: :hard) # prints "2 -> hard"
29
+ sig.disconnect(handle) # stops that listener
29
30
  ```
30
31
 
31
- Three instance methods:
32
+ A signal has three instance methods:
32
33
 
33
- - **`connect(&block)`** registers a listener and returns it as the *handle*. Listeners
34
- fire in the order they connected.
35
- - **`emit(...)`** notifies every listener.
36
- - **`disconnect(handle)`** removes the listener returned by `connect`.
34
+ - **`connect(&block)`** registers a listener and returns it as the *handle*.
35
+ Listeners fire in the order they connected.
36
+ - **`emit(...)`** notifies every listener.
37
+ - **`disconnect(handle)`** removes the listener that `connect` returned.
37
38
 
38
39
  ### Keyword in, positional out
39
40
 
40
- The field names exist to give `emit` a **self-documenting, mistake-catching
41
- signature** you call `emit(index:, value:)`, not `emit(2, :hard)`, so a wrong or
42
- missing field raises at the call site. But the *listener* block receives the values
43
- **positionally**:
41
+ **`emit` takes keywords; listeners receive positional values.** The field names
42
+ give `emit` a self-documenting signature. You call `emit(index:, value:)`, not
43
+ `emit(2, :hard)`, so a wrong or missing field raises at the call site:
44
44
 
45
45
  ```ruby
46
46
  ChangeSignal = Signal.define(:index, :value)
@@ -48,29 +48,29 @@ sig.connect { |index, value| ... } # positional params
48
48
  sig.emit(index: 2, value: :hard) # keyword args -> it.call(2, :hard)
49
49
  ```
50
50
 
51
- This is deliberate. Ruby blocks bind positional parameters cleanly but handle
52
- keyword arguments awkwardly, so the generated `emit` translates `emit(x:, y:)` into
53
- `it.call(x, y)`.
51
+ Ruby blocks bind positional parameters cleanly but handle keywords awkwardly. The
52
+ generated `emit` therefore translates `emit(x:, y:)` into `it.call(x, y)`.
54
53
 
55
- A single-field signal does not follow this keyword convention, in this case the single parameter is non-keyworded.
54
+ A signal with a single field takes its argument positionally, without a keyword:
56
55
 
57
56
  ```ruby
58
57
  PlaySound = Signal.define(:id)
59
- sig.connect { @audio.play_sound(it) }
58
+ sig = PlaySound.new
59
+ sig.connect { puts "play #{it}" }
60
60
  sig.emit(:boom)
61
61
  ```
62
62
 
63
63
  ### No per-emit allocation
64
64
 
65
- `emit` forwards its arguments straight to each listener — it never collects them
66
- into an array or hash. Defining the signature with explicit fields (rather than a
67
- `*splat`) is what makes this allocation-free, which matters because some signals
68
- fire every frame (the engine's rule: never allocate on the hot path).
65
+ **`emit` allocates nothing.** It passes its arguments straight to each listener,
66
+ never collecting them into an array or hash. The explicit fields make this
67
+ possible; a `*splat` signature would allocate. Some signals fire every frame, and
68
+ the engine never allocates on the hot path.
69
69
 
70
70
  ## The DSL: declaring a signal slot
71
71
 
72
- Hand-wiring a signal onto a class is repetitive an ivar to hold the instance, a
73
- public method to subscribe, and a way to emit:
72
+ Wiring a signal onto a class by hand repeats itself. The class needs an ivar for
73
+ the instance, a public method to subscribe, and a way to emit:
74
74
 
75
75
  ```ruby
76
76
  # Without the DSL:
@@ -80,63 +80,68 @@ def on_clicked(&block) = @on_clicked.connect(&block)
80
80
  def activate = @on_clicked.emit
81
81
  ```
82
82
 
83
- `RGame::Engine::Signal::DSL` collapses that to one declaration. `extend` it, then declare
84
- slots with `signal`:
83
+ `RGame::Engine::Signal::DSL` reduces that to one declaration. `extend` it, then
84
+ declare slots with `signal`:
85
85
 
86
86
  ```ruby
87
- class Button < Control
87
+ require 'rgame'
88
+
89
+ class Lever < RGame::Engine::Node2D
88
90
  extend RGame::Engine::Signal::DSL
89
91
 
90
- signal :on_clicked # a no-arg signal
91
- # signal :on_changed, Signal.define(:index, :value) # a typed one
92
+ signal :on_pulled # a no-arg signal
93
+ signal :on_changed, RGame::Engine::Signal.define(:index, :value) # a typed one
92
94
 
93
- def activate = on_clicked_signal.emit
95
+ def pull = on_pulled_signal.emit
94
96
  end
97
+
98
+ lever = Lever.new
99
+ lever.on_pulled { puts 'pulled' }
100
+ lever.pull
95
101
  ```
96
102
 
97
- `signal :on_clicked` generates two methods:
103
+ `signal :on_pulled` generates two methods:
98
104
 
99
- - **`on_clicked(&block)`** *public*. Subscribe a listener; returns the handle. This
100
- is the API observers use: `button.on_clicked { ... }`.
101
- - **`on_clicked_signal`** *private*. The lazily-built `Signal` instance. Emit
102
- through it from inside the class: `on_clicked_signal.emit`.
105
+ - **`on_pulled(&block)`** is *public*. It subscribes a listener and returns the
106
+ handle. Observers call it: `lever.on_pulled { ... }`.
107
+ - **`on_pulled_signal`** is *private*. It returns the `Signal` instance, built on
108
+ first use. The class emits through it: `on_pulled_signal.emit`.
103
109
 
104
- The signal is created on first use (`@on_clicked ||= type.new`), so the host wires
105
- **nothing** in `initialize`. Pass a signal class as the second argument for a typed
106
- slot; omit it for a no-arg signal.
110
+ The reader builds the signal on first use, so the host wires **nothing** in
111
+ `initialize`. Pass a signal class as the second argument for a typed slot. Omit
112
+ it for a signal without a payload.
107
113
 
108
- A note on cost: the generated methods use `define_method`, and emitting goes through
109
- the private reader rather than a bare ivar one extra method dispatch per emit
110
- (single-digit nanoseconds under YJIT, and `emit` itself stays a full-speed `def`).
111
- Negligible for UI and per-frame signals. For a signal emitted thousands of times per
112
- frame, hand-write it against a direct ivar instead.
114
+ **The DSL costs one extra method call per emit.** Emitting goes through the
115
+ private reader instead of a bare ivar. `emit` itself stays an ordinary `def`. UI
116
+ and per-frame signals never notice. For a signal emitted thousands of times per
117
+ frame, write it by hand against an ivar.
113
118
 
114
119
  ## Two shapes of signal
115
120
 
116
- **Per-instance signals (the DSL).** Each object owns its channels. This is the UI
117
- pattern: every `Button` has its own `on_clicked`, every `Selector` its own
118
- `on_changed`. The `signal` macro is built for exactly this (it stores the instance
119
- in an ivar).
121
+ **Per-instance signals use the DSL.** Each object owns its channels. UI works this
122
+ way: every `UI::Button` has its own `on_activated`, and every `UI::OptionButton`
123
+ its own `on_changed`. The `signal` macro stores the instance in an ivar, which
124
+ suits exactly this case.
120
125
 
121
- **A shared signal hub (module-level).** When one global channel serves the whole app,
122
- expose signals as module state instead. `RGame::Engine::AudioBus` is the example: it holds
123
- `Signal.define(:id).new` instances at module scope and exposes them through reader
124
- methods, so gameplay anywhere does `RGame::Engine::AudioBus.on_play_sound.emit(:boom)`
125
- and the `AudioDirector` connects once. The DSL doesn't apply here (there is no
126
- per-instance ivar); the hub hand-rolls the readers.
126
+ **A shared hub holds signals at module level.** Use one when a single channel
127
+ serves the whole game. `RGame::Engine::AudioBus` holds its signals at module scope
128
+ and exposes them through readers. Gameplay anywhere calls
129
+ `RGame::Engine::AudioBus.play_sound(:boom)`, and the `AudioDirector` connects
130
+ once to `AudioBus.on_play_sound`. The DSL does not apply, because there is no
131
+ instance; the hub writes its readers by hand.
127
132
 
128
133
  ## When to reach for a signal
129
134
 
130
135
  Follow the engine's communication rules (see [Scene graph](scene_graph.md)):
131
136
 
132
- - **Parent → child:** call methods directly. No signal needed; the parent holds the
133
- reference.
134
- - **Child → parent, or sibling → sibling:** the child *exposes* a signal and the
135
- parent (or a parent-arranged observer) subscribes. A `Button` exposes `on_clicked`;
136
- its `Menu` parent connects in `on_add` and re-exposes a higher-level
137
- `on_selected(index, id)` to the scene. Edges stay direct node-to-node.
138
- - **Cross-cutting app concerns** with no natural owner (audio, later maybe analytics):
139
- a module-level hub like `AudioBus`.
140
-
141
- Keep the emitter ignorant of its listeners: a signal with no observers connected
142
- emits harmlessly to nobody.
137
+ - **Parent → child:** call methods directly. The parent holds the reference, so
138
+ it needs no signal.
139
+ - **Child → parent, or sibling → sibling:** the child *exposes* a signal, and the
140
+ parent or an observer the parent arranges subscribes. A `UI::Button` exposes
141
+ `on_activated`, and the scene that adds it to a menu connects to it. Edges stay
142
+ direct, node to node.
143
+ - **Concerns that cut across the game** and have no natural owner, such as
144
+ audio: use a module-level hub like `AudioBus`.
145
+
146
+ Keep the emitter ignorant of its listeners. A signal with no listeners emits to
147
+ nobody, without error.
data/docs/api/systems.md CHANGED
@@ -1,45 +1,34 @@
1
1
  # Systems & shared resources
2
2
 
3
- Some things a node needs don't live on the node: a tilemap, the world bounds, a
4
- shared collision world. The engine resolves this the way scene-graph engines do —
5
- shared resources are **systems that live on an anchor node and are reached by
6
- walking the tree**, not threaded through constructors. There is **no `GameContext`
7
- bag**: a system is just an `RGame::Engine::Component` on a boundary node, found with the
8
- same `get_component` every node already has.
9
-
10
- > Status: the anchor + lookup mechanism (`root`, `scene`, `system`), the
11
- > tree-lifecycle hooks, and deferred removal (`queue_free`) are in place.
12
- > `examples/14_asteroids` exercises the whole path end to end and shows **both
13
- > scopes**: a scene-scoped `CollisionWorld` system and a root-scoped `HighScores`
14
- > system. `examples/15_tiled_world` adds a second scene-scoped system, `TileWorld`
15
- > (the tile map: collision, world bounds, drawing). More systems arrive with the
16
- > rest of the component port (see `docs/wip/components.md`).
17
-
18
- ## Two scopes = two anchor nodes
19
-
20
- Scope is a property of the **owner** you attach a system to, not of the system
21
- itself — the same insight behind Unreal's `UGameInstanceSubsystem` (whole session)
22
- vs `UWorldSubsystem` (one level), and Godot's autoload singletons vs per-scene
23
- nodes.
24
-
25
- - **Global scope → the root node.** `root` is set once and never changes, reachable
26
- from every node. Program-lifetime systems (e.g. an audio bus, i18n) are components
27
- on the root: `node.root.get_component(AudioBus)`.
28
- - **Scene scope → the scene node** (what `SceneStack` pushes). Scene-lifetime systems
29
- (the collision world, the tilemap/world-bounds holder) are components on *that*
30
- node — born when the scene is pushed, gone when it's popped:
31
- `node.scene.get_component(CollisionWorld)`.
3
+ Some things a node needs do not live on the node: a tile map, the world bounds, a
4
+ shared collision world. **Shared resources are systems. A system lives on an
5
+ anchor node, and other nodes reach it by walking the tree**, not through
6
+ constructor arguments. A system is an ordinary `RGame::Engine::Component` on a
7
+ boundary node, found with the same `get_component` every node has.
8
+
9
+ ## Two scopes, two anchor nodes
10
+
11
+ **Scope belongs to the node you attach a system to**, not to the system.
12
+
13
+ - **Global scope the root node.** Every node can reach the root, and it never
14
+ changes. Systems that live as long as the program are components on the root,
15
+ such as `Players` and `Viewports`: `node.root.get_component(RGame::Engine::Players)`.
16
+ - **Scene scope the scene node**, the node `SceneStack` pushes. Systems that
17
+ live as long as a scene are components on *that* node, such as the collision
18
+ world or the tile world. They appear when the scene is pushed and go when it is
19
+ popped: `node.scene.get_component(CollisionWorld)`.
32
20
 
33
21
  ## The anchors
34
22
 
35
- Both anchors are **methods that walk the parent chain**, not cached fields. A cached
36
- back-link set at add-time goes stale when a node is built before it's mounted (its
37
- children would cache the wrong root); resolving on access can't.
23
+ **Both anchors are methods that walk the parent chain**, not cached fields. A
24
+ back-link cached at add time would go stale for a node built before it is
25
+ mounted: its children would cache the wrong root. Resolving on every access
26
+ cannot go stale.
38
27
 
39
- - `root` `@parent ? @parent.root : self`. The top-most node is its own root.
40
- - `scene` the nearest ancestor marked as a scene boundary. `SceneStack#push` marks
41
- the pushed scene with `scene.scene = scene`; descendants resolve up to it. Outside
42
- any scene, `scene` is `nil`.
28
+ - `root` is `@parent ? @parent.root : self`. The top-most node is its own root.
29
+ - `scene` is the nearest ancestor marked as a scene boundary. `SceneStack#push`
30
+ marks the pushed scene with `scene.scene = scene`, and descendants resolve up to
31
+ it. Outside any scene, `scene` is `nil`.
43
32
 
44
33
  ### Looking a system up
45
34
 
@@ -47,72 +36,196 @@ children would cache the wrong root); resolving on access can't.
47
36
  node.system(CollisionWorld)
48
37
  ```
49
38
 
50
- `Node2D#system(klass)` checks the **scene scope first, then the global root**, so a
51
- scene can override a global default and free-standing nodes still find globals. Use
52
- the explicit anchor (`node.root.get_component` / `node.scene.get_component`) when you
53
- specifically mean one scope.
39
+ **`Node2D#system(klass)` checks the scene first, then the root.** A scene can
40
+ therefore override a global default, and nodes outside any scene still find
41
+ globals. To mean one scope specifically, use its anchor:
42
+ `node.root.get_component` or `node.scene.get_component`.
54
43
 
55
- ## Registering with a system — use the lifecycle, not `initialize`
44
+ ### Ask for a contract, not a class
56
45
 
57
- A system and its clients only connect once everything is **in the live tree**, so
58
- wiring happens in the tree-lifecycle hooks, never in `initialize` (where a node has
59
- no anchors). See [Lifecycle](scene_graph.md#lifecycle-constructing-vs-entering-the-tree).
46
+ **The lookup matches by ancestry**, so `klass` can be a module the system
47
+ includes instead of its own class. One question can then have several answers.
60
48
 
61
- The entered-tree cascade guarantees ordering that makes this safe: a scene's own
62
- components `on_attach` (so a `CollisionWorld` on the scene node exists), then the
63
- scene's `on_add`, then its children enter — so by the time a child collider attaches,
64
- the scene-scoped system it looks up is already there.
49
+ "How big is the world" shows this. A flat game mounts
50
+ [`Components::World`](components.md#world). A tile game mounts
51
+ [`Components::TileWorld`](components.md#tileworld), which derives the same two
52
+ numbers from its map. Both include `Components::WorldBounds`, so a component
53
+ that needs bounds asks for the *contract*:
54
+
55
+ ```ruby
56
+ def on_attach
57
+ world = node.system(RGame::Engine::Components::WorldBounds)
58
+ @width = world.world_width
59
+ @height = world.world_height
60
+ end
61
+ ```
62
+
63
+ `ScreenWrap` and `DespawnOffscreen` work this way. They run unchanged in either
64
+ kind of scene and never learn which one they are in. Naming the contract keeps
65
+ the two implementations from drifting apart.
66
+
67
+ ## Registering with a system: use the lifecycle, not `initialize`
68
+
69
+ **A system and its clients connect in the tree-lifecycle hooks**, never in
70
+ `initialize`. They can only connect once everything is in the live tree, and a
71
+ node under construction has no anchors. See
72
+ [Lifecycle](scene_graph.md#lifecycle-constructing-vs-entering-the-tree).
73
+
74
+ The entered-tree cascade fixes an order that makes this safe. The scene's own
75
+ components run `on_attach` first, so a `CollisionWorld` on the scene node exists.
76
+ Then the scene's `on_add` runs, then its children enter. By the time a child
77
+ collider attaches, the scene-scoped system it looks up is already there.
65
78
 
66
79
  ```ruby
67
80
  # CircleCollider (engine/components/circle_collider.rb) registers itself when it
68
81
  # enters the tree and releases the registration when it leaves — the engine fires
69
82
  # both hooks, so a spawned/despawned entity can't leak a registration.
70
83
  class CircleCollider < RGame::Engine::Component
71
- def on_attach = node.system(CollisionWorld).register(self)
84
+ def on_attach = node.system(CollisionWorld)&.register(self)
72
85
  def on_detach = node.system(CollisionWorld)&.unregister(self)
73
86
  end
74
87
  ```
75
88
 
76
- ## The two the platform mounts for you
89
+ **A collider tolerates a missing world; most clients should not.** Both hooks
90
+ above use `&.`. A collider in a scene without a `CollisionWorld` is a shape that
91
+ reports nothing. A tile-only game wants exactly that: its character carries a
92
+ feet box to be *stopped* by (see [`Mover`](components.md#mover)), and there are no
93
+ pairs to find. The cost is that an `on_hit` handler in such a scene never fires,
94
+ and nothing reports it. Weigh that deliberately. A client that is useless without
95
+ its system raises instead, as a mover's `blocked_by:` does.
96
+
97
+ ## The two systems `Game` mounts
77
98
 
78
- `RGame::Game` puts two systems on the root before the tree comes alive, so any
79
- node can reach them without a game wiring anything:
99
+ **`RGame::Game` puts two systems on the root before the tree goes live.** Any node
100
+ can reach them without the game wiring anything:
80
101
 
81
102
  | | |
82
103
  |---|---|
83
104
  | `node.system(RGame::Engine::Players)` | who is playing — devices, bindings, cameras, and who a newly used controller belongs to |
84
105
  | `node.system(RGame::Engine::Viewports)` | how the screen is divided — one `View` per active player, and collapsing the split |
85
106
 
86
- They are ordinary root-scoped systems, mounted the same way a game would mount
87
- its own. A scene that needs a camera to follow asks the first
88
- (`players.primary.camera`); a cutscene that needs to collapse the split asks the
89
- second (`viewports.solo!(camera)`), from wherever in the tree it happens to be
90
- and with nothing threaded into it. That reachability is the whole reason they
91
- are systems rather than something `Game` hands down.
107
+ They are ordinary root-scoped systems, mounted the way a game mounts its own. A
108
+ scene that needs a camera to follow asks `Players` (`players.primary.camera`). A
109
+ cutscene that collapses the split asks `Viewports` (`viewports.solo!(camera)`).
110
+ Both work from anywhere in the tree, with nothing passed in. That reach is why
111
+ they are systems and not objects `Game` hands down.
92
112
 
93
113
  See [Input](input.md#players-seats-and-joining) and
94
114
  [Scene graph](scene_graph.md#viewports-and-views).
95
115
 
116
+ ## Collision: two indexes, one resolver
117
+
118
+ Collision is the largest structure built from systems. Three pages cover its
119
+ parts: the [components](components.md#boxcollider) a node carries, the
120
+ [systems](#systems-that-index-their-clients-the-tag-registry-pattern) a scene
121
+ mounts, and the
122
+ [building blocks](internals.md#collisionsystem--move-an-actor-against-its-blockers)
123
+ underneath. This section shows how they fit together.
124
+
125
+ **A node has exactly one collision shape, and exactly one component owns it.** The
126
+ [collider](components.md#boxcollider) *is* the shape. A
127
+ [mover](components.md#mover) that wants to be stopped reads its sibling's box
128
+ instead of building a second one. The rectangle that stops a step is the
129
+ rectangle that reports a contact, so retuning one retunes both.
130
+
131
+ **Collision uses two indexes, on purpose.** A tile map is already an index: the
132
+ wall a step would hit follows from arithmetic on the step. A `TileWorld` divides
133
+ by the tile size and asks the grid. Actors have no such structure, so a
134
+ `CollisionWorld` buckets them into a
135
+ [`SpatialHash`](internals.md#spatialhash--uniform-grid-broadphase) each step.
136
+ Baking tile shapes into the broadphase would rebuild an index the grid already
137
+ is. It would also cost time every frame on a map of tens of thousands of tiles.
138
+
139
+ **The unification happens one level up.** A **blocker source** answers one
140
+ question over plain numbers: where does this box land when it moves `dx`? Three
141
+ sources exist:
142
+
143
+ - `TileBlockers`, over the grid;
144
+ - `ActorBlockers`, over the broadphase;
145
+ - `BoundsBlockers`, over the world's edges.
146
+
147
+ A blocked mover builds a
148
+ [`CollisionSystem`](internals.md#collisionsystem--move-an-actor-against-its-blockers)
149
+ at attach, from the sources its `blocked_by:` names. That system asks each source
150
+ and takes the most restrictive answer on each axis. The axis-separated order that
151
+ produces wall-sliding therefore exists **once**. An actor slides off a villager
152
+ exactly as it slides off a fence.
153
+
154
+ A source may also answer `travel?`: can this box move along a segment without
155
+ being stopped? `TileBlockers` does. A [`Navigator`](components.md#navigator) uses
156
+ it to check a route against the same resolver that will stop its walk.
157
+
158
+ | | Mounted on the scene | Owned by the node |
159
+ |---|---|---|
160
+ | Tiles | [`TileWorld`](components.md#tileworld), which hands out one shared `TileBlockers` | — |
161
+ | Actors | [`CollisionWorld`](components.md#collisionworld), the broadphase | an `ActorBlockers` per mover, holding its own collider and layer list |
162
+ | The world's edge | any [`WorldBounds`](components.md#world) | a `BoundsBlockers` |
163
+ | The step | — | one `CollisionSystem`, built at attach from the names above |
164
+
165
+ Neither system needs the other, and most scenes mount one. `examples/scroll_map`
166
+ has a map and no broadphase. `examples/collision` has a broadphase and no map.
167
+ `examples/collision_tiles` mounts both, and the difference shows only in the names
168
+ listed in `blocked_by`.
169
+
170
+ "What stops a mover" and "what a collider touches" remain two questions, and a
171
+ game sees the difference. See
172
+ [Blocking and overlapping](#blocking-and-overlapping-are-two-reports-and-a-pair-gets-one-of-them)
173
+ below, after the broadphase.
174
+
96
175
  ## Systems that index their clients (the tag-registry pattern)
97
176
 
98
- A many-to-many system (broadphase collision) lives on the scene node and keeps its
99
- own index of registered clients, so it processes only nearby candidates instead of
100
- walking the tree for every pair. `CollisionWorld`
101
- (engine/components/collision_world.rb) holds a `SpatialHash` for exactly this — a
102
- spatial index of registered colliders, rebuilt each `update`. This indexing is the
103
- same idea as Godot's **groups**: a registry of node references. It is *not* an ECS —
104
- it indexes references, carries no component data, and gains none of ECS's
105
- data-locality; it's a lightweight index.
177
+ **A many-to-many system keeps its own index of registered clients**, so it checks
178
+ only nearby candidates instead of walking the tree for every pair. Broadphase
179
+ collision works this way, on the scene node. `CollisionWorld`
180
+ (engine/components/collision_world.rb) holds a `SpatialHash`: a spatial index of
181
+ registered colliders, rebuilt each `update`. The index ignores shape, so
182
+ `CircleCollider` and `BoxCollider` share one and collide with each other. The
183
+ index holds node references only. It carries no component data and gives no
184
+ data-locality benefit; it is a lightweight registry.
106
185
 
107
- `CollisionWorld` is layer-agnostic: it reports every overlapping pair by firing each
108
- collider's `on_hit` signal with the other collider, and the owning node decides what
109
- a contact *means* by reading the other's `layer` tag:
186
+ **`CollisionWorld` ignores layers.** It reports every overlapping pair to both
187
+ colliders. The owning node decides what a contact *means* by reading the other
188
+ collider's `layer` tag:
110
189
 
111
190
  ```ruby
112
191
  collider.on_hit { |other| queue_free if other.layer == :bullet } # in a Rock node
113
192
  ```
114
193
 
115
- Because it's a normal component on the scene node, it rides the `update` traversal
116
- (its broadphase runs in `update`) and is torn down with the scene. See
117
- `examples/14_asteroids` for the whole loop: ship, bullets, and rocks spawning,
118
- colliding, and despawning through this system.
194
+ **A contact arrives as two edges, not as a state.** `on_hit` fires on the step a
195
+ pair starts overlapping. `on_separated` fires on the step it stops. Each fires
196
+ once per pair, with nothing in between. A handler can therefore count, play a
197
+ sound or spend a life without guarding itself. That is why the system keeps an
198
+ `Engine::ContactSet` per collider, instead of forwarding each step's broadphase
199
+ results.
200
+
201
+ `CollisionWorld` is a normal component on the scene node. It runs in the
202
+ `update` traversal, where its broadphase runs, and goes away with the scene.
203
+ `examples/collision` shows the whole loop: circles and crates registering,
204
+ overlapping and separating through this system.
205
+
206
+ ### Blocking and overlapping are two reports, and a pair gets one of them
207
+
208
+ **The same broadphase answers a second question.** A
209
+ [mover](components.md#mover) that names a collider layer in `blocked_by:` is
210
+ *stopped* by every box with that layer. It stops flush against the box's edge, as
211
+ against a solid tile. One `BoxCollider` can thus be a wall to one actor and a
212
+ trigger for another. Who declared the layer decides which; the collider itself
213
+ says nothing.
214
+
215
+ **The two reports never both fire for one pair.** Blocking leaves the boxes
216
+ exactly touching. `CollisionBox.overlap?` uses the half-open span `[x, x + w)` on
217
+ purpose. On a grid, pieces on neighbouring squares border each other constantly,
218
+ and an inclusive test would report each as a contact. Measured on two 12×6 boxes:
219
+ touching exactly, `on_hit` does not fire; overlapping by half a pixel, it does.
220
+ That convention makes the two reports mutually exclusive by construction.
221
+
222
+ A blocked pair therefore reports **no** contact. A mover that must both stop and
223
+ react listens to `on_blocked`, not `on_hit`; see [`Mover`](components.md#mover).
224
+ The rule of thumb:
225
+
226
+ | The question | The report | Where it lives |
227
+ |---|---|---|
228
+ | what may I not pass through | `on_blocked` / `on_unblocked` | the mover that was stopped |
229
+ | what am I touching | `on_hit` / `on_separated` | both colliders of the pair |
230
+
231
+ `examples/collision_tiles` shows the first, and `examples/collision` the second.