rgame 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -1
  3. data/README.md +67 -65
  4. data/docs/api/README.md +86 -82
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -165
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1032 -265
  10. data/docs/api/drawing.md +160 -160
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +149 -50
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +226 -148
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +397 -244
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +186 -73
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +395 -146
  23. data/docs/api/ui.md +883 -54
  24. data/docs/api/values.md +216 -33
  25. data/examples/assets/README.md +322 -0
  26. data/examples/assets/blip.ogg +0 -0
  27. data/examples/assets/glyphs.json +5 -0
  28. data/examples/assets/glyphs.png +0 -0
  29. data/examples/assets/hero.json +12 -0
  30. data/examples/assets/hero.png +0 -0
  31. data/examples/assets/icons.json +13 -0
  32. data/examples/assets/icons.png +0 -0
  33. data/examples/assets/music.ogg +0 -0
  34. data/examples/assets/skills.json +10 -0
  35. data/examples/assets/skills.png +0 -0
  36. data/examples/assets/tileset.png +0 -0
  37. data/examples/assets/tileset.tsx +65 -0
  38. data/examples/assets/town.tmx +26 -0
  39. data/examples/assets/ui.json +11 -0
  40. data/examples/assets/ui.png +0 -0
  41. data/examples/collision/locales/en.yml +8 -0
  42. data/examples/collision/main.rb +316 -0
  43. data/examples/collision_tiles/locales/en.yml +9 -0
  44. data/examples/collision_tiles/main.rb +274 -0
  45. data/examples/fullscreen/locales/en.yml +10 -0
  46. data/examples/fullscreen/main.rb +216 -0
  47. data/examples/game_menu/locales/en.yml +8 -0
  48. data/examples/game_menu/main.rb +170 -0
  49. data/examples/input_glyphs/locales/en.yml +14 -0
  50. data/examples/input_glyphs/main.rb +213 -0
  51. data/examples/jump_topdown/locales/en.yml +9 -0
  52. data/examples/jump_topdown/main.rb +178 -0
  53. data/examples/localization/locales/de.yml +12 -0
  54. data/examples/localization/locales/en.yml +13 -0
  55. data/examples/localization/main.rb +158 -0
  56. data/examples/menu_navigation/locales/en.yml +23 -0
  57. data/examples/menu_navigation/main.rb +365 -0
  58. data/examples/music/locales/en.yml +7 -0
  59. data/examples/music/main.rb +134 -0
  60. data/examples/pathfinding/locales/en.yml +17 -0
  61. data/examples/pathfinding/main.rb +298 -0
  62. data/examples/pooling/locales/en.yml +7 -0
  63. data/examples/pooling/main.rb +259 -0
  64. data/examples/quick_wheel/locales/en.yml +16 -0
  65. data/examples/quick_wheel/main.rb +184 -0
  66. data/examples/radial_menu/locales/en.yml +16 -0
  67. data/examples/radial_menu/main.rb +184 -0
  68. data/examples/save_load/locales/en.yml +11 -0
  69. data/examples/save_load/main.rb +207 -0
  70. data/examples/save_load_ids/locales/en.yml +11 -0
  71. data/examples/save_load_ids/main.rb +322 -0
  72. data/examples/scroll_map/locales/en.yml +4 -0
  73. data/examples/scroll_map/main.rb +140 -0
  74. data/examples/signals/locales/en.yml +6 -0
  75. data/examples/signals/main.rb +278 -0
  76. data/examples/skill_bar/locales/en.yml +14 -0
  77. data/examples/skill_bar/main.rb +159 -0
  78. data/examples/sound/locales/en.yml +6 -0
  79. data/examples/sound/main.rb +122 -0
  80. data/examples/split_screen/locales/en.yml +9 -0
  81. data/examples/split_screen/main.rb +304 -0
  82. data/examples/sprite/locales/en.yml +8 -0
  83. data/examples/sprite/main.rb +180 -0
  84. data/examples/timer/locales/en.yml +12 -0
  85. data/examples/timer/main.rb +273 -0
  86. data/examples/velocity/locales/en.yml +6 -0
  87. data/examples/velocity/main.rb +196 -0
  88. data/examples/walk/locales/en.yml +4 -0
  89. data/examples/walk/main.rb +99 -0
  90. data/exe/rgame +9 -0
  91. data/ext/rgame_core/app/app.c +33 -3
  92. data/ext/rgame_core/app/locale.c +67 -0
  93. data/ext/rgame_core/app/locale.h +28 -0
  94. data/ext/rgame_core/audio/audio.c +39 -2
  95. data/ext/rgame_core/example.rb +0 -49
  96. data/ext/rgame_core/extconf.rb +0 -125
  97. data/ext/rgame_core/include/rgame/core.h +38 -2
  98. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  99. data/ext/rgame_core/ruby/core_ext.c +30 -7
  100. data/ext/rgame_core/ruby/core_ext.h +3 -0
  101. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  102. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  103. data/ext/rgame_core/ruby/renderer_ext.c +20 -20
  104. data/ext/rgame_util/extconf.rb +2 -20
  105. data/ext/rgame_util/route_search.c +305 -0
  106. data/ext/rgame_util/route_search.h +86 -0
  107. data/ext/rgame_util/route_search_ext.c +150 -0
  108. data/ext/rgame_util/solid_grid.c +58 -0
  109. data/ext/rgame_util/solid_grid.h +49 -0
  110. data/ext/rgame_util/solid_grid_ext.c +161 -0
  111. data/ext/rgame_util/tile_sweep.c +164 -0
  112. data/ext/rgame_util/tile_sweep.h +62 -0
  113. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  114. data/ext/rgame_util/util_ext.c +3 -0
  115. data/ext/rgame_util/util_ext.h +15 -0
  116. data/lib/rgame/boot.rb +0 -10
  117. data/lib/rgame/cli/new_project.rb +139 -0
  118. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  119. data/lib/rgame/cli/templates/README.md.tt +93 -0
  120. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  121. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  122. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  123. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  124. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  125. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  126. data/lib/rgame/cli/templates/rspec.tt +2 -0
  127. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  128. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  129. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  130. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  131. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  132. data/lib/rgame/cli.rb +66 -0
  133. data/lib/rgame/core/app.rb +6 -44
  134. data/lib/rgame/core/asset_manager.rb +13 -31
  135. data/lib/rgame/core/audio.rb +37 -16
  136. data/lib/rgame/core/font.rb +0 -3
  137. data/lib/rgame/core/locale.rb +22 -0
  138. data/lib/rgame/core/nine_slice.rb +0 -21
  139. data/lib/rgame/core/renderer.rb +6 -63
  140. data/lib/rgame/core/sprite_sheet.rb +0 -3
  141. data/lib/rgame/core/tile_map_renderer.rb +2 -19
  142. data/lib/rgame/core/ui_atlas.rb +28 -13
  143. data/lib/rgame/core.rb +1 -8
  144. data/lib/rgame/engine/actor_blockers.rb +131 -0
  145. data/lib/rgame/engine/animation_set.rb +1 -0
  146. data/lib/rgame/engine/audio_director.rb +36 -6
  147. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  148. data/lib/rgame/engine/camera.rb +3 -3
  149. data/lib/rgame/engine/circle_collider.rb +4 -2
  150. data/lib/rgame/engine/collision_box.rb +26 -1
  151. data/lib/rgame/engine/collision_system.rb +110 -22
  152. data/lib/rgame/engine/component.rb +34 -10
  153. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  154. data/lib/rgame/engine/components/animated_sprite.rb +27 -25
  155. data/lib/rgame/engine/components/box_collider.rb +99 -0
  156. data/lib/rgame/engine/components/camera_follow.rb +6 -5
  157. data/lib/rgame/engine/components/character_body.rb +20 -61
  158. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  159. data/lib/rgame/engine/components/collision_world.rb +159 -31
  160. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  161. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  162. data/lib/rgame/engine/components/hop.rb +76 -0
  163. data/lib/rgame/engine/components/identity.rb +73 -0
  164. data/lib/rgame/engine/components/mover.rb +285 -0
  165. data/lib/rgame/engine/components/navigator.rb +145 -0
  166. data/lib/rgame/engine/components/path_follow.rb +123 -31
  167. data/lib/rgame/engine/components/player_controller.rb +5 -2
  168. data/lib/rgame/engine/components/pool.rb +1 -1
  169. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  170. data/lib/rgame/engine/components/sprite.rb +12 -6
  171. data/lib/rgame/engine/components/targeting.rb +9 -11
  172. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  173. data/lib/rgame/engine/components/tile_world.rb +41 -13
  174. data/lib/rgame/engine/components/timer.rb +1 -1
  175. data/lib/rgame/engine/components/velocity.rb +23 -7
  176. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  177. data/lib/rgame/engine/components/world.rb +133 -0
  178. data/lib/rgame/engine/contact_set.rb +74 -0
  179. data/lib/rgame/engine/culling.rb +4 -6
  180. data/lib/rgame/engine/debug_overlay.rb +5 -11
  181. data/lib/rgame/engine/i18n/plural.rb +45 -0
  182. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  183. data/lib/rgame/engine/i18n/template.rb +59 -0
  184. data/lib/rgame/engine/i18n.rb +276 -51
  185. data/lib/rgame/engine/input/action_mapper.rb +0 -26
  186. data/lib/rgame/engine/input/actions.rb +2 -8
  187. data/lib/rgame/engine/input/input_map.rb +39 -21
  188. data/lib/rgame/engine/nav_grid.rb +87 -0
  189. data/lib/rgame/engine/node2d.rb +248 -133
  190. data/lib/rgame/engine/path.rb +4 -6
  191. data/lib/rgame/engine/players.rb +6 -13
  192. data/lib/rgame/engine/presentation.rb +171 -0
  193. data/lib/rgame/engine/scene/scene_stack.rb +4 -4
  194. data/lib/rgame/engine/sealed_privates.rb +54 -0
  195. data/lib/rgame/engine/spatial_hash.rb +51 -19
  196. data/lib/rgame/engine/text.rb +194 -0
  197. data/lib/rgame/engine/tile_blockers.rb +63 -0
  198. data/lib/rgame/engine/tile_map.rb +2 -3
  199. data/lib/rgame/engine/tile_map_layer.rb +0 -2
  200. data/lib/rgame/engine/tileset.rb +2 -4
  201. data/lib/rgame/engine/timer.rb +2 -2
  202. data/lib/rgame/engine/ui/button.rb +248 -0
  203. data/lib/rgame/engine/ui/column.rb +20 -0
  204. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  205. data/lib/rgame/engine/ui/menu.rb +246 -71
  206. data/lib/rgame/engine/ui/navigation.rb +57 -0
  207. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  208. data/lib/rgame/engine/ui/option_button.rb +163 -0
  209. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  210. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  211. data/lib/rgame/engine/ui/pointing.rb +146 -0
  212. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  213. data/lib/rgame/engine/ui/ring.rb +55 -0
  214. data/lib/rgame/engine/ui/row.rb +21 -0
  215. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  216. data/lib/rgame/engine/ui/stack.rb +58 -0
  217. data/lib/rgame/engine/ui/stepping.rb +93 -0
  218. data/lib/rgame/engine/ui/text_button.rb +59 -0
  219. data/lib/rgame/engine/viewports.rb +2 -5
  220. data/lib/rgame/engine/world_view.rb +5 -4
  221. data/lib/rgame/engine.rb +31 -25
  222. data/lib/rgame/game.rb +99 -27
  223. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  224. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  225. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  226. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  227. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  228. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  229. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  230. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  231. data/lib/rgame/rubocop/default.yml +39 -0
  232. data/lib/rgame/rubocop/plugin.rb +45 -0
  233. data/lib/rgame/rubocop.rb +11 -0
  234. data/lib/rgame/util/color.rb +20 -24
  235. data/lib/rgame/util/controls.rb +14 -28
  236. data/lib/rgame/util/route_search.rb +27 -0
  237. data/lib/rgame/util/save_file.rb +107 -0
  238. data/lib/rgame/util/solid_grid.rb +37 -0
  239. data/lib/rgame/util/tensor.rb +0 -9
  240. data/lib/rgame/util/tile_sweep.rb +36 -0
  241. data/lib/rgame/util/z.rb +4 -14
  242. data/lib/rgame/util.rb +4 -3
  243. data/lib/rgame/version.rb +1 -1
  244. data/lib/rgame.rb +0 -15
  245. metadata +152 -11
  246. data/lib/rgame/engine/actor.rb +0 -53
  247. data/lib/rgame/engine/body.rb +0 -49
  248. data/lib/rgame/engine/cached_label.rb +0 -33
  249. data/lib/rgame/engine/input/player_controller.rb +0 -14
  250. data/lib/rgame/engine/matrix.rb +0 -32
  251. data/lib/rgame/engine/resettable.rb +0 -67
  252. data/lib/rgame/engine/tile_collision.rb +0 -78
  253. data/lib/rgame/engine/ui/menu_item.rb +0 -84
