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,12 +3,12 @@
3
3
  module RGame
4
4
  module Engine
5
5
  # A repeating interval timer for periodic events that aren't driven by input — a
6
- # spawner emitting an enemy every N seconds, a tower's fire rate, a wave clock.
6
+ # spawner emitting an enemy every N seconds, a turret's fire rate, a wave clock.
7
7
  #
8
8
  # It only accumulates time; the owner decides what each elapsed interval means. That
9
9
  # split lets one primitive serve both styles: "act automatically" (consume every ready
10
10
  # interval) and "stay loaded until conditions allow" (check `ready?`, but `consume`
11
- # only when actually acting — so a tower with no target keeps its shot ready instead of
11
+ # only when actually acting — so a turret with no target keeps its shot ready instead of
12
12
  # wasting it). Pure and allocation-free, so it ticks on the per-frame path.
13
13
  #
14
14
  # timer = Engine::Timer.new(0.8)
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # Anything a Menu can hold: the state a button has — enabled, focused,
7
+ # pressed — and a signal for when it is chosen, with no look at all.
8
+ #
9
+ # class Swatch < UI::Button
10
+ # def initialize(color:, **)
11
+ # super(**)
12
+ # @color = color
13
+ # end
14
+ #
15
+ # def on_draw(renderer, _view)
16
+ # inset = state == :focused ? 0 : 4
17
+ # renderer.rect(inset, inset, width - (inset * 2), height - (inset * 2), color: @color)
18
+ # end
19
+ # end
20
+ #
21
+ # The look is a subclass's `on_draw`, and it reads `state` to decide what
22
+ # to draw. Everything the menu needs from a button — being told it is
23
+ # focused or pressed, being activated, being asked to `adjust` — lives
24
+ # here, so a subclass that only draws cannot leave any of it out.
25
+ #
26
+ # There is no hover, because there is no pointer: what a mouse-driven
27
+ # control would get from the cursor being over it, this gets from the Menu
28
+ # telling it it is the focused one.
29
+ #
30
+ # Its position is its own, resolved through the tree like any node's, so a
31
+ # Menu inside a PlayerLayer puts its buttons inside that player's region
32
+ # without either of them arranging it.
33
+ #
34
+ # A Button with no `on_draw` of its own draws nothing.
35
+ #
36
+ # ## The label is a translation key
37
+ #
38
+ # `label: 'play'` is a key, and the button holds it as an Engine::Text, so
39
+ # what is drawn follows `I18n.locale` without the button being rebuilt.
40
+ # Text that must not be translated — a player's name — is passed as one:
41
+ #
42
+ # UI::TextButton.new(label: 'play') # the key 'play'
43
+ # UI::TextButton.new(label: Engine::Text.literal(name)) # the name, in every language
44
+ #
45
+ # A label is drawn as it reads, so a `Text` with variables shows the values
46
+ # its last `with` was given. The node that owns them calls `with` in
47
+ # `update`, and the label follows:
48
+ #
49
+ # @continue = Engine::Text.new('continue', :saves)
50
+ # menu.add(UI::PanelButton.new(label: @continue))
51
+ # def on_update(_dt) = @continue.with(saves: @save_count)
52
+ #
53
+ # One given no `with` yet raises on the first draw, naming the keywords.
54
+ #
55
+ # `label_scope` puts a scope in front of a label given as a key, and
56
+ # UI::Menu sets it from its own `scope:` as the button is added. A label
57
+ # given as a `Text` is the caller's, scope and all, and nothing changes it.
58
+ #
59
+ # ## When a press activates
60
+ #
61
+ # `activate_on:` is `:release` by default: confirm draws the button pressed
62
+ # while it is held and activates it when it is let go, so a player who
63
+ # moves focus away while still holding cancels. `:press` activates on the
64
+ # way down — what a skill bar wants — and keeps the button drawn pressed
65
+ # for at least `PRESS_FEEDBACK` seconds, because a tap is otherwise a
66
+ # single frame of pressed that nobody sees. Its 0.1 s is Unity's, which
67
+ # holds a submitted button pressed that long; Godot holds a shortcut's for
68
+ # 0.2 s.
69
+ #
70
+ # Either way a button only acts on a press it saw start. A press whose
71
+ # release it did not see — its menu was paused or covered in between — is
72
+ # dropped rather than finished, so a menu that closes itself from
73
+ # `on_activated` and is opened again does not come back pressed. See
74
+ # UI::Menu for the other half, a menu that has not yet seen confirm up.
75
+ #
76
+ # ## A hotkey
77
+ #
78
+ # `hotkey:` names an action that presses this button whether or not it is
79
+ # focused, and without moving focus — a skill bar's number keys:
80
+ #
81
+ # UI::IconButton.new(image: :torch, hotkey: :skill3)
82
+ #
83
+ # A hotkey always activates on the press, whatever `activate_on:` says,
84
+ # and draws pressed for `PRESS_FEEDBACK` or while held. Its release
85
+ # activates nothing. The menu reads it, so the action has to be declared
86
+ # in the player's InputMap; an undeclared one raises on the first frame.
87
+ #
88
+ # **A press belongs to the source that started it.** The menu passes
89
+ # `:confirm` or `:hotkey` to `press`, `release` and `cancel_press`, and
90
+ # while one source holds the button a press from the other is ignored and
91
+ # its release ends nothing. So a player holding confirm on a `:release`
92
+ # button who taps that button's hotkey activates it once, when confirm is
93
+ # let go; and losing focus ends a confirm hold but not a hotkey one.
94
+ class Button < Node2D
95
+ signal :on_activated
96
+
97
+ PRESS_FEEDBACK = 0.1
98
+ STATES = %i[idle focused pressed disabled].freeze
99
+ ACTIVATE_ON = %i[release press].freeze
100
+ SOURCES = %i[confirm hotkey].freeze
101
+
102
+ attr_accessor :enabled
103
+ # The Engine::Text drawn for this button, or nil.
104
+ attr_reader :label
105
+ # The scope a label given as a key resolves under, or nil.
106
+ attr_reader :label_scope
107
+ # `hotkey` is an action name, or nil.
108
+ attr_reader :activate_on, :hotkey
109
+
110
+ def initialize(label: nil, enabled: true, activate_on: :release, hotkey: nil, **)
111
+ super(**)
112
+ unless ACTIVATE_ON.include?(activate_on)
113
+ raise ArgumentError, "activate_on: must be :release or :press, not #{activate_on.inspect}"
114
+ end
115
+
116
+ @label_scope = nil
117
+ self.label = label
118
+ @enabled = enabled
119
+ @activate_on = activate_on
120
+ @hotkey = hotkey
121
+ @focused = false
122
+ @holder = nil
123
+ @feedback = 0.0
124
+ end
125
+
126
+ # Sets the label: a key String or Symbol, which the button makes an
127
+ # Engine::Text of under `label_scope`; a `Text`, used as it is; or nil.
128
+ def label=(label)
129
+ @label = label && text_for(label)
130
+ @label_from_key = !(label.nil? || label.is_a?(Text))
131
+ end
132
+
133
+ # Sets the scope a label given as a key resolves under — `'title_menu'`
134
+ # makes the key `'play'` read `'title_menu.play'` — and resolves it again
135
+ # on the next draw.
136
+ def label_scope=(scope)
137
+ @label_scope = scope&.to_s
138
+ @label.scope = @label_scope if @label_from_key
139
+ end
140
+
141
+ def enabled? = @enabled
142
+ def focused? = @focused
143
+ def pressed? = !@holder.nil? || @feedback.positive?
144
+
145
+ # What to draw: `:disabled` whenever the button is disabled, whatever
146
+ # else is true; then `:pressed`, `:focused`, and otherwise `:idle`.
147
+ # Pressed does not require focus: a press can outlast the focus that
148
+ # started it by its `PRESS_FEEDBACK`.
149
+ def state
150
+ return :disabled unless @enabled
151
+ return :pressed if pressed?
152
+
153
+ @focused ? :focused : :idle
154
+ end
155
+
156
+ # Called by the Menu. Calls `on_focus_changed` when the value actually
157
+ # changes, and never for a repeated assignment, so a menu that reasserts
158
+ # focus every frame does not replay a focus sound every frame. Losing
159
+ # focus lets go of a confirm press without activating it; a hotkey press
160
+ # was never about focus, and is kept.
161
+ def focused=(value)
162
+ return if @focused == value
163
+
164
+ @focused = value
165
+ @holder = nil if !value && @holder == :confirm
166
+ on_focus_changed(value)
167
+ end
168
+
169
+ # Called by the Menu when a press from `source` — `:confirm` or
170
+ # `:hotkey` — starts on this button. A hotkey press, or any press under
171
+ # `activate_on: :press`, activates at once and returns what `activate`
172
+ # did; a confirm press under `:release` holds and returns nil. Ignored,
173
+ # returning nil, while disabled or while any source already holds it.
174
+ def press(source = :confirm)
175
+ unless SOURCES.include?(source)
176
+ raise ArgumentError, "source must be :confirm or :hotkey, not #{source.inspect}"
177
+ end
178
+ return nil unless @enabled && @holder.nil?
179
+
180
+ @holder = source
181
+ return nil unless source == :hotkey || @activate_on == :press
182
+
183
+ activate_with_feedback
184
+ end
185
+
186
+ # Called by the Menu when a press `source` started is let go. Activates
187
+ # a confirm press under `activate_on: :release`, and returns what
188
+ # `activate` did; ends nothing another source started.
189
+ def release(source = :confirm)
190
+ return nil unless @holder == source
191
+
192
+ @holder = nil
193
+ activate if source == :confirm && @activate_on == :release
194
+ end
195
+
196
+ # Called by the Menu for a press from `source` whose release this button
197
+ # never saw. Drops it, and the feedback with it, activating nothing.
198
+ def cancel_press(source = :confirm)
199
+ return unless @holder == source
200
+
201
+ @holder = nil
202
+ @feedback = 0.0
203
+ end
204
+
205
+ # The instant press: activates, and shows pressed for `PRESS_FEEDBACK`
206
+ # though nothing holds the button. What a hotkey press and a
207
+ # `:press` confirm do, available on its own for a press that ends
208
+ # somewhere other than on this button. Returns what `activate` did.
209
+ def activate_with_feedback
210
+ return nil unless @enabled
211
+
212
+ @feedback = PRESS_FEEDBACK
213
+ activate
214
+ end
215
+
216
+ # What the focused button does with `ui_left` / `ui_right`, which the
217
+ # menu hands down without knowing what kind of button it is talking to.
218
+ # A plain button has nothing to change, and answers nil the way a
219
+ # disabled `activate` does. UI::OptionButton is the one that overrides it.
220
+ def adjust(_delta) = nil
221
+
222
+ # Fires the signal and returns the button, or nil if it is disabled — so
223
+ # a caller never has to check first, and a disabled button cannot be
224
+ # activated by any route.
225
+ def activate
226
+ return nil unless @enabled
227
+
228
+ on_activated_signal.emit
229
+ self
230
+ end
231
+
232
+ # Counts down the pressed feedback, then does what every node does.
233
+ def update(dt)
234
+ @feedback -= dt if @feedback.positive? && !@paused
235
+ super
236
+ end
237
+
238
+ # Hook: override to react to gaining or losing focus — a sound, the
239
+ # start of an animation. Called only on a change.
240
+ def on_focus_changed(focused); end
241
+
242
+ private
243
+
244
+ def text_for(shown) = shown.is_a?(Text) ? shown : Text.new(shown, scope: @label_scope)
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A Menu layout: buttons stacked downwards from the menu's origin, all the
7
+ # same size, `spacing` pixels apart. A UI::Stack on the vertical axis, so
8
+ # UI::Stepping moves through it with `ui_up` and `ui_down`.
9
+ #
10
+ # UI::Menu.new(layout: UI::Column.new(item_width: 220, item_height: 44))
11
+ #
12
+ # It takes no `axis:` — a column that is not vertical is a UI::Row.
13
+ class Column < Stack
14
+ def initialize(item_width:, item_height:, spacing: 8)
15
+ super(axis: :vertical, item_width: item_width, item_height: item_height, spacing: spacing)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A picture, tinted by state, with an optional caption: the round,
7
+ # image-only entry of a quick-select wheel, or a skill with its name
8
+ # underneath.
9
+ #
10
+ # disc = UI::ShapeStyle.new(shape: :disc)
11
+ # wheel.add(UI::IconButton.new(image: :home, style: disc)).on_activated { go_home }
12
+ # bar.add(UI::IconButton.new(image: :torch, label: 'torch', style: disc))
13
+ #
14
+ # `image:` is an image id — a registered Symbol or a path String — drawn at
15
+ # its natural size, centred in the slot, or centred in the space above the
16
+ # caption when there is one. The caption is centred along the bottom edge,
17
+ # inside the slot, so the slot is still the button's whole extent.
18
+ #
19
+ # **A caption sits under the style, not on it.** With a caption the style
20
+ # is handed the space above it, so a disc is drawn round the picture and the
21
+ # caption reads on whatever is behind the button, in the button's own label
22
+ # colours. A caption wider than the disc — "Watering can" under a tool — is
23
+ # the ordinary case in a skill bar, and one drawn across a disc's edge would
24
+ # be half on the fill and half off it, legible on neither.
25
+ #
26
+ # `image: nil` draws no picture and leaves the caption, for an entry whose
27
+ # art is not in yet. An id nobody registered is a mistake rather than that
28
+ # case, and fails on the first draw like any other image.
29
+ #
30
+ # **Tint is a multiply**, so the art should be white: white takes each
31
+ # colour in `tints:` exactly, and dark art takes none of them. Scales
32
+ # default to 1 in every state because images sample nearest-neighbour, so
33
+ # any scale but a whole number doubles some pixel rows and not others; focus
34
+ # shows through the tint and the style instead. `tints:` and `scales:` need
35
+ # every state, checked here rather than the first frame one is reached.
36
+ #
37
+ # No style by default: an icon on its own is a complete look. The style is
38
+ # drawn at `z: 0` or below, the picture and the caption at `z: 1`. A style
39
+ # answering `content_color(state)` — UI::ShapeStyle does — replaces the tint
40
+ # in any state it names, because what reads on its fill is the style's to
41
+ # say: the pressed tint is the same gold as a ShapeStyle's pressed fill. It
42
+ # does not touch the caption, which is not on the fill.
43
+ class IconButton < Button
44
+ TINTS = {
45
+ idle: Util::Color.new(200, 200, 212),
46
+ focused: Util::Color.new(255, 255, 255),
47
+ pressed: Util::Color.new(240, 200, 96),
48
+ disabled: Util::Color.new(90, 88, 100)
49
+ }.freeze
50
+ SCALES = { idle: 1, focused: 1, pressed: 1, disabled: 1 }.freeze
51
+
52
+ attr_reader :image, :style, :tints, :scales, :label_color, :disabled_label_color
53
+
54
+ def initialize(image:, style: nil, tints: TINTS, scales: SCALES,
55
+ label_color: TextButton::LABEL_COLOR,
56
+ disabled_label_color: TextButton::DISABLED_LABEL_COLOR, **)
57
+ super(**)
58
+ @image = image
59
+ @style = style
60
+ @style_names_content = style.respond_to?(:content_color)
61
+ @tints = STATES.to_h { |state| [state, Util::Color.coerce(tints.fetch(state))] }.freeze
62
+ @scales = STATES.to_h { |state| [state, scales.fetch(state)] }.freeze
63
+ @label_color = Util::Color.coerce(label_color)
64
+ @disabled_label_color = Util::Color.coerce(disabled_label_color)
65
+ end
66
+
67
+ def on_draw(renderer, _view)
68
+ current = state
69
+ caption_height = @label ? renderer.text_height : 0
70
+ @style&.draw(renderer, current, width, height - caption_height)
71
+ content = @style_names_content ? @style.content_color(current) : nil
72
+ draw_image(renderer, current, content, (height - caption_height) / 2.0)
73
+ draw_caption(renderer, caption_height) if @label
74
+ end
75
+
76
+ private
77
+
78
+ def draw_image(renderer, current, content, cy)
79
+ return unless @image
80
+
81
+ renderer.image(@image, width / 2.0, cy,
82
+ scale: @scales.fetch(current), z: 1, color: content || @tints.fetch(current))
83
+ end
84
+
85
+ def draw_caption(renderer, caption_height)
86
+ text = @label.to_s
87
+ renderer.text(text, (width - renderer.text_width(text)) / 2, height - caption_height,
88
+ z: 1, color: @enabled ? @label_color : @disabled_label_color)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end