rgame 0.4.0-x86_64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +251 -0
  3. data/LICENSE +26 -0
  4. data/README.md +325 -0
  5. data/docs/api/README.md +224 -0
  6. data/docs/api/app.md +249 -0
  7. data/docs/api/assets.md +463 -0
  8. data/docs/api/audio.md +249 -0
  9. data/docs/api/cli.md +259 -0
  10. data/docs/api/components.md +1112 -0
  11. data/docs/api/drawing.md +398 -0
  12. data/docs/api/examples.md +263 -0
  13. data/docs/api/game.md +219 -0
  14. data/docs/api/images.md +118 -0
  15. data/docs/api/input.md +476 -0
  16. data/docs/api/internals.md +289 -0
  17. data/docs/api/localization.md +285 -0
  18. data/docs/api/scene_graph.md +539 -0
  19. data/docs/api/signals.md +147 -0
  20. data/docs/api/systems.md +231 -0
  21. data/docs/api/text.md +133 -0
  22. data/docs/api/tile_maps.md +237 -0
  23. data/docs/api/toolbox.md +493 -0
  24. data/docs/api/ui.md +927 -0
  25. data/docs/api/values.md +316 -0
  26. data/examples/assets/README.md +322 -0
  27. data/examples/assets/blip.ogg +0 -0
  28. data/examples/assets/glyphs.json +5 -0
  29. data/examples/assets/glyphs.png +0 -0
  30. data/examples/assets/hero.json +12 -0
  31. data/examples/assets/hero.png +0 -0
  32. data/examples/assets/icons.json +13 -0
  33. data/examples/assets/icons.png +0 -0
  34. data/examples/assets/music.ogg +0 -0
  35. data/examples/assets/skills.json +10 -0
  36. data/examples/assets/skills.png +0 -0
  37. data/examples/assets/tileset.png +0 -0
  38. data/examples/assets/tileset.tsx +65 -0
  39. data/examples/assets/town.tmx +26 -0
  40. data/examples/assets/ui.json +11 -0
  41. data/examples/assets/ui.png +0 -0
  42. data/examples/collision/locales/en.yml +8 -0
  43. data/examples/collision/main.rb +316 -0
  44. data/examples/collision_tiles/locales/en.yml +9 -0
  45. data/examples/collision_tiles/main.rb +274 -0
  46. data/examples/fullscreen/locales/en.yml +10 -0
  47. data/examples/fullscreen/main.rb +216 -0
  48. data/examples/game_menu/locales/en.yml +8 -0
  49. data/examples/game_menu/main.rb +170 -0
  50. data/examples/input_glyphs/locales/en.yml +14 -0
  51. data/examples/input_glyphs/main.rb +213 -0
  52. data/examples/jump_topdown/locales/en.yml +9 -0
  53. data/examples/jump_topdown/main.rb +178 -0
  54. data/examples/localization/locales/de.yml +12 -0
  55. data/examples/localization/locales/en.yml +13 -0
  56. data/examples/localization/main.rb +158 -0
  57. data/examples/menu_navigation/locales/en.yml +23 -0
  58. data/examples/menu_navigation/main.rb +365 -0
  59. data/examples/music/locales/en.yml +7 -0
  60. data/examples/music/main.rb +134 -0
  61. data/examples/pathfinding/locales/en.yml +17 -0
  62. data/examples/pathfinding/main.rb +298 -0
  63. data/examples/pooling/locales/en.yml +7 -0
  64. data/examples/pooling/main.rb +259 -0
  65. data/examples/quick_wheel/locales/en.yml +16 -0
  66. data/examples/quick_wheel/main.rb +184 -0
  67. data/examples/radial_menu/locales/en.yml +16 -0
  68. data/examples/radial_menu/main.rb +184 -0
  69. data/examples/save_load/locales/en.yml +11 -0
  70. data/examples/save_load/main.rb +207 -0
  71. data/examples/save_load_ids/locales/en.yml +11 -0
  72. data/examples/save_load_ids/main.rb +322 -0
  73. data/examples/scroll_map/locales/en.yml +4 -0
  74. data/examples/scroll_map/main.rb +140 -0
  75. data/examples/signals/locales/en.yml +6 -0
  76. data/examples/signals/main.rb +278 -0
  77. data/examples/skill_bar/locales/en.yml +14 -0
  78. data/examples/skill_bar/main.rb +159 -0
  79. data/examples/sound/locales/en.yml +6 -0
  80. data/examples/sound/main.rb +122 -0
  81. data/examples/split_screen/locales/en.yml +9 -0
  82. data/examples/split_screen/main.rb +304 -0
  83. data/examples/sprite/locales/en.yml +8 -0
  84. data/examples/sprite/main.rb +180 -0
  85. data/examples/timer/locales/en.yml +12 -0
  86. data/examples/timer/main.rb +273 -0
  87. data/examples/velocity/locales/en.yml +6 -0
  88. data/examples/velocity/main.rb +196 -0
  89. data/examples/walk/locales/en.yml +4 -0
  90. data/examples/walk/main.rb +99 -0
  91. data/exe/rgame +9 -0
  92. data/lib/rgame/boot.rb +3 -0
  93. data/lib/rgame/cli/new_project.rb +139 -0
  94. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  95. data/lib/rgame/cli/templates/README.md.tt +93 -0
  96. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  97. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  98. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  99. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  100. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  101. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  102. data/lib/rgame/cli/templates/rspec.tt +2 -0
  103. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  104. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  105. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  106. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  107. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  108. data/lib/rgame/cli.rb +66 -0
  109. data/lib/rgame/core/app.rb +46 -0
  110. data/lib/rgame/core/asset_manager.rb +206 -0
  111. data/lib/rgame/core/audio.rb +145 -0
  112. data/lib/rgame/core/font.rb +46 -0
  113. data/lib/rgame/core/gamepad.rb +55 -0
  114. data/lib/rgame/core/image.rb +55 -0
  115. data/lib/rgame/core/input.rb +71 -0
  116. data/lib/rgame/core/locale.rb +22 -0
  117. data/lib/rgame/core/nine_slice.rb +142 -0
  118. data/lib/rgame/core/recording.rb +54 -0
  119. data/lib/rgame/core/renderer.rb +354 -0
  120. data/lib/rgame/core/sprite_sheet.rb +105 -0
  121. data/lib/rgame/core/tile_map_renderer.rb +172 -0
  122. data/lib/rgame/core/ui_atlas.rb +101 -0
  123. data/lib/rgame/core/virtual_gamepad.rb +26 -0
  124. data/lib/rgame/core.rb +18 -0
  125. data/lib/rgame/core_ext.so +0 -0
  126. data/lib/rgame/engine/actor_blockers.rb +131 -0
  127. data/lib/rgame/engine/animation_set.rb +50 -0
  128. data/lib/rgame/engine/animator.rb +44 -0
  129. data/lib/rgame/engine/audio_bus.rb +24 -0
  130. data/lib/rgame/engine/audio_director.rb +59 -0
  131. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  132. data/lib/rgame/engine/camera.rb +78 -0
  133. data/lib/rgame/engine/circle_collider.rb +34 -0
  134. data/lib/rgame/engine/collision_box.rb +59 -0
  135. data/lib/rgame/engine/collision_system.rb +132 -0
  136. data/lib/rgame/engine/component.rb +64 -0
  137. data/lib/rgame/engine/components/action_trigger.rb +40 -0
  138. data/lib/rgame/engine/components/animated_sprite.rb +71 -0
  139. data/lib/rgame/engine/components/box_collider.rb +99 -0
  140. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  141. data/lib/rgame/engine/components/character_body.rb +50 -0
  142. data/lib/rgame/engine/components/circle_collider.rb +80 -0
  143. data/lib/rgame/engine/components/collision_world.rb +231 -0
  144. data/lib/rgame/engine/components/despawn_offscreen.rb +42 -0
  145. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  146. data/lib/rgame/engine/components/hop.rb +76 -0
  147. data/lib/rgame/engine/components/identity.rb +73 -0
  148. data/lib/rgame/engine/components/mover.rb +285 -0
  149. data/lib/rgame/engine/components/navigator.rb +145 -0
  150. data/lib/rgame/engine/components/path_follow.rb +176 -0
  151. data/lib/rgame/engine/components/player_controller.rb +27 -0
  152. data/lib/rgame/engine/components/pool.rb +53 -0
  153. data/lib/rgame/engine/components/screen_wrap.rb +49 -0
  154. data/lib/rgame/engine/components/sprite.rb +47 -0
  155. data/lib/rgame/engine/components/targeting.rb +52 -0
  156. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  157. data/lib/rgame/engine/components/tile_world.rb +109 -0
  158. data/lib/rgame/engine/components/timer.rb +75 -0
  159. data/lib/rgame/engine/components/velocity.rb +43 -0
  160. data/lib/rgame/engine/components/wander_controller.rb +64 -0
  161. data/lib/rgame/engine/components/world.rb +133 -0
  162. data/lib/rgame/engine/contact_set.rb +74 -0
  163. data/lib/rgame/engine/culling.rb +45 -0
  164. data/lib/rgame/engine/debug_overlay.rb +111 -0
  165. data/lib/rgame/engine/i18n/plural.rb +45 -0
  166. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  167. data/lib/rgame/engine/i18n/template.rb +59 -0
  168. data/lib/rgame/engine/i18n.rb +322 -0
  169. data/lib/rgame/engine/input/action_mapper.rb +100 -0
  170. data/lib/rgame/engine/input/actions.rb +92 -0
  171. data/lib/rgame/engine/input/input_map.rb +196 -0
  172. data/lib/rgame/engine/layout.rb +82 -0
  173. data/lib/rgame/engine/nav_grid.rb +87 -0
  174. data/lib/rgame/engine/node2d.rb +555 -0
  175. data/lib/rgame/engine/path.rb +76 -0
  176. data/lib/rgame/engine/player.rb +69 -0
  177. data/lib/rgame/engine/player_layer.rb +70 -0
  178. data/lib/rgame/engine/players.rb +205 -0
  179. data/lib/rgame/engine/pool.rb +51 -0
  180. data/lib/rgame/engine/presentation.rb +171 -0
  181. data/lib/rgame/engine/scene/scene_stack.rb +87 -0
  182. data/lib/rgame/engine/sealed_privates.rb +54 -0
  183. data/lib/rgame/engine/signal.rb +75 -0
  184. data/lib/rgame/engine/spatial_hash.rb +116 -0
  185. data/lib/rgame/engine/text.rb +194 -0
  186. data/lib/rgame/engine/tile_blockers.rb +63 -0
  187. data/lib/rgame/engine/tile_map.rb +147 -0
  188. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  189. data/lib/rgame/engine/tileset.rb +99 -0
  190. data/lib/rgame/engine/timer.rb +51 -0
  191. data/lib/rgame/engine/ui/button.rb +248 -0
  192. data/lib/rgame/engine/ui/column.rb +20 -0
  193. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  194. data/lib/rgame/engine/ui/menu.rb +290 -0
  195. data/lib/rgame/engine/ui/navigation.rb +57 -0
  196. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  197. data/lib/rgame/engine/ui/option_button.rb +163 -0
  198. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  199. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  200. data/lib/rgame/engine/ui/pointing.rb +146 -0
  201. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  202. data/lib/rgame/engine/ui/ring.rb +55 -0
  203. data/lib/rgame/engine/ui/row.rb +21 -0
  204. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  205. data/lib/rgame/engine/ui/stack.rb +58 -0
  206. data/lib/rgame/engine/ui/stepping.rb +93 -0
  207. data/lib/rgame/engine/ui/text_button.rb +59 -0
  208. data/lib/rgame/engine/view.rb +76 -0
  209. data/lib/rgame/engine/viewports.rb +171 -0
  210. data/lib/rgame/engine/world_view.rb +71 -0
  211. data/lib/rgame/engine.rb +86 -0
  212. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  213. data/lib/rgame/fonts/OFL.txt +102 -0
  214. data/lib/rgame/game.rb +271 -0
  215. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  216. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  217. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  218. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  219. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  220. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  221. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  222. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  223. data/lib/rgame/rubocop/default.yml +39 -0
  224. data/lib/rgame/rubocop/plugin.rb +45 -0
  225. data/lib/rgame/rubocop.rb +11 -0
  226. data/lib/rgame/util/color.rb +23 -0
  227. data/lib/rgame/util/controls.rb +169 -0
  228. data/lib/rgame/util/route_search.rb +27 -0
  229. data/lib/rgame/util/save_file.rb +107 -0
  230. data/lib/rgame/util/solid_grid.rb +37 -0
  231. data/lib/rgame/util/tensor.rb +3 -0
  232. data/lib/rgame/util/tile_sweep.rb +36 -0
  233. data/lib/rgame/util/z.rb +123 -0
  234. data/lib/rgame/util.rb +10 -0
  235. data/lib/rgame/util_ext.so +0 -0
  236. data/lib/rgame/version.rb +12 -0
  237. data/lib/rgame.rb +5 -0
  238. data/licenses/SDL2/LICENSE.txt +18 -0
  239. metadata +309 -0
