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,20 +1,25 @@
1
1
  # Internal building blocks
2
2
 
3
- Low-level, pure-Ruby classes the engine's [components](components.md) and
4
- [systems](systems.md) are built on. A game author rarely constructs these directly —
5
- they sit *behind* a component (a `CharacterBody` resolves through `CollisionSystem`, an
6
- `AnimatedSprite` plays through an `Animator`, a `CollisionWorld` indexes through a
7
- `SpatialHash`) — but they are documented here because they carry the load-bearing
8
- algorithms and are the seams the component tests drive. None `require "gosu"`.
3
+ **The engine's [components](components.md) and [systems](systems.md) are built on
4
+ these low-level, pure-Ruby classes.** A game author rarely constructs them. They
5
+ sit *behind* components:
9
6
 
10
- For the helpers a game *does* reach for directly (pools, localization, the camera,
11
- collision boxes, …), see [Utilities](toolbox.md).
7
+ - a blocked `Mover` resolves through `CollisionSystem`;
8
+ - an `AnimatedSprite` plays through an `Animator`;
9
+ - a `CollisionWorld` indexes through a `SpatialHash` and remembers contacts
10
+ through a `ContactSet`.
11
+
12
+ This page documents them because they hold the core algorithms, and component
13
+ specs drive them directly.
14
+
15
+ [Toolbox](toolbox.md) covers the helpers a game *does* use directly: pools,
16
+ the camera, collision boxes. [Localization](localization.md) covers `I18n`.
12
17
 
13
18
  ## `SpatialHash` — uniform-grid broadphase
14
19
 
