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
@@ -0,0 +1,278 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Signals — a node announcing that something happened, to nobody in particular.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/signals/main.rb
8
+ #
9
+ # **Enter** stands on the plate. Hold **Space** and hold **E**: both feed one
10
+ # component, at different rates. It exercises:
11
+ # - Signal::DSL — `signal :on_pressed` on a node of your own;
12
+ # - Signal.define(:field) — a signal that carries a payload;
13
+ # - Components::ActionTrigger — one component, several actions, one signal;
14
+ # - the connect handle, and the one place in the engine that can use it.
15
+ #
16
+ # ## The plate does not know what a door is
17
+ #
18
+ # That sentence is the whole reason signals exist. A plate that opened a door
19
+ # would have to be handed a door, which means whatever built the plate would have
20
+ # to know about doors, and a plate with nothing to open would be a special case
21
+ # to write. Instead the plate says *that it was pressed* and stops:
22
+ #
23
+ # on_pressed_signal.emit
24
+ #
25
+ # The door and the lamp connect to it, and neither appears anywhere in the
26
+ # plate. Adding a third thing that reacts is one line, in the third thing.
27
+ #
28
+ # ## Declaring one is a single line
29
+ #
30
+ # class Plate < RGame::Engine::Node2D
31
+ # signal :on_pressed
32
+ # end
33
+ #
34
+ # `Node2D` extends `Signal::DSL`, so every node and every component has this
35
+ # available. That one line generates two methods:
36
+ #
37
+ # `on_pressed { ... }` public — subscribe, and get a handle back
38
+ # `on_pressed_signal` private — the Signal itself, to emit on
39
+ #
40
+ # **Emitting is private on purpose.** Only the plate can say the plate was
41
+ # pressed. That is the same "give the user a blank hook, keep the machinery
42
+ # separate" rule that makes `on_draw` overridable while `draw` is not: the thing
43
+ # a stranger is invited to do is the safe one.
44
+ #
45
+ # ## A payload, for signals that have something to say
46
+ #
47
+ # `signal :on_pressed` carries nothing, because "it happened" is all there is.
48
+ # `Signal.define(:action)` builds one that carries a field, and
49
+ # `Components::ActionTrigger` uses exactly that:
50
+ #
51
+ # signal :on_triggered, Engine::Signal.define(:action)
52
+ #
53
+ # The engine allows one component of a class per node, so a node that wanted a
54
+ # separate component per action could not have one. Emitting the action name
55
+ # instead lets a single trigger serve `fire` and `poke` together, and the
56
+ # listener filters. Hold both keys here and watch the two counters move at
57
+ # different rates: one component, two cooldowns.
58
+ #
59
+ # ## The handle, and why almost nothing uses it
60
+ #
61
+ # `on_pressed { ... }` hands back a handle, and there is no public way to give it
62
+ # back — `on_pressed_signal` is private, and `disconnect` lives on the Signal.
63
+ # That is deliberate rather than missing. **A node's listeners die with the
64
+ # node**, so a subtree that goes away takes its subscriptions with it and there
65
+ # is nothing to leak.
66
+ #
67
+ # The exception is the one signal that outlives everything. `Engine::AudioBus` is
68
+ # a *module*, so it is built the other way round — it exposes its Signals
69
+ # publicly rather than through the DSL — and `Engine::AudioDirector` disconnects
70
+ # from them when it is released:
71
+ #
72
+ # @bus.on_play_sound.disconnect(@handles[0])
73
+ #
74
+ # It has to. A module-level hub holds its listeners for as long as the process
75
+ # runs, and the one that was forgotten there held the audio device, which held
76
+ # the asset manager, which held the window. Reach for the public-Signal shape
77
+ # when a hub outlives its subscribers, and for the DSL everywhere else.
78
+ #
79
+ # ## What it does not solve
80
+ #
81
+ # Ordering. Listeners are called in the order they connected, and nothing here
82
+ # offers a priority — if two reactions must happen in a particular order, the
83
+ # thing that cares should own both rather than connecting twice.
84
+
85
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
86
+ require 'rgame/game'
87
+
88
+ Controls = RGame::Util::Controls
89
+
90
+ WIDTH = 640
91
+ HEIGHT = 480
92
+ ASSETS = File.expand_path('../assets', __dir__)
93
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
94
+
95
+ # A plate that announces presses. It has no idea anything is listening, and
96
+ # nothing below it in this file is named anywhere inside it.
97
+ class Plate < RGame::Engine::Node2D
98
+ signal :on_pressed
99
+
100
+ PLATE_W = 150
101
+ PLATE_H = 34
102
+ UP = RGame::Util::Color.new(110, 120, 140)
103
+ DOWN = RGame::Util::Color.new(210, 190, 110)
104
+
105
+ def initialize(**)
106
+ super(width: PLATE_W, height: PLATE_H, **)
107
+ @down = false
108
+ end
109
+
110
+ def on_control(actions)
111
+ @down = actions.held?(:stand_on_plate)
112
+ # An edge, not the held state: one press is one event, however long the key
113
+ # stays down. The Signal has no opinion on that — deciding when a thing has
114
+ # happened is the emitter's job.
115
+ on_pressed_signal.emit if actions.pressed?(:stand_on_plate)
116
+ end
117
+
118
+ def on_draw(renderer, _view)
119
+ renderer.rect(0, 0, PLATE_W, PLATE_H, color: @down ? DOWN : UP)
120
+ end
121
+ end
122
+
123
+ # Reacts by opening. It knows about the plate; the plate does not know about it.
124
+ class Door < RGame::Engine::Node2D
125
+ FRAME = RGame::Util::Color.new(70, 78, 96)
126
+ LEAF = RGame::Util::Color.new(180, 140, 96)
127
+ DOOR_W = 64
128
+ DOOR_H = 96
129
+ SPEED = 2.2 # fractions of the doorway per second
130
+
131
+ def initialize(plate:, **)
132
+ super(width: DOOR_W, height: DOOR_H, **)
133
+ @plate = plate
134
+ @open = false
135
+ @openness = 0.0
136
+ end
137
+
138
+ # Connecting is the whole of the wiring, and it happens here rather than in
139
+ # `initialize` because the plate has to exist first — which in this scene it
140
+ # does, but a listener built outside the tree would not be able to rely on it.
141
+ def on_add = @plate.on_pressed { @open = !@open }
142
+
143
+ def on_update(dt)
144
+ target = @open ? 1.0 : 0.0
145
+ step = SPEED * dt
146
+ @openness += (target - @openness).clamp(-step, step)
147
+ end
148
+
149
+ def on_draw(renderer, _view)
150
+ renderer.rect(0, 0, DOOR_W, DOOR_H, color: FRAME)
151
+ # The leaf slides up into the frame, so an open door is a short one.
152
+ leaf = DOOR_H * (1.0 - @openness)
153
+ renderer.rect(0, DOOR_H - leaf, DOOR_W, leaf, color: LEAF) if leaf > 1.0
154
+ end
155
+ end
156
+
157
+ # Reacts by lighting, and knows nothing about the door reacting to the same
158
+ # thing. Two listeners on one signal, neither aware of the other.
159
+ class Lamp < RGame::Engine::Node2D
160
+ OFF = RGame::Util::Color.new(64, 62, 56)
161
+ ON = RGame::Util::Color.new(255, 226, 130)
162
+ RADIUS = 22
163
+ FADE = 1.6
164
+
165
+ def initialize(plate:, **)
166
+ super(width: RADIUS * 2, height: RADIUS * 2, **)
167
+ @plate = plate
168
+ @glow = 0.0
169
+ end
170
+
171
+ def on_add = @plate.on_pressed { @glow = 1.0 }
172
+
173
+ # The glow is state advanced by dt, not a clock read while drawing — the
174
+ # standing rule, and the reason pausing this node would freeze it mid-fade.
175
+ def on_update(dt)
176
+ @glow -= dt * FADE
177
+ @glow = 0.0 if @glow.negative?
178
+ end
179
+
180
+ def on_draw(renderer, _view)
181
+ renderer.circle(0, 0, RADIUS, color: @glow.positive? ? ON : OFF)
182
+ renderer.circle(0, 0, RADIUS * @glow, color: ON) if @glow.positive?
183
+ end
184
+ end
185
+
186
+ # One component, two actions, one signal carrying which. The counters are
187
+ # separate so the two cooldowns are visible as different rates rather than
188
+ # described as them.
189
+ class Repeater < RGame::Engine::Node2D
190
+ FIRE_COOLDOWN = 0.20
191
+ POKE_COOLDOWN = 0.55
192
+ PIP = 12
193
+ GAP = 5
194
+ ROW = 26
195
+ FIRE_COLOR = RGame::Util::Color.new(235, 120, 100)
196
+ POKE_COLOR = RGame::Util::Color.new(120, 200, 255)
197
+ MAX_PIPS = 28
198
+
199
+ def initialize(**)
200
+ super
201
+ @counts = { fire: 0, poke: 0 }
202
+ end
203
+
204
+ def on_add
205
+ trigger = add_component(
206
+ RGame::Engine::Components::ActionTrigger.new(fire: FIRE_COOLDOWN, poke: POKE_COOLDOWN)
207
+ )
208
+ # The payload is what makes one listener enough. Without it this would need
209
+ # a signal per action, and the component is allowed only one of itself per
210
+ # node to hang them on.
211
+ trigger.on_triggered { |action| @counts[action] += 1 }
212
+ end
213
+
214
+ def on_draw(renderer, _view)
215
+ draw_row(renderer, @counts[:fire], 0, FIRE_COLOR)
216
+ draw_row(renderer, @counts[:poke], ROW, POKE_COLOR)
217
+ end
218
+
219
+ private
220
+
221
+ # Counted in rectangles rather than in text, because a row makes two rates
222
+ # comparable at a glance in a way two numbers do not.
223
+ # hot-path
224
+ def draw_row(renderer, count, y, color)
225
+ [count, MAX_PIPS].min.times do |i|
226
+ renderer.rect(i * (PIP + GAP), y, PIP, PIP, color: color)
227
+ end
228
+ end
229
+ end
230
+
231
+ class Scene < RGame::Engine::Node2D
232
+ BACKDROP = RGame::Util::Color.new(30, 34, 44)
233
+
234
+ def initialize
235
+ super
236
+ @help_plate = RGame::Engine::Text.new('help.plate')
237
+ @help_unnamed = RGame::Engine::Text.new('help.unnamed')
238
+ @help_cooldowns = RGame::Engine::Text.new('help.cooldowns')
239
+ end
240
+
241
+ def on_add
242
+ plate = add_node(Plate.new(x: 60, y: 150))
243
+ # Both are handed the plate and connect themselves. The plate is handed
244
+ # nothing at all.
245
+ add_node(Door.new(plate: plate, x: 300, y: 110))
246
+ add_node(Lamp.new(plate: plate, x: 480, y: 158))
247
+ add_node(Repeater.new(x: 60, y: 330))
248
+ end
249
+
250
+ def on_draw(renderer, view)
251
+ renderer.rect(0, 0, view.width, view.height, color: BACKDROP)
252
+
253
+ renderer.text(@help_plate, 12, 12)
254
+ renderer.text(@help_unnamed, 12, 34)
255
+ renderer.text(@help_cooldowns, 12, 290)
256
+ end
257
+ end
258
+
259
+ game = RGame::Game.new(
260
+ root: Scene.new,
261
+ caption: 'Signals',
262
+ width: WIDTH,
263
+ height: HEIGHT,
264
+ media_root: ASSETS,
265
+ locales: LOCALES,
266
+ # `stand_on_plate` rather than reading `ui_confirm` directly, and the reason is
267
+ # the trap that costs an afternoon: **a key already in the default map keeps
268
+ # doing its old job too.** `ui_confirm` is Enter *and* Space, and Space is
269
+ # `fire`, which the ActionTrigger below reads — so a plate on `ui_confirm`
270
+ # would also be pressed by the key that is supposed to only feed the repeater.
271
+ # Two actions may read one key, both fire, and nothing warns.
272
+ input_map: RGame::Engine::InputMap.default.merge(
273
+ stand_on_plate: { buttons: [Controls::KEY_RETURN] },
274
+ poke: { buttons: [Controls::KEY_E] }
275
+ )
276
+ )
277
+
278
+ game.start
@@ -0,0 +1,14 @@
1
+ # The English table: every string this example draws.
2
+ en:
3
+ help:
4
+ bar: Left and right move, Enter (or A) uses — or press 1 to 5
5
+ status:
6
+ focused: "Focused: %{skill}"
7
+ used: "Used: %{skill}"
8
+ nothing_used: "Used: nothing yet"
9
+ skills:
10
+ wand: Wand
11
+ wrench: Wrench
12
+ torch: Torch
13
+ hammer: Hammer
14
+ watering_can: Watering can
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Skill bar — a row of tools, stepped through like a menu and fired by hotkey.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/skill_bar/main.rb
8
+ #
9
+ # Left and right (or the d-pad) move along the bar, and Enter or Space (or A)
10
+ # uses the focused tool. The number keys 1 to 5 use a tool directly, wherever the
11
+ # focus is. Every use clicks. It exercises:
12
+ # - UI::Row — a UI::Menu layout that lines its buttons up side by side;
13
+ # - UI::Stepping — the default navigation, which takes its axis from the
14
+ # layout, so a Row steps with left and right with nothing else said;
15
+ # - UI::Button's `hotkey:` — an action that presses one button, focused or not;
16
+ # - `activate_on: :press` — a use happens on the way down, as a skill should;
17
+ # - UI::IconButton with a caption, on a disc UI::ShapeStyle — a picture with
18
+ # its name underneath;
19
+ # - InputMap.default.merge — declaring the five hotkey actions;
20
+ # - a UI atlas's `images` — the five tools, cut from one strip by name.
21
+ #
22
+ # ## Two ways to press one button
23
+ #
24
+ # A farming sim's tool bar and a combat game's arts are the same menu: a row of
25
+ # buttons, one of them focused. What makes it a *bar* is that each button can
26
+ # also be reached without moving the focus at all. Press 5 with the Torch
27
+ # focused and the Watering can is used, drawn pressed for a moment, and the
28
+ # focus stays on the Torch — a hotkey is a second way to press a button, not a
29
+ # way to move to it.
30
+ #
31
+ # The two ways do not add up. Holding a number key uses the tool once, not once a
32
+ # frame; pressing a tool's number while Enter is already pressing it uses it
33
+ # once. The button remembers which of them started the press, so neither can end
34
+ # the other's.
35
+ #
36
+ # `activate_on: :press` is what a skill wants: the tool is used the frame the key
37
+ # goes down. A settings menu keeps the default, `:release`, where a player can
38
+ # still move away before letting go. Pressed stays visible for
39
+ # `UI::Button::PRESS_FEEDBACK` after a tap, because a single pressed frame is
40
+ # too short to see.
41
+ #
42
+ # ## The caption sits under the disc
43
+ #
44
+ # A captioned IconButton draws its style only in the space above the caption, so
45
+ # "Watering can", wider than the disc, reads on the background in every state.
46
+ #
47
+ # ## What this example does not solve
48
+ #
49
+ # Cooldowns — a sweep over a used tool needs an arc the renderer does not have —
50
+ # and a glyph on each button saying which key uses it. `examples/input_glyphs`
51
+ # draws the glyph for an action, if a bar wants one. The bar is also at a fixed
52
+ # position rather than centred on the view: its buttons are placed when they are
53
+ # added, and the view's size exists only at draw time.
54
+
55
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
56
+ require 'rgame/game'
57
+
58
+ WIDTH = 640
59
+ HEIGHT = 480
60
+ ASSETS = File.expand_path('../assets', __dir__)
61
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
62
+ Controls = RGame::Util::Controls
63
+
64
+ # The bar, and what was last used from it.
65
+ class SkillBar < RGame::Engine::Node2D
66
+ UI = RGame::Engine::UI
67
+
68
+ SLOT_WIDTH = 100
69
+ SLOT_HEIGHT = 104
70
+
71
+ # Left to right, with the action that uses each without moving focus. Each
72
+ # image names an entry of skills.json's `images`.
73
+ SKILLS = [
74
+ %i[wand wand skill1],
75
+ %i[wrench wrench skill2],
76
+ %i[torch torch skill3],
77
+ %i[hammer hammer skill4],
78
+ %i[watering_can watering_can skill5]
79
+ ].freeze
80
+
81
+ DISC = UI::ShapeStyle.new(shape: :disc)
82
+ CLICK = 'blip.ogg'
83
+
84
+ attr_reader :menu, :used
85
+
86
+ def initialize(**)
87
+ super
88
+ @used = nil
89
+ @menu = add_node(UI::Menu.new(layout: UI::Row.new(item_width: SLOT_WIDTH, item_height: SLOT_HEIGHT),
90
+ scope: 'skills'))
91
+ SKILLS.each { |key, image, hotkey| add_skill(key, image, hotkey) }
92
+ end
93
+
94
+ # The name of the focused tool, as its button draws it.
95
+ def focused_name = @menu.focused.label
96
+
97
+ private
98
+
99
+ def add_skill(key, image, hotkey)
100
+ button = UI::IconButton.new(image: image, label: key, hotkey: hotkey, activate_on: :press, style: DISC)
101
+ @menu.add(button).on_activated do
102
+ @used = button.label
103
+ RGame::Engine::AudioBus.play_sound(CLICK)
104
+ end
105
+ end
106
+ end
107
+
108
+ # The captions. Added after the bar, so its last line is the last text of every
109
+ # frame — which is what the drive script reads. Each sentence is one key with the
110
+ # tool's name as a variable, and the name is the button's own label, so a
111
+ # translator writes each name once and drawing an unchanged caption allocates
112
+ # nothing.
113
+ class Caption < RGame::Engine::Node2D
114
+ def initialize(bar:, **)
115
+ super(**)
116
+ @bar = bar
117
+ @help = RGame::Engine::Text.new('help.bar')
118
+ @focused = RGame::Engine::Text.new('status.focused', :skill)
119
+ @used = RGame::Engine::Text.new('status.used', :skill)
120
+ @nothing_used = RGame::Engine::Text.new('status.nothing_used')
121
+ end
122
+
123
+ def on_draw(renderer, _view)
124
+ renderer.text(@help, 12, 12)
125
+ renderer.text(@focused.with(skill: @bar.focused_name.to_s), 12, HEIGHT - 52)
126
+ used = @bar.used
127
+ renderer.text(used ? @used.with(skill: used.to_s) : @nothing_used, 12, HEIGHT - 30)
128
+ end
129
+ end
130
+
131
+ class Scene < RGame::Engine::Node2D
132
+ def on_add
133
+ bar = add_node(SkillBar.new(y: 300))
134
+ bar.x = (WIDTH - bar.menu.bounds_width) / 2
135
+ add_node(Caption.new(bar: bar))
136
+ end
137
+ end
138
+
139
+ game = RGame::Game.new(
140
+ root: Scene.new,
141
+ caption: 'Skill bar',
142
+ width: WIDTH,
143
+ height: HEIGHT,
144
+ media_root: ASSETS,
145
+ locales: LOCALES,
146
+ input_map: RGame::Engine::InputMap.default.merge(
147
+ skill1: { buttons: [Controls::KEY_1] },
148
+ skill2: { buttons: [Controls::KEY_2] },
149
+ skill3: { buttons: [Controls::KEY_3] },
150
+ skill4: { buttons: [Controls::KEY_4] },
151
+ skill5: { buttons: [Controls::KEY_5] }
152
+ )
153
+ )
154
+
155
+ # An image id that is a Symbol is a name rather than a path, so the tools are
156
+ # registered once: the atlas cuts each from the strip and registers it by name.
157
+ game.renderer.register_ui_atlas(game.assets.ui_atlas('skills.json'))
158
+
159
+ game.start
@@ -0,0 +1,6 @@
1
+ # The English table: every string this example draws.
2
+ en:
3
+ help:
4
+ press: Press Space (or A on a controller) — fast, to hear them overlap
5
+ hud:
6
+ plays: "plays: %{plays}"
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Sound — a sound effect fired by a button, and the seam it travels through.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/sound/main.rb
8
+ #
9
+ # Space (or A on a pad) plays a blip. Press it fast: each press is another
10
+ # voice, so they overlap rather than cutting each other off. It exercises:
11
+ # - Core::Sample — a decoded, fire-and-forget effect, named by its path;
12
+ # - Engine::AudioBus — where gameplay says *what happened*;
13
+ # - Engine::AudioDirector — what turns that into playback, subscribed by
14
+ # RGame::Game;
15
+ # - Engine::Text — a count on screen, from a key, that costs no String per frame.
16
+ #
17
+ # ## Why a node does not just call the audio device
18
+ #
19
+ # It cannot. A node lives in `RGame::Engine`, and that layer may not name
20
+ # `RGame::Core` at all — not a require, not a constant. So `Scene#on_control`
21
+ # below emits on `AudioBus`, a global signal hub that knows nothing about sound,
22
+ # and an `AudioDirector` forwards it to the real device. `RGame::Game` subscribes
23
+ # that director when it starts and releases it when the loop ends, so this file
24
+ # wires nothing.
25
+ #
26
+ # That looks like ceremony until you notice what it buys: the same scene runs in
27
+ # a headless spec with a recording fake substituted for the device, and the spec
28
+ # asserts on *what was asked for* rather than on what came out of a speaker.
29
+ # `spec/support/shared_examples/an_audio_server.rb` is the contract both sides
30
+ # are held to.
31
+ #
32
+ # ## A Sample is not a Song
33
+ #
34
+ # There are two types on purpose. This is a `Sample`: decoded up front, and every
35
+ # `play_sound` starts a **new voice** layered over whatever is already sounding.
36
+ # It has no `playing?`, because the question has no answer for a sound that may
37
+ # be going five times at once. `examples/music` is the other type.
38
+ #
39
+ # ## Naming a sound is naming a file
40
+ #
41
+ # `'blip.ogg'` is a path, resolved through the asset manager on first use and
42
+ # remembered — the same thing `examples/walk` does with `'hero.json'`. Nothing is
43
+ # registered anywhere in this file.
44
+ #
45
+ # The other id space is a Symbol, which `audio.register_sound(:hit, ...)` binds
46
+ # to a sound the game chose the name for, or assembled rather than loaded. Audio
47
+ # and the renderer take the same two, so one rule covers both: a String is a
48
+ # file, a Symbol is a name.
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
+ class Scene < RGame::Engine::Node2D
59
+ RING = RGame::Util::Color.new(120, 200, 255)
60
+ FADE = 3.0 # how fast the flash decays, in units per second
61
+ MIN_R = 18.0 # radius at rest
62
+ GROW = 90.0 # extra radius at full flash
63
+
64
+ def initialize
65
+ super
66
+ @flash = 0.0
67
+ @plays = 0
68
+ # Built once, here, and that is the whole trick: the key's text renders when
69
+ # the count changes rather than when a frame is drawn. See the note above
70
+ # on_draw.
71
+ @plays_label = RGame::Engine::Text.new('hud.plays', :plays)
72
+ @help = RGame::Engine::Text.new('help.press')
73
+ end
74
+
75
+ def on_control(actions)
76
+ return unless actions.pressed?(:fire)
77
+
78
+ # The whole of "make a noise": a fact, emitted. Nothing here knows whether
79
+ # anything is listening, or what it would play it on.
80
+ RGame::Engine::AudioBus.play_sound('blip.ogg')
81
+
82
+ @flash = 1.0
83
+ @plays += 1
84
+ end
85
+
86
+ # The flash is state, advanced by dt — not a clock read at draw time. That is
87
+ # the standing rule (CLAUDE.md, "`draw` renders state"), and it is why pausing
88
+ # this node would freeze the ring mid-fade instead of letting it run on.
89
+ def on_update(dt)
90
+ @flash -= dt * FADE
91
+ @flash = 0.0 if @flash.negative?
92
+ end
93
+
94
+ # A count, drawn as text, allocating nothing.
95
+ #
96
+ # `"plays: #{@plays}"` written here would build a String on every frame
97
+ # forever, and `Game/NoInterpolationInHotPath` is right to refuse it: a steady
98
+ # 60fps frame that allocates is a GC pause waiting to happen.
99
+ # `Engine::Text` holds the last string and renders `plays: %{plays}` from the
100
+ # table again only when the value changes, so pressing the key costs one
101
+ # allocation and the thousand frames between presses cost none.
102
+ #
103
+ # **Reach for it rather than inventing a way round the rule.** A label built
104
+ # from something that changes is common enough that the engine owns the
105
+ # answer — see CLAUDE.md, "A label built from a changing value".
106
+ def on_draw(renderer, _view)
107
+ renderer.circle(WIDTH / 2, HEIGHT / 2, MIN_R + (GROW * @flash), color: RING)
108
+ renderer.text(@help, 12, 12)
109
+ renderer.text(@plays_label.with(plays: @plays), 12, 44)
110
+ end
111
+ end
112
+
113
+ game = RGame::Game.new(
114
+ root: Scene.new,
115
+ caption: 'Sound',
116
+ width: WIDTH,
117
+ height: HEIGHT,
118
+ media_root: ASSETS,
119
+ locales: LOCALES
120
+ )
121
+
122
+ game.start
@@ -0,0 +1,9 @@
1
+ # The English table: every string this example draws.
2
+ en:
3
+ help:
4
+ keys: "Player one: arrows or WASD. Space waves"
5
+ join: Press A on a controller to join player two
6
+ hud:
7
+ # The number is given once, when the badge is built, and the Text keeps it.
8
+ player: "Player %{number}"
9
+ waves: "waves: %{count}"