@@ -3,20 +3,102 @@
3
3
  module RGame
4
4
  module Engine
5
5
  module UI
6
- # A vertical list of things to choose from, navigated by keyboard or
7
- # controller.
6
+ # Buttons to choose from, navigated by keyboard or controller.
8
7
  #
9
- # menu = layer.add_node(UI::Menu.new(item_width: 220, item_height: 44))
10
- # menu.add_item('Resume').on_activated { close }
11
- # menu.add_item('Quit').on_activated { game.close }
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 }
12
11
  #
13
12
  # ## Focus is the whole design
14
13
  #
15
- # With no pointer there is no hover, so something has to own *which
16
- # control is focused* and how the directions move it. That is this class,
17
- # and everything else about a menu follows from it: an item draws
18
- # differently because it is focused, and `ui_confirm` activates the focused
19
- # one.
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.
20
102
  #
21
103
  # ## Focus is per player, and that costs nothing
22
104
  #
@@ -29,85 +111,178 @@ module RGame
29
111
  #
30
112
  # ## What this is not
31
113
  #
32
- # It is a menu, not a widget library. Items are stacked vertically at a
33
- # fixed size, and that is the whole of its layout. The package this
34
- # replaces positioned everything absolutely and hit-tested a mouse; none
35
- # of it is a reference, and how UI should be laid out in general is still
36
- # an open question — see docs/api/ui.md, "What this is not".
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".
37
117
  class Menu < Node2D