15
- `RGame::Engine::SpatialHash` (`rgame/engine/spatial_hash`) is a broadphase index for collision:
16
- bucket colliders into fixed-size grid cells, then test only candidates that share a
17
- cell instead of every pair. It is the index inside
20
+ **`RGame::Engine::SpatialHash` (`rgame/engine/spatial_hash`) is a broadphase index
21
+ for collision.** It buckets colliders into fixed-size grid cells, then tests only
22
+ candidates that share a cell, not every pair. It is the index inside
18
23
  [`CollisionWorld`](components.md#collisionworld).
19
24
 
20
25
  ```ruby
@@ -24,87 +29,261 @@ rocks.each { |r| hash.insert(r, *r.aabb) } # insert the static set
24
29
  hash.query(*bullet.aabb) { |rock| ...narrowphase... }
25
30
  ```
26
31
 
27
- Typical per-frame use is `clear`, `insert` every collider of one set, then `query`
28
- around each moving collider. Both `insert` and `query` take an AABB (`x, y, w, h`);
29
- an item spanning several cells is inserted into each and so **may be yielded more than
30
- once** by `query`. That dedup is deliberately the narrowphase caller's job — guard with
31
- `next if a.dead? || b.dead?` to keep hits idempotent which lets the hash skip a
32
- per-query visited set and stay allocation-free. Cell keys are packed into a single
33
- tagged Fixnum (with an offset so off-screen / mid-wrap negative cells stay
34
- non-negative), so keying allocates nothing either.
35
-
36
- `query_circle(cx, cy, r, &)` is the radial counterpart to `query`: it yields the items
37
- in the cells the circle's bounding box covers (delegating to the same cell walk), for
38
- range and nearest lookups. It is still broadphase — it carries the same may-yield-twice
39
- contract, and the caller refines candidates by true distance (see
40
- [`CollisionWorld`](components.md#collisionworld)'s `query_circle`/`nearest`).
41
-
42
- ## `TileCollision` — axis-separated AABB-vs-tile resolution
43
-
44
- `RGame::Engine::TileCollision` (`rgame/engine/tile_collision`) resolves an axis-aligned box against a
45
- grid of solid tiles. `solid` is a callable `solid.call(col, row) -> bool`, so the tile
46
- source is decoupled (a `TileMap`, a fake in tests). It moves the box **one axis at a
47
- time** `resolve_x` then `resolve_y` (fed the resolved x) — which gives wall-sliding: a
48
- diagonal push into a wall keeps the component that's still free.
32
+ A typical frame calls `clear`, inserts every collider of one set, then runs
33
+ `query` around each moving collider. `insert` and `query` both take an AABB
34
+ (`x, y, w, h`).
35
+
36
+ `remove(item, x, y, w, h)` undoes one `insert`. Pass the box the item was
37
+ inserted at, not where it is now: the hash does not remember where it put
38
+ anything. Removing an item that is not there does nothing.
39
+
40
+ **`query` may yield an item more than once.** An item spanning several cells sits
41
+ in each of them. Removing repeats is the narrowphase caller's job, which spares the
42
+ hash a per-query visited set and keeps it allocation-free. `CollisionWorld` checks
43
+ each pair against the [`ContactSet`](#contactset--the-two-edges-of-a-contact) it
44
+ fills for the step, which it needs anyway. A caller that only *selects*, like
45
+ `nearest`, ignores repeats instead. The hash packs each cell key into one tagged
46
+ Fixnum, offset so negative cells stay non-negative. Keying therefore allocates
47
+ nothing either.
48
+
49
+ `query_circle(cx, cy, r, &)` is the radial form of `query`. It yields the items in
50
+ the cells the circle's bounding box covers, through the same cell walk. Use it for
51
+ range and nearest lookups. It remains a broadphase with the same may-yield-twice
52
+ contract. The caller refines candidates by true distance (see
53
+ [`CollisionWorld`](components.md#collisionworld)'s `query_circle` and `nearest`).
54
+
55
+ `cell_empty?(x, y)` asks whether the single cell *containing the point* `(x, y)`
56
+ is empty. It takes a point, not a region, so pass any coordinate inside the cell
57
+ you mean. It allocates nothing. Unlike a plain read of the bucket Hash, a miss
58
+ creates no bucket.
59
+
60
+ **The cell walk is half-open on the far edge.** The hash buckets an item by its
61
+ bounding box. A box ending exactly on a cell boundary stops at the cell before it.
62
+ A piece filling one cell therefore lands in that cell only. "Bucketed here" means
63
+ exactly "overlaps this cell's area", so `cell_empty?` tests occupancy, not
64
+ candidacy. `CollisionBox.overlap?` uses the same convention, and the two must
65
+ agree. Bucketing one cell too far would only cost extra candidates. Bucketing one
66
+ cell short would miss a real contact.
67
+
68
+ The index cannot know whether an occupant still counts.
69
+ [`CollisionWorld#cell_empty?`](components.md#collisionworld) therefore wraps this
70
+ method and skips colliders whose node is queued for removal.
71
+
72
+ ## `ContactSet` — the two edges of a contact
73
+
74
+ **`RGame::Engine::ContactSet` (`rgame/engine/contact_set`) turns a per-step overlap
75
+ test into two edges**: the step a pair *starts* touching and the step it *stops*.
76
+ [`CollisionWorld`](components.md#collisionworld) owns one per registered collider
77
+ and is its only driver there. Nothing in the class concerns overlap, though, and
78
+ it has a second user. Every [`Mover`](components.md#mover) keeps one of what
79
+ stopped its step, and derives `on_blocked` / `on_unblocked` from it the same way.
80
+ Underneath its names, the class is "the set of things true this step and last".
49
81
 
50
82
  ```ruby
51
- tiles = RGame::Engine::TileCollision.new(tile_width: 16, tile_height: 16,
52
- solid: ->(col, row) { map.solid_tile?(col, row) })
53
- nx = tiles.resolve_x(x, y, w, h, dx) # snaps flush against a solid in the dx direction
54
- ny = tiles.resolve_y(nx, y, w, h, dy)
83
+ contacts.begin_frame # last step's list becomes the one to compare against
84
+ contacts.touching?(other) # already recorded this step? (the broadphase repeats)
85
+ contacts.started?(other) # not touching last step, so this is an on_hit
86
+ contacts.add(other) # record it for this step
87
+ contacts.each_ended { |o| ... } # touching last step, not now, so this is an on_separated
88
+ contacts.reset # forget both steps — a pooled collider registering again
55
89
  ```
56
90
 
57
- It assumes per-step movement smaller than a tile (no tunneling), which holds for the
58
- engine's speeds. It is the maths inside [`CollisionSystem`](#collisionsystem--move-an-actor-against-the-tiles-and-the-world).
91
+ **It uses two arrays, swapped each step, and checks membership by a linear scan
92
+ on identity.** Both choices serve the per-frame path. `Array#clear` keeps the
93
+ capacity the array grew to, so a lasting contact allocates nothing after the first
94
+ few steps. A Set or a Hash would allocate on every insert. A collider touching more
95
+ than a handful of things at once points to a design problem elsewhere; it is no
96
+ reason to index this.
97
+
98
+ `started?` reads only the previous step's list. It answers the same before and
99
+ after `add`, so the world can use it as a guard in step order.
59
100
 
60
- ## `CollisionSystem` — move an actor against the tiles and the world
101
+ ## `TileBlockers` — the tile grid as a blocker source
61
102
 
62
- `RGame::Engine::CollisionSystem` (`rgame/engine/collision_system`) wraps `TileCollision` with a
63
- world-bounds clamp and an actor-facing `move`. It is what
64
- [`TileWorld`](components.md#tileworld) delegates to (and what a `CharacterBody` moves
65
- through).
103
+ **`RGame::Engine::TileBlockers` (`rgame/engine/tile_blockers`) resolves an
104
+ axis-aligned box against the solid tiles of a
105
+ [`Util::SolidGrid`](values.md#rgameutilsolidgrid)**, at a given tile size.
106
+ [`TileWorld`](components.md#tileworld) builds one over the grid it reads the map
107
+ into; a spec builds the grid itself. `TileBlockers` reads the grid and never copies
108
+ it, so a cell changed with `set_solid` stops the next step. It resolves each
109
+ axis on its own. [`CollisionSystem`](#collisionsystem--move-an-actor-against-its-blockers)
110
+ feeds one axis's result into the other.
66
111
 
67
112
  ```ruby
68
- collision = RGame::Engine::CollisionSystem.new(
69
- tile_collision: tiles, world_width: map.pixel_width, world_height: map.pixel_height
70
- )
113
+ require 'rgame'
114
+
115
+ grid = RGame::Util::SolidGrid.build(20, 15) { |col, _row| col == 5 } # a wall at x 80..96
116
+ tiles = RGame::Engine::TileBlockers.new(grid: grid, tile_width: 16, tile_height: 16)
117
+
118
+ x, y, w, h = 58.0, 32.0, 12, 6
119
+ nx = tiles.resolve_x(x, y, w, h, 14.0) # => 68.0 — snapped flush against the wall's left edge
120
+ ny = tiles.resolve_y(nx, y, w, h, 4.0) # => 36.0
121
+ tiles.travel?(10.0, 32.0, w, h, 50.0, 0.0) # => true
122
+ tiles.travel?(10.0, 32.0, w, h, 90.0, 0.0) # => false — the wall is in the way
123
+ ```
124
+
125
+ [`Util::TileSweep`](values.md#rgameutiltilesweep) does the arithmetic, in C;
126
+ `TileBlockers` presents it as a blocker source. Results are Floats. It assumes each
127
+ step moves less than a tile, so nothing tunnels. The engine's speeds meet that.
128
+
129
+ **`travel?(x, y, w, h, dx, dy)` answers whether the box can move `(dx, dy)`
130
+ without any resolve stopping it short.** It is the optional extra question of the
131
+ [blocker-source protocol](#collisionsystem--move-an-actor-against-its-blockers).
132
+ It sweeps the box in overlapping windows half a tile long and a quarter tile
133
+ apart. It resolves each window x-then-y and y-then-x. A `true` holds for a walker
134
+ that resolves its own steps against this source, as long as each step is under a
135
+ quarter tile: 240 px/s at 60 ticks a second on 16 px tiles. It may refuse a segment
136
+ such a walker could manage. It never clears one the walker could not.
137
+ [`Navigator`](components.md#navigator) smooths its routes with it.
138
+
139
+ `blocker` returns the sentinel `TileBlockers::TILES`: one object for the life of
140
+ the process, answering `layer` → `:tiles` and `node` → `nil`. `TileBlockers` holds
141
+ no per-step state, so every body on the map can share one.
142
+ [`TileWorld#blockers`](components.md#tileworld) hands out that shared instance.
143
+
144
+ ## `ActorBlockers` — the registered colliders as a blocker source
145
+
146
+ **`RGame::Engine::ActorBlockers` (`rgame/engine/actor_blockers`) does the same
147
+ arithmetic, with the edge coming from another collider instead of a grid line.**
148
+ It asks a broadphase what lies near the swept box, and snaps flush against the
149
+ nearest candidate.
150
+
151
+ ```ruby
152
+ actors = RGame::Engine::ActorBlockers.new(world: collision_world, owner: my_collider,
153
+ layers: %i[npc hero])
154
+ ```
155
+
156
+ **Unlike the other two sources, it belongs to one mover.** It holds that mover's
157
+ own collider, to exclude it by identity, and the layer list the mover declared.
158
+ Two movers with different `blocked_by` cannot share one. That is why a
159
+ [`Mover`](components.md#mover) builds its own `CollisionSystem` instead of
160
+ borrowing the scene's.
161
+
162
+ It does three things a grid does not need:
163
+
164
+ - **The nearest candidate wins**, tracked as a running minimum. The broadphase can
165
+ therefore offer one collider once per shared cell without harm.
166
+ - **It leaves an existing overlap unresolved.** A step is blocked only if it
167
+ *crosses* an edge the mover started on the near side of. A pair that starts
168
+ overlapping stays overlapping and is not teleported apart.
169
+ - **It handles boxes only.** It skips a `CircleCollider` on a declared layer.
170
+
171
+ Its `moved` passes the mover to
172
+ [`CollisionWorld#reindex`](components.md#collisionworld). That keeps a later query
173
+ in the same step exact.
174
+
175
+ ## `BoundsBlockers` — the edge of the world as a blocker source
176
+
177
+ **`RGame::Engine::BoundsBlockers` (`rgame/engine/bounds_blockers`) stops a box from
178
+ leaving the region a [`WorldBounds`](components.md#world) describes.** It reads
179
+ the two numbers once at construction, because those bounds are immutable by
180
+ contract. Its `blocker` is the sentinel `BoundsBlockers::BOUNDS`, answering
181
+ `layer` → `:bounds`.
182
+
183
+ **A mover declares the edge as a source; the engine applies no automatic clamp.**
184
+ Stopping at the edge is one of three responses to it. `ScreenWrap` and
185
+ `DespawnOffscreen` are the others, and a node may carry only one
186
+ ([`WorldBounds.one_response!`](components.md#world)). A mover that did not ask is
187
+ not held.
188
+
189
+ ## `CollisionSystem` — move an actor against its blockers
190
+
191
+ **`RGame::Engine::CollisionSystem` (`rgame/engine/collision_system`) holds a list of
192
+ blocker sources and moves an actor against them.** A blocked
193
+ [`Mover`](components.md#mover) builds one at attach, from the sources its
194
+ `blocked_by:` names, and passes itself as the actor.
195
+
196
+ ```ruby
197
+ collision = RGame::Engine::CollisionSystem.new(blockers: [tiles, actors])
71
198
  collision.move(actor, dx, dy) # actor responds to x / y / x= / y= / collision_box
72
199
  ```
73
200
 
74
- `move` reads the actor's [`CollisionBox`](toolbox.md#collisionbox--an-actors-feet-box)
75
- AABB, resolves it through `TileCollision` on both axes, clamps the box inside the world
76
- as a backstop, and writes the resolved position back to the actor (accounting for the
77
- box's offset from the sprite origin).
201
+ `move` reads the actor's
202
+ [`CollisionBox`](toolbox.md#collisionbox--an-actors-feet-box) AABB and resolves it
203
+ on both axes. It writes the resolved position back to the actor, accounting for the
204
+ box's offset from the sprite origin. Finally it tells every source that the step
205
+ happened. **It has no clamp of its own.** Everything that can stop a step is a
206
+ source, the world's edge included.
207
+
208
+ A blocker source answers four questions. Only the first two involve arithmetic:
209
+
210
+ ```ruby
211
+ source.resolve_x(x, y, w, h, dx) # -> where the box's left edge lands moving dx
212
+ source.resolve_y(x, y, w, h, dy) # -> where the box's top edge lands moving dy
213
+ source.blocker # -> what produced that edge, or nil
214
+ source.moved(actor, from_x, from_y, w, h) # -> the step has been written back
215
+ ```
216
+
217
+ A fifth is optional. Of the three sources, only `TileBlockers` answers it:
218
+
219
+ ```ruby
220
+ source.travel?(x, y, w, h, dx, dy) # -> can the box move (dx, dy) without being stopped?
221
+ ```
222
+
223
+ **"Stopped" means a resolve lands short of where the step was heading.** A landing
224
+ past it does not count: `move` ignores it, and it does not stop a travel either.
225
+ `BoundsBlockers` gives such a landing to a box that starts outside the world. Under
226
+ this reading, a box travels past several sources exactly when it travels past each
227
+ one alone. The shared example group `a blocker source answering travel?`
228
+ (`spec/support/shared_examples/`) states that meaning. It checks a source's answer
229
+ against a walker stepping through the source's own resolves.
230
+
231
+ The three sources are `TileBlockers`, `ActorBlockers` and `BoundsBlockers`. **The
232
+ system asks each source and takes the most restrictive answer on each axis**: the
233
+ smallest landing for a rightward or downward step, the largest for a leftward or
234
+ upward one. No source needs to know the others exist, and `blockers: []` means free
235
+ movement.
236
+
237
+ `blocked_x` and `blocked_y` hold what the winning source reported for each axis, or
238
+ nil when that axis was free. The system asks each winner during the resolve, not
239
+ afterwards, because a source's `blocker` is per axis and the next resolve overwrites
240
+ it. Every blocker answers `layer` and `node`, tiles and world edges included. A
241
+ caller reads `blocked_x.layer` without checking what kind of thing stopped it.
242
+
243
+ The system calls `moved` on every source with the box the step **started** from. A
244
+ source over a moving index can then re-bucket the mover, without anyone storing a
245
+ box for it; see `ActorBlockers` above.
246
+
247
+ Keeping the list in the system, not inside a source, has two consequences. First,
248
+ the axis-separated order exists once for every source: resolve x, then resolve y
249
+ with the resolved x. That order produces wall-sliding, where a diagonal push into a
250
+ wall keeps the component that is still free. Second, the loop walks an index
251
+ instead of using `map` or `min`. It runs per actor, per axis, per frame, and both a
252
+ block and an intermediate array would allocate.
78
253
 
79
254
  ## `AnimationSet` — pure frame maths
80
255
 
81
- `RGame::Engine::AnimationSet` (`rgame/engine/animation_set`) turns an atlas's animation table plus an
82
- elapsed time into the sprite-sheet cell to show no renderer, no images, fully testable.
256
+ **`RGame::Engine::AnimationSet` (`rgame/engine/animation_set`) turns an animation
257
+ table and an elapsed time into the sprite-sheet cell to show.** It needs no
258
+ renderer and no images, so specs test it fully.
83
259
 
84
260
  ```ruby
261
+ require 'rgame'
262
+
85
263
  set = RGame::Engine::AnimationSet.new(
86
264
  stand: { row: 0, col: 1, frames: 1, fps: 1 },
87
265
  walk_right: { row: 1, frames: 3, fps: 6 }
88
266
  )
89
- set.frame(:walk_right, elapsed) # => [row, col, flip_x]
267
+ set.frame(:walk_right, 0.4) # => [1, 2, false] — [row, col, flip_x]
90
268
  ```
91
269
 
92
270
  Each animation is `{ row:, col: (start column, default 0), frames:, fps:, flip_x: }`.
93
- `frame(name, elapsed)` advances `frames` columns from `col` at `fps`, wrapping — so a
94
- held animation cycles.
271
+ `frame(name, elapsed)` advances through `frames` columns from `col` at `fps`, and
272
+ wraps around. A held animation therefore cycles.
95
273
 
96
274
  ## `Animator` — animation playback state
97
275
 
98
- `RGame::Engine::Animator` (`rgame/engine/animator`) owns the playback state on top of an
99
- `AnimationSet`: the current animation name and elapsed time. It is what
100
- [`AnimatedSprite`](components.md#animatedsprite) drives.
276
+ **`RGame::Engine::Animator` (`rgame/engine/animator`) owns playback state on top of
277
+ an `AnimationSet`**: the current animation name and its elapsed time.
278
+ [`AnimatedSprite`](components.md#animatedsprite) drives it.
101
279
 
102
280
  ```ruby
103
281
  animator = RGame::Engine::Animator.new(set, initial: :stand)
104
282
  animator.play(:walk_right) # switch (a no-op if already playing, so a walk keeps cycling)
105
283
  animator.update(dt) # advance elapsed time
106
- animator.frame # => [row, col, flip_x] for the current animation now
284
+ animator.frame # => [row, col, flip_x] for the current animation now
107
285
  ```
108
286
 
109
- `play` only restarts elapsed time on an actual change, so calling it every frame with the
110
- current intent keeps a continuing walk smooth rather than stuttering on frame 0.
287
+ `play` resets elapsed time only when the animation changes. Calling it
288
+ every frame with the current intent keeps a walk smooth instead of stuttering on
289
+ frame 0.
@@ -0,0 +1,285 @@
1
+ # Localization
2
+
3
+ **Text a game draws is a translation key, and the words live in YAML tables.**
4
+ `RGame::Game` loads the tables and picks the player's language at startup. A
5
+ node draws an [`Engine::Text`](toolbox.md#text--the-string-a-node-draws) built
6
+ from a key. A UI button's `label:` [is a key too](ui.md#labels-are-translation-keys).
7
+ `RGame::Engine::I18n` (`rgame/engine/i18n`) holds the tables and the current
8
+ language, and every `Text` reads through it.
9
+
10
+ `examples/localization` shows all of it on one screen: plurals, a variable, a
11
+ fallback, a language menu and a saved choice. See [Examples](examples.md#localization).
12
+
13
+ ## Where the tables go
14
+
15
+ **A game on `RGame::Game` keeps its tables under `locales/` in its media root**,
16
+ one or more `.yml` files in Rails' format. The top-level key is the locale, and
17
+ keys nest below it:
18
+
19
+ ```yaml
20
+ # media/locales/en.yml
21
+ en:
22
+ title_menu:
23
+ play: Play
24
+ hud:
25
+ score: "Score: %{score}"
26
+ apples:
27
+ zero: No apples
28
+ one: "%{count} apple"
29
+ other: "%{count} apples"
30
+ ```
31
+
32
+ ```yaml
33
+ # media/locales/de.yml
34
+ de:
35
+ title_menu:
36
+ play: Spielen
37
+ hud:
38
+ score: "Punkte: %{score}"
39
+ apples:
40
+ one: "%{count} Apfel"
41
+ other: "%{count} Äpfel"
42
+ ```
43
+
44
+ `Game.new` lists every `.yml` under `locales:` and loads each through the asset
45
+ manager, sorted by path. `locales:` defaults to `'locales'`. It is relative to
46
+ `media_root` unless it is absolute. A directory that does not exist loads
47
+ nothing, and every key then shows as itself. See
48
+ [Game](game.md#translations-and-the-players-language) for the loading order.
49
+
50
+ A project from `rgame new` keeps its tables in `assets/locales/`, which is its
51
+ media root plus the default. See
52
+ [The `rgame` command](cli.md#text-comes-from-a-translation-table).
53
+
54
+ One file may hold several locales, and several files may hold one. A second load
55
+ of a locale merges key by key into what is loaded. A key set twice takes the
56
+ later value.
57
+
58
+ ## Drawing translated text
59
+
60
+ A node builds a `Text` once and reads it in `on_draw`. A read with unchanged
61
+ variables and an unchanged language returns the same String and allocates
62
+ nothing:
63
+
64
+ ```ruby
65
+ class Hud < RGame::Engine::Node2D
66
+ def initialize(**)
67
+ super
68
+ @score = RGame::Engine::Text.new('hud.score', :score)
69
+ @apples = RGame::Engine::Text.new('hud.apples', :count)
70
+ @title = RGame::Engine::Text.new('hud.title')
71
+ end
72
+
73
+ def on_draw(renderer, _view)
74
+ renderer.text(@score.with(score: @points), 12, 10)
75
+ renderer.text(@apples.with(count: @apples_held), 12, 30)
76
+ renderer.text(@title, 12, 50)
77
+ end
78
+ end
79
+ ```
80
+
81
+ A `Text` with no variables goes to `text` as it is, because it answers `to_str`.
82
+
83
+ A switch of `I18n.locale` renders every `Text` again on its next read. Nothing
84
+ subscribes to the switch. [`Text`](toolbox.md#text--the-string-a-node-draws)
85
+ covers variables, scopes, `Text.literal` for text that never translates, and
86
+ `Text.computed`.
87
+
88
+ `I18n` itself is a global module, so this works headless, in a spec or anywhere
89
+ else:
90
+
91
+ ```ruby
92
+ require 'rgame'
93
+
94
+ i18n = RGame::Engine::I18n
95
+ i18n.load(<<~YAML, source: 'locales/game.yml')
96
+ en:
97
+ menu:
98
+ title: Main Menu
99
+ greeting: "Hello, %{name}"
100
+ apples:
101
+ zero: No apples
102
+ one: "%{count} apple"
103
+ other: "%{count} apples"
104
+ de:
105
+ menu:
106
+ title: Hauptmenü
107
+ apples:
108
+ one: "%{count} Apfel"
109
+ other: "%{count} Äpfel"
110
+ YAML
111
+
112
+ i18n.locale = 'de_AT'
113
+ i18n.chain # => [:"de-AT", :de, :en]
114
+ i18n.t('menu.title') # => "Hauptmenü" — from the de table
115
+ i18n.t('greeting', scope: 'menu', name: 'Ada') # => "Hello, Ada" — de lacks it, en has it
116
+ i18n.t('apples', count: 3) # => "3 Äpfel"
117
+ i18n.missing_keys(:de) # => ["menu.greeting"]
118
+ i18n.choose(%w[fr-CA de-CH]) # => :"de-CH" — the de table covers it
119
+ ```
120
+
121
+ ## Tables
122
+
123
+ `load(yaml, source:)` parses a String. `source:` names the file in error
124
+ messages. `load_hash` does the same for a Hash, with Symbol or String keys.
125
+ `I18n` never opens a file: `RGame::Game` reads the files and hands `load` their
126
+ text.
127
+
128
+ `load` uses `YAML.safe_load` with aliases allowed. It raises
129
+ `Psych::DisallowedClass` for an object tag and `Psych::SyntaxError` for broken
130
+ YAML. YAML reads an unquoted `on`, `off`, `yes`, `no`, `true`, `false` or `~` as
131
+ a boolean or `nil`. `load` raises `ArgumentError` for such a key or value, naming
132
+ where it is, so quote it. A number value becomes its text, and so does a
133
+ whole-number key. Any other key, such as `1.5`, raises `ArgumentError` too.
134
+
135
+ Each load compiles every value once. A String becomes a template with its
136
+ placeholders already found. `%%{` writes a literal `%{`. A key without
137
+ placeholders returns the same frozen String on every call.
138
+
139
+ `available` lists the locales that have a table, in load order.
140
+
141
+ ## Variables and `t`
142
+
143
+ `%{name}` in a translation prints a variable. A `Text` declares its variables as
144
+ names and receives them as keywords to `with`.
145
+
146
+ `t(key, scope: nil, **vars)` looks `key` up, or `"scope.key"` when `scope:` is
147
+ given, and interpolates `vars`. It raises `ArgumentError` when the translation
148
+ uses a variable `vars` does not hold. Variables it does not use are ignored.
149
+ `t` allocates on every call, so keep it off the per-frame path.
150
+
151
+ `render(key, names, vars)` is what a `Text` reads through. It resolves `key` like
152
+ `t`, with `vars` holding exactly `names`. A translation whose placeholders are
153
+ not `names` goes to the [missing policy](#missing-keys), as
154
+ [`Text`](toolbox.md#when-it-renders-again) describes.
155
+
156
+ ## Plurals
157
+
158
+ A key whose nested keys are all CLDR plural categories (`zero`, `one`, `two`,
159
+ `few`, `many`, `other`), with text values and `other` among them, is a plural.
160
+ Any other nested Hash is a level of keys. `t` needs `count:` for a plural and
161
+ raises `ArgumentError` without it. `count` is also available as `%{count}`. A
162
+ `Text` for a plural needs `:count` among its names.
163
+
164
+ `t` picks the form by these rules, in order:
165
+
166
+ 1. An explicit `zero` form wins for a count of 0, in every language. A table
167
+ without one uses its language's rule: German reads `0 Äpfel` from `other`.
168
+ 2. The plural rule sorts the count into a category. The rule belongs to the
169
+ table that supplied the key, not to the current locale. English text reached
170
+ through a fallback from `:pl` counts like English.
171
+ 3. A category the table leaves out reads `other`.
172
+
173
+ `I18n::PluralRules::BUILT_IN` holds whole-number rules for en, de, nl, sv, da,
174
+ nb, fi, it, es, pt, fr, ru, uk, pl, cs, ja, zh, ko and ar. A built-in rule puts a
175
+ count that is not an Integer in `other`. A language without a rule counts like
176
+ English. `plural_rule(language) { |count| ... }` adds or replaces a rule; its
177
+ block returns a category Symbol. A rule for a regional locale, such as `'pt-PT'`,
178
+ wins over its language's rule for tables of that locale.
179
+
180
+ ## Locales and the fallback chain
181
+
182
+ `locale=` switches the language, and `default=` sets the last locale every lookup
183
+ falls back to. Both start at `:en`. Both normalize what they are given:
184
+ `de_AT`, `'de-at'` and `:'de-AT'` all become `:'de-AT'`, and `normalize` is
185
+ public. A locale needs no table of its own.
186
+
187
+ `chain` lists where a lookup looks, in order: the locale, each shorter prefix of
188
+ it, then the default. `I18n` rebuilds it on a switch, not on every lookup. A key
189
+ the German table lacks therefore shows the English text in German, when English
190
+ is the default.
191
+
192
+ `choose(preferred)` takes the player's locales, most wanted first. It returns the
193
+ first whose own chain meets a table, normalized but not shortened. The default
194
+ does not count as a match, and with no match `choose` returns the default.
195
+
196
+ ## The player's language
197
+
198
+ **`Game.new` sets `I18n.locale` to `I18n.choose(RGame::Core.preferred_locales)`**
199
+ after loading the tables: the first locale the operating system prefers that a
200
+ table covers, or the default. [App](app.md) describes what
201
+ `RGame::Core.preferred_locales` returns on each platform.
202
+
203
+ A language the player picked belongs between `new` and `start`, because `new`
204
+ has already chosen from the OS by then. `examples/localization` saves it with
205
+ [`Util::SaveFile`](values.md) and restores it this way:
206
+
207
+ ```ruby
208
+ game = MyGame.new
209
+ saved = save.read[:language]
210
+ if saved.is_a?(String) && !saved.empty?
211
+ RGame::Engine::I18n.locale = RGame::Engine::I18n.choose([saved, *RGame::Core.preferred_locales])
212
+ end
213
+ game.start
214
+ ```
215
+
216
+ Passing the saved locale through `choose` ignores a saved language the game has
217
+ no table for. A menu that switches language sets `I18n.locale` and writes the
218
+ save. `I18n` holds one language for the whole process, so split-screen players
219
+ share it.
220
+
221
+ ## Missing keys
222
+
223
+ A key is missing when no locale in the chain has it. `missing=` decides what
224
+ `t` returns then:
225
+
226
+ | `missing` | `t` on a missing key |
227
+ |---|---|
228
+ | `:key` (the start) | returns the key, with its scope |
229
+ | `:raise` | raises `I18n::MissingKey`, whose `key` and `chain` say where it looked |
230
+ | a callable | calls it with the key and the chain, and returns its result |
231
+
232
+ Any other value raises `ArgumentError`. A game on `RGame::Game` keeps `:key`, so
233
+ a missing key shows as itself on screen.
234
+
235
+ `missing_keys(locale)` lists the keys the default's table has and the locale's
236
+ own chain lacks, in the default table's order. It skips a key the locale gets
237
+ from a parent, such as `de-AT` from `de`.
238
+
239
+ ## Specs
240
+
241
+ **Specs raise on a missing key and check every table against the default.** A
242
+ project from `rgame new` does both. Its `spec/spec_helper.rb` loads the tables
243
+ before every example:
244
+
245
+ ```ruby
246
+ LOCALES = Dir[File.expand_path('../assets/locales/**/*.yml', __dir__)]
247
+ .to_h { |path| [path, File.read(path)] }.freeze
248
+
249
+ RSpec.configure do |config|
250
+ config.before do
251
+ RGame::Engine::I18n.reset
252
+ LOCALES.each { |path, yaml| RGame::Engine::I18n.load(yaml, source: path) }
253
+ RGame::Engine::I18n.missing = :raise
254
+ end
255
+ end
256
+ ```
257
+
258
+ Its `spec/locales_spec.rb` expects `missing_keys` to be empty for every locale in
259
+ `available`. A key added to `en.yml` and not to `de.yml` then fails `rake`.
260
+ [The `rgame` command](cli.md#text-comes-from-a-translation-table) describes both
261
+ files.
262
+
263
+ ## `generation`
264
+
265
+ **`generation` is an Integer that moves whenever what a key resolves to may have
266
+ changed.** It moves on every load, on a switch to a different locale or default,
267
+ on every `plural_rule`, and on `reset`. A switch to the locale already current leaves it alone. Cached
268
+ text compares `generation` with the value it last saw, and resolves again only
269
+ when it differs.
270
+
271
+ `reset` forgets every table and added plural rule. It restores `:en` as locale
272
+ and default, and `:key` as the missing policy. rgame's own headless suite calls
273
+ `reset` and sets `missing = :raise` before every example.
274
+
275
+ ## What this is not
276
+
277
+ - **Text is not measured in the engine layer.** A button slot has a fixed width,
278
+ so choose one wide enough for the longest translation.
279
+ - **One language per process.** Split-screen players cannot read different
280
+ languages.
281
+ - **No right-to-left text or shaping.** The shipped font covers Latin, Greek and
282
+ Cyrillic; nothing selects a font per locale.
283
+ - **Only text translates.** Images, audio and other assets have no per-locale
284
+ variant.
285
+ - **No `default:`, lazy lookup, array values or symbol links** from Rails.