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,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Menu navigation — more than one screen, and settings that are real.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/menu_navigation/main.rb
8
+ #
9
+ # Up and down move the focus, Enter or A chooses, Escape goes back. On the
10
+ # settings screen **left and right change the value under the cursor**. Quit,
11
+ # run it again, and the settings are where you left them. It exercises:
12
+ # - Scene::SceneStack — push, pop and replace, and the difference between them;
13
+ # - UI::OptionButton — a menu row whose value is chosen from a list;
14
+ # - Util::SaveFile holding settings rather than a saved game;
15
+ # - RGame::Game's fullscreen, scale_mode and audio volume, driven from a menu.
16
+ #
17
+ # ## Push and replace are different questions, and this file asks both
18
+ #
19
+ # A SceneStack updates only its top scene but **draws all of them**, and that is
20
+ # what makes it a stack rather than a variable holding the current screen. So
21
+ # the choice between pushing and replacing is really one question: *should the
22
+ # thing underneath still be there?*
23
+ #
24
+ # Settings is **pushed** over the title, and the title keeps drawing behind the
25
+ # panel — that is why the settings screen needs a panel at all. Choosing Back
26
+ # **pops**, and the title is still exactly as it was, because it was never taken
27
+ # apart. Play **replaces** the title, because a title screen behind a running
28
+ # game is nothing but a thing to draw.
29
+ #
30
+ # ## Nothing switches scenes while the tree is being walked
31
+ #
32
+ # `Shell#go` records a request and `Shell#on_update` carries it out. A menu item
33
+ # activates during `control`, which is the middle of a traversal of the very
34
+ # subtree the switch is about to take apart, and unbuilding a tree while walking
35
+ # it is the sort of bug that shows up somewhere else entirely.
36
+ #
37
+ # Deferring costs one line and buys a second thing for free: two requests in one
38
+ # tick — the Back item and Escape both firing — collapse into one transition
39
+ # rather than two pops.
40
+ #
41
+ # ## Settings apply now and persist immediately
42
+ #
43
+ # Each row applies its change the moment it is made: the volume you hear is the
44
+ # volume shown, and the window changes as you move the cursor. That is what a
45
+ # settings screen is *for*, and it removes the whole apply/cancel/revert
46
+ # question this example has no business answering.
47
+ #
48
+ # The file is written on every change too. A settings screen changes a handful
49
+ # of values a handful of times, and `SaveFile` writes atomically, so there is
50
+ # nothing to be gained by batching it and one less rule about when a save
51
+ # happens.
52
+ #
53
+ # ## A settings file is untrusted input
54
+ #
55
+ # `Settings#load` checks every value against the list the menu actually offers
56
+ # and falls back to the default for anything else. That is not defensive habit.
57
+ # `Game#scale_mode=` raises on a mode it does not know, so a hand-edited file,
58
+ # a copy from a newer version of the game, or a `null` where a number should be
59
+ # is otherwise a game that cannot be started — and the player's only fix is to
60
+ # find and delete a file nobody told them about.
61
+ #
62
+ # `UI::OptionButton#value=` has the same shape for the same reason: a value the
63
+ # list no longer offers leaves the row where it is instead of raising.
64
+
65
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
66
+ require 'rgame/game'
67
+
68
+ UI = RGame::Engine::UI
69
+
70
+ # The same 8:5 `examples/fullscreen` uses, and for the same reason: this screen
71
+ # offers a scale mode as a setting, so the four choices have to look like four
72
+ # different things. A logical size that matched the display's aspect ratio would
73
+ # make `:stretch` and `:letterbox` identical, and one that divided it evenly
74
+ # would bring `:integer` in with them.
75
+ WIDTH = 512
76
+ HEIGHT = 320
77
+ ASSETS = File.expand_path('../assets', __dir__)
78
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
79
+
80
+ BLIP = 'blip.ogg'
81
+
82
+ # The game's settings, backed by one JSON file.
83
+ #
84
+ # Plain Ruby and no node: settings are a value a scene reads, not a thing in the
85
+ # tree. It is handed to the scenes that need it rather than reached for through
86
+ # a global, which is also what lets a spec build one over a temporary directory.
87
+ class Settings
88
+ # Everything about a setting in one place: what it is called, what it may be,
89
+ # what each value is written as, and what it falls back to. The menu builds
90
+ # its rows from this and `load` validates the file against it, so a row can
91
+ # never offer a value the file would reject, or the other way round.
92
+ #
93
+ # A label and what `display` returns are translation keys; a percentage is a
94
+ # number rather than a word, so it is a literal. The captions are made once
95
+ # per value, when a row is built, rather than inside a draw method — see
96
+ # UI::OptionButton, and Game/NoInterpolationInHotPath for why a label built
97
+ # while drawing is a bug rather than a style.
98
+ #
99
+ # **These live in a class rather than at the top of the file, and a proc is
100
+ # why.** A block written at the top level of a script captures that script's
101
+ # local variables — `game`, down at the bottom, among them — and a constant
102
+ # holding that block keeps them for the life of the process. The window is
103
+ # then never released, which shows up not as a leak but as a crash on the way
104
+ # out. Inside a class body there is no such scope to capture.
105
+ ROWS = {
106
+ fullscreen: { label: 'fullscreen', default: false,
107
+ values: [false, true].freeze,
108
+ display: ->(on) { on ? 'on' : 'off' } },
109
+ scale: { label: 'scale', default: :letterbox,
110
+ values: RGame::Engine::Presentation::MODES,
111
+ display: RGame::Engine::UI::OptionButton::DISPLAY },
112
+ volume: { label: 'volume', default: 75,
113
+ values: [0, 25, 50, 75, 100].freeze,
114
+ display: ->(percent) { RGame::Engine::Text.literal("#{percent}%") } }
115
+ }.freeze
116
+
117
+ def initialize(save)
118
+ @save = save
119
+ @values = load
120
+ end
121
+
122
+ def [](key) = @values.fetch(key)
123
+
124
+ # Writes the whole file on every change. See the note at the top of this file.
125
+ def []=(key, value)
126
+ @values[key] = value
127
+ @save.write(@values)
128
+ end
129
+
130
+ # Volume reaches the device as a gain, where 1.0 is unchanged.
131
+ def volume_gain = @values.fetch(:volume) / 100.0
132
+
133
+ private
134
+
135
+ # Anything the menu does not offer becomes the default. See "A settings file
136
+ # is untrusted input".
137
+ def load
138
+ stored = @save.read
139
+ ROWS.to_h do |key, row|
140
+ value = coerce(key, stored[key])
141
+ [key, row.fetch(:values).include?(value) ? value : row.fetch(:default)]
142
+ end
143
+ end
144
+
145
+ # JSON has no Symbol, so `:letterbox` comes back as `"letterbox"`. Converting
146
+ # here rather than at the call site keeps the rest of the file working in the
147
+ # values the game actually uses.
148
+ def coerce(key, value) = key == :scale && value.is_a?(String) ? value.to_sym : value
149
+ end
150
+
151
+ # The root: scene navigation, and the settings every screen shares.
152
+ class Shell < RGame::Engine::Node2D
153
+ def initialize(settings:)
154
+ super()
155
+ @settings = settings
156
+ @stack = add_component(RGame::Engine::Scene::SceneStack.new)
157
+ @pending = nil
158
+ end
159
+
160
+ def on_add = show(:title)
161
+
162
+ # `push` keeps what is under it; `replace` does not; `pop` returns to it. Each
163
+ # one records the request and returns, for the reason at the top of this file.
164
+ def show(name) = @pending = [:push, name]
165
+ def swap(name) = @pending = [:replace, name]
166
+ def back = @pending = [:pop, nil]
167
+
168
+ # Runs after the active scene's own update has unwound, so the tree being
169
+ # rebuilt is not one the traversal is standing in.
170
+ def on_update(_dt)
171
+ return unless @pending
172
+
173
+ action, name = @pending
174
+ @pending = nil
175
+ case action
176
+ when :push then @stack.push(build(name))
177
+ when :replace then @stack.replace(build(name))
178
+ when :pop then @stack.pop
179
+ end
180
+ end
181
+
182
+ private
183
+
184
+ def build(name)
185
+ case name
186
+ when :title then TitleScene.new
187
+ when :settings then SettingsScene.new(settings: @settings)
188
+ when :play then PlayScene.new
189
+ end
190
+ end
191
+ end
192
+
193
+ # The title: a heading and three choices.
194
+ #
195
+ # The menu sits at a fixed inset rather than centred, which is the same
196
+ # limitation `examples/game_menu` runs into — centring needs the region's size
197
+ # and that only arrives at draw time. See docs/api/ui.md, "What this is not".
198
+ class TitleScene < RGame::Engine::Node2D
199
+ MENU_X = 56
200
+ MENU_Y = 104
201
+ ITEM_WIDTH = 240
202
+ ITEM_HEIGHT = 34
203
+
204
+ def initialize(**)
205
+ super
206
+ @heading = RGame::Engine::Text.new('title.heading')
207
+ @help = RGame::Engine::Text.new('title.help')
208
+ end
209
+
210
+ def on_add
211
+ menu = add_node(UI::Menu.new(x: MENU_X, y: MENU_Y, scope: 'title',
212
+ layout: UI::Column.new(item_width: ITEM_WIDTH, item_height: ITEM_HEIGHT)))
213
+ menu.add(UI::PanelButton.new(label: 'play')).on_activated { root.swap(:play) }
214
+ menu.add(UI::PanelButton.new(label: 'settings')).on_activated { root.show(:settings) }
215
+ menu.add(UI::PanelButton.new(label: 'quit')).on_activated { root.context.close }
216
+ end
217
+
218
+ def on_draw(renderer, _view)
219
+ renderer.text(@heading, MENU_X, 44)
220
+ renderer.text(@help, MENU_X, 68)
221
+ end
222
+ end
223
+
224
+ # The settings screen, pushed over the title.
225
+ #
226
+ # It draws a panel because the title is still drawing behind it — that is the
227
+ # visible half of what pushing rather than replacing means.
228
+ class SettingsScene < RGame::Engine::Node2D
229
+ PANEL_X = 28
230
+ PANEL_Y = 28
231
+ PANEL_WIDTH = WIDTH - (PANEL_X * 2)
232
+ PANEL_HEIGHT = HEIGHT - (PANEL_Y * 2)
233
+ PADDING = 18
234
+ ITEM_WIDTH = PANEL_WIDTH - (PADDING * 2)
235
+ ITEM_HEIGHT = 34
236
+
237
+ def initialize(settings:, **)
238
+ super(**)
239
+ @settings = settings
240
+ @heading = RGame::Engine::Text.new('settings.heading')
241
+ end
242
+
243
+ def on_add
244
+ @menu = add_node(UI::Menu.new(x: PANEL_X + PADDING, y: PANEL_Y + PADDING + 30, scope: 'settings',
245
+ layout: UI::Column.new(item_width: ITEM_WIDTH, item_height: ITEM_HEIGHT)))
246
+ Settings::ROWS.each { |key, row| option(key, row) }
247
+ @menu.add(UI::PanelButton.new(label: 'back')).on_activated { root.back }
248
+ end
249
+
250
+ # Escape does what Back does, because that is what every player will try
251
+ # first. Both go through the same deferred request, so pressing one on the
252
+ # same tick as the other is still one pop.
253
+ def on_control(actions)
254
+ root.back if actions.pressed?(:ui_cancel)
255
+ end
256
+
257
+ def on_draw(renderer, _view)
258
+ renderer.nine_slice(:panel, PANEL_X, PANEL_Y, PANEL_WIDTH, PANEL_HEIGHT)
259
+ renderer.text(@heading, PANEL_X + PADDING, PANEL_Y + PADDING, z: 1)
260
+ end
261
+
262
+ private
263
+
264
+ # One row per setting, built from the same table the file is validated
265
+ # against, and starting on whatever is currently in force.
266
+ def option(key, row)
267
+ values = row.fetch(:values)
268
+ button = @menu.add(UI::OptionButton.new(label: row.fetch(:label), values: values, display: row.fetch(:display),
269
+ index: values.index(@settings[key]) || 0))
270
+ button.on_changed { |value| change(key, value) }
271
+ end
272
+
273
+ def change(key, value)
274
+ @settings[key] = value
275
+ apply(key, value)
276
+ end
277
+
278
+ # `root.context` is the Game. A node may not *name* RGame::Core, but it may
279
+ # call methods on an object it is handed — the same duck-typing it uses on a
280
+ # renderer, and what `examples/fullscreen` does with the same object.
281
+ def apply(key, value)
282
+ game = root.context
283
+ case key
284
+ when :fullscreen then game.fullscreen = value
285
+ when :scale then game.scale_mode = value
286
+ when :volume then game.audio.volume = value / 100.0
287
+ end
288
+ end
289
+ end
290
+
291
+ # Somewhere for the settings to be true: a marker sweeping the view, and a sound
292
+ # on demand so the volume is something you can hear rather than read.
293
+ class PlayScene < RGame::Engine::Node2D
294
+ MARGIN = 34
295
+ RADIUS = 16
296
+ SPEED = 0.6 # sweeps per second
297
+ DISC = RGame::Util::Color.new(180, 160, 240)
298
+ EDGE = RGame::Util::Color.new(120, 200, 255)
299
+ THICK = 3
300
+
301
+ def initialize(**)
302
+ super
303
+ @phase = 0.0
304
+ @help = RGame::Engine::Text.new('play.help')
305
+ end
306
+
307
+ def on_control(actions)
308
+ RGame::Engine::AudioBus.play_sound(BLIP) if actions.pressed?(:ui_confirm)
309
+ root.swap(:title) if actions.pressed?(:ui_cancel)
310
+ end
311
+
312
+ # The sweep is state advanced by dt, and where it lands on screen is worked
313
+ # out from the view at draw time — so it follows a window that grew without
314
+ # anything here listening for the change.
315
+ def on_update(dt)
316
+ @phase = (@phase + (dt * SPEED)) % 2.0
317
+ end
318
+
319
+ def on_draw(renderer, view)
320
+ right = view.width - MARGIN
321
+ bottom = view.height - MARGIN
322
+ renderer.line(MARGIN, MARGIN, right, MARGIN, thickness: THICK, color: EDGE)
323
+ renderer.line(MARGIN, bottom, right, bottom, thickness: THICK, color: EDGE)
324
+
325
+ travelled = @phase > 1.0 ? 2.0 - @phase : @phase # there, then back
326
+ renderer.circle(MARGIN + (travelled * (right - MARGIN)), view.height / 2,
327
+ RADIUS, color: DISC)
328
+
329
+ renderer.text(@help, MARGIN, 16)
330
+ end
331
+ end
332
+
333
+ # `dir:` is normally left out and the file lands in the platform's own data
334
+ # directory. It is overridable so a driven run writes somewhere disposable
335
+ # instead of into the home directory of whoever is running it.
336
+ settings = Settings.new(
337
+ RGame::Util::SaveFile.new('settings.json', game: 'rgame-examples',
338
+ dir: ENV.fetch('RGAME_SAVE_DIR', nil))
339
+ )
340
+
341
+ # Fullscreen and the scale mode are **constructor arguments**, not something
342
+ # applied on the first tick: a window switched after it is up shows one windowed
343
+ # frame first, and that flash reads as a broken startup. See
344
+ # `examples/fullscreen`. Volume has no such problem — there is no first frame of
345
+ # sound — so it is set on the device below.
346
+ game = RGame::Game.new(
347
+ root: Shell.new(settings: settings),
348
+ caption: 'Menu navigation',
349
+ width: WIDTH,
350
+ height: HEIGHT,
351
+ media_root: ASSETS,
352
+ locales: LOCALES,
353
+ fullscreen: settings[:fullscreen],
354
+ scale_mode: settings[:scale]
355
+ )
356
+
357
+ game.audio.volume = settings.volume_gain
358
+
359
+ # A nine-slice id names an *element of an atlas* rather than a file, so there is
360
+ # nothing for the asset manager to resolve `:panel` to on demand. Registering
361
+ # the atlas binds every element in it, which is what the menu rows draw
362
+ # themselves from as well.
363
+ game.renderer.register_ui_atlas(game.assets.ui_atlas('ui.json'))
364
+
365
+ game.start
@@ -0,0 +1,7 @@
1
+ # The English table: every string this example draws.
2
+ en:
3
+ help:
4
+ keys: Enter / A starts, Escape / B stops
5
+ status:
6
+ stopped: stopped — press Enter
7
+ playing: playing — press Enter again; listen for whether it restarts
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Music — a looping track, started and stopped.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/music/main.rb
8
+ #
9
+ # Enter or Space starts it; Escape stops it. Press start again while it is
10
+ # already playing and **nothing happens** — that is deliberate, and the reason
11
+ # is below. It exercises:
12
+ # - Core::Song — a streamed track with one voice, which can be stopped and
13
+ # asked whether it is playing, named by its path;
14
+ # - Engine::AudioBus play_music / stop_music;
15
+ # - Engine::AudioDirector, the same one `examples/sound` uses, subscribed by
16
+ # RGame::Game.
17
+ #
18
+ # ## A Song is not a Sample
19
+ #
20
+ # The two types exist so that this distinction is in the type rather than in a
21
+ # convention. A `Sample` (see `examples/sound`) is decoded up front and gets a
22
+ # fresh voice per play, so it layers and `playing?` would be meaningless. A
23
+ # `Song` is *streamed* and has exactly one voice: it can be stopped, and it can
24
+ # be asked whether it is running.
25
+ #
26
+ # ## Starting it twice does not restart it
27
+ #
28
+ # `Audio#play_music` returns early when the song is already playing. That is not
29
+ # a nicety — a scene that emits `play_music` from `on_add` every time it is
30
+ # entered would otherwise chop the track back to zero each time the player
31
+ # walked through a door.
32
+ #
33
+ # **You confirm that by ear, not from this window.** Press Enter again while it
34
+ # is playing: the bar below carries on, but that only proves *this scene* did
35
+ # not reset its own timer. Whether the audio restarted is a fact about the
36
+ # device, and the only honest way to check it is to listen for the track
37
+ # jumping back to its opening bar.
38
+ #
39
+ # ## The bar is state, not a clock
40
+ #
41
+ # Nothing on a draw path reads a clock — `draw` is not called on a schedule and
42
+ # a wall clock cannot be paused or reproduced (CLAUDE.md, "`draw` renders
43
+ # state"). So the seconds below are accumulated from `dt` in `update`. It is a
44
+ # rough playhead, not a reading off the song: it says how long ago this scene
45
+ # *asked* for music, which is close enough to watch the 24s loop point go past
46
+ # and listen for whether the wrap clicks. `tools/shrink_ogg.c` measures the seam
47
+ # at 2.3% of the music's own largest step *and* the silence at each end at zero
48
+ # — this is where you find out whether those numbers were telling the truth.
49
+
50
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
51
+ require 'rgame/game'
52
+
53
+ WIDTH = 640
54
+ HEIGHT = 480
55
+ ASSETS = File.expand_path('../assets', __dir__)
56
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
57
+
58
+ LOOP_SECONDS = 24.05 # the length of music.ogg; see examples/assets/README.md
59
+
60
+ class Scene < RGame::Engine::Node2D
61
+ BAR_X = 60
62
+ BAR_Y = 260
63
+ BAR_W = 520
64
+ BAR_H = 26
65
+ TRACK = RGame::Util::Color.new(40, 48, 66)
66
+ FILL = RGame::Util::Color.new(120, 200, 255)
67
+
68
+ def initialize
69
+ super
70
+ @playing = false
71
+ @elapsed = 0.0
72
+ @help = RGame::Engine::Text.new('help.keys')
73
+ @stopped = RGame::Engine::Text.new('status.stopped')
74
+ @started = RGame::Engine::Text.new('status.playing')
75
+ @status = @stopped
76
+ end
77
+
78
+ def on_control(actions)
79
+ start if actions.pressed?(:ui_confirm)
80
+ stop if actions.pressed?(:ui_cancel)
81
+ end
82
+
83
+ def on_update(dt)
84
+ return unless @playing
85
+
86
+ @elapsed += dt
87
+ end
88
+
89
+ def on_draw(renderer, _view)
90
+ renderer.text(@help, 12, 12)
91
+ renderer.text(@status, 12, 34)
92
+
93
+ # Where the playhead sits inside one pass of the loop. It wraps at
94
+ # LOOP_SECONDS, so the bar resetting is the loop point going past.
95
+ renderer.rect(BAR_X, BAR_Y, BAR_W, BAR_H, color: TRACK)
96
+ position = @elapsed % LOOP_SECONDS
97
+ renderer.rect(BAR_X, BAR_Y, BAR_W * (position / LOOP_SECONDS), BAR_H, color: FILL)
98
+ end
99
+
100
+ private
101
+
102
+ # The bus call is unconditional on purpose: this node has no idea whether the
103
+ # track is already going, and does not need one. The guard lives in
104
+ # Audio#play_music, which owns the state that answers it.
105
+ #
106
+ # `@playing` below is *not* that state duplicated — it only drives the bar and
107
+ # the label, which is why the bar carrying on through a second press proves
108
+ # nothing about the device.
109
+ def start
110
+ RGame::Engine::AudioBus.play_music('music.ogg')
111
+ return if @playing
112
+
113
+ @playing = true
114
+ @status = @started
115
+ end
116
+
117
+ def stop
118
+ RGame::Engine::AudioBus.stop_music
119
+ @playing = false
120
+ @elapsed = 0.0
121
+ @status = @stopped
122
+ end
123
+ end
124
+
125
+ game = RGame::Game.new(
126
+ root: Scene.new,
127
+ caption: 'Music',
128
+ width: WIDTH,
129
+ height: HEIGHT,
130
+ media_root: ASSETS,
131
+ locales: LOCALES
132
+ )
133
+
134
+ game.start
@@ -0,0 +1,17 @@
1
+ # The English table: every string this example draws.
2
+ en:
3
+ help:
4
+ cursor: Arrows / d-pad move the cursor; Return / Space / A sends the hero
5
+ route: "Dots: the route the search found. Lines: the route the hero walks"
6
+ status:
7
+ idle: Move the cursor to a tile and confirm
8
+ walking: "walking: %{waypoints}, %{cells}"
9
+ arrived: "arrived: %{waypoints}, %{cells}"
10
+ waypoints:
11
+ one: "%{count} waypoint"
12
+ other: "%{count} waypoints"
13
+ cells:
14
+ one: "%{count} cell"
15
+ other: "%{count} cells"
16
+ refused: no route there
17
+ refused_after: "no route there; %{route}"