38
- # Navigation wraps: a short vertical list is quicker to use when the
39
- # ends join, and every console menu does it.
40
- def initialize(item_width:, item_height:, spacing: 8,
41
- style: MenuItem::STYLE, **)
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, **)
42
131
  super(**)
43
- @item_width = item_width
44
- @item_height = item_height
45
- @spacing = spacing
46
- @style = style
47
- @items = []
48
- @focused_index = 0
49
- end
50
-
51
- attr_reader :items, :focused_index
52
-
53
- # Adds an item below the last one and returns it, so a caller can
54
- # connect to its signal in the same line.
55
- def add_item(label, enabled: true)
56
- item = MenuItem.new(label: label, enabled: enabled, style: @style,
57
- x: 0, y: @items.size * (@item_height + @spacing),
58
- width: @item_width, height: @item_height)
59
- @items << item
60
- add_node(item)
61
- refocus
62
- item
63
- end
64
-
65
- def focused = @items[@focused_index]
66
-
67
- # Moves focus by `delta`, skipping anything disabled, and wrapping. Does
68
- # nothing at all if no item can take focus.
69
- def focus_by(delta)
70
- return if @items.empty?
71
-
72
- index = @focused_index
73
- @items.size.times do
74
- index = (index + delta) % @items.size
75
- next unless @items[index].enabled?
76
-
77
- focus(index)
78
- return
79
- end
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
80
161
  end
