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
data/docs/api/audio.md CHANGED
@@ -1,14 +1,22 @@
1
1
  # Audio
2
2
 
3
+ rgame plays sound through three classes. `Audio` is the sound device. A `Sample`
4
+ is a short sound that can play over itself. A `Song` is a long sound streamed
5
+ from disk.
6
+
7
+ **A scene in an `RGame::Game` never touches these classes.** It emits on
8
+ [`AudioBus`](toolbox.md#audiobus--decoupled-audio-facts), for example
9
+ `RGame::Engine::AudioBus.play_sound('hit.ogg')`, and the game's director plays it.
10
+ The example below drives the device directly from a plain `App`:
11
+
3
12
  ```ruby
4
13
  require 'rgame/core'
5
14
 
6
15
  class MyGame < RGame::Core::App
7
16
  def initialize
8
17
  super(width: 800, height: 600, caption: 'demo')
9
- @audio = RGame::Core::Audio.new
10
- @hit = @audio.sample('assets/hit.ogg')
11
- @music = @audio.song('assets/theme.ogg')
18
+ @hit = audio.sample('assets/hit.ogg')
19
+ @music = audio.song('assets/theme.ogg')
12
20
  @music.play(looping: true)
13
21
  end
14
22
 
@@ -16,35 +24,40 @@ class MyGame < RGame::Core::App
16
24
  @hit.play if id == RGame::Util::Controls::KEY_SPACE
17
25
  end
18
26
  end
19
- ```
20
27
 
21
- Three classes. `Audio` is the sound device; `Sample` is a short sound played
22
- over itself; `Song` is a long one streamed from disk.
28
+ MyGame.new.run
29
+ ```
23
30
 
24
- Ogg Vorbis and WAV are the formats. Nothing else — see [What is not
25
- here](#what-is-not-here).
31
+ rgame decodes Ogg Vorbis and WAV, and nothing else. See
32
+ [What is not here](#what-is-not-here).
26
33
 
27
34
  ## The device
28
35
 
29
36
  ```ruby
30
- audio = RGame::Core::Audio.new
31
- audio.backend # => "PulseAudio"
32
- audio.volume # => 1.0
37
+ audio = app.audio # the one a game uses
38
+ audio.backend # => "PulseAudio"
39
+ audio.volume # => 1.0
33
40
  audio.volume = 0.8
34
41
  ```
35
42
 
36
- **It takes no app.** Sound is not tied to a window: it survives one being
37
- resized or recreated, and there is no GL context involved. One device for the
38
- program is the normal arrangement.
43
+ **Use `App#audio`.** An app opens the device on first use and hands it the app's
44
+ asset manager. That manager lets `play_sound('hurt.ogg')` name a file.
45
+ `RGame::Core::Audio.new` builds a standalone device for a tool or a spec. It has
46
+ no manager, so it plays only objects it receives or has registered.
47
+
48
+ **The device is not tied to a window.** Unlike an `Image`, a sound belongs to no
49
+ GL context. It survives a window resize or rebuild, and `Audio.new` takes no app.
50
+ An app holds one device only because a program wants exactly one, like its asset
51
+ manager.
39
52
 
40
- **A machine with no sound hardware still gets a working device.** It opens a
41
- null backend and plays silently rather than raising, and `#backend` returns
42
- `"Null"`. That is deliberate — a game should run on a CI runner, in a container,
43
- or on a laptop with the sound card switched off, and crashing at startup over
44
- something nobody asked for is the worse failure. If your game wants to know, ask
45
- `#backend`; nothing else changes.
53
+ **A machine without sound hardware still gets a working device.** It opens a
54
+ null backend and plays silence instead of raising, and `#backend` returns
55
+ `"Null"`. A game should run on a CI runner, in a container, or on a laptop with
56
+ its sound card off. Crashing at startup over sound would be the worse failure.
57
+ Ask `#backend` if your game needs to know; nothing else changes.
46
58
 
47
- `#volume` is the master volume, multiplied into everything the device plays.
59
+ `#volume` is the master volume. The device multiplies it into everything it
60
+ plays.
48
61
 
49
62
  ## Samples
50
63
 
@@ -55,21 +68,20 @@ hit.play
55
68
  hit.play # a second voice, over the first
56
69
  ```
57
70
 
58
- A sample is decoded once, into memory, and played as often as you like.
59
- **Playing one that is already sounding layers another voice over it** rather
60
- than restarting it which is what makes a fast run of footsteps sound like
61
- footsteps instead of one stuttering step.
71
+ A sample decodes once, into memory, and plays as often as you like. **Playing a
72
+ sample that is already sounding layers another voice over it.** It does not
73
+ restart. A fast run of footsteps therefore sounds like footsteps, not one
74
+ stuttering step.
62
75
 
63
- There is no handle for an individual play, and no way to stop one: a sample is
64
- fire-and-forget. Volume belongs to the sample and reaches every voice it has
65
- out, including the ones already sounding.
76
+ A sample is fire-and-forget. A single play has no handle and cannot be stopped.
77
+ Volume belongs to the sample and reaches every voice it has out, including those
78
+ already sounding.
66
79
 
67
- Keep samples for short sounds. A sample holds its whole decoded length in
68
- memory — measured at roughly 10 MB per minute of CD-quality stereo so a music
69
- track belongs in a `Song`.
80
+ **Use samples for short sounds only.** A sample holds its whole decoded length in
81
+ memory, roughly 10 MB per minute of CD-quality stereo. Put music in a `Song`.
70
82
 
71
- Two samples loaded from the **same path** share one decoded copy, so loading a
72
- file twice costs nothing the second time.
83
+ Two samples loaded from the **same path** share one decoded copy. Loading a file
84
+ a second time costs nothing.
73
85
 
74
86
  ## Songs
75
87
 
@@ -81,46 +93,71 @@ music.volume = 0.6
81
93
  music.stop
82
94
  ```
83
95
 
84
- A song is streamed from the file as it plays, so a three-minute track costs a
85
- buffer rather than forty megabytes.
96
+ A song streams from its file as it plays. A three-minute track costs a buffer,
97
+ not forty megabytes.
86
98
 
87
- **A song is one voice.** Playing one that is already playing restarts it from
88
- the beginning; so does playing it after `stop`. There is no pause `stop` then
89
- `play` is "from the top", not "resume".
99
+ **A song is one voice.** Playing a song that is already playing restarts it from
100
+ the beginning, and so does playing it after `stop`. Songs cannot pause: `stop`
101
+ then `play` starts from the top.
90
102
 
91
103
  `#playing?` and `#looping?` report what the song was last told to do.
92
- `#looping?` is the flag, not a count.
104
+ `#looping?` is a flag, not a count.
93
105
 
94
- **"One song at a time" is your rule, not the engine's.** Two songs can play at
95
- once, which is what a crossfade is; if a game wants only one, it stops the old
96
- one before starting the new one.
106
+ **The engine allows several songs at once**; a crossfade needs two. A game that
107
+ wants one song at a time stops the old one before starting the next.
97
108
 
98
109
  ## Playing by id
99
110
 
100
- The same boundary drawing has: gameplay emits a fact and names the sound,
101
- because a scene may not hold a `Sample`.
111
+ **Gameplay names a sound; it does not hold one.** Drawing follows the same rule,
112
+ because a scene may not hold a `Sample`. `examples/sound` and `examples/music`
113
+ show both kinds. The first fires and layers a sample from a button. The second
114
+ loops, stops and restarts a song.
102
115
 
103
116
  ```ruby
104
- audio.register_sound(:hit, app.assets.sound('example 09/hurt.ogg'))
105
- audio.register_music(:theme, app.assets.song('example 09/theme.ogg'))
106
-
107
- audio.play_sound(:hit)
108
- audio.play_music(:theme) # loops
117
+ audio.play_sound('hurt.ogg')
118
+ audio.play_music('theme.ogg') # loops
109
119
  audio.stop_music
110
120
  ```
111
121
 
112
- Registration only, unlike the renderer's draw-by-id: a sound id is whatever a
113
- game wants to call it, and there is no per-frame path here to make resolving a
114
- path worth caching. An unknown id is a `KeyError`.
122
+ **Ids come in two kinds, the same two the renderer uses.** A **String is a
123
+ root-relative path**. The device resolves it through the asset manager on first
124
+ use and remembers the result. A **Symbol is a name the game chose**, and only
125
+ registration binds one:
115
126
 
116
- **`play_music` is idempotent.** Asking for the track that is already playing
117
- does nothing, so a scene that re-emits the request every time it is entered
118
- never restarts the music mid-loop.
127
+ ```ruby
128
+ audio.register_sound(:hit, app.assets.sound('hurt.ogg'))
129
+ audio.register_music(:theme, app.assets.song('theme.ogg'))
130
+ audio.play_sound(:hit)
131
+ audio.play_music(:theme)
132
+ ```
119
133
 
120
- **`stop_music` stops the song *this registry* started.** Gosu had a
121
- process-wide "current song"; there is no such global here, because one-song-at-
122
- a-time is a game's policy rather than the engine's. A `Song` you started by hand
123
- is yours to stop, and `stop_music` with nothing playing is a no-op.
134
+ Registration also *overrides* a path. A game uses that to bind a sound it built
135
+ instead of loaded. An id that is neither registered nor resolvable raises
136
+ `KeyError`. `nil` raises `TypeError`, because an asset that resolved to nothing
137
+ is a different bug from a mistyped name.
138
+
139
+ **The device caches every resolution, and must.** `play_music` asks the song
140
+ whether it is already playing. Resolving one path to two `Song` objects would
141
+ defeat that check and restart the track on every request.
142
+
143
+ **A game wires none of this.** When `RGame::Game` starts, it subscribes an
144
+ [`AudioDirector`](toolbox.md) to the global `AudioBus`. When the loop ends, it
145
+ unsubscribes it. A scene that emits on the bus is heard with no setup. The engine
146
+ owns both steps because each failure is invisible. Without a director, the tree
147
+ runs and the events fire, but nothing plays. A director left on the bus keeps
148
+ the device, the asset manager and the whole `App` alive for the life of the
149
+ process. A single game never notices, but a process running two games does.
150
+
151
+ **`play_music` is idempotent.** Asking for the track already playing does
152
+ nothing. A scene that repeats the request each time it is entered never restarts
153
+ the music mid-loop.
154
+
155
+ **`stop_music` stops the song `play_music` most recently started**, not whatever
156
+ is sounding. The engine keeps no process-wide "current song", because one song at
157
+ a time is a game's policy. **`play_music` with a different track does not stop the
158
+ previous one**, so both play. To switch tracks, call `stop_music` first. You stop
159
+ a `Song` you started by hand yourself. `stop_music` with nothing playing does
160
+ nothing.
124
161
 
125
162
  ## Loading and failure
126
163
 
@@ -132,13 +169,18 @@ RGame::Core::Sample.new(audio, path) # the same thing
132
169
  RGame::Core::Song.new(audio, path)
133
170
  ```
134
171
 
135
- Both forms exist. Prefer `audio.sample` it reads in the direction the objects
136
- depend, and a stand-in device can offer it while `Sample.new` cannot (see
137
- [Testing](#testing)).
172
+ **Prefer `audio.sample` and `audio.song`.** They read in the direction the
173
+ objects depend, and a stand-in device can offer them, while `Sample.new` cannot
174
+ be faked (see [Testing](#testing)).
175
+
176
+ `audio.sample`, `audio.song` and the two constructors resolve a relative path
177
+ against the working directory, and cache nothing. `app.assets.sound(path)` and
178
+ `app.assets.song(path)` resolve against `media_root` and cache, and a String id
179
+ passed to `play_sound` or `play_music` goes through them.
138
180
 
139
- A file that cannot be read, or that is not a format the engine decodes, raises
140
- `RGame::Core::Sample::LoadError` or `RGame::Core::Song::LoadError`, naming the
141
- file. Both are `StandardError`, so an ordinary `rescue` catches them:
181
+ A file the engine cannot read or decode raises `RGame::Core::Sample::LoadError`
182
+ or `RGame::Core::Song::LoadError`, naming the file. Both inherit from
183
+ `StandardError`, so an ordinary `rescue` catches them:
142
184
 
143
185
  ```ruby
144
186
  @music = begin
@@ -149,12 +191,12 @@ rescue RGame::Core::Song::LoadError => e
149
191
  end
150
192
  ```
151
193
 
152
- The decision is made by **content, not by extension** a text file named
153
- `.ogg` is refused.
194
+ The engine checks the **content, not the extension**. It refuses a text file
195
+ named `.ogg`.
154
196
 
155
197
  ## Volume
156
198
 
157
- Every volume the device's, a sample's, a song's — behaves the same way:
199
+ Every volume behaves the same way, whether the device's, a sample's or a song's:
158
200
 
159
201
  | Value | Effect |
160
202
  |---|---|
@@ -163,33 +205,32 @@ Every volume — the device's, a sample's, a song's — behaves the same way:
163
205
  | above `1.0` | amplified; clipping is yours to avoid |
164
206
  | below `0.0` | clamped to `0.0` |
165
207
 
166
- Negative is clamped rather than refused because a fader driven by a slider or an
167
- easing curve undershoots constantly, and silence is the meaningful answer — a
168
- negative volume would phase-invert the samples, which is *louder*.
208
+ **A negative volume clamps to silence instead of raising.** A fader driven by a
209
+ slider or an easing curve undershoots all the time, and silence is the useful
210
+ answer. A true negative volume would invert the phase, which sounds *louder*.
169
211
 
170
- Volumes are 32-bit floats inside the mixer, so `0.8` reads back as
212
+ The mixer stores volumes as 32-bit floats, so `0.8` reads back as
171
213
  `0.800000011920929`. Compare with a tolerance, not with `==`.
172
214
 
173
215
  ## What it costs
174
216
 
175
- Nothing needs freeing. A sample or a song releases what it holds when it is
176
- collected, and each one keeps its device alive for as long as it exists so
177
- dropping your reference to the `Audio` while a sound is still around is safe,
178
- in either order.
217
+ **Nothing needs freeing.** A sample or song releases its memory when it is
218
+ collected. Each keeps its device alive while it exists. Dropping your reference
219
+ to the `Audio` while a sound remains is safe, in either order.
179
220
 
180
- `RGame::Core::Audio.debug_live_sounds` reports how many samples and songs exist.
181
- It is there for tests, not for gameplay.
221
+ `RGame::Core::Audio.debug_live_sounds` returns how many samples and songs exist.
222
+ It serves tests, not gameplay.
182
223
 
183
224
  ## Testing
184
225
 
185
- Audio follows the same pattern as drawing: the engine layer is handed a device
186
- and calls it by method name, never by class, so a headless spec can substitute
187
- one that makes no sound and records everything.
226
+ **Audio follows the drawing pattern.** The engine layer receives a device and
227
+ calls it by method name, never by class. A headless spec can substitute a device
228
+ that makes no sound and records every call.
188
229
 
189
- `spec/support/fake_audio.rb` is that stand-in, and
190
- `spec/support/shared_examples/an_audio_server.rb` is the interface both it and
191
- the real device are run against so the fake cannot drift into describing an
192
- engine that no longer exists.
230
+ rgame's own suite uses `spec/support/fake_audio.rb` as that stand-in.
231
+ `spec/support/shared_examples/an_audio_server.rb` defines the contract, and both
232
+ the fake and the real device run against it. A fake that drifted from the device
233
+ would keep `rake spec` green while the game played nothing.
193
234
 
194
235
  ```ruby
195
236
  audio = FakeAudio.new
@@ -199,10 +240,10 @@ expect(audio.played?('hit.ogg')).to be(true)
199
240
  expect(audio.calls.map(&:name)).to eq(%i[sample sample_play])
200
241
  ```
201
242
 
202
- Nothing there loads a file, opens a device, or needs a sound card.
243
+ That spec loads no file, opens no device and needs no sound card.
203
244
 
204
245
  ## What is not here
205
246
 
206
- MP3 and FLAC (Vorbis and WAV only, to keep the gem small), positional and 3D
207
- audio, effects and filters, fades, pausing, seeking, per-play handles, playback
208
- position, and recording.
247
+ rgame audio has no MP3 or FLAC; it decodes Vorbis and WAV only, to keep the gem
248
+ small. It also lacks positional and 3D audio, effects and filters, fades,
249
+ pausing, seeking, per-play handles, playback position and recording.
data/docs/api/cli.md ADDED
@@ -0,0 +1,259 @@
1
+ # The `rgame` command
2
+
3
+ Installing the gem puts one command on your PATH.
4
+
5
+ ```
6
+ gem install rgame
7
+ rgame new tictactoe
8
+ ```
9
+
10
+ | Command | Does |
11
+ |---|---|
12
+ | `rgame new NAME` | Creates the directory `NAME` and writes a runnable project into it |
13
+ | `rgame version` | Prints the installed engine version |
14
+ | `rgame help` | Prints usage |
15
+
16
+ `rgame new` accepts a name made of letters, digits, underscores and dashes,
17
+ starting with a letter, and refuses anything else. It also refuses a
18
+ path that exists and is not a directory, and a directory that holds anything. It
19
+ writes into an existing *empty* directory.
20
+
21
+ ## What `rgame new tictactoe` writes
22
+
23
+ ```
24
+ tictactoe/
25
+ ├── Gemfile rgame, plus rspec and rubocop for development
26
+ ├── Rakefile rake spec, rake rubocop, rake
27
+ ├── README.md
28
+ ├── .ruby-version the Ruby that ran `rgame new`
29
+ ├── .gitignore .rspec .rubocop.yml
30
+ ├── main.rb boots the game and nothing else
31
+ ├── game.rb class TictactoeGame < RGame::Game
32
+ ├── assets/ the game's media_root
33
+ │ └── locales/
34
+ │ └── en.yml the English translation table
35
+ ├── nodes/
36
+ │ └── root.rb class Root < RGame::Engine::Node2D
37
+ └── spec/
38
+ ├── spec_helper.rb
39
+ ├── locales_spec.rb
40
+ └── nodes/
41
+ └── root_spec.rb
42
+ ```
43
+
44
+ Then:
45
+
46
+ ```
47
+ cd tictactoe
48
+ bundle install
49
+ bundle exec rspec # passes
50
+ bundle exec rubocop # green
51
+ ruby main.rb # a window saying "Hello from tictactoe!"
52
+ ```
53
+
54
+ The generator builds the class name from the project name. It splits on
55
+ underscores and dashes and capitalises each part. `tic_tac_toe` and
56
+ `tic-tac-toe` both give `TicTacToeGame`.
57
+
58
+ The project records two versions, both taken from the running generator rather
59
+ than from a template. The `Gemfile` pins the engine loosely
60
+ (`gem 'rgame', '~> 0.3'` from rgame 0.3.0). `.ruby-version` records the exact Ruby that ran
61
+ `rgame new`, the one interpreter the project is known to work on. The Gemfile
62
+ reads that file instead of repeating the number:
63
+
64
+ ```ruby
65
+ ruby file: '.ruby-version'
66
+ ```
67
+
68
+ Version managers and Bundler both read `.ruby-version`, so the two cannot drift
69
+ apart.
70
+
71
+ **Bundler treats that line as an exact requirement.** On any other Ruby,
72
+ `bundle install` refuses to run. `4.0` does not match `4.0.5`; it matches only
73
+ `4.0`. To accept a range, state it in the `Gemfile`. `.ruby-version` must stay a
74
+ plain version number, because version managers read it:
75
+
76
+ ```ruby
77
+ ruby '~> 4.0' # instead of `ruby file: '.ruby-version'`
78
+ ```
79
+
80
+ ## Why the layout looks like this
81
+
82
+ **The generated tree follows the engine's own layering.** The layout makes the
83
+ right split the easy one in a new project. Three files carry it.
84
+
85
+ **`game.rb` is the only file that requires `rgame/game`,** so it is the only one
86
+ that loads SDL and OpenGL. It is the project's counterpart of
87
+ [`RGame::Game`](game.md): the one class allowed to know both halves of the
88
+ engine.
89
+
90
+ ```ruby
91
+ require 'rgame/game'
92
+ require_relative 'nodes/root'
93
+
94
+ class TictactoeGame < RGame::Game
95
+ WIDTH = 640
96
+ HEIGHT = 480
97
+
98
+ def initialize(**)
99
+ super(root: Root.new,
100
+ caption: 'Tictactoe',
101
+ width: WIDTH,
102
+ height: HEIGHT,
103
+ media_root: File.join(__dir__, 'assets'),
104
+ **)
105
+ end
106
+ end
107
+ ```
108
+
109
+ The bare `**` forwards every keyword to `RGame::Game`, so all its options still
110
+ work. Pass `players: 2` for split-screen. Pass `input:` to drive the game from a
111
+ scripted input backend with no hardware attached.
112
+
113
+ **`nodes/` requires `rgame`,** the graphics-free half. That loads `RGame::Util`
114
+ and `RGame::Engine`, and no graphics library. A node receives a renderer at draw
115
+ time and calls its methods by name. It never stores the renderer and never
116
+ learns its class.
117
+
118
+ ```ruby
119
+ require 'rgame'
120
+
121
+ class Root < RGame::Engine::Node2D
122
+ def initialize
123
+ super
124
+ @greeting = RGame::Engine::Text.new('root.greeting')
125
+ end
126
+
127
+ def on_draw(renderer, _view)
128
+ renderer.text(@greeting, 20, 20)
129
+ end
130
+ end
131
+ ```
132
+
133
+ Override `on_control(actions)`, `on_update(dt)` and `on_draw(renderer, view)`,
134
+ not `control`, `update` or `draw`. The engine does its bookkeeping in the outer
135
+ methods and calls these hooks, so there is no `super` to forget. See
136
+ [Scene graph](scene_graph.md).
137
+
138
+ **`spec/spec_helper.rb` also requires `rgame`,** and `.rspec` loads it before
139
+ every spec. It requires every file under `nodes/` too. The generated suite
140
+ therefore runs headless. It has no window, no GPU and no clock, and `RGame::Core` is undefined.
141
+ A spec that names Core fails loudly instead of opening a window.
142
+
143
+ For the same reason, the generated spec uses a plain spy, not a verified double.
144
+ The renderer it replaces lives on the far side of a line the suite does not
145
+ cross. The generated `.rubocop.yml` turns `RSpec/VerifiedDoubles` off and writes
146
+ down that reason:
147
+
148
+ ```ruby
149
+ RSpec.describe Root do
150
+ describe '#on_draw' do
151
+ it 'draws its greeting from the English table' do
152
+ renderer = spy('renderer')
153
+
154
+ described_class.new.on_draw(renderer, nil)
155
+
156
+ expect(renderer).to have_received(:text).with('Hello from tictactoe!', 20, 20)
157
+ end
158
+ end
159
+ end
160
+ ```
161
+
162
+ The node passes its `Text` to `text`, so the spy records the `Text`. It still
163
+ matches the String, because a `Text` is `==` to the String it reads. See
164
+ [`Text`](toolbox.md#text--the-string-a-node-draws).
165
+
166
+ Put new game logic under `nodes/`, and the whole simulation stays testable in
167
+ milliseconds with no display, however large the game grows. Logic in `game.rb`
168
+ loses that.
169
+
170
+ ## Text comes from a translation table
171
+
172
+ **The generated root node draws a key, not a String.** `assets/locales/en.yml`
173
+ holds the text, in Rails' format:
174
+
175
+ ```yaml
176
+ en:
177
+ root:
178
+ greeting: "Hello from tictactoe!"
179
+ ```
180
+
181
+ `RGame::Game` loads every `.yml` under `assets/locales/` and picks the player's
182
+ language from their operating system. See
183
+ [Game](game.md#translations-and-the-players-language). The node builds an
184
+ [`Engine::Text`](toolbox.md#text--the-string-a-node-draws) once and draws it
185
+ every frame. To add a language, add a file such as `de.yml` with the same keys.
186
+ [Localization](localization.md) covers the format, plurals and the fallback.
187
+
188
+ **The generated spec helper loads the same tables before every example.** It
189
+ reads the files once, then calls `I18n.reset`, loads each table and sets
190
+ `I18n.missing = :raise`. Every example therefore starts in the default locale
191
+ with the game's own tables, whatever the example before it loaded. A spec that
192
+ draws a key no table has fails with `I18n::MissingKey`.
193
+
194
+ The spec helper names `assets/locales` itself, because `spec/` cannot load
195
+ `game.rb`. Move the directory in one place and every spec that draws a key
196
+ fails. Reloading costs about 8 µs per key per example.
197
+
198
+ **`spec/locales_spec.rb` fails while a language lacks a key.** It checks that
199
+ the default locale has a table, and that `I18n.missing_keys` is empty for every
200
+ loaded locale. Its failure names each locale and its missing keys, such as
201
+ `{de: ["root.greeting"]}`. In the game itself, a missing key falls back to the
202
+ default locale's text, and a key no table has shows as itself.
203
+
204
+ ## The generated RuboCop configuration
205
+
206
+ The generator loads `rubocop-performance`, `rubocop-rspec` and rgame's own cops,
207
+ relaxes the `Metrics/*` cops for a game's long `update` and `draw` methods, and
208
+ allows short coordinate names. `RSpec/SpecFilePathFormat` skips
209
+ `spec/locales_spec.rb`, which describes `I18n` but checks the tables rather than a
210
+ source file.
211
+
212
+ **The gem ships its cops as a RuboCop plugin.** The generated `.rubocop.yml`
213
+ loads it by path and class:
214
+
215
+ ```yaml
216
+ plugins:
217
+ - rgame/rubocop:
218
+ plugin_class_name: RuboCop::Game::Plugin
219
+ ```
220
+
221
+ The plain form, `- rgame`, would make RuboCop require the whole engine just to
222
+ lint. An existing project gets the cops by adding those three lines.
223
+
224
+ | Cop | Refuses | Where |
225
+ |---|---|---|
226
+ | `Game/NoInterpolationInHotPath` | string interpolation in a per-frame method | everywhere but `spec/` |
227
+ | `Game/NoNeedlessAllocation` | a throwaway Array or Range literal on a per-frame path | everywhere but `spec/` |
228
+ | `Game/DrawInLocalSpace` | a node's draw method reading its own `x`, `y` or `world_x` | everywhere |
229
+ | `Game/NoLiteralText` | a String literal passed to `text` or `text_width` | everywhere |
230
+ | `Game/NoCoreInEngineLayer` | naming `RGame::Core`, or requiring `rgame/core` or `rgame/game` | `nodes/` and `spec/` |
231
+
232
+ A per-frame method is `update`, `control`, `draw`, `on_update`, `on_control` or
233
+ `on_draw`, or any method with a `# hot-path` comment on the line above its
234
+ `def`. For a label that changes, the answer to the first cop is an
235
+ [`Engine::Text`](toolbox.md#text--the-string-a-node-draws).
236
+
237
+ `Game/NoCoreInEngineLayer` guards the headless line the layout above draws. A
238
+ spec that names `RGame::Core` already fails when it runs, but the cop also
239
+ catches a branch no spec reaches. A bare `Core` counts only inside
240
+ `module RGame`, so a game's own `Core` module passes.
241
+
242
+ `Game/NoEngineInCoreLayer` ships too, switched off. It guards the engine's own
243
+ repository, and a game has no layer for it to guard.
244
+
245
+ ## Adding to the generator
246
+
247
+ `rgame new` derives its file list from `lib/rgame/cli/templates/`. A new file in
248
+ a generated project needs a new template and nothing else; there is no manifest.
249
+ The generator writes only files, so it creates a directory only by writing a
250
+ template into it.
251
+ Templates are ERB and may call `app_name`, `game_class`, `caption`,
252
+ `ruby_version` and `rgame_requirement`.
253
+
254
+ **No template may have a name starting with a dot.** The gemspec packages
255
+ `lib/**/*` with `Dir.glob`, which skips dotfiles. A template called `.gitignore`
256
+ would work in a checkout but be missing from the installed gem. Dotfile
257
+ templates therefore use a plain name (`gitignore.tt`).
258
+ `RGame::CLI::NewProject::DOTFILES` renames them on the way out.
259
+ `spec/packaging_spec.rb` fails if a dotfile template appears.