rgame 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +144 -1
  3. data/README.md +67 -65
  4. data/docs/api/README.md +86 -82
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -165
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1032 -265
  10. data/docs/api/drawing.md +160 -160
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +149 -50
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +226 -148
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +397 -244
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +186 -73
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +395 -146
  23. data/docs/api/ui.md +883 -54
  24. data/docs/api/values.md +216 -33
  25. data/examples/assets/README.md +322 -0
  26. data/examples/assets/blip.ogg +0 -0
  27. data/examples/assets/glyphs.json +5 -0
  28. data/examples/assets/glyphs.png +0 -0
  29. data/examples/assets/hero.json +12 -0
  30. data/examples/assets/hero.png +0 -0
  31. data/examples/assets/icons.json +13 -0
  32. data/examples/assets/icons.png +0 -0
  33. data/examples/assets/music.ogg +0 -0
  34. data/examples/assets/skills.json +10 -0
  35. data/examples/assets/skills.png +0 -0
  36. data/examples/assets/tileset.png +0 -0
  37. data/examples/assets/tileset.tsx +65 -0
  38. data/examples/assets/town.tmx +26 -0
  39. data/examples/assets/ui.json +11 -0
  40. data/examples/assets/ui.png +0 -0
  41. data/examples/collision/locales/en.yml +8 -0
  42. data/examples/collision/main.rb +316 -0
  43. data/examples/collision_tiles/locales/en.yml +9 -0
  44. data/examples/collision_tiles/main.rb +274 -0
  45. data/examples/fullscreen/locales/en.yml +10 -0
  46. data/examples/fullscreen/main.rb +216 -0
  47. data/examples/game_menu/locales/en.yml +8 -0
  48. data/examples/game_menu/main.rb +170 -0
  49. data/examples/input_glyphs/locales/en.yml +14 -0
  50. data/examples/input_glyphs/main.rb +213 -0
  51. data/examples/jump_topdown/locales/en.yml +9 -0
  52. data/examples/jump_topdown/main.rb +178 -0
  53. data/examples/localization/locales/de.yml +12 -0
  54. data/examples/localization/locales/en.yml +13 -0
  55. data/examples/localization/main.rb +158 -0
  56. data/examples/menu_navigation/locales/en.yml +23 -0
  57. data/examples/menu_navigation/main.rb +365 -0
  58. data/examples/music/locales/en.yml +7 -0
  59. data/examples/music/main.rb +134 -0
  60. data/examples/pathfinding/locales/en.yml +17 -0
  61. data/examples/pathfinding/main.rb +298 -0
  62. data/examples/pooling/locales/en.yml +7 -0
  63. data/examples/pooling/main.rb +259 -0
  64. data/examples/quick_wheel/locales/en.yml +16 -0
  65. data/examples/quick_wheel/main.rb +184 -0
  66. data/examples/radial_menu/locales/en.yml +16 -0
  67. data/examples/radial_menu/main.rb +184 -0
  68. data/examples/save_load/locales/en.yml +11 -0
  69. data/examples/save_load/main.rb +207 -0
  70. data/examples/save_load_ids/locales/en.yml +11 -0
  71. data/examples/save_load_ids/main.rb +322 -0
  72. data/examples/scroll_map/locales/en.yml +4 -0
  73. data/examples/scroll_map/main.rb +140 -0
  74. data/examples/signals/locales/en.yml +6 -0
  75. data/examples/signals/main.rb +278 -0
  76. data/examples/skill_bar/locales/en.yml +14 -0
  77. data/examples/skill_bar/main.rb +159 -0
  78. data/examples/sound/locales/en.yml +6 -0
  79. data/examples/sound/main.rb +122 -0
  80. data/examples/split_screen/locales/en.yml +9 -0
  81. data/examples/split_screen/main.rb +304 -0
  82. data/examples/sprite/locales/en.yml +8 -0
  83. data/examples/sprite/main.rb +180 -0
  84. data/examples/timer/locales/en.yml +12 -0
  85. data/examples/timer/main.rb +273 -0
  86. data/examples/velocity/locales/en.yml +6 -0
  87. data/examples/velocity/main.rb +196 -0
  88. data/examples/walk/locales/en.yml +4 -0
  89. data/examples/walk/main.rb +99 -0
  90. data/exe/rgame +9 -0
  91. data/ext/rgame_core/app/app.c +33 -3
  92. data/ext/rgame_core/app/locale.c +67 -0
  93. data/ext/rgame_core/app/locale.h +28 -0
  94. data/ext/rgame_core/audio/audio.c +39 -2
  95. data/ext/rgame_core/example.rb +0 -49
  96. data/ext/rgame_core/extconf.rb +0 -125
  97. data/ext/rgame_core/include/rgame/core.h +38 -2
  98. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  99. data/ext/rgame_core/ruby/core_ext.c +30 -7
  100. data/ext/rgame_core/ruby/core_ext.h +3 -0
  101. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  102. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  103. data/ext/rgame_core/ruby/renderer_ext.c +20 -20
  104. data/ext/rgame_util/extconf.rb +2 -20
  105. data/ext/rgame_util/route_search.c +305 -0
  106. data/ext/rgame_util/route_search.h +86 -0
  107. data/ext/rgame_util/route_search_ext.c +150 -0
  108. data/ext/rgame_util/solid_grid.c +58 -0
  109. data/ext/rgame_util/solid_grid.h +49 -0
  110. data/ext/rgame_util/solid_grid_ext.c +161 -0
  111. data/ext/rgame_util/tile_sweep.c +164 -0
  112. data/ext/rgame_util/tile_sweep.h +62 -0
  113. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  114. data/ext/rgame_util/util_ext.c +3 -0
  115. data/ext/rgame_util/util_ext.h +15 -0
  116. data/lib/rgame/boot.rb +0 -10
  117. data/lib/rgame/cli/new_project.rb +139 -0
  118. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  119. data/lib/rgame/cli/templates/README.md.tt +93 -0
  120. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  121. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  122. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  123. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  124. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  125. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  126. data/lib/rgame/cli/templates/rspec.tt +2 -0
  127. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  128. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  129. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  130. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  131. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  132. data/lib/rgame/cli.rb +66 -0
  133. data/lib/rgame/core/app.rb +6 -44
  134. data/lib/rgame/core/asset_manager.rb +13 -31
  135. data/lib/rgame/core/audio.rb +37 -16
  136. data/lib/rgame/core/font.rb +0 -3
  137. data/lib/rgame/core/locale.rb +22 -0
  138. data/lib/rgame/core/nine_slice.rb +0 -21
  139. data/lib/rgame/core/renderer.rb +6 -63
  140. data/lib/rgame/core/sprite_sheet.rb +0 -3
  141. data/lib/rgame/core/tile_map_renderer.rb +2 -19
  142. data/lib/rgame/core/ui_atlas.rb +28 -13
  143. data/lib/rgame/core.rb +1 -8
  144. data/lib/rgame/engine/actor_blockers.rb +131 -0
  145. data/lib/rgame/engine/animation_set.rb +1 -0
  146. data/lib/rgame/engine/audio_director.rb +36 -6
  147. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  148. data/lib/rgame/engine/camera.rb +3 -3
  149. data/lib/rgame/engine/circle_collider.rb +4 -2
  150. data/lib/rgame/engine/collision_box.rb +26 -1
  151. data/lib/rgame/engine/collision_system.rb +110 -22
  152. data/lib/rgame/engine/component.rb +34 -10
  153. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  154. data/lib/rgame/engine/components/animated_sprite.rb +27 -25
  155. data/lib/rgame/engine/components/box_collider.rb +99 -0
  156. data/lib/rgame/engine/components/camera_follow.rb +6 -5
  157. data/lib/rgame/engine/components/character_body.rb +20 -61
  158. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  159. data/lib/rgame/engine/components/collision_world.rb +159 -31
  160. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  161. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  162. data/lib/rgame/engine/components/hop.rb +76 -0
  163. data/lib/rgame/engine/components/identity.rb +73 -0
  164. data/lib/rgame/engine/components/mover.rb +285 -0
  165. data/lib/rgame/engine/components/navigator.rb +145 -0
  166. data/lib/rgame/engine/components/path_follow.rb +123 -31
  167. data/lib/rgame/engine/components/player_controller.rb +5 -2
  168. data/lib/rgame/engine/components/pool.rb +1 -1
  169. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  170. data/lib/rgame/engine/components/sprite.rb +12 -6
  171. data/lib/rgame/engine/components/targeting.rb +9 -11
  172. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  173. data/lib/rgame/engine/components/tile_world.rb +41 -13
  174. data/lib/rgame/engine/components/timer.rb +1 -1
  175. data/lib/rgame/engine/components/velocity.rb +23 -7
  176. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  177. data/lib/rgame/engine/components/world.rb +133 -0
  178. data/lib/rgame/engine/contact_set.rb +74 -0
  179. data/lib/rgame/engine/culling.rb +4 -6
  180. data/lib/rgame/engine/debug_overlay.rb +5 -11
  181. data/lib/rgame/engine/i18n/plural.rb +45 -0
  182. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  183. data/lib/rgame/engine/i18n/template.rb +59 -0
  184. data/lib/rgame/engine/i18n.rb +276 -51
  185. data/lib/rgame/engine/input/action_mapper.rb +0 -26
  186. data/lib/rgame/engine/input/actions.rb +2 -8
  187. data/lib/rgame/engine/input/input_map.rb +39 -21
  188. data/lib/rgame/engine/nav_grid.rb +87 -0
  189. data/lib/rgame/engine/node2d.rb +248 -133
  190. data/lib/rgame/engine/path.rb +4 -6
  191. data/lib/rgame/engine/players.rb +6 -13
  192. data/lib/rgame/engine/presentation.rb +171 -0
  193. data/lib/rgame/engine/scene/scene_stack.rb +4 -4
  194. data/lib/rgame/engine/sealed_privates.rb +54 -0
  195. data/lib/rgame/engine/spatial_hash.rb +51 -19
  196. data/lib/rgame/engine/text.rb +194 -0
  197. data/lib/rgame/engine/tile_blockers.rb +63 -0
  198. data/lib/rgame/engine/tile_map.rb +3 -5
  199. data/lib/rgame/engine/tile_map_layer.rb +0 -2
  200. data/lib/rgame/engine/tileset.rb +2 -4
  201. data/lib/rgame/engine/timer.rb +2 -2
  202. data/lib/rgame/engine/ui/button.rb +248 -0
  203. data/lib/rgame/engine/ui/column.rb +20 -0
  204. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  205. data/lib/rgame/engine/ui/menu.rb +246 -71
  206. data/lib/rgame/engine/ui/navigation.rb +57 -0
  207. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  208. data/lib/rgame/engine/ui/option_button.rb +163 -0
  209. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  210. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  211. data/lib/rgame/engine/ui/pointing.rb +146 -0
  212. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  213. data/lib/rgame/engine/ui/ring.rb +55 -0
  214. data/lib/rgame/engine/ui/row.rb +21 -0
  215. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  216. data/lib/rgame/engine/ui/stack.rb +58 -0
  217. data/lib/rgame/engine/ui/stepping.rb +93 -0
  218. data/lib/rgame/engine/ui/text_button.rb +59 -0
  219. data/lib/rgame/engine/viewports.rb +2 -5
  220. data/lib/rgame/engine/world_view.rb +5 -4
  221. data/lib/rgame/engine.rb +31 -25
  222. data/lib/rgame/game.rb +99 -27
  223. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  224. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  225. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  226. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  227. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  228. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  229. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  230. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  231. data/lib/rgame/rubocop/default.yml +39 -0
  232. data/lib/rgame/rubocop/plugin.rb +45 -0
  233. data/lib/rgame/rubocop.rb +11 -0
  234. data/lib/rgame/util/color.rb +20 -24
  235. data/lib/rgame/util/controls.rb +14 -28
  236. data/lib/rgame/util/route_search.rb +27 -0
  237. data/lib/rgame/util/save_file.rb +107 -0
  238. data/lib/rgame/util/solid_grid.rb +37 -0
  239. data/lib/rgame/util/tensor.rb +0 -9
  240. data/lib/rgame/util/tile_sweep.rb +36 -0
  241. data/lib/rgame/util/z.rb +4 -14
  242. data/lib/rgame/util.rb +4 -3
  243. data/lib/rgame/version.rb +1 -1
  244. data/lib/rgame.rb +0 -15
  245. metadata +167 -12
  246. data/lib/rgame/engine/actor.rb +0 -53
  247. data/lib/rgame/engine/body.rb +0 -49
  248. data/lib/rgame/engine/cached_label.rb +0 -33
  249. data/lib/rgame/engine/input/player_controller.rb +0 -14
  250. data/lib/rgame/engine/matrix.rb +0 -32
  251. data/lib/rgame/engine/resettable.rb +0 -67
  252. data/lib/rgame/engine/tile_collision.rb +0 -78
  253. data/lib/rgame/engine/ui/menu_item.rb +0 -84