81
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.
82
171
  def focus(index)
172
+ previous = focused
83
173
  @focused_index = index
84
- @items.each_with_index { |item, i| item.focused = (i == 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
85
189
  end
86
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.
87
207
  def on_control(actions)
88
- focus_by(-1) if actions.pressed?(:ui_up)
89
- focus_by(1) if actions.pressed?(:ui_down)
208
+ trigger_edge = control_trigger(actions) if @trigger
209
+ open_now if trigger_edge == :press
210
+ return unless @open
90
211
 
91
- current = focused
92
- return if current.nil?
212
+ @navigation&.on_control(actions)
213
+ press_hotkeys(actions)
214
+ @trigger ? release_trigger(trigger_edge) : confirm(actions)
215
+ end
93
216
 
94
- current.pressed = actions.held?(:ui_confirm)
95
- current.activate if actions.pressed?(:ui_confirm)
217
+ # Lets the navigation count time, then does what every node does.
218
+ def update(dt)
219
+ @navigation&.update(dt) unless @paused
220
+ super
96
221
  end
97
222
 
98
223
  private
99
224
 
100
- # Keeps focus on something usable as items arrive: the first item to be
101
- # added takes it, and a disabled first item hands it on.
102
- def refocus
103
- return if focused&.enabled?
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
104
255
 
105
- @items.each_with_index do |item, index|
106
- next unless item.enabled?
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
107
279
 
108
- return focus(index)
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)
109
285
  end
110
- focus(0)
111
286
  end
112
287
  end
113
288
  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