data/docs/api/ui.md ADDED
@@ -0,0 +1,927 @@
1
+ # UI
2
+
3
+ This page covers menus navigated by keyboard or controller, and the region of the
4
+ screen where one player's UI lives.
5
+
6
+ **The UI has no pointer.** `RGame::Core::Input` has no mouse, and the id range a
7
+ mouse would use stays empty. Nothing hovers. A mouse-driven control reacts to the
8
+ cursor being over it; a control here reacts to being the **focused** one. The rest
9
+ of the design follows from that.
10
+
11
+ ## A player's own screen
12
+
13
+ **`RGame::Engine::PlayerLayer` is the region.** It draws its subtree once, clipped
14
+ to that player's viewport and translated to its corner, and that player's
15
+ controller drives it:
16
+
17
+ ```ruby
18
+ layer = scene.add_node(RGame::Engine::PlayerLayer.new(player: game.players[1]))
19
+ layer.add_node(inventory)
20
+ ```
21
+
22
+ [Scene graph](scene_graph.md#a-players-own-screen) describes what it does, and when
23
+ it draws nothing.
24
+
25
+ ## `RGame::Engine::UI::Menu`
26
+
27
+ A `Menu` holds buttons to choose from, with one of them focused.
28
+
29
+ ```ruby
30
+ UI = RGame::Engine::UI
31
+
32
+ column = UI::Column.new(item_width: 220, item_height: 44)
33
+ menu = layer.add_node(UI::Menu.new(layout: column, scope: 'pause_menu'))
34
+ menu.add(UI::PanelButton.new(label: 'resume')).on_activated { close }
35
+ menu.add(UI::PanelButton.new(label: 'save')).on_activated { save }
36
+ menu.add(UI::PanelButton.new(label: 'quit', enabled: false))
37
+ ```
38
+
39
+ Each `label:` is a translation key, and `scope:` puts `pause_menu.` in front of it,
40
+ so the buttons draw `pause_menu.resume`, `pause_menu.save` and `pause_menu.quit`
41
+ from the loaded tables. See [Labels are translation keys](#labels-are-translation-keys).
42
+
43
+ **The menu holds buttons; it never builds them.** The button decides its own look.
44
+ Any [`UI::Button`](#rgameengineuibutton) subclass goes in through `add`, shipped or
45
+ written by the game, and one menu may mix them.
46
+
47
+ **A list and a radial wheel are the same class.** They differ in **where the
48
+ buttons sit** and **how input moves focus**. A menu is built with one answer to
49
+ each:
50
+
51
+ | | Answers | Shipped |
52
+ |---|---|---|
53
+ | `layout:` | where each button goes, its size, and the [bounds](#layouts-column-row-and-ring) of them all | [`Column`](#layouts-column-row-and-ring), [`Row`](#layouts-column-row-and-ring), [`Ring`](#layouts-column-row-and-ring) |
54
+ | `navigation:` | which button this frame's input focuses | [`Stepping`](#stepping) (the default), [`Pointing`](#pointing), or [`nil`](#a-menu-with-no-navigation) |
55
+
56
+ ```ruby
57
+ ring = UI::Ring.new(radius: 120, item_width: 96, item_height: 30)
58
+ wheel = layer.add_node(UI::Menu.new(x: 320, y: 240, layout: ring, navigation: UI::Pointing.new))
59
+ wheel.add(UI::PanelButton.new(label: 'sword')).on_activated { equip(:sword) }
60
+ wheel.add(UI::PanelButton.new(label: 'bow')).on_activated { equip(:bow) }
61
+ ```
62
+
63
+ The menu keeps everything that stays the same across combinations:
64
+
65
+ | | |
66
+ |---|---|
67
+ | `ui_confirm` | press the focused button on the way down, release it on the way up — see [When a press activates](#when-a-press-activates) |
68
+ | each button's `hotkey` | press that button, focused or not — see [Hotkeys](#hotkeys) |
69
+ | `add(button)` | append a button, re-arrange them all, and return it; `TypeError` for anything that is not a `UI::Button` |
70
+ | `buttons`, `focused`, `focused_index` | what it holds and what is focused — `nil` when nothing is |
71
+ | `focus(index)` | focus a button directly, or nothing with `nil`; only buttons whose focus changes are told |
72
+ | `layout`, `navigation` | the two parts it was built with |
73
+ | `scope:`, `scope` | a scope for its buttons' label keys, or `nil` — see [Labels are translation keys](#labels-are-translation-keys) |
74
+ | `open?`, `open`, `close` | whether it is shown and takes input — see [Open and closed](#open-and-closed) |
75
+ | `trigger:`, `trigger` | an action that holds the menu open — see [A menu held open by an action](#a-menu-held-open-by-an-action) |
76
+ | `on_opened`, `on_closed` | signals; `on_closed` passes the button a trigger's release activated, or `nil` |
77
+ | `bounds_x`, `bounds_y`, `bounds_width`, `bounds_height` | the rectangle enclosing every button, relative to the menu, as its layout reports it — all zero while empty |
78
+
79
+ The actions come from the [universal set](input.md#the-universal-ui-set) that every
80
+ `InputMap` merges over, so a menu works without the game declaring anything.
81
+
82
+ **The menu confirms, not the navigation.** A navigation only decides which button
83
+ is focused. A new navigation therefore cannot forget to activate, and every
84
+ combination confirms the same way. Each frame the menu runs its navigation, then
85
+ every hotkey, then confirm. A menu with a trigger handles the trigger's press first
86
+ and its release last.
87
+
88
+ ### Labels are translation keys
89
+
90
+ **A button's `label:` is a key**, looked up in the tables `RGame::Game` loads. The
91
+ button holds it as an [`Engine::Text`](toolbox.md#text--the-string-a-node-draws)
92
+ and passes it to `text` as it is. A switch of `I18n.locale` therefore redraws every label on
93
+ the next frame, with no button rebuilt, and an unchanged label allocates nothing.
94
+
95
+ ```ruby
96
+ require 'rgame'
97
+
98
+ UI = RGame::Engine::UI
99
+ i18n = RGame::Engine::I18n
100
+ i18n.load_hash(en: { title_menu: { play: 'Play' }, common: { quit: 'Quit' } },
101
+ de: { title_menu: { play: 'Spielen' }, common: { quit: 'Beenden' } })
102
+
103
+ menu = UI::Menu.new(layout: UI::Column.new(item_width: 200, item_height: 40), scope: 'title_menu')
104
+ play = menu.add(UI::TextButton.new(label: 'play'))
105
+ quit = menu.add(UI::TextButton.new(label: RGame::Engine::Text.new('quit', scope: 'common')))
106
+ name = menu.add(UI::TextButton.new(label: RGame::Engine::Text.literal('Ada')))
107
+
108
+ play.label.to_s # => "Play" — the key title_menu.play
109
+ quit.label.to_s # => "Quit" — its own scope
110
+ i18n.locale = :de
111
+ play.label.to_s # => "Spielen"
112
+ name.label.to_s # => "Ada" — a literal, in every locale
113
+ ```
114
+
115
+ What a button does with `label:`:
116
+
117
+ | Given | Holds |
118
+ |---|---|
119
+ | a String or Symbol | a `Text` for that key, under the button's `label_scope` |
120
+ | a `Text` | that `Text`, scope and all |
121
+ | `Text.literal(string)` | `string`, never translated — a player's name, a number |
122
+ | `nil` | nothing; `IconButton` then draws no caption |
123
+
124
+ **A label with variables shows the values its last `with` was given.** The button
125
+ draws the label without knowing the values. The node that owns them sets them in
126
+ `update`, and the label follows on the next draw:
127
+
128
+ ```ruby
129
+ require 'rgame'
130
+
131
+ RGame::Engine::I18n.load_hash(en: { continue: 'Continue (%{saves} saves)' })
132
+
133
+ saves = RGame::Engine::Text.new('continue', :saves)
134
+ button = RGame::Engine::UI::PanelButton.new(label: saves)
135
+
136
+ saves.with(saves: 3) # in the owner's update, whenever the count may change
137
+ button.label.to_s # => "Continue (3 saves)"
138
+ saves.with(saves: 4)
139
+ button.label.to_s # => "Continue (4 saves)"
140
+ ```
141
+
142
+ A `with` whose values are unchanged renders nothing and allocates nothing, so
143
+ calling it every `update` costs nothing. A label given no `with` yet raises
144
+ `ArgumentError` on its first draw, naming the keywords it needs.
145
+
146
+ **`scope:` on a menu reaches only keys.** The menu sets each button's
147
+ `label_scope` as the button is added, unless the button already has one. A button
148
+ applies it to a label it built from a key, including one assigned later with
149
+ `label=`. A label given as a `Text` is never re-scoped, so one `Text` shared by two
150
+ menus reads the same in both. The scope reaches the menu's own buttons, not nodes
151
+ deeper in the tree.
152
+
153
+ With no table loaded, a key shows as itself under the default missing policy, so
154
+ `label: 'Play'` draws the word Play in a game with no locale files. A spec suite
155
+ that sets `I18n.missing = :raise` fails on it instead. See
156
+ [Missing keys](localization.md#missing-keys).
157
+
158
+ ### Layouts: `Column`, `Row` and `Ring`
159
+
160
+ | | Places buttons | `axis` | Built with |
161
+ |---|---|---|---|
162
+ | `Column` | downwards from the menu's origin | `:vertical` | `item_width:`, `item_height:`, `spacing: 8` |
163
+ | `Row` | rightwards from the menu's origin | `:horizontal` | `item_width:`, `item_height:`, `spacing: 8` |
164
+ | `Ring` | round a circle **centred on** the menu's origin, the first straight up, then clockwise | `:vertical` | `radius:`, `item_width:`, `item_height:` |
165
+
166
+ ```ruby
167
+ bar = layer.add_node(UI::Menu.new(layout: UI::Row.new(item_width: 64, item_height: 64)))
168
+ ```
169
+
170
+ **`Column` and `Row` are one `UI::Stack` with a fixed `axis:`.** They compute the
171
+ same thing with x and y swapped. Neither accepts `axis:`, since a column that is
172
+ not vertical is a row. Passing one raises an unknown-keyword error instead of being
173
+ ignored. `Stack.new(axis:, item_width:, item_height:, spacing: 8)` takes either
174
+ axis in `Stack::AXES`, and raises `ArgumentError` for anything else.
175
+
176
+ A layout is any object that answers three methods. The first two work relative to
177
+ the menu:
178
+
179
+ - `arrange(buttons)` sets each button's `x`, `y`, `width` and `height`.
180
+ - `bounds(buttons)` returns `[x, y, width, height]`, the rectangle enclosing the
181
+ buttons, or `[0, 0, 0, 0]` for none.
182
+ - `axis` returns `:vertical` or `:horizontal`. [`Stepping`](#stepping) moves focus
183
+ along it unless told otherwise. A game's own layout without `axis` raises
184
+ `NoMethodError` when a menu is built with it and the default navigation. It works
185
+ with an explicit `Stepping.new(axis:)` or any other navigation.
186
+
187
+ The menu calls `arrange` and `bounds` after every `add`, so a ring re-spaces itself
188
+ as it grows. The menu copies the bounds into its own readers, so a backdrop drawn
189
+ from them allocates nothing. A layout keeps no state about a menu, so one instance
190
+ may serve several menus.
191
+
192
+ `Column` and `Row` bounds match their slots exactly. `Ring` bounds are the square
193
+ around the whole circle of slots: `2 * radius + item_width` wide and
194
+ `2 * radius + item_height` tall, whatever the count. A backdrop behind a wheel
195
+ therefore keeps its size as buttons are added.
196
+
197
+ ### `Stepping`
198
+
199
+ **`Stepping`, the default, moves focus one button at a time** along an axis, in
200
+ the order the buttons were added.
201
+
202
+ | | Vertical axis | Horizontal axis |
203
+ |---|---|---|
204
+ | move focus, skipping disabled buttons, wrapping at the ends | `ui_up` / `ui_down` | `ui_left` / `ui_right` |
205
+ | `adjust` the focused button | `ui_left` / `ui_right` | `ui_up` / `ui_down` |
206
+
207
+ | | |
208
+ |---|---|
209
+ | `Stepping.new(axis: nil)` | `nil` takes the layout's `axis`; `:vertical` or `:horizontal` overrides it |
210
+ | `axis` | the axis in use — resolved when the menu is built |
211
+ | `step(delta)` | move focus forwards along the axis, `delta` times |
212
+
213
+ **The axis follows the layout.** `Menu.new(layout: UI::Row.new(...))` steps with
214
+ left and right, with nothing else said. If the axis had to be set separately,
215
+ someone would eventually forget, and a row would step with up and down. An axis
216
+ outside `Stack::AXES` raises `ArgumentError` when the menu is built.
217
+
218
+ Focus starts on the first enabled button. It is never empty while the menu has one.
219
+
220
+ **The axis moves focus; the other pair goes to the focused button.** `Stepping`
221
+ does not know what kind of button it addresses. It calls `adjust`, and a plain
222
+ button returns `nil`. That makes an `OptionButton` work, and is why a settings menu
223
+ uses `Stepping`.
224
+
225
+ ### `Pointing`
226
+
227
+ **With `Pointing`, focus is the button a stick points at.** On a `Ring`, that makes
228
+ a radial menu.
229
+
230
+ | | |
231
+ |---|---|
232
+ | `ui_radial_x` / `ui_radial_y` | the direction; focuses the button nearest to it by angle |
233
+ | `dead_zone` | the shortest deflection that selects, on the combined vector (default 0.5) |
234
+ | `grace` | how long focus survives the stick entering the dead zone, in seconds — see below |
235
+ | `index_at(x, y)` | the index a vector points at, or `nil` inside the dead zone |
236
+ | `aim_x`, `aim_y` | the last direction read, for a game drawing a pointer |
237
+
238
+ **The direction is the selection.** There is no "next". Seen from the menu's
239
+ origin, each button's centre is a direction, and the one closest to the stick's
240
+ direction takes focus. On a ring, that cuts the circle into one sector per button,
241
+ centred on it. The angles come from where the buttons sit, so no layout
242
+ can disagree. Eight buttons on a ring match the eight directions of the arrow keys,
243
+ so a keyboard works too.
244
+
245
+ **Inside the dead zone, nothing is focused**, and confirm activates nothing. A
246
+ released stick springs back through the middle. A wheel that kept its last
247
+ selection would hand a player who lets go and presses A whatever the stick passed
248
+ on its way back.
249
+
250
+ This dead zone applies to the combined vector, *after* `ActionMapper` removes its
251
+ own per-axis dead zone (0.15) and rescales the rest. The two do different jobs. The
252
+ per-axis one stops a worn stick from drifting. It is far too small to decide that a
253
+ player means a direction.
254
+
255
+ **A grace window delays clearing, for a wheel chosen by letting go.** `grace:` keeps
256
+ focus for that many seconds after the stick enters the dead zone, counted in
257
+ `update(dt)`, then clears it. By default it is `Pointing::GRACE` (0.15 s) on a menu
258
+ with a [trigger](#a-menu-held-open-by-an-action), and 0 on any other. An
259
+ always-open wheel therefore behaves as described above, and a held wheel gets the
260
+ window automatically. The stick leaving the dead zone starts a fresh window.
261
+ Pointing at a disabled button still clears focus at once.
262
+
263
+ **A disabled button is never focused**, so pointing at one selects nothing. Left
264
+ and right are directions here, so `Pointing` cannot adjust an `OptionButton`.
265
+
266
+ A plain `Menu` draws no wheel. [`RadialMenu`](#rgameengineuiradialmenu) draws the
267
+ backdrop, the dead zone and a pointer from `aim_x` / `aim_y`. A game's own wheel
268
+ reads the same two.
269
+
270
+ ### A navigation of your own
271
+
272
+ Subclass `RGame::Engine::UI::Navigation` and override its hooks:
273
+
274
+ ```ruby
275
+ class FirstEnabled < RGame::Engine::UI::Navigation
276
+ def on_control(_actions) = menu.focus(menu.buttons.index(&:enabled?))
277
+ end
278
+ ```
279
+
280
+ | Hook | Called |
281
+ |---|---|
282
+ | `on_control(actions)` | every frame the menu is open, before the menu handles `ui_confirm` |
283
+ | `on_buttons_changed` | after a button is added |
284
+ | `update(dt)` | every update while the menu is not paused — where a navigation counts time |
285
+ | `on_opened` | when the menu opens; `Pointing` forgets its aim and focus here |
286
+
287
+ `menu` returns the menu it drives. **A navigation drives exactly one menu**, because
288
+ `Pointing` keeps the last direction it read. Passing one instance to a second menu
289
+ raises `ArgumentError` instead of letting two menus share a pointer. The
290
+ constructor default builds a fresh `Stepping` for every menu.
291
+
292
+ ### A menu with no navigation
293
+
294
+ `navigation: nil` means input never moves focus:
295
+
296
+ ```ruby
297
+ bar = layer.add_node(UI::Menu.new(layout: UI::Row.new(item_width: 48, item_height: 48), navigation: nil))
298
+ bar.add(UI::IconButton.new(image: :potion, hotkey: :skill1)).on_activated { drink }
299
+ ```
300
+
301
+ Adding a button focuses nothing, and nothing focuses a button later. `ui_confirm`
302
+ has nothing to act on, and [hotkeys](#hotkeys) are the only way in: the action bar
303
+ of a game played on hotkeys alone. Omitting the keyword gives a `Stepping`, so `nil`
304
+ is always a deliberate choice, never a forgotten argument. A game can still call
305
+ `focus` on such a menu, and confirm then acts on that button.
306
+
307
+ ### Open and closed
308
+
309
+ **A closed menu draws nothing**, neither its backdrop nor its buttons, and its
310
+ navigation, hotkeys and confirm do nothing. A menu without a trigger starts open. A
311
+ pause menu is built once and toggled:
312
+
313
+ ```ruby
314
+ class PauseMenu < RGame::Engine::Node2D
315
+ UI = RGame::Engine::UI
316
+
317
+ def on_add
318
+ @menu = add_node(UI::PanelMenu.new(x: 56, y: 56, layout: UI::Column.new(item_width: 180, item_height: 34)))
319
+ @menu.add(UI::PanelButton.new(label: 'resume')).on_activated { @menu.close }
320
+ @menu.close
321
+ end
322
+
323
+ def on_control(actions)
324
+ return unless actions.pressed?(:ui_cancel)
325
+
326
+ @menu.open? ? @menu.close : @menu.open
327
+ end
328
+ end
329
+ ```
330
+
331
+ `open` and `close` do nothing when the menu is already in that state. Each emits
332
+ `on_opened` or `on_closed` (with `nil`) only on a change. Opening calls the
333
+ navigation's `on_opened`. Under `Stepping`, focus stays where it was.
334
+
335
+ **Closed is not paused.** A closed menu still receives `control` and `update`. A
336
+ trigger can therefore reopen it, and a button's pressed feedback runs out while the
337
+ menu is shut instead of showing when it reappears. Pausing a menu's node still stops
338
+ everything, as for any node. The game decides what else happens while a menu is
339
+ open, such as pausing the hero or dimming the world. It acts from the two signals,
340
+ or wherever it calls `open`.
341
+
342
+ ### A menu held open by an action
343
+
344
+ A quick menu: hold a button to open a wheel, point, and let go to choose.
345
+
346
+ ```ruby
347
+ UI = RGame::Engine::UI
348
+
349
+ input_map = RGame::Engine::InputMap.default.merge(
350
+ quick_menu: { buttons: [RGame::Util::Controls::KEY_TAB, RGame::Util::Controls::PAD_LEFT_SHOULDER] }
351
+ )
352
+
353
+ wheel = layer.add_node(UI::RadialMenu.new(x: 320, y: 240, radius: 150, button_width: 64, trigger: :quick_menu))
354
+ disc = UI::ShapeStyle.new(shape: :disc)
355
+ wheel.add(UI::IconButton.new(image: :home, style: disc)).on_activated { go_home }
356
+ wheel.on_opened { world.time_scale = 0.25 }
357
+ wheel.on_closed { |_chosen| world.time_scale = 1.0 }
358
+ ```
359
+
360
+ `trigger:` names an action. With a trigger, the menu behaves like this:
361
+
362
+ | When | The menu |
363
+ |---|---|
364
+ | built | is closed |
365
+ | the trigger goes down | opens, and the navigation forgets the last opening |
366
+ | it is held | moves focus as its navigation says; hotkeys work |
367
+ | it comes up | activates the focused button, if any, and closes; `on_closed` passes that button, or `nil` |
368
+ | `ui_confirm` is pressed | nothing — letting go is the only way to choose |
369
+
370
+ **A release with nothing focused chooses nothing**, not the last button the stick
371
+ passed. A player changes their mind by centring the stick and letting go. A stick
372
+ returns to the middle a frame or two before a shoulder button comes up. So on a
373
+ menu with a trigger, `Pointing` keeps focus for its [grace window](#pointing) first.
374
+ Without it, releasing both at once would nearly always choose nothing.
375
+
376
+ **Only a press the menu saw start opens it**, as with every other press. A trigger
377
+ already down when the menu appears opens nothing until released and pressed again.
378
+ A trigger that comes up while the menu is paused closes it without choosing.
379
+
380
+ **`open` raises on a menu with a trigger.** A menu opened by hand would wait for the
381
+ release of a press it never saw. `close` works. It cancels for a player who is hit
382
+ while holding the wheel: the release that follows neither chooses nor reopens.
383
+
384
+ The trigger works under every navigation. With `navigation: nil`, the release
385
+ activates whatever the game focused, typically in `on_opened`.
386
+
387
+ The release activates the button with `activate`, without pressed feedback. The menu
388
+ closes on that frame, so nothing would show the feedback.
389
+
390
+ One limit remains. A stick that overshoots the middle as it springs back can point
391
+ at the opposite button for a frame or two. The grace window only delays the dead
392
+ zone and does not cover that.
393
+
394
+ ### Focus is per player, and it costs nothing
395
+
396
+ **Two players can each have a menu open, independently, and neither menu mentions
397
+ players.** A menu inside a `PlayerLayer` inherits that player as its
398
+ `input_owner`, and children inherit ownership. The `actions` its `on_control`
399
+ receives already belong to that player.
400
+
401
+ The menu does nothing special for this.
402
+ [Ownership routing](scene_graph.md#who-a-node-answers-to) does the work one layer
403
+ down.
404
+
405
+ ### `RGame::Engine::UI::PanelMenu`
406
+
407
+ **A `Menu` that draws its own backdrop**: one nine-slice around its buttons,
408
+ extended by `padding` on every side.
409
+
410
+ ```ruby
411
+ UI = RGame::Engine::UI
412
+
413
+ column = UI::Column.new(item_width: 180, item_height: 34)
414
+ menu = layer.add_node(UI::PanelMenu.new(x: 56, y: 56, layout: column))
415
+ menu.add(UI::PanelButton.new(label: 'resume')).on_activated { close }
416
+ menu.add(UI::PanelButton.new(label: 'quit')).on_activated { quit }
417
+ ```
418
+
419
+ | | |
420
+ |---|---|
421
+ | `panel:` | the nine-slice id to draw (default `:panel`) |
422
+ | `padding:` | how far the panel reaches beyond the bounds on each side (default 16) |
423
+
424
+ **The menu's bounds size the panel**, and its layout recomputes them on every
425
+ `add`. A button added later grows the panel, and nothing has to track the button
426
+ count.
427
+
428
+ **The menu's origin remains the layout's.** With a `Column`, that is the first
429
+ button's top-left corner, and the panel starts `padding` above and to the left of
430
+ it. To put a panel's corner at (40, 40), place the menu at (40 + padding,
431
+ 40 + padding). The buttons are the menu's children, so they draw over the panel
432
+ with no `z`.
433
+
434
+ Any other backdrop works the same way: subclass `Menu` and draw it in `on_draw` from
435
+ `bounds_x`, `bounds_y`, `bounds_width` and `bounds_height`.
436
+
437
+ ### `RGame::Engine::UI::RadialMenu`
438
+
439
+ **A wheel**: a `Menu` that builds its own `Ring` and `Pointing`. It draws a backdrop
440
+ disc, the dead zone to scale, and a pointer from the centre towards where the stick
441
+ aims.
442
+
443
+ ```ruby
444
+ UI = RGame::Engine::UI
445
+
446
+ game.renderer.register_ui_atlas(game.assets.ui_atlas('icons.json'))
447
+
448
+ wheel = layer.add_node(UI::RadialMenu.new(x: 320, y: 240, radius: 150, button_width: 64))
449
+ disc = UI::ShapeStyle.new(shape: :disc)
450
+ wheel.add(UI::IconButton.new(image: :home, style: disc)).on_activated { go_home }
451
+ wheel.add(UI::IconButton.new(image: :save, style: disc)).on_activated { save }
452
+ ```
453
+
454
+ | | |
455
+ |---|---|
456
+ | `radius:` | from the centre to each button's middle, as `Ring`'s |
457
+ | `button_width:`, `button_height:` | the slot size; `button_height` defaults to `button_width` |
458
+ | `dead_zone:` | as `Pointing`'s (default `Pointing::DEAD_ZONE`, 0.5) |
459
+ | `grace:` | as `Pointing`'s: `Pointing::GRACE` with a `trigger:`, 0 without, unless given |
460
+ | `padding:` | how far the backdrop reaches beyond the bounds (default 16) |
461
+ | `backdrop:`, `dead_zone_color:`, `pointer:` | a colour for each part, `RadialMenu::BACKDROP`, `DEAD_ZONE` and `POINTER` by default; `nil` omits that part |
462
+
463
+ **The menu's origin is the centre of the wheel.** The backdrop's radius is half the
464
+ larger side of the bounds, plus `padding`. `Ring` bounds cover the whole circle, so
465
+ the radius is the same with one button as with eight. The dead zone is drawn at
466
+ `dead_zone * radius`, exactly where a pointer tip inside it selects nothing. **The
467
+ pointer's tip is clamped to the ring.** Two arrow keys read as (1, 1), longer than a
468
+ stick can reach, and would otherwise poke past it. All three parts draw before the
469
+ buttons, which are the menu's children.
470
+
471
+ Every other keyword goes to `Menu`, `trigger:` included. A `RadialMenu` with a
472
+ trigger is the [held wheel](#a-menu-held-open-by-an-action).
473
+
474
+ **`layout:` and `navigation:` raise `ArgumentError`.** Forwarding them would let
475
+ either replace the ring or the pointing the wheel is made of, without warning. For a
476
+ ring stepped with `Stepping`, build a plain `Menu` with a `Ring`.
477
+
478
+ Anything drawn in the middle of the wheel, such as the chosen item, belongs to a node
479
+ added *after* the menu. Between nodes, the tree decides what lands on top, so
480
+ whatever the wheel's parent draws itself sits under the backdrop.
481
+ `backdrop_radius` returns the disc's radius, for a game sizing something to it.
482
+
483
+ ### `RGame::Engine::UI::Button`
484
+
485
+ **A `Button` has state, a label and an `on_activated` signal, but no look.** A
486
+ subclass supplies the look in `on_draw`, reading `state`:
487
+
488
+ | `state` | When |
489
+ |---|---|
490
+ | `:disabled` | `enabled: false`, whatever else is true |
491
+ | `:pressed` | a press is held on it, or its pressed feedback is still running |
492
+ | `:focused` | the menu's navigation focused it |
493
+ | `:idle` | none of those |
494
+
495
+ | | |
496
+ |---|---|
497
+ | `label`, `label=` | the `Engine::Text` drawn, or `nil`; set from a key, a `Text` or `nil` — see [Labels are translation keys](#labels-are-translation-keys) |
498
+ | `label_scope`, `label_scope=` | the scope a label given as a key resolves under; a [menu's `scope:`](#labels-are-translation-keys) sets it |
499
+ | `enabled`, `enabled?` | whether it can be activated; `Stepping` and `Pointing` skip a disabled button |
500
+ | `focused?`, `pressed?`, `state` | read by `on_draw` |
501
+ | `activate_on` | `:release` (the default) or `:press`; anything else raises `ArgumentError` |
502
+ | `hotkey` | an action name that presses this button, focused or not, or `nil` — see [Hotkeys](#hotkeys) |
503
+ | `activate` | fire `on_activated` and return the button, or `nil` when disabled |
504
+ | `activate_with_feedback` | `activate`, and draw pressed for `PRESS_FEEDBACK` — the instant press, needing nothing held |
505
+ | `adjust(delta)` | what horizontal input does to it under `Stepping`; `nil` — nothing to change |
506
+ | `on_focus_changed(focused)` | hook, called only when focus changes |
507
+
508
+ `focused=`, `press(source)`, `release(source)` and `cancel_press(source)` form the
509
+ menu's side of the interface. `source` is `:confirm` (the default) or `:hotkey`.
510
+ Focus movement skips a disabled button, and no route can activate one, so a caller
511
+ never checks first.
512
+
513
+ #### A button of your own
514
+
515
+ **Subclass `Button` and draw.** The class inherits focus, pressing, activation and
516
+ placement, so it only defines its look:
517
+
518
+ ```ruby
519
+ class EdgeButton < RGame::Engine::UI::Button
520
+ COLORS = {
521
+ idle: RGame::Util::Color.new(200, 200, 200), focused: RGame::Util::Color.new(255, 255, 255),
522
+ pressed: RGame::Util::Color.new(255, 220, 120), disabled: RGame::Util::Color.new(110, 110, 110)
523
+ }.freeze
524
+
525
+ def on_draw(renderer, _view)
526
+ color = COLORS.fetch(state)
527
+ renderer.rect(0, 0, 4, height, color: color) unless state == :idle
528
+ renderer.text(label, 12, (height - renderer.text_height) / 2, color: color)
529
+ end
530
+ end
531
+
532
+ menu.add(EdgeButton.new(label: 'continue')).on_activated { resume }
533
+ ```
534
+
535
+ A `Button` without `on_draw` draws nothing, which suits an invisible slot.
536
+
537
+ A look that differs from a shipped button only in what sits behind the label needs
538
+ no subclass. Pass a [style](#styles) to a `TextButton`.
539
+
540
+ Three other designs would not work:
541
+
542
+ - **A `look:` on the menu**, applied to every button in it. One argument would
543
+ restyle a whole menu. But the menu could never mix an icon button with a text
544
+ button. The look would have to draw every kind of button it met. A game's own
545
+ button would need a look *and* a menu subclass.
546
+ - **A factory on the menu**, `add_item(label, class:)`. Every button class takes
547
+ different arguments: an image, a list of values, a style. The factory would have
548
+ to grow all of them, or forward them blindly and report a typo from a class the
549
+ caller never named.
550
+ - **A button as a component** on a plain node. A button has a position, a size and
551
+ children that draw over it, and it is a child of the menu. It is a node. As a
552
+ component, the menu would have to look it up on a sibling.
553
+
554
+ #### When a press activates
555
+
556
+ `ui_confirm` reaches the focused button as a press and a release. `activate_on:`
557
+ decides which one activates:
558
+
559
+ | | `activate_on: :release` (default) | `activate_on: :press` |
560
+ |---|---|---|
561
+ | activates | when confirm is let go, if focus did not move while held | as confirm goes down |
562
+ | drawn pressed | while held | at least `Button::PRESS_FEEDBACK` (0.1 s), or while held if longer |
563
+
564
+ **A settings menu keeps the default.** Holding confirm shows the press, and moving
565
+ away before letting go cancels it. `:press` suits buttons that answer instantly,
566
+ such as a skill bar. The feedback counts down in `update(dt)`, so a paused button
567
+ keeps it, and a spec advances it by passing seconds.
568
+
569
+ **A button acts only on a press it saw start.** Two rules ensure that:
570
+
571
+ - **A menu accepts no press until it has seen `ui_confirm` up.** A submenu added
572
+ from `on_activated` is controlled later in the same tick, while the key that opened
573
+ it is still down. Without this rule, the submenu would read that press again and
574
+ activate its own focused button.
575
+ - **A press whose release the button never saw is dropped**, feedback included, and
576
+ activates nothing. A menu that closes itself from `on_activated` stops being
577
+ controlled, so it never sees the key come up. Next time it is controlled, it finds
578
+ the key up with no release edge and lets go. It does not reopen pressed.
579
+
580
+ Neither rule sees a pause that starts *after* the release. A `:press` button closed
581
+ within `PRESS_FEEDBACK` of release keeps the rest of its feedback, and shows it when
582
+ reopened. A menu covered by a pushed scene is not controlled either, so it keeps
583
+ drawing the state it had when covered.
584
+
585
+ #### Hotkeys
586
+
587
+ **`hotkey:` names an action that presses the button, wherever focus is:**
588
+
589
+ ```ruby
590
+ controls = RGame::Util::Controls
591
+ input_map = RGame::Engine::InputMap.default.merge(
592
+ skill1: { buttons: [controls::KEY_1] },
593
+ skill2: { buttons: [controls::KEY_2] }
594
+ )
595
+
596
+ bar.add(UI::IconButton.new(image: :torch, label: 'torch', hotkey: :skill1)).on_activated { light }
597
+ bar.add(UI::IconButton.new(image: :hammer, label: 'hammer', hotkey: :skill2)).on_activated { build }
598
+ ```
599
+
600
+ | | |
601
+ |---|---|
602
+ | activates | as the key goes down, **whatever `activate_on:` says** |
603
+ | drawn pressed | at least `PRESS_FEEDBACK`, or while held if longer — focused or not |
604
+ | its release | activates nothing |
605
+ | focus | does not move |
606
+
607
+ A hotkey is a second way to press a button, not a way to move to it. A button is
608
+ pressed by being focused while confirm goes down, or by its own key. Hotkeys follow
609
+ the same rules as confirm. A menu accepts a hotkey's press only after seeing that key
610
+ up since the button was added, so a menu opened by a hotkey does not fire from the
611
+ same press. A press whose release was never seen is dropped. A disabled button
612
+ ignores its hotkey. Hotkeys work under every navigation, and under none.
613
+
614
+ The player's `InputMap` must declare the action. The menu reads it every frame, so
615
+ an undeclared action raises `KeyError`, naming it, on the first frame.
616
+
617
+ **A press belongs to the source that started it.** The button records whether
618
+ confirm or its hotkey holds it. While one does, a press from the other is ignored,
619
+ and that source's release ends nothing:
620
+
621
+ - Confirm held on a `:release` button, then its hotkey pressed and released: nothing
622
+ activates, and the button stays pressed. Letting go of confirm activates it, once.
623
+ - A hotkey and confirm going down on the same tick on the focused button: it
624
+ activates once.
625
+ - Focus moving away ends a confirm hold, as always, and leaves a hotkey hold alone.
626
+
627
+ ### Styles
628
+
629
+ **A style draws what sits behind a button's content, per state.** It is any object
630
+ answering one method in the button's local space, and optionally a second:
631
+
632
+ ```ruby
633
+ style.draw(renderer, state, width, height)
634
+ style.content_color(state) # optional: the colour content takes on this state's fill, or nil
635
+ ```
636
+
637
+ The button holds its style and calls it before drawing its own content; the menu
638
+ never sees it. **A style draws at `z: 0` or below.** The button draws its label or
639
+ icon at `z: 1`, and shapes default to `z: 50`, so a style without an explicit `z`
640
+ would cover the content.
641
+
642
+ **The style decides what reads on its fill.** A style that answers
643
+ `content_color(state)` sets the colour of the button's label or icon in every state
644
+ where it returns a colour. `TextButton`, `OptionButton` and `IconButton`'s picture
645
+ all follow it. They fall back to their own `label_color:` or `tints:` wherever it
646
+ returns `nil`, and for a style without the method. The style picks the fill, so only
647
+ the style can pick what shows on it. A `ShapeStyle`'s pressed fill is the same gold
648
+ as `IconButton`'s pressed tint. Without `content_color`, a pressed icon on a disc
649
+ would vanish. Two styles ship.
650
+
651
+ `UI::NineSliceStyle` stretches one element of a UI atlas over the slot:
652
+
653
+ ```ruby
654
+ style = RGame::Engine::UI::NineSliceStyle.new(idle: :plank, focused: :plank_lit,
655
+ pressed: :plank_down, disabled: :plank_grey)
656
+ style.with(focused: :plank_glow) # a copy with one element replaced
657
+ ```
658
+
659
+ | | |
660
+ |---|---|
661
+ | `idle:`, `focused:`, `pressed:`, `disabled:` | the element drawn in each state; all four required |
662
+ | `elements` | the four, as a Hash keyed by state |
663
+ | `with(**changes)` | a copy with some elements replaced |
664
+ | `content_color(state)` | always `nil`: the art is the game's, so the button's own colours are chosen for it — `PanelButton`'s dark label for the shipped atlas |
665
+
666
+ `UI::ShapeStyle` draws a rectangle or a disc, and needs nothing registered:
667
+
668
+ ```ruby
669
+ UI = RGame::Engine::UI
670
+
671
+ round = UI::ShapeStyle.new(shape: :disc)
672
+ flat = UI::ShapeStyle.new(colors: UI::ShapeStyle::COLORS.merge(idle: nil), outline: nil)
673
+ ```
674
+
675
+ | | |
676
+ |---|---|
677
+ | `shape:` | `:rect` (default) or `:disc`; anything else raises `ArgumentError` |
678
+ | `colors:` | the fill per state, a `Color` or `[r, g, b]`; `nil` draws no fill in that state; a state missing raises `KeyError` |
679
+ | `outline:` | drawn under the fill while focused or pressed; `nil` for none |
680
+ | `border:` | how far the fill is inset (default 3) |
681
+ | `content:` | the colour content takes over each state's fill, or `nil` for the button's own (default `ShapeStyle::CONTENT`: dark `(46, 34, 24)` while pressed, `nil` otherwise); a state missing raises `KeyError` |
682
+
683
+ **The fill is inset by `border` in every state**, and the outline covers the whole
684
+ shape beneath it. A button therefore keeps its size across states; focus uncovers
685
+ the ring the fill leaves. A disc is centred in the slot, as wide as the slot's
686
+ shorter side. `UI::ShapeStyle::DEFAULT` uses every default, and a `TextButton` draws
687
+ it unless told otherwise.
688
+
689
+ Both styles check every state when built, not on the first frame a button reaches
690
+ it. They also coerce their colours then, so drawing allocates nothing.
691
+
692
+ #### A style of your own
693
+
694
+ ```ruby
695
+ class Underline
696
+ LIT = RGame::Util::Color.new(255, 255, 255)
697
+ DIM = RGame::Util::Color.new(90, 90, 90)
698
+
699
+ def draw(renderer, state, width, height)
700
+ return if state == :idle
701
+
702
+ renderer.rect(0, height - 2, width, 2, z: 0, color: state == :disabled ? DIM : LIT)
703
+ end
704
+ end
705
+
706
+ menu.add(RGame::Engine::UI::TextButton.new(label: 'continue', style: Underline.new))
707
+ ```
708
+
709
+ The colours are `Color`s built once. An `[r, g, b]` literal would make the renderer
710
+ build a new `Color` on every draw. `Underline` has no `content_color`, so the label
711
+ keeps the button's own colour in every state. A style that fills behind the label
712
+ should say what reads on that fill.
713
+
714
+ ### `RGame::Engine::UI::TextButton`
715
+
716
+ **A label centred on a style.** It needs no art, so build menus with it before the
717
+ art exists:
718
+
719
+ ```ruby
720
+ UI = RGame::Engine::UI
721
+
722
+ menu.add(UI::TextButton.new(label: 'play')).on_activated { start }
723
+ menu.add(UI::TextButton.new(label: 'credits', style: UI::ShapeStyle.new(shape: :disc)))
724
+ menu.add(UI::TextButton.new(label: 'quit', style: nil))
725
+ ```
726
+
727
+ | | |
728
+ |---|---|
729
+ | `label:` | required, because it is drawn: a key or a `Text` |
730
+ | `style:` | a [style](#styles); `UI::ShapeStyle::DEFAULT` unless given, `nil` for the label alone |
731
+ | `label_color:`, `disabled_label_color:` | a `Color` or `[r, g, b]`; defaults `TextButton::LABEL_COLOR` and `DISABLED_LABEL_COLOR`; a style's [content colour](#styles) takes precedence in the states it names |
732
+
733
+ The style draws first, and the label over it at `z: 1`. A subclass that draws more
734
+ than a label overrides the private `draw_foreground(renderer)`, not `on_draw`. It
735
+ then keeps its style without having to draw it. `OptionButton` works this way.
736
+
737
+ ### `RGame::Engine::UI::PanelButton`
738
+
739
+ **A `TextButton` with a nine-slice style**: the shipped atlas's button, with a dark
740
+ label. Its style is `PanelButton::STYLE`, which uses one atlas element per state:
741
+
742
+ | `state` | Element |
743
+ |---|---|
744
+ | `:focused` | `button_focus` |
745
+ | `:pressed` | `button_pressed` |
746
+ | `:idle` | `button_idle` |
747
+ | `:disabled` | `button_disabled` |
748
+
749
+ Everything else comes from `TextButton`, and you can override every default. A game
750
+ with its own art need not follow the shipped atlas's names:
751
+
752
+ ```ruby
753
+ UI = RGame::Engine::UI
754
+
755
+ menu.add(UI::PanelButton.new(label: 'load', style: UI::PanelButton::STYLE.with(idle: :my_idle)))
756
+ ```
757
+
758
+ ### `RGame::Engine::UI::OptionButton`
759
+
760
+ **A row whose value is chosen from a list.** It draws `Label < value >`. A chevron
761
+ appears only where there is somewhere to go, which is how a player learns they
762
+ reached an end.
763
+
764
+ ```ruby
765
+ UI = RGame::Engine::UI
766
+
767
+ volume = menu.add(UI::OptionButton.new(label: 'volume', values: [0, 25, 50, 75, 100],
768
+ display: ->(percent) { RGame::Engine::Text.literal("#{percent}%") }))
769
+ volume.on_changed { |value| game.audio.volume = value / 100.0 }
770
+ shadows = menu.add(UI::OptionButton.new(label: 'shadows', values: %i[off low high]))
771
+ ```
772
+
773
+ | | |
774
+ |---|---|
775
+ | `values:`, `index:`, `display:` | construction: the list, the starting position (default 0, clamped into the list), and how a value becomes its caption |
776
+ | `values`, `index`, `value` | the list, where it sits, and the value there |
777
+ | `caption` | the `Engine::Text` drawn for the current value, or `nil` for an empty list |
778
+ | `value = something` | select by value; a value the list does not offer is ignored |
779
+ | `adjust(delta)` | move the selection, clamped; the button if it moved, `nil` if not |
780
+ | `on_changed` | emits the new value, and only when it changed |
781
+
782
+ It is a `PanelButton`, so focus, the four state elements and `enabled: false` work
783
+ as on any other row.
784
+
785
+ **Values clamp, while focus wraps.** Menu buttons have no magnitude, so joining a
786
+ list's ends only makes a short list quicker to navigate. Values usually do have a
787
+ magnitude. Wrapping would turn "one louder" at the top of a volume range into
788
+ silence.
789
+
790
+ **`display` runs once per value, when the row is built.** It turns each value into
791
+ a caption, while the values stay whatever the game acts on. Running it at draw time
792
+ would allocate a String every frame for every row on screen; see
793
+ [Drawing](drawing.md).
794
+
795
+ **A caption is a key, like the label.** `display` returns a key, which the row makes
796
+ a `Text` of under its `label_scope`, or a `Text`, kept as it is. A caption `Text`
797
+ with variables shows the values its last `with` was given, as a label does. The
798
+ default,
799
+ `OptionButton::DISPLAY`, reads a Symbol value as its own key and draws any other
800
+ value as a literal of its `to_s`. So `%i[off low high]` looks up `off`, `low` and
801
+ `high`, and `[0, 50, 100]` draws the numbers. In a menu with `scope: 'settings'`,
802
+ the keys become `settings.off` and so on. YAML reads unquoted `on`, `off`, `yes` and
803
+ `no` as booleans, so a table spells those keys in quotes: `'off': Off`.
804
+
805
+ **The value column is as wide as the widest caption**, and centres the current one.
806
+ The row measures it again whenever a caption's String changes: after a locale
807
+ switch, a new scope, or a `with` with new values. A `Text` never edits its String,
808
+ so each draw compares every caption's String with the one last measured, by object
809
+ identity rather than by content. A switch to longer captions widens the column, and
810
+ a draw where nothing changed measures nothing.
811
+
812
+ `value=` ignores values the list does not offer so that restoring a setting from a
813
+ file is safe. A save written by another version of the game, or edited by hand,
814
+ leaves the row unchanged instead of raising.
815
+
816
+ ### `RGame::Engine::UI::IconButton`
817
+
818
+ **A picture, tinted by state, with an optional caption.** It suits the round entry
819
+ of a quick-select wheel, or a skill with its name underneath.
820
+
821
+ ```ruby
822
+ UI = RGame::Engine::UI
823
+
824
+ game.renderer.register_ui_atlas(game.assets.ui_atlas('icons.json'))
825
+
826
+ disc = UI::ShapeStyle.new(shape: :disc)
827
+ bar.add(UI::IconButton.new(image: :star, style: disc)).on_activated { favourite }
828
+ bar.add(UI::IconButton.new(image: 'icons/hoe.png', label: 'hoe', style: disc))
829
+ ```
830
+
831
+ | | |
832
+ |---|---|
833
+ | `image:` | an image id — a registered Symbol or a path String — or `nil` |
834
+ | `label:` | optional; a key or a `Text`, drawn as a caption along the bottom of the slot |
835
+ | `style:` | a [style](#styles); none unless given |
836
+ | `tints:` | the `color:` the image is drawn with, per state (default `IconButton::TINTS`) |
837
+ | `scales:` | the image's scale per state (default 1 in every state) |
838
+ | `label_color:`, `disabled_label_color:` | the caption's, as `TextButton`'s |
839
+
840
+ **The image draws at natural size, centred in the slot.** With a caption, it centres
841
+ in the space above the caption, and the caption centres along the bottom edge.
842
+ **With a caption, the style also draws in that space above it.** A disc then sits
843
+ around the picture, and the caption reads below it on whatever lies behind the
844
+ button. A caption is often wider than the disc, like "Watering can" under a tool.
845
+ Drawn across the disc's edge, it would sit half on the fill and half off, legible on
846
+ neither. **Everything stays inside the slot.** A navigation reading the slot's
847
+ centre and a backdrop sized from the menu's bounds therefore work for icon buttons
848
+ as for any other.
849
+
850
+ **Tint multiplies**, so draw the art in white. White shows each tint exactly; dark
851
+ art takes none. **Scales default to 1** because images use nearest-neighbour
852
+ sampling. A scale that is not a whole number doubles some pixel rows and not others.
853
+ Focus shows through the tint and the style instead. `tints:` and `scales:` must name
854
+ every state, and raise `KeyError` at construction if one is missing.
855
+
856
+ With a style that names a content colour, that colour replaces the tint in the
857
+ states it names. On the default `ShapeStyle`, that is the pressed state only, where a
858
+ dark icon shows on the gold fill. The caption keeps `label_color:` and
859
+ `disabled_label_color:` whatever the style says, because it does not sit on the
860
+ style's fill. Without a style, the pressed tint stays gold, which reads on a dark
861
+ background.
862
+
863
+ `image: nil` draws the caption alone, for an entry whose art is not ready. An id
864
+ with nothing registered under it is a different case: it raises on the first draw, as
865
+ for any other image.
866
+
867
+ ### Getting the art on screen
868
+
869
+ **Register a UI atlas once.** Nine-slice ids name an *element of an atlas*, not a
870
+ file, so the asset manager has nothing to resolve on demand:
871
+
872
+ ```ruby
873
+ game.renderer.register_ui_atlas(game.assets.ui_atlas('ui/ui_atlas.json'))
874
+ ```
875
+
876
+ The repository's `media/ui/ui_atlas.json` holds `panel` and the four button elements
877
+ above. See [Assets](assets.md).
878
+
879
+ The same call registers **images**. An atlas descriptor with an `images` section,
880
+ rectangles cut whole from the sheet, puts each one in the renderer's image registry
881
+ under its name. A strip of icons then becomes `IconButton.new(image: :home)` with
882
+ nothing else to write:
883
+
884
+ ```ruby
885
+ game.renderer.register_ui_atlas(game.assets.ui_atlas('icons.json'))
886
+ ```
887
+
888
+ `examples/assets/icons.json` names eight icons, used by `examples/radial_menu`. See
889
+ [UI atlases](assets.md#ui-atlases) for the descriptor.
890
+
891
+ A `TextButton` on a `ShapeStyle` needs none of this, and an `IconButton` needs only
892
+ its image. A path String resolves through the asset manager, and a Symbol through a
893
+ UI atlas or `renderer.register_image`; see [Drawing](drawing.md).
894
+
895
+ The examples build up in steps:
896
+
897
+ - `examples/game_menu` is the smallest complete use. A menu opens over a running
898
+ world, pauses only the node that opened it, and closes again.
899
+ - `examples/menu_navigation` adds a title screen and a settings screen pushed over
900
+ it. Its rows change fullscreen, the scale mode and the volume, and write them to a
901
+ file.
902
+ - `examples/radial_menu` is a `RadialMenu` of `IconButton`s, with icons from a UI
903
+ atlas.
904
+ - `examples/quick_wheel` holds the same wheel open with Tab or a shoulder button.
905
+ - `examples/skill_bar` is a `Row` of captioned `IconButton`s, stepped with left and
906
+ right and each fired by a hotkey.
907
+
908
+ ## What this is not
909
+
910
+ **This is a menu, not a widget library.** Every button in a menu has the same size,
911
+ placed by a column, a row or a ring. That is the whole layout system: no grid, no
912
+ nesting, no scrolling lists, and no general layout model. It has no text entry and no
913
+ continuous control. `OptionButton` covers a setting with a handful of values; a
914
+ free-moving slider needs a control that does not exist.
915
+
916
+ **Buttons are not sized to their text.** A layout places buttons when they are
917
+ added, and engine code has nothing to measure a label with at that point. The
918
+ renderer is the only measuring object a node receives, and it arrives in `draw`.
919
+ `RGame::Core::Font#text_width` works at any time, but it is a Core type the engine
920
+ layer may not hold. Every slot therefore has the size its layout was built with, and
921
+ a longer label needs a wider slot, in the longest language the game ships. Sizing
922
+ buttons to text would require text measurement in the engine layer. A layout would
923
+ also have to re-arrange its menu whenever a label changed, and a switch of language
924
+ changes them all.
925
+
926
+ **`scope:` does not inherit down the tree.** It is a `Menu` option. A HUD or a
927
+ dialog that is not a menu scopes each of its own `Text`s.