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,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Collision tiles — walking into a wall.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/collision_tiles/main.rb
8
+ #
9
+ # Arrow keys, WASD, a d-pad or a left stick walk the hero. The trees and the
10
+ # fence are solid; hold a diagonal against the fence and watch what happens.
11
+ # East of the start is a spiky ball that stops the hero and costs a life. It
12
+ # exercises:
13
+ # - Components::TileWorld — the scene-scoped system that owns the map, and
14
+ # answers every question about what is solid;
15
+ # - Components::CollisionWorld — the broadphase the ball's box lives in;
16
+ # - Components::FeetCollider — the shape at the hero's feet, derived from the
17
+ # sprite's size, and the one shape both of those stop;
18
+ # - Components::CharacterBody with `blocked_by: %i[tiles spike]` — steps
19
+ # resolved against the map *and* the ball, using that shape;
20
+ # - CharacterBody's `on_blocked` — what stopped a step, as an event;
21
+ # - Components::CameraFollow — a camera on the feet rather than on the head;
22
+ # - Engine::Text — the life count, a key with a variable, rendered only when it changes;
23
+ # - the `:tilemap` asset loader and TileMapLayer, both from `examples/scroll_map`.
24
+ #
25
+ # ## Two indexes, one list of names
26
+ #
27
+ # The walls are not objects. A character against a grid of solid tiles is not a
28
+ # pairwise overlap problem, because there are no pairs to find — a tile map
29
+ # already knows what is where, so the wall a step would hit is arithmetic on the
30
+ # step: divide by the tile size and ask the grid. Bucketing forty thousand tiles
31
+ # into a spatial hash would be building a second index of something already
32
+ # indexed, and then paying for it every frame.
33
+ #
34
+ # The spiky ball is an object, and it is in one. The scene mounts a
35
+ # `CollisionWorld`, the ball wears a box on the `:spike` layer, and the hero
36
+ # names that layer beside `:tiles`. So a step here is resolved against a grid and
37
+ # against a broadphase at once, one axis at a time, stopping at whichever answer
38
+ # is nearer — and the only place in this file where that shows is the list of
39
+ # names in `blocked_by`.
40
+ #
41
+ # `examples/collision` is the third case, and the one neither of those is: shapes
42
+ # that are told when they start and stop *overlapping*, with nothing stopped.
43
+ #
44
+ # ## What is solid is decided in Tiled, not in code
45
+ #
46
+ # A tile is solid because it carries a collision shape in the tileset — an
47
+ # `<objectgroup>` on that tile in `tileset.tsx` — and `Engine::Tileset` reads
48
+ # that. Nothing in this file lists tile ids, and adding a solid tile to the map
49
+ # means drawing it in Tiled.
50
+ #
51
+ # ## A feet box, not the sprite's box
52
+ #
53
+ # The translucent rectangle drawn over the hero is the thing that actually
54
+ # collides. It is twelve by six at the bottom of a sixteen by twenty-two sprite,
55
+ # and everything above it is a picture that nothing tests.
56
+ #
57
+ # A top-down view needs that. The sprite is drawn as though seen from in front,
58
+ # so its upper half is *height* rather than floor, and a character whose whole
59
+ # frame collided could not stand with their head overlapping the fence behind
60
+ # them — which is what standing close to it looks like from this angle.
61
+ #
62
+ # `FeetCollider` builds it from the node's own dimensions, which
63
+ # `AnimatedSprite` fills in from the sprite frame when it attaches. So the box
64
+ # follows the art: re-export the hero at a different size and the feet stay at
65
+ # the feet. Note what the `Hero` does *not* pass — a sprite size, an offset, or
66
+ # the box to anybody else. One component owns the shape and the body reads it.
67
+ #
68
+ # ## Sliding is the whole feel
69
+ #
70
+ # Walk diagonally into the fence and the hero keeps moving. The step is resolved
71
+ # one axis at a time, so the blocked half is dropped and the free half is kept,
72
+ # and a player pressing down-left against a wall travels left along it.
73
+ #
74
+ # It is worth doing deliberately: put the hero against the fence, hold down and
75
+ # left together, and you arrive at the one gap in it without ever steering. A
76
+ # version that stopped dead on contact would be correct by the letter — you did
77
+ # walk into a wall — and would feel broken in a way a player cannot articulate.
78
+ #
79
+ # ## Being stopped is an event
80
+ #
81
+ # `on_blocked` fires on the step something starts stopping the body and
82
+ # `on_unblocked` on the step it stops, each once per blocker. That is what spends
83
+ # a life here: walk into the ball and lose one, keep pushing and lose no more,
84
+ # back off and walk in again and lose another.
85
+ #
86
+ # It has to be that signal rather than `on_hit`, and this is the part worth
87
+ # reading twice. Blocking leaves the two boxes exactly *touching*, and an overlap
88
+ # test is half-open, so a successfully blocked pair does not overlap and reports
89
+ # no contact at all. A ball that hurt through `on_hit` would have to be walked
90
+ # into — which is exactly what blocking it prevents.
91
+ #
92
+ # The handler is given whatever stopped the step, and reads `layer` and `node`
93
+ # off it the same way whichever kind it was. So the hero's one handler asks
94
+ # `by.layer == :spike` and ignores the fence, which arrives as `:tiles` with no
95
+ # node behind it.
96
+ #
97
+ # ## The system is not optional, and says so
98
+ #
99
+ # `blocked_by: [:tiles]` raises at attach when the scene has no `TileWorld`, and
100
+ # again when the node has no collider to resolve, rather than falling back to
101
+ # free movement. The fallback would look like a collision bug — an actor walking
102
+ # through walls — and the cause would be a scene three files away that never
103
+ # mounted the system.
104
+ #
105
+ # ## What this scene does not solve
106
+ #
107
+ # Nothing here moves but the hero. The ball is an ordinary actor with a box in
108
+ # the broadphase, so it could walk about and block just the same — a crowd whose
109
+ # every walker declares `%i[tiles hero npc]` is that, and shoulders past itself. A hazard that holds still is
110
+ # simply the smaller thing to read.
111
+ #
112
+ # Nor is anything here overlapping. Blocking and contact are reports of two
113
+ # different facts and a game usually wants both: a trigger walked through, a
114
+ # pickup that vanishes, a puddle that slows whoever stands in it. Every one of
115
+ # those is `on_hit` on a box nobody named in a `blocked_by`, which is
116
+ # `examples/collision`.
117
+
118
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
119
+ require 'rgame/game'
120
+
121
+ WIDTH = 640
122
+ HEIGHT = 480
123
+ ASSETS = File.expand_path('../assets', __dir__)
124
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
125
+
126
+ MAP = 'town.tmx'
127
+ SPEED = 80.0 # px/s
128
+
129
+ # The feet: twelve wide and six tall, centred at the bottom of the 16x22 sprite.
130
+ FEET_WIDTH = 12
131
+ FEET_HEIGHT = 6
132
+
133
+ # Where the camera looks: the middle of that box rather than the node's origin,
134
+ # which is the sprite's top-left corner. Without it the hero drifts below the
135
+ # centre of the screen by half a sprite, which reads as the camera lagging.
136
+ CAMERA_OFFSET_X = 8
137
+ CAMERA_OFFSET_Y = 19
138
+
139
+ # North of the fence and a few tiles east of its gap, so that holding a diagonal
140
+ # into it arrives there.
141
+ START_X = 384.0
142
+ START_Y = 272.0
143
+
144
+ # The spiky ball: three tiles east of the hero, on the open ground between the
145
+ # trees, and low enough that its box sits across the hero's feet.
146
+ BALL_X = 434.0
147
+ BALL_Y = 284.0
148
+ BALL_SIZE = 12
149
+
150
+ LIVES = 3
151
+
152
+ # A sprite, a feet box, a body that knows about walls, and a camera. The only
153
+ # thing this class writes itself is the drawing of that box, so what collides is
154
+ # visible.
155
+ class Hero < RGame::Engine::Node2D
156
+ FEET = RGame::Util::Color.rgba(255, 110, 110, 120)
157
+
158
+ attr_reader :lives
159
+
160
+ def initialize(camera:, **)
161
+ super(**)
162
+ @lives = LIVES
163
+ add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: 'hero.json'))
164
+ # The two lines that differ from `examples/walk`: a shape, and a body told
165
+ # what that shape may not pass through. Everything about the intent — the
166
+ # controller writing it, the sprite reading it back as a facing, the speed it
167
+ # is scaled by — is the same CharacterBody as there.
168
+ @collider = add_component(RGame::Engine::Components::FeetCollider.new(
169
+ width: FEET_WIDTH, height: FEET_HEIGHT
170
+ ))
171
+ body = add_component(RGame::Engine::Components::CharacterBody.new(
172
+ speed: SPEED, blocked_by: %i[tiles spike]
173
+ ))
174
+ # One handler for everything that can stop a step, because everything that
175
+ # can stop a step reports the same two things. The fence arrives here too, as
176
+ # :tiles with no node behind it, and is ignored.
177
+ body.on_blocked { |by| @lives = [@lives - 1, 0].max if by.layer == :spike }
178
+ add_component(RGame::Engine::Components::PlayerController.new)
179
+ add_component(RGame::Engine::Components::CameraFollow.new(
180
+ camera: camera, offset_x: CAMERA_OFFSET_X, offset_y: CAMERA_OFFSET_Y
181
+ ))
182
+ end
183
+
184
+ # Components draw first, so this lands over the sprite. The box's offsets are
185
+ # relative to the node's origin, which is exactly where the renderer already
186
+ # is — a collision box and local space agree about what (0, 0) means.
187
+ def on_draw(renderer, _view)
188
+ box = @collider.box
189
+ renderer.rect(box.offset_x, box.offset_y, box.width, box.height, color: FEET)
190
+ end
191
+ end
192
+
193
+ # The hazard: a box on the `:spike` layer, and a drawing of a ball with spikes on
194
+ # it. It has no body and no controller — it never moves — so the only thing it
195
+ # contributes to a step is its rectangle, sitting in the broadphase waiting to be
196
+ # found.
197
+ class SpikyBall < RGame::Engine::Node2D
198
+ BODY = RGame::Util::Color.rgba(190, 90, 210, 255)
199
+ SPIKE = RGame::Util::Color.rgba(120, 40, 140, 255)
200
+ RADIUS = BALL_SIZE / 2.0
201
+ SPIKE_LENGTH = 5
202
+
203
+ def initialize(**)
204
+ super
205
+ add_component(RGame::Engine::Components::BoxCollider.new(
206
+ width: BALL_SIZE, height: BALL_SIZE, layer: :spike
207
+ ))
208
+ end
209
+
210
+ # Local space, like every other on_draw: the node's transform is already
211
+ # pushed, so the ball is drawn around its own origin and lands wherever the
212
+ # node is.
213
+ def on_draw(renderer, _view)
214
+ renderer.line(RADIUS, -SPIKE_LENGTH, RADIUS, BALL_SIZE + SPIKE_LENGTH, thickness: 2.0, color: SPIKE)
215
+ renderer.line(-SPIKE_LENGTH, RADIUS, BALL_SIZE + SPIKE_LENGTH, RADIUS, thickness: 2.0, color: SPIKE)
216
+ renderer.circle(RADIUS, RADIUS, RADIUS, color: BODY)
217
+ end
218
+ end
219
+
220
+ # The scene: mount the map, mount the world, put the hero in it. The same three
221
+ # steps as `examples/scroll_map`, with an actor that collides instead of a rig
222
+ # that does not.
223
+ class Scene < RGame::Engine::Node2D
224
+ def on_add
225
+ map = root.context.assets.tilemap(MAP).map
226
+ players = root.system(RGame::Engine::Players)
227
+
228
+ # Solidity and the world's size come from the same system, because they are
229
+ # the same fact about the same map. Handing it the cameras bounds them to
230
+ # the map's edges.
231
+ add_component(RGame::Engine::Components::TileWorld.new(
232
+ map: map, tilemap_id: MAP, cameras: players.map(&:camera)
233
+ ))
234
+ # The second index, for the things the map knows nothing about. Its cells are
235
+ # sized to the actors rather than to the 16px tiles: a broadphase cell wants
236
+ # to hold a handful of the things it buckets, and these are a dozen pixels
237
+ # across.
238
+ add_component(RGame::Engine::Components::CollisionWorld.new(cell_size: 32))
239
+
240
+ view = add_node(RGame::Engine::WorldView.new)
241
+ # The node handed back is the gap between the ground layers and anything
242
+ # Tiled flags `above` — where things that walk around belong.
243
+ actors = RGame::Engine::TileMapLayer.mount(view)
244
+ actors.add_node(SpikyBall.new(x: BALL_X, y: BALL_Y))
245
+ @hero = actors.add_node(Hero.new(camera: players.primary.camera, x: START_X, y: START_Y))
246
+ # Built here rather than in on_draw: the text renders once per change of the
247
+ # count, and the frames in between read the string it kept.
248
+ @lives_label = RGame::Engine::Text.new('hud.lives', :lives)
249
+ @help_walk = RGame::Engine::Text.new('help.walk')
250
+ @help_slide = RGame::Engine::Text.new('help.slide')
251
+ @help_box = RGame::Engine::Text.new('help.box')
252
+ @help_ball = RGame::Engine::Text.new('help.ball')
253
+ end
254
+
255
+ # Screen space: outside the WorldView, so it stays put while the map scrolls.
256
+ def on_draw(renderer, _view)
257
+ renderer.text(@help_walk, 12, 12)
258
+ renderer.text(@help_slide, 12, 34)
259
+ renderer.text(@help_box, 12, 56)
260
+ renderer.text(@help_ball, 12, 78)
261
+ renderer.text(@lives_label.with(lives: @hero.lives), 12, 100)
262
+ end
263
+ end
264
+
265
+ game = RGame::Game.new(
266
+ root: Scene.new,
267
+ caption: 'Collision tiles',
268
+ width: WIDTH,
269
+ height: HEIGHT,
270
+ media_root: ASSETS,
271
+ locales: LOCALES
272
+ )
273
+
274
+ game.start
@@ -0,0 +1,10 @@
1
+ # The English table: every string this example draws.
2
+ en:
3
+ state:
4
+ fullscreen: fullscreen — F returns to a window
5
+ windowed: windowed — F goes fullscreen
6
+ modes:
7
+ disabled: "left/right — scale_mode :disabled, the view is the window"
8
+ stretch: "left/right — scale_mode :stretch, fills and distorts"
9
+ letterbox: "left/right — scale_mode :letterbox, uniform with bars"
10
+ integer: "left/right — scale_mode :integer, whole-number scale only"
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Fullscreen — starting fullscreen, and switching while the game runs.
4
+ #
5
+ # Run it windowed or fullscreen, and start in any scale mode:
6
+ #
7
+ # ruby examples/fullscreen/main.rb
8
+ # RGAME_FULLSCREEN=1 ruby examples/fullscreen/main.rb
9
+ # RGAME_SCALE=integer ruby examples/fullscreen/main.rb
10
+ #
11
+ # **F** (or Y on a pad) switches fullscreen. **Left and right** cycle through all
12
+ # four scale modes. Watch the circle: it is round in three of them and an ellipse
13
+ # in `:stretch`, which is the whole difference between uniform and per-axis
14
+ # scaling in one shape. It exercises:
15
+ # - RGame::Game.new(fullscreen:) — the window opens that way;
16
+ # - App#fullscreen? / #fullscreen= — switching at any time;
17
+ # - RGame::Game#scale_mode= — switching that at any time too;
18
+ # - the `view` a node is drawn with — where its size comes from;
19
+ # - InputMap.default.merge — declaring one action of your own.
20
+ #
21
+ # ## Two ways in, because games need both
22
+ #
23
+ # **Opening fullscreen is a constructor argument, not a switch afterwards.**
24
+ # Both end up fullscreen, but a switch made after the window is up shows one
25
+ # windowed frame first — the flash that reads as a broken startup. Passing
26
+ # `fullscreen: true` creates the window fullscreen and there is no first frame
27
+ # to see. A game whose settings file says fullscreen should pass the setting
28
+ # here, not apply it on the first tick.
29
+ #
30
+ # **A game need not offer a way back.** Nothing here is required: leave the
31
+ # toggle out and the game runs fullscreen for its whole life, which is a
32
+ # perfectly ordinary choice.
33
+ #
34
+ # ## Desktop fullscreen, not a mode change
35
+ #
36
+ # The window takes the whole screen at the screen's own resolution. Nothing asks
37
+ # the display to switch mode, so the change is instant, costs no mode list to
38
+ # choose from, and leaves every other window where it was. What a game gets is a
39
+ # bigger view rather than a different one.
40
+ #
41
+ # ## Two answers to a bigger window, and this file shows both
42
+ #
43
+ # Under `:disabled` the view *is* the window: it grows, and the border below
44
+ # grows with it because it is drawn from `view.width`. Extra screen becomes
45
+ # extra room. That is what a tool or an editor wants, and it is why the mode
46
+ # exists — but it is the opt-out rather than the default.
47
+ #
48
+ # Under any other mode `width` and `height` stop describing the window and start
49
+ # describing the game. The view stays 512x320 however big the window is, and the
50
+ # whole frame is scaled onto it — so the border stays exactly where it is and
51
+ # gets bigger. That is what a play area wants, it is what `RGame::Game` does
52
+ # unless told otherwise, and it is the only one of the two that saves a game
53
+ # whose layout is hardcoded.
54
+ #
55
+ # ## Why 512x320, which is nobody's screen
56
+ #
57
+ # The size is picked to make the four modes *differ*, which is the opposite of
58
+ # what a real game wants and exactly what an example about them needs.
59
+ #
60
+ # A logical size that shares its aspect ratio with the display makes `:stretch`
61
+ # and `:letterbox` compute the same numbers, and one that divides the display
62
+ # evenly makes `:integer` agree with both. Choose 640x360 on a 1920x1080 screen
63
+ # and all three land on 3.000x3.000 — four modes, three of them identical, and
64
+ # nothing to look at. **The prettiest resolution is the one that teaches least.**
65
+ #
66
+ # 8:5 matches no common display, so `:stretch` always distorts. And the fit is
67
+ # never a whole number on a 16:9 screen, so `:integer` always gives up some
68
+ # screen that `:letterbox` keeps. Measured, on the sizes people actually own:
69
+ #
70
+ # screen stretch letterbox integer
71
+ # 1920x1080 3.75 x 3.375 3.375 3
72
+ # 2560x1440 5.00 x 4.500 4.500 4
73
+ # 3840x2160 7.50 x 6.750 6.750 6
74
+ #
75
+ # A game would choose the other way round. Pick the logical size so that the
76
+ # scale on your players' screens is a whole number, and `:integer` costs nothing
77
+ # at all.
78
+ #
79
+ # Cycling through the four with left and right is the quickest way to see what
80
+ # each costs:
81
+ #
82
+ # :disabled the border grows to the window; nothing is scaled
83
+ # :stretch fills the window; the circle goes oval
84
+ # :letterbox uniform, centred, bars on two sides
85
+ # :integer the same but whole-number only, so usually wider bars
86
+ #
87
+ # `:integer` is what keeps pixel art crisp: at 1.875x some source pixels cover
88
+ # two screen pixels and some cover one, and the unevenness crawls as things
89
+ # move. It costs screen — see RGame::Engine::Presentation for the measurements
90
+ # against real screen sizes.
91
+ #
92
+ # ## Layout comes from the view, not from a constant
93
+ #
94
+ # `WIDTH` and `HEIGHT` below are what the *window* opens at and returns to. They
95
+ # are not what to draw against: fullscreen makes the view the size of the
96
+ # screen. Every `draw` is handed the `view` it is being drawn into, and
97
+ # `view.width` / `view.height` are the only honest source for "how big is the
98
+ # thing I am drawing in" — which is why the border below follows the switch with
99
+ # nothing listening for it.
100
+
101
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
102
+ require 'rgame/game'
103
+
104
+ Controls = RGame::Util::Controls
105
+
106
+ # Deliberately not a shape any display has — see the note above on why an
107
+ # example about scale modes wants a resolution that scales *badly*.
108
+ WIDTH = 512
109
+ HEIGHT = 320
110
+ ASSETS = File.expand_path('../assets', __dir__)
111
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
112
+
113
+ # Set in the environment so one file can demonstrate both openings without being
114
+ # edited. A real game reads this from its settings file — see the plan's
115
+ # `examples/save_load`.
116
+ START_FULLSCREEN = ENV.fetch('RGAME_FULLSCREEN', '0') != '0'
117
+
118
+ # :letterbox (what RGame::Game does unless told otherwise), :integer, :stretch
119
+ # or :disabled. A real game reads this from its settings the same way it reads
120
+ # the fullscreen flag. This one starts on `:disabled` so that pressing right
121
+ # walks the list in the order the prose above discusses them.
122
+ SCALE_MODE = ENV.fetch('RGAME_SCALE', 'disabled').to_sym
123
+
124
+ class Scene < RGame::Engine::Node2D
125
+ INSET = 24
126
+ MARK = 40
127
+ THICK = 3
128
+ EDGE = RGame::Util::Color.new(120, 200, 255)
129
+ CORNER = RGame::Util::Color.new(255, 210, 120)
130
+
131
+ DISC = RGame::Util::Color.new(180, 160, 240)
132
+
133
+ # Taken from the engine rather than written out, so this example cannot fall
134
+ # behind the modes that actually exist.
135
+ MODES = RGame::Engine::Presentation::MODES
136
+
137
+ # One Text per state rather than a string built per frame: a label made with
138
+ # interpolation in a draw method allocates a String every frame, which is what
139
+ # Game/NoInterpolationInHotPath refuses. Each mode's line is keyed by the mode.
140
+ STATE = { true => RGame::Engine::Text.new('state.fullscreen'),
141
+ false => RGame::Engine::Text.new('state.windowed') }.freeze
142
+ MODE_LABEL = MODES.to_h { |mode| [mode, RGame::Engine::Text.new(mode, scope: 'modes')] }.freeze
143
+
144
+ def initialize(**)
145
+ super
146
+ @mode_index = MODES.index(SCALE_MODE) || 0
147
+ end
148
+
149
+ def on_control(actions)
150
+ # `context` is the Game, which is an App. A node may not *name* RGame::Core,
151
+ # but it may call methods on an object it is handed — the same duck-typing a
152
+ # node uses on the renderer.
153
+ if actions.pressed?(:fullscreen)
154
+ app = root.context
155
+ app.fullscreen = !app.fullscreen?
156
+ end
157
+
158
+ # ui_left and ui_right come from the default map, so cycling needs no action
159
+ # of its own.
160
+ cycle_mode(-1) if actions.pressed?(:ui_left)
161
+ cycle_mode(1) if actions.pressed?(:ui_right)
162
+ end
163
+
164
+ # `view` is the region being drawn into. Under fullscreen it is the screen;
165
+ # windowed it is the window. Reading it rather than WIDTH/HEIGHT is the whole
166
+ # reason the border below keeps up.
167
+ def on_draw(renderer, view)
168
+ right = view.width - INSET
169
+ bottom = view.height - INSET
170
+
171
+ renderer.line(INSET, INSET, right, INSET, thickness: THICK, color: EDGE)
172
+ renderer.line(INSET, bottom, right, bottom, thickness: THICK, color: EDGE)
173
+ renderer.line(INSET, INSET, INSET, bottom, thickness: THICK, color: EDGE)
174
+ renderer.line(right, INSET, right, bottom, thickness: THICK, color: EDGE)
175
+
176
+ # Corner marks, so the border is not just a rectangle of unknown size: they
177
+ # stay put while the edges between them move.
178
+ renderer.rect(INSET, INSET, MARK, THICK, color: CORNER)
179
+ renderer.rect(INSET, INSET, THICK, MARK, color: CORNER)
180
+ renderer.rect(right - MARK, bottom - THICK, MARK, THICK, color: CORNER)
181
+ renderer.rect(right - THICK, bottom - MARK, THICK, MARK, color: CORNER)
182
+
183
+ # Round under every mode but :stretch, which scales the axes by different
184
+ # factors and turns it into an ellipse. One shape says more about what a
185
+ # mode does than the two lines of text below it.
186
+ renderer.circle(view.width / 2, view.height / 2, view.height / 5, color: DISC)
187
+
188
+ renderer.text(STATE.fetch(root.context.fullscreen?), INSET + 12, INSET + 12)
189
+ renderer.text(MODE_LABEL.fetch(MODES[@mode_index]), INSET + 12, INSET + 34)
190
+ end
191
+
192
+ private
193
+
194
+ def cycle_mode(step)
195
+ @mode_index = (@mode_index + step) % MODES.length
196
+ root.context.scale_mode = MODES[@mode_index]
197
+ end
198
+ end
199
+
200
+ game = RGame::Game.new(
201
+ root: Scene.new,
202
+ caption: 'Fullscreen',
203
+ width: WIDTH,
204
+ height: HEIGHT,
205
+ media_root: ASSETS,
206
+ locales: LOCALES,
207
+ fullscreen: START_FULLSCREEN,
208
+ scale_mode: SCALE_MODE,
209
+ # :fullscreen is this game's own action; everything else comes from the
210
+ # default map. F is a convention players already know.
211
+ input_map: RGame::Engine::InputMap.default.merge(
212
+ fullscreen: { buttons: [Controls::KEY_F, Controls::PAD_Y] }
213
+ )
214
+ )
215
+
216
+ game.start
@@ -0,0 +1,8 @@
1
+ # The English table: every string this example draws.
2
+ en:
3
+ help:
4
+ walk: Walk with the arrow keys — Escape opens the menu
5
+ menu:
6
+ resume: Resume
7
+ save: Save game
8
+ quit: Quit
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Game menu — a menu that opens over a world which keeps running.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/game_menu/main.rb
8
+ #
9
+ # Walk with the arrow keys / WASD / a gamepad. Escape (or B) opens the menu;
10
+ # up and down move the focus, Enter or A activates, Escape closes. It exercises:
11
+ # - PlayerLayer — one player's own region of the screen, above the world;
12
+ # - UI::PanelMenu / UI::PanelButton — a focused list on a panel that sizes
13
+ # itself to the buttons, navigated without a pointer;
14
+ # - UI::Menu#open / #close — a menu that is built once and shown when wanted;
15
+ # - Node2D#paused — one node stops while the rest of the tree carries on;
16
+ # - renderer.nine_slice — chrome drawn at any size from one small piece of art.
17
+ #
18
+ # ## The thing to watch
19
+ #
20
+ # **The villagers keep walking while your menu is open.** Only the hero stops,
21
+ # because pausing is a property of a *node*, not of the world: `hero.paused =
22
+ # true` halts that node's `control` and `update` and everything below it, and
23
+ # nothing else in the tree notices. That is what lets two players each open
24
+ # their own menu while the shared world runs on, and this is the single-player
25
+ # shape of it.
26
+ #
27
+ # The hero and the villagers are the same three components with a different
28
+ # controller in the middle: PlayerController reads the player's actions,
29
+ # WanderController rolls its own directions. Nothing else about them differs.
30
+ #
31
+ # ## What this example does not solve
32
+ #
33
+ # Layout. The menu sits at a fixed margin inside the player's region, because
34
+ # centring needs the region's size and that only arrives at draw time. See
35
+ # docs/api/ui.md, "What this is not" — the UI package is focus and activation,
36
+ # and no more than that yet.
37
+
38
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
39
+ require 'rgame/game'
40
+
41
+ WIDTH = 640
42
+ HEIGHT = 480
43
+ ASSETS = File.expand_path('../assets', __dir__)
44
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
45
+
46
+ SHEET = 'hero.json'
47
+ HERO_SPEED = 90.0
48
+ NPC_SPEED = 55.0
49
+ NPC_SPAWNS = [[140, 120], [430, 170], [220, 330], [480, 360]].freeze
50
+
51
+ # Seeded so the villagers wander the same way every run and two driven runs can
52
+ # be compared. `tools/drive_test_project.rb --seed N` overrides it.
53
+ DEFAULT_SEED = 0x6E11
54
+
55
+ # A walker that stays inside the window. Both the hero and the villagers are
56
+ # this; only the controller hung on them differs.
57
+ class Walker < RGame::Engine::Node2D
58
+ def on_update(_dt)
59
+ self.x = x.clamp(0, WIDTH - width)
60
+ self.y = y.clamp(0, HEIGHT - height)
61
+ end
62
+ end
63
+
64
+ # The pause menu: a PanelMenu, and the hero it stops.
65
+ #
66
+ # It lives inside a PlayerLayer, so three things are already true without this
67
+ # class arranging any of them — it draws in that player's region, its
68
+ # coordinates are relative to that region, and the input it reads is that
69
+ # player's. Nothing here mentions viewports or players.
70
+ class GameMenu < RGame::Engine::Node2D
71
+ PADDING = 16
72
+ ITEM_WIDTH = 180
73
+ ITEM_HEIGHT = 34
74
+ SPACING = 8
75
+
76
+ def initialize(hero:, **)
77
+ super(**)
78
+ @hero = hero
79
+ end
80
+
81
+ def on_add
82
+ column = RGame::Engine::UI::Column.new(item_width: ITEM_WIDTH, item_height: ITEM_HEIGHT, spacing: SPACING)
83
+ # The panel reaches PADDING beyond the buttons, so placing the menu PADDING
84
+ # in puts the panel's corner at this node's origin.
85
+ @menu = add_node(RGame::Engine::UI::PanelMenu.new(x: PADDING, y: PADDING, padding: PADDING, layout: column,
86
+ scope: 'menu'))
87
+ @menu.add(RGame::Engine::UI::PanelButton.new(label: 'resume')).on_activated { close }
88
+ # Disabled, so the example shows that state of the art — and because saving
89
+ # is `examples/save_load`'s subject rather than this one's.
90
+ @menu.add(RGame::Engine::UI::PanelButton.new(label: 'save', enabled: false))
91
+ @menu.add(RGame::Engine::UI::PanelButton.new(label: 'quit')).on_activated { root.context.close }
92
+ @menu.close # built once, shown when Escape asks for it
93
+ end
94
+
95
+ # A closed menu draws nothing and takes no focus or confirm, but its parent
96
+ # still reads Escape, which is what opens it again.
97
+ def on_control(actions)
98
+ toggle if actions.pressed?(:ui_cancel)
99
+ end
100
+
101
+ private
102
+
103
+ def toggle = @menu.open? ? close : open
104
+
105
+ def open
106
+ @menu.open
107
+ @hero.paused = true # only the hero: the villagers walk on
108
+ end
109
+
110
+ def close
111
+ @menu.close
112
+ @hero.paused = false
113
+ end
114
+ end
115
+
116
+ class Scene < RGame::Engine::Node2D
117
+ MENU_MARGIN = 40
118
+
119
+ def initialize
120
+ super
121
+ @rng = Random.new(ENV.fetch('RGAME_SEED', DEFAULT_SEED).to_i)
122
+ @help = RGame::Engine::Text.new('help.walk')
123
+ end
124
+
125
+ def on_add
126
+ hero = add_node(walker(RGame::Engine::Components::PlayerController.new,
127
+ HERO_SPEED, WIDTH / 2, HEIGHT / 2))
128
+ NPC_SPAWNS.each do |x, y|
129
+ add_node(walker(RGame::Engine::Components::WanderController.new(rng: @rng), NPC_SPEED, x, y))
130
+ end
131
+
132
+ # The player's own layer, and the menu inside it. With one seat this covers
133
+ # the whole window; with two it would be half of it, and nothing here or in
134
+ # GameMenu would change.
135
+ player = root.system(RGame::Engine::Players).primary
136
+ layer = add_node(RGame::Engine::PlayerLayer.new(player: player))
137
+ layer.add_node(GameMenu.new(hero: hero, x: MENU_MARGIN, y: MENU_MARGIN))
138
+ end
139
+
140
+ def on_draw(renderer, _view)
141
+ renderer.text(@help, 12, 12)
142
+ end
143
+
144
+ private
145
+
146
+ def walker(controller, speed, x, y)
147
+ node = Walker.new(x: x, y: y)
148
+ node.add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: SHEET))
149
+ node.add_component(RGame::Engine::Components::CharacterBody.new(speed: speed))
150
+ node.add_component(controller)
151
+ node
152
+ end
153
+ end
154
+
155
+ game = RGame::Game.new(
156
+ root: Scene.new,
157
+ caption: 'Game menu',
158
+ width: WIDTH,
159
+ height: HEIGHT,
160
+ media_root: ASSETS,
161
+ locales: LOCALES
162
+ )
163
+
164
+ # The one thing that has to be registered by hand: a nine-slice id names an
165
+ # *element of an atlas*, not a file, so there is nothing for the asset manager
166
+ # to resolve `:panel` to on demand. Registering the atlas binds every element in
167
+ # it under its own name.
168
+ game.renderer.register_ui_atlas(game.assets.ui_atlas('ui.json'))
169
+
170
+ game.start