@@ -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
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # Focus chosen by direction: the button a stick points at is the focused
7
+ # one. Built for a UI::Ring, which is what makes a radial menu.
8
+ #
9
+ # wheel = UI::Menu.new(x: 320, y: 240,
10
+ # layout: UI::Ring.new(radius: 120, item_width: 96, item_height: 30),
11
+ # navigation: UI::Pointing.new)
12
+ #
13
+ # It reads `ui_radial_x` and `ui_radial_y` from the universal set every
14
+ # InputMap is merged over. They share the left stick with `move_x` /
15
+ # `move_y` by default and are still separate actions, so a game can move
16
+ # its wheel to the right stick without rebinding how its players walk.
17
+ #
18
+ # ## The button pointed at is the nearest by angle
19
+ #
20
+ # Each button's centre, seen from the menu's origin, is a direction, and the
21
+ # stick's direction focuses whichever button's direction is closest. On a
22
+ # ring centred on the origin that cuts the circle into one equal sector per
23
+ # button, centred on it. It asks the layout nothing: the angles come from
24
+ # where the buttons actually are, so a ring that starts somewhere else, or a
25
+ # layout that is not a ring, cannot disagree with it.
26
+ #
27
+ # ## Below the dead zone nothing is focused
28
+ #
29
+ # A stick at rest is a direction too, just a meaningless one, and a
30
+ # released stick springs back through the middle on its way there. So a
31
+ # deflection shorter than `dead_zone` focuses nothing, and a confirm in
32
+ # that state activates nothing — letting go of the stick and pressing A
33
+ # never picks whatever happened to be under it last.
34
+ #
35
+ # That dead zone is a *length*, on the combined vector, and sits on top of
36
+ # the per-axis one ActionMapper applies: that one takes 0.15 off each axis
37
+ # and rescales, and exists to stop a worn stick drifting. It is far too
38
+ # small to decide that a player means a direction.
39
+ #
40
+ # A disabled button is never focused, so pointing at one selects nothing.
41
+ #
42
+ # ## A grace window, for a stick that springs back
43
+ #
44
+ # `grace:` is how long, in seconds, focus survives the stick entering the
45
+ # dead zone before it clears. A player choosing by letting go of a
46
+ # trigger lets go of the stick a moment earlier, and the stick is back in
47
+ # the middle before the trigger comes up; the window is what lets that
48
+ # release still choose. Leaving the stick at rest for longer than it
49
+ # chooses nothing, which is the dead-zone rule again, only later.
50
+ #
51
+ # It applies to the dead zone only: pointing at a disabled button clears
52
+ # focus at once. Time is counted in `update(dt)`, so a paused menu's window
53
+ # does not run out.
54
+ #
55
+ # `GRACE`, 0.15 s, is nine frames at 60 Hz. A released stick is back in the
56
+ # middle within a frame or two and overshoots for about 50 ms more before
57
+ # its spring settles; the window covers both, plus the moment between a
58
+ # thumb leaving the stick and a finger leaving the shoulder button. Much
59
+ # longer, and centring the stick to cancel starts to feel like waiting.
60
+ class Pointing < Navigation
61
+ DEAD_ZONE = 0.5
62
+ GRACE = 0.15
63
+
64
+ # `grace` is what was passed, or once the menu is built, the resolved
65
+ # default. nil before then when none was passed.
66
+ attr_reader :dead_zone, :grace, :aim_x, :aim_y
67
+
68
+ # `grace: nil` resolves when the menu is built: GRACE on a menu with a
69
+ # trigger, which is chosen by letting go, and 0.0 on any other.
70
+ def initialize(dead_zone: DEAD_ZONE, grace: nil)
71
+ super()
72
+ @dead_zone = dead_zone
73
+ @grace = grace
74
+ @aim_x = 0.0
75
+ @aim_y = 0.0
76
+ @at_rest = false
77
+ @at_rest_for = 0.0
78
+ end
79
+
80
+ def attach(menu)
81
+ super
82
+ @grace = menu.trigger ? GRACE : 0.0 if @grace.nil?
83
+ end
84
+
85
+ # The index of the button `(x, y)` points at, or nil if the vector is
86
+ # shorter than the dead zone. `y` is positive downwards, like the stick
87
+ # and the screen. Whether that button is enabled is not this method's
88
+ # question.
89
+ def index_at(x, y)
90
+ return nil if Math.hypot(x, y) < @dead_zone
91
+
92
+ aim = Math.atan2(y, x)
93
+ nearest = nil
94
+ best = Float::INFINITY
95
+ menu.buttons.each_index do |index|
96
+ gap = angle_between(aim, direction_of(menu.buttons[index]))
97
+ next unless gap < best
98
+
99
+ best = gap
100
+ nearest = index
101
+ end
102
+ nearest
103
+ end
104
+
105
+ def on_control(actions)
106
+ @aim_x = actions.axis(:ui_radial_x)
107
+ @aim_y = actions.axis(:ui_radial_y)
108
+ if Math.hypot(@aim_x, @aim_y) < @dead_zone
109
+ @at_rest_for = 0.0 unless @at_rest
110
+ @at_rest = true
111
+ menu.focus(nil) unless @at_rest_for < @grace
112
+ else
113
+ @at_rest = false
114
+ index = index_at(@aim_x, @aim_y)
115
+ menu.focus(index && menu.buttons[index].enabled? ? index : nil)
116
+ end
117
+ end
118
+
119
+ # Counts how long the stick has been at rest, for the grace window.
120
+ def update(dt)
121
+ @at_rest_for += dt if @at_rest
122
+ end
123
+
124
+ # Forgets the last aim and focuses nothing, so a menu opened again does
125
+ # not start where the last opening left off.
126
+ def on_opened
127
+ @aim_x = 0.0
128
+ @aim_y = 0.0
129
+ @at_rest = false
130
+ menu.focus(nil)
131
+ end
132
+
133
+ private
134
+
135
+ def direction_of(button)
136
+ Math.atan2(button.y + (button.height / 2.0), button.x + (button.width / 2.0))
137
+ end
138
+
139
+ def angle_between(first, second)
140
+ gap = (first - second).abs % (Math::PI * 2)
141
+ gap > Math::PI ? (Math::PI * 2) - gap : gap
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A wheel: a Menu with its buttons on a UI::Ring and focus chosen by
7
+ # UI::Pointing, drawing its own backdrop disc, the dead zone to scale, and
8
+ # a pointer from the centre towards where the stick aims.
9
+ #
10
+ # wheel = layer.add_node(UI::RadialMenu.new(x: 320, y: 240, radius: 150, button_width: 64))
11
+ # wheel.add(UI::IconButton.new(image: :home, style: UI::ShapeStyle.new(shape: :disc)))
12
+ #
13
+ # The menu's origin is the centre of the wheel. The backdrop is sized from
14
+ # the ring's bounds — the square enclosing every slot, whatever the number
15
+ # of buttons — plus `padding`, so it does not change size as buttons are
16
+ # added. The pointer's tip is clamped to the ring: two arrow keys read as
17
+ # (1, 1), which is longer than a stick can reach.
18
+ #
19
+ # `backdrop:`, `dead_zone_color:` and `pointer:` are colours, and `nil`
20
+ # omits that part. The buttons are children, so they draw over all three.
21
+ #
22
+ # `trigger:` makes it the wheel held open by a button — see UI::Menu, "A
23
+ # menu held open by an action" — and `grace:` is its UI::Pointing's, which
24
+ # already defaults to `Pointing::GRACE` on a menu with a trigger.
25
+ #
26
+ # It builds its own layout and navigation, so passing `layout:` or
27
+ # `navigation:` raises ArgumentError: forwarded on, either would silently
28
+ # replace the ring or the pointing this class is made of. A wheel stepped
29
+ # through with the default navigation is a UI::Menu with a UI::Ring.
30
+ class RadialMenu < Menu
31
+ BACKDROP = Util::Color.new(44, 40, 52)
32
+ DEAD_ZONE = Util::Color.new(76, 72, 88)
33
+ POINTER = Util::Color.new(240, 236, 224)
34
+
35
+ POINTER_THICKNESS = 3.0
36
+ POINTER_TIP_RADIUS = 6
37
+
38
+ attr_reader :padding, :backdrop, :dead_zone_color, :pointer
39
+
40
+ def initialize(radius:, button_width:, button_height: button_width, dead_zone: Pointing::DEAD_ZONE,
41
+ grace: nil, padding: 16, backdrop: BACKDROP, dead_zone_color: DEAD_ZONE, pointer: POINTER,
42
+ **options)
43
+ refuse_preset_keywords(options)
44
+ super(layout: Ring.new(radius: radius, item_width: button_width, item_height: button_height),
45
+ navigation: Pointing.new(dead_zone: dead_zone, grace: grace), **options)
46
+ @padding = padding
47
+ @backdrop = backdrop&.then { Util::Color.coerce(it) }
48
+ @dead_zone_color = dead_zone_color&.then { Util::Color.coerce(it) }
49
+ @pointer = pointer&.then { Util::Color.coerce(it) }
50
+ end
51
+
52
+ def on_draw(renderer, _view)
53
+ radius = layout.radius
54
+ renderer.circle(0, 0, backdrop_radius, color: @backdrop) if @backdrop
55
+ renderer.circle(0, 0, navigation.dead_zone * radius, color: @dead_zone_color) if @dead_zone_color
56
+ draw_pointer(renderer, radius) if @pointer
57
+ end
58
+
59
+ # The radius of the disc drawn behind the ring: half the larger side of
60
+ # the bounds, plus `padding`.
61
+ def backdrop_radius
62
+ ([bounds_width, bounds_height].max / 2.0) + @padding
63
+ end
64
+
65
+ private
66
+
67
+ def refuse_preset_keywords(options)
68
+ return unless options.key?(:layout) || options.key?(:navigation)
69
+
70
+ raise ArgumentError, 'a RadialMenu builds its own layout: and navigation:; use UI::Menu to choose them'
71
+ end
72
+
73
+ def draw_pointer(renderer, radius)
74
+ aim_x = navigation.aim_x
75
+ aim_y = navigation.aim_y
76
+ reach = radius / [Math.hypot(aim_x, aim_y), 1.0].max
77
+ tip_x = aim_x * reach
78
+ tip_y = aim_y * reach
79
+ renderer.line(0, 0, tip_x, tip_y, thickness: POINTER_THICKNESS, color: @pointer)
80
+ renderer.circle(tip_x, tip_y, POINTER_TIP_RADIUS, color: @pointer)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A Menu layout: items spaced evenly round a circle **centred on the
7
+ # menu's origin**, the first straight up and the rest clockwise.
8
+ #
9
+ # UI::Menu.new(x: 320, y: 240, navigation: UI::Pointing.new,
10
+ # layout: UI::Ring.new(radius: 120, item_width: 96, item_height: 30))
11
+ #
12
+ # Each item is centred on its point of the circle, so `radius` is the
13
+ # distance from the menu's origin to an item's middle. Adding an item
14
+ # re-spaces every item, because the gap between them depends on how many
15
+ # there are.
16
+ #
17
+ # Paired with UI::Pointing it is a radial menu. With the default
18
+ # UI::Stepping it is a ring that `ui_up` and `ui_down` step round, which is
19
+ # why its `axis` is `:vertical`.
20
+ class Ring
21
+ attr_reader :radius, :item_width, :item_height
22
+
23
+ def axis = :vertical
24
+
25
+ def initialize(radius:, item_width:, item_height:)
26
+ @radius = radius
27
+ @item_width = item_width
28
+ @item_height = item_height
29
+ end
30
+
31
+ def arrange(items)
32
+ count = items.size
33
+ items.each_with_index do |item, index|
34
+ angle = Math::PI * 2 * index / count
35
+ item.x = (Math.sin(angle) * @radius) - (@item_width / 2.0)
36
+ item.y = (-Math.cos(angle) * @radius) - (@item_height / 2.0)
37
+ item.width = @item_width
38
+ item.height = @item_height
39
+ end
40
+ end
41
+
42
+ # `[x, y, width, height]` of the square that encloses the whole circle
43
+ # of slots, centred on the menu's origin — not the tightest box round
44
+ # the items placed so far, so a backdrop keeps its size as a ring grows.
45
+ # All zero for no items.
46
+ def bounds(items)
47
+ return [0, 0, 0, 0] if items.empty?
48
+
49
+ [-@radius - (@item_width / 2.0), -@radius - (@item_height / 2.0),
50
+ (@radius * 2) + @item_width, (@radius * 2) + @item_height]
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A Menu layout: buttons side by side rightwards from the menu's origin,
7
+ # all the same size, `spacing` pixels apart — a skill bar, a row of tabs.
8
+ # A UI::Stack on the horizontal axis, so the default UI::Stepping moves
9
+ # through it with `ui_left` and `ui_right`, with nothing else said.
10
+ #
11
+ # UI::Menu.new(layout: UI::Row.new(item_width: 64, item_height: 64))
12
+ #
13
+ # It takes no `axis:` — a row that is not horizontal is a UI::Column.
14
+ class Row < Stack
15
+ def initialize(item_width:, item_height:, spacing: 8)
16
+ super(axis: :horizontal, item_width: item_width, item_height: item_height, spacing: spacing)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A button background drawn from plain shapes — a rectangle or a disc, a
7
+ # fill per state, and an outline while focused or pressed — so a menu can
8
+ # be built and played before any art exists.
9
+ #
10
+ # round = UI::ShapeStyle.new(shape: :disc)
11
+ # quiet = UI::ShapeStyle.new(colors: UI::ShapeStyle::COLORS.merge(idle: nil), outline: nil)
12
+ #
13
+ # The fill is inset by `border` in every state, and the outline is the whole
14
+ # shape drawn under it, so a button does not change size as its state
15
+ # changes: focus only uncovers the ring the fill leaves. A disc is centred in
16
+ # the slot, as large as its shorter side.
17
+ #
18
+ # `nil` in `colors:` draws no fill in that state, and `outline: nil` draws no
19
+ # outline. Colours are coerced once, here, so `colors:` takes arrays and a
20
+ # draw allocates nothing; and a missing state is a `KeyError` here, rather
21
+ # than on the first frame a button happens to be disabled.
22
+ #
23
+ # Fill and outline are drawn at `z: 0` and `z: -1`. Shapes default to
24
+ # `z: 50`, which would cover the label or icon a button draws at `z: 1`.
25
+ #
26
+ # ## What reads on the fill is the style's to say
27
+ #
28
+ # `content:` is the colour a button draws its label or icon in over each
29
+ # state's fill, or `nil` to keep the button's own. The pressed fill is gold,
30
+ # and so is IconButton's pressed tint, so without it a pressed icon is gold
31
+ # on gold and vanishes; a light label nearly does. The style picks the fill,
32
+ # so it is the one place that can pick what goes on it. Defaults to dark
33
+ # while pressed and the button's own colour otherwise.
34
+ class ShapeStyle
35
+ SHAPES = %i[rect disc].freeze
36
+ OUTLINED = %i[focused pressed].freeze
37
+
38
+ COLORS = {
39
+ idle: Util::Color.new(52, 48, 62),
40
+ focused: Util::Color.new(72, 66, 88),
41
+ pressed: Util::Color.new(240, 200, 96),
42
+ disabled: Util::Color.new(40, 38, 46)
43
+ }.freeze
44
+ OUTLINE = Util::Color.new(240, 200, 96)
45
+ CONTENT = { idle: nil, focused: nil, pressed: Util::Color.new(46, 34, 24), disabled: nil }.freeze
46
+
47
+ FILL_Z = 0
48
+ OUTLINE_Z = -1
49
+
50
+ attr_reader :shape, :colors, :outline, :border, :content
51
+
52
+ def initialize(shape: :rect, colors: COLORS, outline: OUTLINE, border: 3, content: CONTENT)
53
+ unless SHAPES.include?(shape)
54
+ raise ArgumentError, "shape: must be one of #{SHAPES.inspect}, not #{shape.inspect}"
55
+ end
56
+
57
+ @shape = shape
58
+ @colors = Button::STATES.to_h { |state| [state, colors.fetch(state)&.then { Util::Color.coerce(it) }] }.freeze
59
+ @content = Button::STATES.to_h do |state|
60
+ [state, content.fetch(state)&.then { Util::Color.coerce(it) }]
61
+ end.freeze
62
+ @outline = outline&.then { Util::Color.coerce(it) }
63
+ @border = border
64
+ end
65
+
66
+ DEFAULT = new
67
+
68
+ # The colour a button draws its content in over this state's fill, or
69
+ # nil for the button's own. See "What reads on the fill" above.
70
+ def content_color(state) = @content.fetch(state)
71
+
72
+ def draw(renderer, state, width, height)
73
+ outlined = @outline && OUTLINED.include?(state)
74
+ fill = @colors.fetch(state)
75
+ if @shape == :disc
76
+ draw_disc(renderer, fill, outlined, width, height)
77
+ else
78
+ draw_rect(renderer, fill, outlined, width, height)
79
+ end
80
+ end
81
+
82
+ private
83
+
84
+ def draw_rect(renderer, fill, outlined, width, height)
85
+ renderer.rect(0, 0, width, height, z: OUTLINE_Z, color: @outline) if outlined
86
+ return unless fill
87
+
88
+ renderer.rect(@border, @border, width - (@border * 2), height - (@border * 2),
89
+ z: FILL_Z, color: fill)
90
+ end
91
+
92
+ def draw_disc(renderer, fill, outlined, width, height)
93
+ cx = width / 2.0
94
+ cy = height / 2.0
95
+ radius = [width, height].min / 2.0
96
+ renderer.circle(cx, cy, radius, z: OUTLINE_Z, color: @outline) if outlined
97
+ renderer.circle(cx, cy, radius - @border, z: FILL_Z, color: fill) if fill
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A Menu layout: buttons in a line from the menu's origin, all the same
7
+ # size, `spacing` pixels apart — downwards along the `:vertical` axis,
8
+ # rightwards along the `:horizontal` one. UI::Column and UI::Row are this
9
+ # with the axis fixed, and are what a game builds.
10
+ #
11
+ # A layout is anything that answers `arrange(buttons)` by setting each
12
+ # button's position and size, relative to the menu, `bounds(buttons)` with
13
+ # the rectangle that encloses them, and `axis` — the direction UI::Stepping
14
+ # moves focus along unless told otherwise. The Menu calls `arrange` and
15
+ # `bounds` every time a button is added, so a layout whose places depend on
16
+ # how many buttons there are — UI::Ring — re-spaces them all. It holds no
17
+ # state about any one menu, so one instance may serve several.
18
+ class Stack
19
+ AXES = %i[vertical horizontal].freeze
20
+
21
+ attr_reader :axis, :item_width, :item_height, :spacing
22
+
23
+ def initialize(axis:, item_width:, item_height:, spacing: 8)
24
+ raise ArgumentError, "axis: must be one of #{AXES.inspect}, not #{axis.inspect}" unless AXES.include?(axis)
25
+
26
+ @axis = axis
27
+ @item_width = item_width
28
+ @item_height = item_height
29
+ @spacing = spacing
30
+ end
31
+
32
+ def arrange(buttons)
33
+ vertical = @axis == :vertical
34
+ step = (vertical ? @item_height : @item_width) + @spacing
35
+ buttons.each_with_index do |button, index|
36
+ button.x = vertical ? 0 : index * step
37
+ button.y = vertical ? index * step : 0
38
+ button.width = @item_width
39
+ button.height = @item_height
40
+ end
41
+ end
42
+
43
+ # `[x, y, width, height]` of the buttons in their line, relative to the
44
+ # menu: all zero for no buttons, since no gap sits before the first.
45
+ def bounds(buttons)
46
+ count = buttons.size
47
+ return [0, 0, 0, 0] if count.zero?
48
+
49
+ if @axis == :vertical
50
+ [0, 0, @item_width, (count * @item_height) + ((count - 1) * @spacing)]
51
+ else
52
+ [0, 0, (count * @item_width) + ((count - 1) * @spacing), @item_height]
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # Focus that moves one button at a time, along an axis: on a vertical one
7
+ # `ui_up` and `ui_down` step through the buttons in the order they were
8
+ # added and `ui_left` / `ui_right` go to the focused button; on a
9
+ # horizontal one the two pairs swap. Menu's default navigation.
10
+ #
11
+ # UI::Menu.new(layout: UI::Column.new(item_width: 220, item_height: 44)) # up and down
12
+ # UI::Menu.new(layout: UI::Row.new(item_width: 64, item_height: 64)) # left and right
13
+ #
14
+ # **The axis is the layout's** unless one is passed, so a UI::Row steps
15
+ # with left and right with nothing else said. `Stepping.new(axis:
16
+ # :horizontal)` overrides it — a UI::Ring stepped round with left and
17
+ # right, say. It is read once, when the menu is built.
18
+ #
19
+ # **Focus is never empty** while the menu has a button. It starts on the
20
+ # first enabled one, skips disabled ones as it moves, and wraps at the
21
+ # ends: a short list is quicker to get around when its ends join, and every
22
+ # console menu does it.
23
+ #
24
+ # **The other pair belongs to the focused button.** Only the button knows
25
+ # whether it has anything to change, so it is asked to `adjust` and a plain
26
+ # PanelButton answers nil. That is what makes UI::OptionButton work, and the
27
+ # reason an option row wants this navigation rather than UI::Pointing,
28
+ # where every direction is one to point in.
29
+ class Stepping < Navigation
30
+ ACTIONS = {
31
+ vertical: %i[ui_up ui_down ui_left ui_right].freeze,
32
+ horizontal: %i[ui_left ui_right ui_up ui_down].freeze
33
+ }.freeze
34
+
35
+ # The axis focus moves along: what was passed, or once the menu is
36
+ # built, its layout's. nil before then when none was passed.
37
+ attr_reader :axis
38
+
39
+ def initialize(axis: nil)
40
+ super()
41
+ @axis = axis
42
+ end
43
+
44
+ # Resolves the axis against the menu's layout. Raises ArgumentError for
45
+ # an axis outside UI::Stack::AXES.
46
+ def attach(menu)
47
+ super
48
+ @axis ||= menu.layout.axis
49
+ unless Stack::AXES.include?(@axis)
50
+ raise ArgumentError, "axis: must be one of #{Stack::AXES.inspect}, not #{@axis.inspect}"
51
+ end
52
+
53
+ @step_back, @step_on, @adjust_down, @adjust_up = ACTIONS.fetch(@axis)
54
+ end
55
+
56
+ # Moves focus by `delta`, skipping anything disabled, and wrapping. Does
57
+ # nothing at all if no button can take focus.
58
+ def step(delta)
59
+ buttons = menu.buttons
60
+ count = buttons.size
61
+ index = menu.focused_index || 0
62
+ tried = 0
63
+ while tried < count
64
+ index = (index + delta) % count
65
+ if buttons[index].enabled?
66
+ menu.focus(index)
67
+ return
68
+ end
69
+ tried += 1
70
+ end
71
+ end
72
+
73
+ def on_control(actions)
74
+ step(-1) if actions.pressed?(@step_back)
75
+ step(1) if actions.pressed?(@step_on)
76
+
77
+ current = menu.focused
78
+ return if current.nil?
79
+
80
+ current.adjust(-1) if actions.pressed?(@adjust_down)
81
+ current.adjust(1) if actions.pressed?(@adjust_up)
82
+ end
83
+
84
+ def on_buttons_changed
85
+ return if menu.focused&.enabled?
86
+
87
+ first = menu.buttons.index(&:enabled?)
88
+ menu.focus(first || 0)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A label centred on a style: the button to build a menu with before any
7
+ # art exists, and the base of every shipped button that draws a label.
8
+ #
9
+ # menu.add(UI::TextButton.new(label: 'play')).on_activated { start }
10
+ # menu.add(UI::TextButton.new(label: 'credits', style: UI::ShapeStyle.new(outline: nil)))
11
+ # menu.add(UI::TextButton.new(label: 'quit', style: nil))
12
+ #
13
+ # With no `style:` it draws UI::ShapeStyle::DEFAULT, which needs nothing
14
+ # registered with the renderer. `style: nil` draws the label alone. The
15
+ # style draws at `z: 0` or below and the label at `z: 1`, so the label is
16
+ # always on top. Focus, pressing and activation are all UI::Button's.
17
+ #
18
+ # The label colours are coerced once, so they may be arrays and a draw
19
+ # still allocates nothing. A style answering `content_color(state)` —
20
+ # UI::ShapeStyle does — overrides them in any state it names, because what
21
+ # reads on a fill is the style's to say: a light label on a gold pressed
22
+ # fill would all but disappear. A subclass that draws more than a label —
23
+ # UI::OptionButton — overrides `draw_foreground`, and so keeps its style
24
+ # without having to remember to draw it.
25
+ class TextButton < Button
26
+ LABEL_COLOR = Util::Color.new(240, 236, 224)
27
+ DISABLED_LABEL_COLOR = Util::Color.new(120, 116, 128)
28
+
29
+ attr_reader :style, :label_color, :disabled_label_color
30
+
31
+ def initialize(label:, style: ShapeStyle::DEFAULT, label_color: LABEL_COLOR,
32
+ disabled_label_color: DISABLED_LABEL_COLOR, **)
33
+ super(label: label, **)
34
+ @style = style
35
+ @style_names_content = style.respond_to?(:content_color)
36
+ @label_color = Util::Color.coerce(label_color)
37
+ @disabled_label_color = Util::Color.coerce(disabled_label_color)
38
+ end
39
+
40
+ def on_draw(renderer, _view)
41
+ @style&.draw(renderer, state, width, height)
42
+ draw_foreground(renderer)
43
+ end
44
+
45
+ private
46
+
47
+ def draw_foreground(renderer)
48
+ text = @label.to_s
49
+ renderer.text(text, centred_x(renderer, text), label_y(renderer), z: 1, color: current_label_color)
50
+ end
51
+
52
+ def current_label_color = style_content_color || (@enabled ? @label_color : @disabled_label_color)
53
+ def style_content_color = @style_names_content ? @style.content_color(state) : nil
54
+ def centred_x(renderer, text) = (width - renderer.text_width(text)) / 2
55
+ def label_y(renderer) = (height - renderer.text_height) / 2
56
+ end
57
+ end
58
+ end
59
+ end