rgame 0.4.0-x86_64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +251 -0
  3. data/LICENSE +26 -0
  4. data/README.md +325 -0
  5. data/docs/api/README.md +224 -0
  6. data/docs/api/app.md +249 -0
  7. data/docs/api/assets.md +463 -0
  8. data/docs/api/audio.md +249 -0
  9. data/docs/api/cli.md +259 -0
  10. data/docs/api/components.md +1112 -0
  11. data/docs/api/drawing.md +398 -0
  12. data/docs/api/examples.md +263 -0
  13. data/docs/api/game.md +219 -0
  14. data/docs/api/images.md +118 -0
  15. data/docs/api/input.md +476 -0
  16. data/docs/api/internals.md +289 -0
  17. data/docs/api/localization.md +285 -0
  18. data/docs/api/scene_graph.md +539 -0
  19. data/docs/api/signals.md +147 -0
  20. data/docs/api/systems.md +231 -0
  21. data/docs/api/text.md +133 -0
  22. data/docs/api/tile_maps.md +237 -0
  23. data/docs/api/toolbox.md +493 -0
  24. data/docs/api/ui.md +927 -0
  25. data/docs/api/values.md +316 -0
  26. data/examples/assets/README.md +322 -0
  27. data/examples/assets/blip.ogg +0 -0
  28. data/examples/assets/glyphs.json +5 -0
  29. data/examples/assets/glyphs.png +0 -0
  30. data/examples/assets/hero.json +12 -0
  31. data/examples/assets/hero.png +0 -0
  32. data/examples/assets/icons.json +13 -0
  33. data/examples/assets/icons.png +0 -0
  34. data/examples/assets/music.ogg +0 -0
  35. data/examples/assets/skills.json +10 -0
  36. data/examples/assets/skills.png +0 -0
  37. data/examples/assets/tileset.png +0 -0
  38. data/examples/assets/tileset.tsx +65 -0
  39. data/examples/assets/town.tmx +26 -0
  40. data/examples/assets/ui.json +11 -0
  41. data/examples/assets/ui.png +0 -0
  42. data/examples/collision/locales/en.yml +8 -0
  43. data/examples/collision/main.rb +316 -0
  44. data/examples/collision_tiles/locales/en.yml +9 -0
  45. data/examples/collision_tiles/main.rb +274 -0
  46. data/examples/fullscreen/locales/en.yml +10 -0
  47. data/examples/fullscreen/main.rb +216 -0
  48. data/examples/game_menu/locales/en.yml +8 -0
  49. data/examples/game_menu/main.rb +170 -0
  50. data/examples/input_glyphs/locales/en.yml +14 -0
  51. data/examples/input_glyphs/main.rb +213 -0
  52. data/examples/jump_topdown/locales/en.yml +9 -0
  53. data/examples/jump_topdown/main.rb +178 -0
  54. data/examples/localization/locales/de.yml +12 -0
  55. data/examples/localization/locales/en.yml +13 -0
  56. data/examples/localization/main.rb +158 -0
  57. data/examples/menu_navigation/locales/en.yml +23 -0
  58. data/examples/menu_navigation/main.rb +365 -0
  59. data/examples/music/locales/en.yml +7 -0
  60. data/examples/music/main.rb +134 -0
  61. data/examples/pathfinding/locales/en.yml +17 -0
  62. data/examples/pathfinding/main.rb +298 -0
  63. data/examples/pooling/locales/en.yml +7 -0
  64. data/examples/pooling/main.rb +259 -0
  65. data/examples/quick_wheel/locales/en.yml +16 -0
  66. data/examples/quick_wheel/main.rb +184 -0
  67. data/examples/radial_menu/locales/en.yml +16 -0
  68. data/examples/radial_menu/main.rb +184 -0
  69. data/examples/save_load/locales/en.yml +11 -0
  70. data/examples/save_load/main.rb +207 -0
  71. data/examples/save_load_ids/locales/en.yml +11 -0
  72. data/examples/save_load_ids/main.rb +322 -0
  73. data/examples/scroll_map/locales/en.yml +4 -0
  74. data/examples/scroll_map/main.rb +140 -0
  75. data/examples/signals/locales/en.yml +6 -0
  76. data/examples/signals/main.rb +278 -0
  77. data/examples/skill_bar/locales/en.yml +14 -0
  78. data/examples/skill_bar/main.rb +159 -0
  79. data/examples/sound/locales/en.yml +6 -0
  80. data/examples/sound/main.rb +122 -0
  81. data/examples/split_screen/locales/en.yml +9 -0
  82. data/examples/split_screen/main.rb +304 -0
  83. data/examples/sprite/locales/en.yml +8 -0
  84. data/examples/sprite/main.rb +180 -0
  85. data/examples/timer/locales/en.yml +12 -0
  86. data/examples/timer/main.rb +273 -0
  87. data/examples/velocity/locales/en.yml +6 -0
  88. data/examples/velocity/main.rb +196 -0
  89. data/examples/walk/locales/en.yml +4 -0
  90. data/examples/walk/main.rb +99 -0
  91. data/exe/rgame +9 -0
  92. data/lib/rgame/boot.rb +3 -0
  93. data/lib/rgame/cli/new_project.rb +139 -0
  94. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  95. data/lib/rgame/cli/templates/README.md.tt +93 -0
  96. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  97. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  98. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  99. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  100. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  101. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  102. data/lib/rgame/cli/templates/rspec.tt +2 -0
  103. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  104. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  105. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  106. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  107. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  108. data/lib/rgame/cli.rb +66 -0
  109. data/lib/rgame/core/app.rb +46 -0
  110. data/lib/rgame/core/asset_manager.rb +206 -0
  111. data/lib/rgame/core/audio.rb +145 -0
  112. data/lib/rgame/core/font.rb +46 -0
  113. data/lib/rgame/core/gamepad.rb +55 -0
  114. data/lib/rgame/core/image.rb +55 -0
  115. data/lib/rgame/core/input.rb +71 -0
  116. data/lib/rgame/core/locale.rb +22 -0
  117. data/lib/rgame/core/nine_slice.rb +142 -0
  118. data/lib/rgame/core/recording.rb +54 -0
  119. data/lib/rgame/core/renderer.rb +354 -0
  120. data/lib/rgame/core/sprite_sheet.rb +105 -0
  121. data/lib/rgame/core/tile_map_renderer.rb +172 -0
  122. data/lib/rgame/core/ui_atlas.rb +101 -0
  123. data/lib/rgame/core/virtual_gamepad.rb +26 -0
  124. data/lib/rgame/core.rb +18 -0
  125. data/lib/rgame/core_ext.so +0 -0
  126. data/lib/rgame/engine/actor_blockers.rb +131 -0
  127. data/lib/rgame/engine/animation_set.rb +50 -0
  128. data/lib/rgame/engine/animator.rb +44 -0
  129. data/lib/rgame/engine/audio_bus.rb +24 -0
  130. data/lib/rgame/engine/audio_director.rb +59 -0
  131. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  132. data/lib/rgame/engine/camera.rb +78 -0
  133. data/lib/rgame/engine/circle_collider.rb +34 -0
  134. data/lib/rgame/engine/collision_box.rb +59 -0
  135. data/lib/rgame/engine/collision_system.rb +132 -0
  136. data/lib/rgame/engine/component.rb +64 -0
  137. data/lib/rgame/engine/components/action_trigger.rb +40 -0
  138. data/lib/rgame/engine/components/animated_sprite.rb +71 -0
  139. data/lib/rgame/engine/components/box_collider.rb +99 -0
  140. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  141. data/lib/rgame/engine/components/character_body.rb +50 -0
  142. data/lib/rgame/engine/components/circle_collider.rb +80 -0
  143. data/lib/rgame/engine/components/collision_world.rb +231 -0
  144. data/lib/rgame/engine/components/despawn_offscreen.rb +42 -0
  145. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  146. data/lib/rgame/engine/components/hop.rb +76 -0
  147. data/lib/rgame/engine/components/identity.rb +73 -0
  148. data/lib/rgame/engine/components/mover.rb +285 -0
  149. data/lib/rgame/engine/components/navigator.rb +145 -0
  150. data/lib/rgame/engine/components/path_follow.rb +176 -0
  151. data/lib/rgame/engine/components/player_controller.rb +27 -0
  152. data/lib/rgame/engine/components/pool.rb +53 -0
  153. data/lib/rgame/engine/components/screen_wrap.rb +49 -0
  154. data/lib/rgame/engine/components/sprite.rb +47 -0
  155. data/lib/rgame/engine/components/targeting.rb +52 -0
  156. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  157. data/lib/rgame/engine/components/tile_world.rb +109 -0
  158. data/lib/rgame/engine/components/timer.rb +75 -0
  159. data/lib/rgame/engine/components/velocity.rb +43 -0
  160. data/lib/rgame/engine/components/wander_controller.rb +64 -0
  161. data/lib/rgame/engine/components/world.rb +133 -0
  162. data/lib/rgame/engine/contact_set.rb +74 -0
  163. data/lib/rgame/engine/culling.rb +45 -0
  164. data/lib/rgame/engine/debug_overlay.rb +111 -0
  165. data/lib/rgame/engine/i18n/plural.rb +45 -0
  166. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  167. data/lib/rgame/engine/i18n/template.rb +59 -0
  168. data/lib/rgame/engine/i18n.rb +322 -0
  169. data/lib/rgame/engine/input/action_mapper.rb +100 -0
  170. data/lib/rgame/engine/input/actions.rb +92 -0
  171. data/lib/rgame/engine/input/input_map.rb +196 -0
  172. data/lib/rgame/engine/layout.rb +82 -0
  173. data/lib/rgame/engine/nav_grid.rb +87 -0
  174. data/lib/rgame/engine/node2d.rb +555 -0
  175. data/lib/rgame/engine/path.rb +76 -0
  176. data/lib/rgame/engine/player.rb +69 -0
  177. data/lib/rgame/engine/player_layer.rb +70 -0
  178. data/lib/rgame/engine/players.rb +205 -0
  179. data/lib/rgame/engine/pool.rb +51 -0
  180. data/lib/rgame/engine/presentation.rb +171 -0
  181. data/lib/rgame/engine/scene/scene_stack.rb +87 -0
  182. data/lib/rgame/engine/sealed_privates.rb +54 -0
  183. data/lib/rgame/engine/signal.rb +75 -0
  184. data/lib/rgame/engine/spatial_hash.rb +116 -0
  185. data/lib/rgame/engine/text.rb +194 -0
  186. data/lib/rgame/engine/tile_blockers.rb +63 -0
  187. data/lib/rgame/engine/tile_map.rb +147 -0
  188. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  189. data/lib/rgame/engine/tileset.rb +99 -0
  190. data/lib/rgame/engine/timer.rb +51 -0
  191. data/lib/rgame/engine/ui/button.rb +248 -0
  192. data/lib/rgame/engine/ui/column.rb +20 -0
  193. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  194. data/lib/rgame/engine/ui/menu.rb +290 -0
  195. data/lib/rgame/engine/ui/navigation.rb +57 -0
  196. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  197. data/lib/rgame/engine/ui/option_button.rb +163 -0
  198. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  199. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  200. data/lib/rgame/engine/ui/pointing.rb +146 -0
  201. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  202. data/lib/rgame/engine/ui/ring.rb +55 -0
  203. data/lib/rgame/engine/ui/row.rb +21 -0
  204. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  205. data/lib/rgame/engine/ui/stack.rb +58 -0
  206. data/lib/rgame/engine/ui/stepping.rb +93 -0
  207. data/lib/rgame/engine/ui/text_button.rb +59 -0
  208. data/lib/rgame/engine/view.rb +76 -0
  209. data/lib/rgame/engine/viewports.rb +171 -0
  210. data/lib/rgame/engine/world_view.rb +71 -0
  211. data/lib/rgame/engine.rb +86 -0
  212. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  213. data/lib/rgame/fonts/OFL.txt +102 -0
  214. data/lib/rgame/game.rb +271 -0
  215. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  216. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  217. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  218. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  219. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  220. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  221. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  222. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  223. data/lib/rgame/rubocop/default.yml +39 -0
  224. data/lib/rgame/rubocop/plugin.rb +45 -0
  225. data/lib/rgame/rubocop.rb +11 -0
  226. data/lib/rgame/util/color.rb +23 -0
  227. data/lib/rgame/util/controls.rb +169 -0
  228. data/lib/rgame/util/route_search.rb +27 -0
  229. data/lib/rgame/util/save_file.rb +107 -0
  230. data/lib/rgame/util/solid_grid.rb +37 -0
  231. data/lib/rgame/util/tensor.rb +3 -0
  232. data/lib/rgame/util/tile_sweep.rb +36 -0
  233. data/lib/rgame/util/z.rb +123 -0
  234. data/lib/rgame/util.rb +10 -0
  235. data/lib/rgame/util_ext.so +0 -0
  236. data/lib/rgame/version.rb +12 -0
  237. data/lib/rgame.rb +5 -0
  238. data/licenses/SDL2/LICENSE.txt +18 -0
  239. metadata +309 -0
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # Buttons to choose from, navigated by keyboard or controller.
7
+ #
8
+ # menu = layer.add_node(UI::Menu.new(layout: UI::Column.new(item_width: 220, item_height: 44)))
9
+ # menu.add(UI::PanelButton.new(label: 'Resume')).on_activated { close }
10
+ # menu.add(UI::PanelButton.new(label: 'Quit')).on_activated { game.close }
11
+ #
12
+ # ## Focus is the whole design
13
+ #
14
+ # With no pointer there is no hover, so something has to own *which button
15
+ # is focused*. That is this class, and everything else about a menu
16
+ # follows from it: a button draws differently because it is focused, and
17
+ # `ui_confirm` activates the focused one.
18
+ #
19
+ # ## The menu holds buttons; it does not build them
20
+ #
21
+ # What a button looks like is the button's business — any UI::Button
22
+ # subclass, shipped or written by the game, goes in through `add`. The menu
23
+ # never picks a look, so one menu can hold a panel button next to a game's
24
+ # own.
25
+ #
26
+ # ## Two questions are handed off, and one is kept
27
+ #
28
+ # A list and a radial wheel are the same menu. What differs is **where the
29
+ # buttons sit** and **how input moves focus**, so those two are passed in:
30
+ #
31
+ # | | Answers | Shipped |
32
+ # |---|---|---|
33
+ # | `layout:` | where each button goes, its size, and the bounds of them all | UI::Column, UI::Row, UI::Ring |
34
+ # | `navigation:` | which button this frame's input focuses | UI::Stepping (default), UI::Pointing, or nil |
35
+ #
36
+ # What stays here is what every menu does the same way: holding the
37
+ # buttons, passing `ui_confirm` to the focused one as a press and a
38
+ # release — when that activates is the button's `activate_on:` — and
39
+ # passing each button's `hotkey` to that button, focused or not. A
40
+ # navigation cannot forget to do either, because it is never asked to.
41
+ #
42
+ # ## A menu with no navigation
43
+ #
44
+ # `navigation: nil` says input never moves focus: the menu focuses nothing
45
+ # when a button is added, so `ui_confirm` has nothing to act on. What
46
+ # focuses a button then is the game calling `focus` — which confirm then
47
+ # acts on, as it would for any navigation.
48
+ #
49
+ # ## A menu acts only on a press it saw start
50
+ #
51
+ # A menu takes no press until it has seen `ui_confirm` up, and no hotkey
52
+ # press on a button until it has seen that hotkey up since the button was
53
+ # added. A menu opened from `on_activated` — a submenu — is controlled
54
+ # later in the same tick, while the key that opened it is still down, and
55
+ # would otherwise read the same press edge again and activate its own
56
+ # button with it.
57
+ #
58
+ # wheel = UI::Menu.new(x: 320, y: 240, navigation: UI::Pointing.new,
59
+ # layout: UI::Ring.new(radius: 120, item_width: 96, item_height: 30))
60
+ #
61
+ # ## A scope for its buttons' keys
62
+ #
63
+ # `scope:` puts a scope in front of every label given as a key, so the
64
+ # buttons of a title menu say `label: 'play'` and draw `title_menu.play`:
65
+ #
66
+ # menu = UI::Menu.new(layout: column, scope: 'title_menu')
67
+ # menu.add(UI::PanelButton.new(label: 'play')) # title_menu.play
68
+ # menu.add(UI::PanelButton.new(label: Engine::Text.new('quit', scope: 'common'))) # common.quit
69
+ #
70
+ # The menu sets it as each button is added, on a button whose
71
+ # `label_scope` is still nil. A label given as an Engine::Text keeps its own
72
+ # scope, nil included, and so does a literal. The scope reaches the
73
+ # buttons of this menu and nothing deeper in the tree.
74
+ #
75
+ # ## Open and closed
76
+ #
77
+ # A closed menu draws nothing — neither its own backdrop nor its buttons —
78
+ # and its input moves no focus and presses nothing. A menu starts open;
79
+ # `close` and `open` are what a pause menu toggles. Closing is not
80
+ # pausing: a closed menu still ticks, so a button's pressed feedback runs
81
+ # out while it is shut instead of waiting to be seen again.
82
+ #
83
+ # ## A menu held open by an action
84
+ #
85
+ # `trigger:` names an action that opens the menu while it is held. Letting
86
+ # it go activates the button focused at that moment, if any, and closes —
87
+ # the console quick wheel, held open by a shoulder button:
88
+ #
89
+ # wheel = UI::RadialMenu.new(x: 320, y: 240, radius: 150, button_width: 64, trigger: :quick_menu)
90
+ #
91
+ # A menu with a trigger starts closed, and `ui_confirm` activates nothing
92
+ # on it: letting go is the only way to choose. Opening only happens on a
93
+ # press this menu saw start, like every other press, so a trigger already
94
+ # down when the menu appears opens nothing; and a trigger that comes up
95
+ # while the menu is paused closes it without choosing. `open` raises on
96
+ # such a menu, because a menu opened by hand would wait for the release of
97
+ # a press it never saw; `close` does not, and is how a game cancels a held
98
+ # wheel.
99
+ #
100
+ # What the world does while the menu is open is the game's to decide, from
101
+ # `on_opened` and `on_closed`. The menu only says when.
102
+ #
103
+ # ## Focus is per player, and that costs nothing
104
+ #
105
+ # A Menu inside a PlayerLayer inherits that player as its `input_owner`,
106
+ # so the `actions` its `on_control` receives are already that player's.
107
+ # Two players with a menu open at once are independent without either menu
108
+ # knowing the other exists, and without a word of focus-specific
109
+ # per-player machinery. That falls out of ownership being inherited down
110
+ # the tree — see docs/api/scene_graph.md, "Who a node answers to".
111
+ #
112
+ # ## What this is not
113
+ #
114
+ # It is a menu, not a widget library. Every button is the same size, placed
115
+ # by its layout, and that is the whole of its layout story — see
116
+ # docs/api/ui.md, "What this is not".
117
+ class Menu < Node2D
118
+ signal :on_opened
119
+ # Emits the button a trigger's release activated, or nil.
120
+ signal :on_closed, Signal.define(:button)
121
+
122
+ # `trigger` is an action name, or nil. `scope` is a String, or nil.
123
+ attr_reader :buttons, :focused_index, :layout, :navigation, :trigger, :scope
124
+
125
+ # The rectangle the layout says encloses every button, relative to the
126
+ # menu — what a subclass draws its backdrop round. Copied on each `add`,
127
+ # so reading them on a draw path costs nothing.
128
+ attr_reader :bounds_x, :bounds_y, :bounds_width, :bounds_height
129
+
130
+ def initialize(layout:, navigation: Stepping.new, trigger: nil, scope: nil, **)
131
+ super(**)
132
+ @scope = scope&.to_s&.freeze
133
+ @layout = layout
134
+ @navigation = navigation
135
+ @trigger = trigger
136
+ @open = trigger.nil?
137
+ @buttons = []
138
+ @bounds_x = @bounds_y = @bounds_width = @bounds_height = 0
139
+ @focused_index = nil
140
+ @confirm_seen_up = false
141
+ @trigger_seen_up = false
142
+ @hotkey_seen_up = []
143
+ navigation&.attach(self)
144
+ end
145
+
146
+ # Adds a button, gives it the menu's `scope` unless it has a
147
+ # `label_scope` already, re-arranges them all, and returns it, so a
148
+ # caller can connect to its signal in the same line. Raises TypeError for
149
+ # anything that is not a UI::Button.
150
+ def add(button)
151
+ raise TypeError, "a Menu holds UI::Button instances, not #{button.class}" unless button.is_a?(Button)
152
+
153
+ button.label_scope = @scope if @scope && button.label_scope.nil?
154
+ @buttons << button
155
+ @hotkey_seen_up << false
156
+ add_node(button)
157
+ @layout.arrange(@buttons)
158
+ @bounds_x, @bounds_y, @bounds_width, @bounds_height = @layout.bounds(@buttons)
159
+ @navigation&.on_buttons_changed
160
+ button
161
+ end
162
+
163
+ # The focused button, or nil when nothing is — which under UI::Pointing
164
+ # is whenever the stick is at rest.
165
+ def focused = @focused_index && @buttons[@focused_index]
166
+
167
+ # Focuses the button at `index`, or nothing for nil. Only the buttons
168
+ # whose focus actually changes are told. It does not check `enabled?`:
169
+ # which buttons may take focus is the navigation's rule, and a disabled
170
+ # button cannot be activated whatever holds it.
171
+ def focus(index)
172
+ previous = focused
173
+ @focused_index = index
174
+ current = focused
175
+ previous.focused = false if previous && !previous.equal?(current)
176
+ current&.focused = true
177
+ end
178
+
179
+ def open? = @open
180
+
181
+ # Opens the menu, lets the navigation forget the last opening, and emits
182
+ # `on_opened`. Nothing if already open. Raises on a menu with a trigger,
183
+ # which only its trigger opens.
184
+ def open
185
+ raise "this menu is opened by holding #{@trigger.inspect}, not by #open" if @trigger
186
+ return if @open
187
+
188
+ open_now
189
+ end
190
+
191
+ # Closes the menu without activating anything and emits `on_closed` with
192
+ # nil. Nothing if already closed.
193
+ def close
194
+ close_with(nil) if @open
195
+ end
196
+
197
+ # Draws nothing while closed.
198
+ def draw(renderer, view)
199
+ super if @open
200
+ end
201
+
202
+ # A trigger's press first, so an opening's first frame already reads the
203
+ # stick; then navigation, so a focus change and a confirm on the same
204
+ # frame confirm the newly focused button; then every hotkey; then
205
+ # confirm, on a menu with no trigger; and a trigger's release last, so it
206
+ # chooses what this frame focused.
207
+ def on_control(actions)
208
+ trigger_edge = control_trigger(actions) if @trigger
209
+ open_now if trigger_edge == :press
210
+ return unless @open
211
+
212
+ @navigation&.on_control(actions)
213
+ press_hotkeys(actions)
214
+ @trigger ? release_trigger(trigger_edge) : confirm(actions)
215
+ end
216
+
217
+ # Lets the navigation count time, then does what every node does.
218
+ def update(dt)
219
+ @navigation&.update(dt) unless @paused
220
+ super
221
+ end
222
+
223
+ private
224
+
225
+ def open_now
226
+ @open = true
227
+ @navigation&.on_opened
228
+ on_opened_signal.emit
229
+ end
230
+
231
+ def close_with(button)
232
+ @open = false
233
+ on_closed_signal.emit(button)
234
+ end
235
+
236
+ def control_trigger(actions)
237
+ edge = press_edge(actions, @trigger, @trigger_seen_up)
238
+ @trigger_seen_up = !actions.held?(@trigger)
239
+ edge
240
+ end
241
+
242
+ def release_trigger(edge)
243
+ case edge
244
+ when :release then close_with(focused&.activate)
245
+ when :missed_release then close_with(nil)
246
+ end
247
+ end
248
+
249
+ def confirm(actions)
250
+ edge = press_edge(actions, :ui_confirm, @confirm_seen_up)
251
+ @confirm_seen_up = !actions.held?(:ui_confirm)
252
+ button = focused
253
+ pass_edge(button, edge, :confirm) if button
254
+ end
255
+
256
+ def press_hotkeys(actions)
257
+ index = 0
258
+ while index < @buttons.size
259
+ button = @buttons[index]
260
+ hotkey = button.hotkey
261
+ if hotkey
262
+ edge = press_edge(actions, hotkey, @hotkey_seen_up[index])
263
+ @hotkey_seen_up[index] = !actions.held?(hotkey)
264
+ pass_edge(button, edge, :hotkey)
265
+ end
266
+ index += 1
267
+ end
268
+ end
269
+
270
+ def press_edge(actions, action, seen_up)
271
+ if actions.pressed?(action)
272
+ :press if seen_up
273
+ elsif actions.released?(action)
274
+ :release
275
+ elsif !actions.held?(action)
276
+ :missed_release
277
+ end
278
+ end
279
+
280
+ def pass_edge(button, edge, source)
281
+ case edge
282
+ when :press then button.press(source)
283
+ when :release then button.release(source)
284
+ when :missed_release then button.cancel_press(source)
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # How a Menu turns a player's input into focus. The base of UI::Stepping
7
+ # and UI::Pointing, and the class to subclass for a third way.
8
+ #
9
+ # class Cycling < UI::Navigation
10
+ # def on_control(actions)
11
+ # menu.focus((menu.focused_index.to_i + 1) % menu.buttons.size) if actions.pressed?(:ui_right)
12
+ # end
13
+ # end
14
+ #
15
+ # A navigation only decides **which button is focused**. Confirming the
16
+ # focused button, pressing it and firing its signal are the Menu's,
17
+ # and the same for every navigation, so a subclass cannot forget them.
18
+ #
19
+ # ## One per menu
20
+ #
21
+ # A navigation may keep state about the menu it drives — UI::Pointing keeps
22
+ # the last direction read — so it belongs to exactly one. Handing the same
23
+ # instance to a second Menu raises, rather than leaving two menus quietly
24
+ # sharing one pointer. Build a fresh one for each menu; the constructor
25
+ # default already does.
26
+ class Navigation
27
+ attr_reader :menu
28
+
29
+ # Called by Menu when it is built with this navigation.
30
+ def attach(menu)
31
+ if @menu && !@menu.equal?(menu)
32
+ raise ArgumentError, "this #{self.class} already navigates another menu — build one per menu"
33
+ end
34
+
35
+ @menu = menu
36
+ end
37
+
38
+ # Reads this frame's actions and moves focus with `menu.focus`. Called
39
+ # before the Menu handles `ui_confirm`, so a focus change and a confirm on
40
+ # the same frame activate the newly focused button.
41
+ def on_control(actions); end
42
+
43
+ # Called after a button is added, for a navigation that has an opinion
44
+ # about where focus starts.
45
+ def on_buttons_changed; end
46
+
47
+ # Called by Menu on each update while the menu is not paused, for a
48
+ # navigation that counts time. Time enters here and nowhere else.
49
+ def update(dt); end
50
+
51
+ # Called by Menu when it opens, for a navigation holding state that
52
+ # belongs to one opening — UI::Pointing forgets its aim.
53
+ def on_opened; end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A button background drawn from a UI atlas: one nine-slice element per
7
+ # state, stretched over the button's whole slot.
8
+ #
9
+ # style = UI::NineSliceStyle.new(idle: :button_idle, focused: :button_focus,
10
+ # pressed: :button_pressed, disabled: :button_disabled)
11
+ # menu.add(UI::TextButton.new(label: 'Play', style: style))
12
+ #
13
+ # Every state is required, so an atlas missing its disabled element is a
14
+ # missing keyword where the style is built rather than a lookup failure the
15
+ # first frame a button is disabled.
16
+ #
17
+ # A style is anything answering `draw(renderer, state, width, height)` in
18
+ # the button's local space, drawing at `z: 0` or below so the button's own
19
+ # content, at `z: 1`, stays on top. This one draws at the nine-slice
20
+ # default of 0.
21
+ #
22
+ # A style may also answer `content_color(state)`: the colour a button's
23
+ # label or icon should take over that state's background, or nil to keep
24
+ # the button's own. Art is the game's, so this one cannot know what reads
25
+ # on it and always answers nil — PanelButton's dark label is chosen for
26
+ # the shipped atlas instead.
27
+ class NineSliceStyle
28
+ # The element name drawn for each state, keyed by `idle`, `focused`,
29
+ # `pressed` and `disabled`.
30
+ attr_reader :elements
31
+
32
+ def initialize(idle:, focused:, pressed:, disabled:)
33
+ @elements = { idle: idle, focused: focused, pressed: pressed, disabled: disabled }.freeze
34
+ end
35
+
36
+ # A copy with some elements replaced — a game whose atlas differs from
37
+ # the shipped one in a single state names only that state.
38
+ #
39
+ # UI::PanelButton::STYLE.with(focused: :my_glow)
40
+ def with(**changes) = self.class.new(**@elements, **changes)
41
+
42
+ def content_color(_state) = nil
43
+
44
+ def draw(renderer, state, width, height)
45
+ renderer.nine_slice(@elements.fetch(state), 0, 0, width, height)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A menu row whose value is chosen from a list, moved with `ui_left` and
7
+ # `ui_right`.
8
+ #
9
+ # quality = menu.add(UI::OptionButton.new(label: 'shadows', values: %i[off low high]))
10
+ # quality.on_changed { |value| settings.shadows = value }
11
+ #
12
+ # It draws `Label < value >`, and the chevrons appear only where
13
+ # there is somewhere to go — which is the only feedback a player gets that
14
+ # they have reached an end of the list.
15
+ #
16
+ # ## Values move by clamping, while focus wraps
17
+ #
18
+ # `Menu` wraps focus at the ends, and this deliberately does not. A list of
19
+ # menu buttons has no magnitude, so joining its ends only makes a short list
20
+ # quicker to get around. A list of *values* usually does have one — volume,
21
+ # difficulty, a resolution — and wrapping turns "one louder" at the top of
22
+ # the range into silence. Every settings screen a player has used clamps,
23
+ # so this does too.
24
+ #
25
+ # ## A caption is a key, unless it is a Text
26
+ #
27
+ # `display` turns a value into what is drawn for it: a key, which the
28
+ # button makes an Engine::Text of under its `label_scope` just as it does
29
+ # the label, or a `Text`, used as it is. The default makes a Symbol value
30
+ # its own key and anything else a literal, so `%i[off low high]` reads
31
+ # the keys `off`, `low` and `high`, and `[0, 50, 100]` draws the numbers:
32
+ #
33
+ # UI::OptionButton.new(label: 'volume', values: [0, 50, 100],
34
+ # display: ->(percent) { Engine::Text.literal("#{percent}%") })
35
+ #
36
+ # `display` is called for the whole list in the constructor. Doing it in
37
+ # `on_draw` instead would allocate a String every frame for every row on
38
+ # screen, which is what `Game/NoInterpolationInHotPath` refuses — and the
39
+ # values themselves are what a game acts on, so they cannot simply be
40
+ # stored as text.
41
+ #
42
+ # A caption `Text` with variables shows the values its last `with` was
43
+ # given, as a label does.
44
+ #
45
+ # The value column is as wide as the widest caption. It is measured again
46
+ # whenever any caption's String changes — a locale switch, a new scope, a
47
+ # `with` with new values — which a draw notices by object identity, so a
48
+ # switch to longer captions widens it and an unchanged draw measures
49
+ # nothing.
50
+ class OptionButton < PanelButton
51
+ # Emits the newly selected value, which is the only thing a listener
52
+ # wants; `index` is available on the button for anything that needs it.
53
+ signal :on_changed, Signal.define(:value)
54
+
55
+ LEFT_CHEVRON = '<'
56
+ RIGHT_CHEVRON = '>'
57
+
58
+ PADDING = 12
59
+ GAP = 8
60
+
61
+ DISPLAY = ->(value) { value.is_a?(Symbol) ? value : Text.literal(value.to_s) }
62
+
63
+ def initialize(label:, values:, index: 0, display: DISPLAY, **)
64
+ super(label: label, **)
65
+ @values = values.to_a.freeze
66
+ shown = @values.map { |value| display.call(value) }
67
+ @captions = shown.map { |caption| text_for(caption) }.freeze
68
+ @keyed_captions = @captions.reject.with_index { |_caption, at| shown[at].is_a?(Text) }.freeze
69
+ @measured = Array.new(@captions.size)
70
+ @index = @values.empty? ? 0 : index.clamp(0, @values.size - 1)
71
+ end
72
+
73
+ attr_reader :values, :index
74
+
75
+ def value = @values[@index]
76
+
77
+ # The Engine::Text drawn for the current value, or nil for an empty list.
78
+ def caption = @captions[@index]
79
+
80
+ # Scopes the captions `display` gave as keys along with the label.
81
+ def label_scope=(scope)
82
+ super
83
+ @keyed_captions.each { |caption| caption.scope = label_scope }
84
+ end
85
+
86
+ # Selects `value` if the list holds it, and says whether it did. A game
87
+ # restoring a saved setting does not have to know where in the list it
88
+ # sits, and a value that is no longer offered — an old save, a list that
89
+ # changed between versions — leaves the button where it was rather than
90
+ # raising.
91
+ def value=(value)
92
+ found = @values.index(value)
93
+ @index = found if found
94
+ end
95
+
96
+ # Moves the selection by `delta`, clamped. Returns the button when it
97
+ # actually moved and nil otherwise, so a caller can tell "pressed at the
98
+ # end of the list" from "changed" without comparing values.
99
+ #
100
+ # Disabled and empty are both nil for the same reason `activate` is:
101
+ # a caller never has to check first.
102
+ def adjust(delta)
103
+ return nil unless enabled?
104
+ return nil if @values.empty?
105
+
106
+ moved = (@index + delta).clamp(0, @values.size - 1)
107
+ return nil if moved == @index
108
+
109
+ @index = moved
110
+ on_changed_signal.emit(value)
111
+ self
112
+ end
113
+
114
+ private
115
+
116
+ def draw_foreground(renderer)
117
+ y = label_y(renderer)
118
+ color = current_label_color
119
+ renderer.text(@label, PADDING, y, z: 1, color: color) if @label
120
+ draw_value(renderer, y, color)
121
+ end
122
+
123
+ def draw_value(renderer, y, color)
124
+ return if @values.empty?
125
+
126
+ chevron = renderer.text_width(RIGHT_CHEVRON)
127
+ right = width - PADDING - chevron
128
+ column = column_width(renderer)
129
+ left = right - GAP - column - GAP - chevron
130
+
131
+ renderer.text(LEFT_CHEVRON, left, y, z: 1, color: color) if @index.positive?
132
+ renderer.text(RIGHT_CHEVRON, right, y, z: 1, color: color) if @index < @values.size - 1
133
+
134
+ text = caption.to_s
135
+ centred = left + chevron + GAP + ((column - renderer.text_width(text)) / 2)
136
+ renderer.text(text, centred, y, z: 1, color: color)
137
+ end
138
+
139
+ def column_width(renderer)
140
+ return @column_width if captions_measured?
141
+
142
+ @column_width = 0
143
+ @captions.each_with_index do |caption, at|
144
+ @measured[at] = caption.to_s
145
+ width = renderer.text_width(@measured[at])
146
+ @column_width = width if width > @column_width
147
+ end
148
+ @column_width
149
+ end
150
+
151
+ def captions_measured?
152
+ at = 0
153
+ while at < @captions.size
154
+ return false unless @captions[at].to_s.equal?(@measured[at])
155
+
156
+ at += 1
157
+ end
158
+ true
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A UI::TextButton on a nine-slice: the shipped atlas's button, one element
7
+ # per state, with a dark label.
8
+ #
9
+ # resume = menu.add(UI::PanelButton.new(label: 'resume'))
10
+ # resume.on_activated { cutscene.close }
11
+ #
12
+ # menu.add(UI::PanelButton.new(label: 'save_game', enabled: false))
13
+ # menu.add(UI::PanelButton.new(label: 'load', style: UI::PanelButton::STYLE.with(idle: :mine)))
14
+ #
15
+ # `STYLE` names the element drawn for each state — which is why the shipped
16
+ # atlas has one for each of the four. It differs from a TextButton only in
17
+ # its defaults; every one of them can still be passed.
18
+ class PanelButton < TextButton
19
+ STYLE = NineSliceStyle.new(idle: :button_idle, focused: :button_focus,
20
+ pressed: :button_pressed, disabled: :button_disabled)
21
+
22
+ LABEL_COLOR = Util::Color.new(46, 34, 24)
23
+ DISABLED_LABEL_COLOR = Util::Color.new(120, 110, 100)
24
+
25
+ def initialize(label:, style: STYLE, label_color: LABEL_COLOR,
26
+ disabled_label_color: DISABLED_LABEL_COLOR, **)
27
+ super
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A menu that draws its own backdrop: a nine-slice round its buttons,
7
+ # grown by `padding` on every side.
8
+ #
9
+ # column = UI::Column.new(item_width: 180, item_height: 34)
10
+ # menu = layer.add_node(UI::PanelMenu.new(x: 56, y: 56, layout: column))
11
+ # menu.add(UI::PanelButton.new(label: 'Resume')).on_activated { close }
12
+ #
13
+ # The panel is sized from the menu's bounds, which the layout recomputes on
14
+ # every `add`, so a button added later grows the panel with it — there is
15
+ # no count to keep in step with the buttons. The menu's origin stays the
16
+ # layout's origin: with a UI::Column that is the first button's corner, and
17
+ # the panel reaches `padding` beyond it up and to the left.
18
+ #
19
+ # The buttons are children, so they draw over the panel without a `z`.
20
+ class PanelMenu < Menu
21
+ attr_reader :panel, :padding
22
+
23
+ def initialize(panel: :panel, padding: 16, **)
24
+ super(**)
25
+ @panel = panel
26
+ @padding = padding
27
+ end
28
+
29
+ def on_draw(renderer, _view)
30
+ renderer.nine_slice(@panel, bounds_x - @padding, bounds_y - @padding,
31
+ bounds_width + (@padding * 2), bounds_height + (@padding * 2))
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end