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,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pathfinding — pick a tile, and the hero works out how to get there.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/pathfinding/main.rb
8
+ #
9
+ # The arrow keys or a d-pad move the tile cursor, and holding one keeps it moving.
10
+ # Return, Space or the pad's A button sends the hero there. The trees and the fence
11
+ # are solid, and the fence has one gap. It exercises:
12
+ # - Components::Navigator — `go_to(world_x, world_y)`: a route over the map,
13
+ # smoothed against the hero's own feet box, and walked;
14
+ # - Components::TileWorld#nav_grid — the search grid, built from the same
15
+ # solidity the collision uses, and asked whether a tile can be stood on;
16
+ # - Components::AnimatedSprite — facing the way the route goes, with nobody
17
+ # pressing a direction;
18
+ # - Components::ActionTrigger — the cursor's held-key repeat;
19
+ # - Components::CameraFollow — on the cursor rather than on the hero;
20
+ # - Engine::Text.computed — a status line assembled from several translations,
21
+ # with plurals, and built only when the route or the language changes;
22
+ # - the TileWorld, FeetCollider and TileMapLayer scene from `examples/collision_tiles`.
23
+ #
24
+ # ## Two drawings of one route
25
+ #
26
+ # The small dots are the route the search found: one per tile, start to target,
27
+ # each a step to a neighbouring tile. The lines are the route the hero walks. They
28
+ # are the same route, and the difference between them is the point of this file.
29
+ #
30
+ # A tile-by-tile route walked as it stands zig-zags. It turns wherever the grid
31
+ # does, which is at every tile a diagonal meets a straight, and a character doing
32
+ # that looks like it is being steered by a spreadsheet. So the navigator pulls the
33
+ # route tight: from each corner it keeps going straight for as long as it can, and
34
+ # turns only where it has to. Send the hero across open ground and there is one
35
+ # line; send it through the fence and there are a handful, against dozens of dots.
36
+ #
37
+ # ## "As long as it can" means as long as the feet fit
38
+ #
39
+ # A straight line is kept only if the hero's feet box — the red box in
40
+ # `examples/collision_tiles`, twelve by six — can travel it without touching a
41
+ # solid tile, and the thing asked is the same `TileWorld` that stops the hero when
42
+ # it walks. A line tested against tiles alone would cut past a tree's corner that a
43
+ # point clears and a feet box clips. The hero does not slide along the tree the way
44
+ # a player-steered character would: it is on a route, so it would stand at that
45
+ # corner pressing into it. The search and the collision cannot disagree about what
46
+ # fits, because they are asking one map.
47
+ #
48
+ # ## The cursor, and where the camera looks
49
+ #
50
+ # The camera follows the cursor, not the hero. A target is picked by looking at it,
51
+ # and a cursor that could be walked off the screen would be lost; the hero is the
52
+ # thing that is allowed to leave the view, and it walks back into it on its way to
53
+ # the tile you picked.
54
+ #
55
+ # The outline is green on a tile the hero can stand on and red on a solid one. That
56
+ # is read from the grid once each time the cursor moves, not every frame. Confirming
57
+ # on a red tile does nothing to the hero — there is no route to a tree, and the
58
+ # navigator says so by returning `false` — so a hero already walking carries on.
59
+ #
60
+ # ## What this scene does not solve
61
+ #
62
+ # The hero plans against the map and nothing else. Give it `blocked_by: %i[tiles
63
+ # npc]` and it waits behind a villager standing on its route rather than going
64
+ # round; a game that wants it to go round calls `go_to` again. The map never
65
+ # changes, so the grid is built once, the first time it is asked for — a door that
66
+ # opens would need a grid that is told. And there is one hero: many walkers heading
67
+ # for one place want a different search from many separate ones.
68
+
69
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
70
+ require 'rgame/game'
71
+
72
+ WIDTH = 640
73
+ HEIGHT = 480
74
+ ASSETS = File.expand_path('../assets', __dir__)
75
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
76
+
77
+ MAP = 'town.tmx'
78
+ TILE = 16
79
+ SPEED = 90.0 # px/s
80
+
81
+ FEET_WIDTH = 12
82
+ FEET_HEIGHT = 6
83
+
84
+ # The tile the hero starts on: north of the fence and well east of its gap, so any
85
+ # route south goes the long way.
86
+ START_COL = 24
87
+ START_ROW = 18
88
+
89
+ # The middle of the feet box, from the hero's origin: centred across the 16px-wide
90
+ # sprite, and half the box up from the bottom of its 22px height. Standing the hero
91
+ # with this point on a tile's centre is standing it on that tile.
92
+ FEET_CENTRE_X = 8
93
+ FEET_CENTRE_Y = 19
94
+
95
+ # How often a held direction moves the cursor another tile.
96
+ CURSOR_REPEAT = 0.12 # s
97
+
98
+ # A sprite, a feet box and a navigator. Where `examples/collision_tiles` has a
99
+ # CharacterBody and a PlayerController, this hero has one component that plans
100
+ # its own movement, and nothing reads the keyboard on its behalf.
101
+ class Hero < RGame::Engine::Node2D
102
+ attr_reader :navigator, :collider
103
+
104
+ def initialize(**)
105
+ super
106
+ add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: 'hero.json'))
107
+ @collider = add_component(RGame::Engine::Components::FeetCollider.new(
108
+ width: FEET_WIDTH, height: FEET_HEIGHT
109
+ ))
110
+ @navigator = add_component(RGame::Engine::Components::Navigator.new(
111
+ speed: SPEED, blocked_by: [:tiles]
112
+ ))
113
+ end
114
+ end
115
+
116
+ # A tile outline, moved a tile at a time. It knows which tile it is on and whether
117
+ # that tile can be stood on; what confirming *means* is the scene's business, so it
118
+ # only reports the tile's centre.
119
+ class Cursor < RGame::Engine::Node2D
120
+ WALKABLE = RGame::Util::Color.rgba(120, 230, 120, 255)
121
+ SOLID = RGame::Util::Color.rgba(240, 90, 90, 255)
122
+ STEPS = { ui_left: [-1, 0], ui_right: [1, 0], ui_up: [0, -1], ui_down: [0, 1] }.freeze
123
+
124
+ signal :on_confirmed, RGame::Engine::Signal.define(:world_x, :world_y)
125
+
126
+ def initialize(col:, row:, camera:)
127
+ super(x: col * TILE, y: row * TILE)
128
+ @col = col
129
+ @row = row
130
+ # ActionTrigger fires on the tick a direction goes down and again every
131
+ # CURSOR_REPEAT while it stays down — which is a key repeat, per action.
132
+ repeat = add_component(RGame::Engine::Components::ActionTrigger.new(
133
+ STEPS.keys.to_h { |action| [action, CURSOR_REPEAT] }
134
+ ))
135
+ repeat.on_triggered { |action| step(*STEPS.fetch(action)) }
136
+ add_component(RGame::Engine::Components::CameraFollow.new(
137
+ camera: camera, offset_x: TILE / 2, offset_y: TILE / 2
138
+ ))
139
+ end
140
+
141
+ def on_add
142
+ @world = system(RGame::Engine::Components::TileWorld)
143
+ step(0, 0)
144
+ end
145
+
146
+ def on_control(actions)
147
+ return unless actions.pressed?(:ui_confirm)
148
+
149
+ on_confirmed_signal.emit(world_x: (@col + 0.5) * TILE, world_y: (@row + 0.5) * TILE)
150
+ end
151
+
152
+ def on_draw(renderer, _view)
153
+ renderer.line(0, 0, TILE, 0, thickness: 2.0, color: @color)
154
+ renderer.line(TILE, 0, TILE, TILE, thickness: 2.0, color: @color)
155
+ renderer.line(TILE, TILE, 0, TILE, thickness: 2.0, color: @color)
156
+ renderer.line(0, TILE, 0, 0, thickness: 2.0, color: @color)
157
+ end
158
+
159
+ private
160
+
161
+ def step(dcol, drow)
162
+ grid = @world.nav_grid
163
+ @col = (@col + dcol).clamp(0, grid.width - 1)
164
+ @row = (@row + drow).clamp(0, grid.height - 1)
165
+ self.x = @col * TILE
166
+ self.y = @row * TILE
167
+ @color = grid.walkable?(@col, @row) ? WALKABLE : SOLID
168
+ end
169
+ end
170
+
171
+ # Both drawings of the hero's current route, in world space: a dot per tile the
172
+ # search returned, and a line per segment of the path being walked.
173
+ #
174
+ # The path's waypoints are where the hero's *origin* goes — its sprite's top-left
175
+ # corner — while the route was planned for the middle of its feet. Adding the feet
176
+ # box's centre back puts the lines on the ground the dots are on.
177
+ class Route < RGame::Engine::Node2D
178
+ DOT = RGame::Util::Color.rgba(255, 255, 255, 200)
179
+ LINE = RGame::Util::Color.rgba(255, 220, 60, 255)
180
+ DOT_SIZE = 4
181
+
182
+ def initialize(hero:)
183
+ super()
184
+ @hero = hero
185
+ end
186
+
187
+ def on_draw(renderer, _view)
188
+ navigator = @hero.navigator
189
+ draw_cells(renderer, navigator.cells) if navigator.cells
190
+ draw_path(renderer, navigator.path) if navigator.path
191
+ end
192
+
193
+ private
194
+
195
+ def draw_cells(renderer, cells)
196
+ offset = (TILE - DOT_SIZE) / 2
197
+ index = 0
198
+ while index < cells.length
199
+ col, row = cells[index]
200
+ renderer.rect((col * TILE) + offset, (row * TILE) + offset, DOT_SIZE, DOT_SIZE, color: DOT)
201
+ index += 1
202
+ end
203
+ end
204
+
205
+ def draw_path(renderer, path)
206
+ box = @hero.collider.box
207
+ feet_x = box.offset_x + (box.width / 2.0)
208
+ feet_y = box.offset_y + (box.height / 2.0)
209
+ index = 1
210
+ while index < path.count
211
+ renderer.line(path.x_at(index - 1) + feet_x, path.y_at(index - 1) + feet_y,
212
+ path.x_at(index) + feet_x, path.y_at(index) + feet_y,
213
+ thickness: 2.0, color: LINE)
214
+ index += 1
215
+ end
216
+ end
217
+ end
218
+
219
+ # The scene: the map and its world, the hero, the route drawn under the hero and
220
+ # the cursor over it — and the one line that connects a confirmed tile to a walk.
221
+ class Scene < RGame::Engine::Node2D
222
+ def on_add
223
+ map = root.context.assets.tilemap(MAP).map
224
+ players = root.system(RGame::Engine::Players)
225
+ add_component(RGame::Engine::Components::TileWorld.new(
226
+ map: map, tilemap_id: MAP, cameras: players.map(&:camera)
227
+ ))
228
+
229
+ view = add_node(RGame::Engine::WorldView.new)
230
+ actors = RGame::Engine::TileMapLayer.mount(view)
231
+ @hero = Hero.new(x: ((START_COL + 0.5) * TILE) - FEET_CENTRE_X,
232
+ y: ((START_ROW + 0.5) * TILE) - FEET_CENTRE_Y)
233
+ actors.add_node(Route.new(hero: @hero))
234
+ actors.add_node(@hero)
235
+ cursor = actors.add_node(Cursor.new(col: START_COL, row: START_ROW, camera: players.primary.camera))
236
+
237
+ cursor.on_confirmed { |world_x, world_y| send_hero(world_x, world_y) }
238
+ @hero.navigator.on_finished do
239
+ @arrived = true
240
+ report
241
+ end
242
+
243
+ @arrived = false
244
+ @status = nil
245
+ @status_label = RGame::Engine::Text.computed(:status) { |status:| describe(status) }
246
+ @help_cursor = RGame::Engine::Text.new('help.cursor')
247
+ @help_route = RGame::Engine::Text.new('help.route')
248
+ end
249
+
250
+ def on_draw(renderer, _view)
251
+ renderer.text(@help_cursor, 12, 12)
252
+ renderer.text(@help_route, 12, 34)
253
+ renderer.text(@status_label.with(status: @status), 12, 56)
254
+ end
255
+
256
+ private
257
+
258
+ def send_hero(world_x, world_y)
259
+ routed = @hero.navigator.go_to(world_x, world_y)
260
+ @arrived = false if routed
261
+ report(refused: !routed)
262
+ end
263
+
264
+ # The status is rebuilt on an event, never per frame, so the label's value only
265
+ # changes when there is something new to say.
266
+ def report(refused: false)
267
+ navigator = @hero.navigator
268
+ @status = [navigator.cells&.length, navigator.path&.count, @arrived, refused].freeze
269
+ end
270
+
271
+ # The status line, in whatever language is current. It is several keys rather
272
+ # than one, because a count needs its own plural and a refusal can wrap a
273
+ # route: `computed` is for text put together like this, and its block runs
274
+ # only when the status or the language changes.
275
+ def describe(status)
276
+ i18n = RGame::Engine::I18n
277
+ return i18n.t('status.idle') unless status
278
+
279
+ cells, waypoints, arrived, refused = status
280
+ route = cells && i18n.t(arrived ? 'status.arrived' : 'status.walking',
281
+ waypoints: i18n.t('status.waypoints', count: waypoints),
282
+ cells: i18n.t('status.cells', count: cells))
283
+ return route unless refused
284
+
285
+ route ? i18n.t('status.refused_after', route: route) : i18n.t('status.refused')
286
+ end
287
+ end
288
+
289
+ game = RGame::Game.new(
290
+ root: Scene.new,
291
+ caption: 'Pathfinding',
292
+ width: WIDTH,
293
+ height: HEIGHT,
294
+ media_root: ASSETS,
295
+ locales: LOCALES
296
+ )
297
+
298
+ game.start
@@ -0,0 +1,7 @@
1
+ # The English table: every string this example draws.
2
+ en:
3
+ status:
4
+ pooled: pooled — Space builds a fresh mote instead
5
+ fresh: fresh objects — Space goes back to the pool
6
+ hud:
7
+ allocated: "objects allocated per second: %{count}"
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pooling — spawning a lot of things without building any of them.
4
+ #
5
+ # Run it:
6
+ #
7
+ # ruby examples/pooling/main.rb
8
+ #
9
+ # **Space** switches between taking motes from a pool and building a fresh one
10
+ # every time. Watch the number. It exercises:
11
+ # - Components::Pool — acquire, add as a child, reclaim on free;
12
+ # - Engine::Pool — the free list underneath it;
13
+ # - Components::DespawnOffscreen — retiring a mote that has left the world;
14
+ # - Components::Timer — the spawn cadence, from `examples/timer`;
15
+ # - Engine::Text — a readout with a variable that changes once a second, not per frame.
16
+ #
17
+ # ## The number on screen is the whole argument
18
+ #
19
+ # Everything else here is prose. The readout samples
20
+ # `GC.stat(:total_allocated_objects)` once a second and shows the difference, and
21
+ # switching modes moves it within a second or two of the press. Measured over a
22
+ # steady eighty motes a second:
23
+ #
24
+ # pooled about 140 objects a second
25
+ # fresh about 750
26
+ #
27
+ # Same spawn rate, same motes on screen, same draw calls. The gap is around seven
28
+ # and a half objects per mote — the node, its two components, and the few arrays
29
+ # and hashes a node keeps for them — and it is *entirely* what the pool removes.
30
+ #
31
+ # A steady sixty-frame-per-second loop that allocates is a GC pause waiting to
32
+ # happen, and a pause is not a slow frame: it is a visible hitch at a moment
33
+ # nobody chose. Bullets and particles are where it starts, because they are the
34
+ # things there are suddenly two hundred of.
35
+ #
36
+ # **The readout only means anything in a plain run.** Under
37
+ # `tools/drive_test_project.rb` both numbers land near eighty-five thousand,
38
+ # because the harness records every draw call it sees and that recording dwarfs
39
+ # everything the game does. Measure the thing you changed, in the configuration
40
+ # you care about.
41
+ #
42
+ # ## A game writes two lines and no bookkeeping
43
+ #
44
+ # @pool.spawn { |mote| mote.reset(...) } # acquire, initialise, add as a child
45
+ # mote.queue_free # → back on the free list
46
+ #
47
+ # There is no acquire/add/reclaim bridge to write, because
48
+ # `Components::Pool#update` rides the tick and reclaims everything freed since
49
+ # the last one. Nothing in this file asks for a mote back, and
50
+ # `Components::DespawnOffscreen` — which knows nothing about pools — retires them
51
+ # by calling the same `queue_free` any other node would.
52
+ #
53
+ # ## A pooled node is an ordinary child
54
+ #
55
+ # It is in the tree, the traversal updates and draws it, its components run. The
56
+ # pool manages membership and nothing else. Pooling is not a parallel world with
57
+ # its own rules — which is worth saying, because the one rule it *does* add is
58
+ # easy to miss.
59
+ #
60
+ # ## The one rule: components go in `initialize`, not `on_add`
61
+ #
62
+ # `on_add` fires on **every** entry into the tree, and a pooled node enters it
63
+ # again on each spawn. A component added there is added a second time, and
64
+ # `add_component` refuses a slot that is already taken — so a pool that worked
65
+ # for one lifetime raises on the next. Mote below adds its components in
66
+ # `initialize`, where a node built by a factory outside the tree collects them
67
+ # once.
68
+ #
69
+ # The mirror of that rule is what makes reuse safe: `Components::Timer` re-arms
70
+ # itself in `on_attach`, and `Components::DespawnOffscreen` re-resolves its world
71
+ # bounds there, so a recycled node inherits neither a spent countdown nor the
72
+ # dimensions of the scene it came from.
73
+ #
74
+ # ## `reset` is the other half of a factory that builds blanks
75
+ #
76
+ # `Engine::Pool.new { Mote.new }` builds a blank, because it has no idea where the
77
+ # next mote will be or where it is going. `spawn` takes a block and runs it
78
+ # *before* the node enters the tree, which is where those answers get filled in.
79
+ #
80
+ # ## What it does not solve
81
+ #
82
+ # A cap. The pool grows to the high-water mark of how many are live at once and
83
+ # never shrinks, which is the intent — the memory is the point of the exercise —
84
+ # but a spawner that can outrun its despawn has no brake here.
85
+
86
+ $LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
87
+ require 'rgame/game'
88
+
89
+ WIDTH = 640
90
+ HEIGHT = 480
91
+ ASSETS = File.expand_path('../assets', __dir__)
92
+ LOCALES = File.expand_path('locales', __dir__) # the text on screen: locales/en.yml
93
+
94
+ SPAWN_EVERY = 0.05 # seconds
95
+ PER_BURST = 4
96
+ SPEED_MIN = 70.0
97
+ SPEED_MAX = 210.0
98
+ SAMPLE = 1.0 # how often the allocation readout is taken
99
+ DEFAULT_SEED = 0x9001
100
+
101
+ # Far enough out that a mote is well off the screen before it is retired, so the
102
+ # despawn is never something you can see happening.
103
+ DESPAWN_MARGIN = 30.0
104
+
105
+ # A blank when the factory builds it, a particular mote when `reset` is done with
106
+ # it. Both of its components are added here rather than in `on_add`, because a
107
+ # pooled node re-enters the tree on every spawn and `on_add` fires every time.
108
+ class Mote < RGame::Engine::Node2D
109
+ SIZE = 7
110
+
111
+ def initialize
112
+ super(width: SIZE, height: SIZE)
113
+ @velocity = add_component(RGame::Engine::Components::Velocity.new)
114
+ add_component(RGame::Engine::Components::DespawnOffscreen.new(margin: DESPAWN_MARGIN))
115
+ @color = RGame::Util::Color.new(255, 255, 255)
116
+ end
117
+
118
+ # Run by the spawner before this node enters the tree, which is the seam a
119
+ # factory that builds blanks needs. Returns self so a fresh mote can be built
120
+ # and placed in one expression.
121
+ def reset(x, y, vx, vy, color)
122
+ self.x = x
123
+ self.y = y
124
+ @velocity.vx = vx
125
+ @velocity.vy = vy
126
+ @color = color
127
+ self
128
+ end
129
+
130
+ def on_draw(renderer, _view) = renderer.rect(-SIZE / 2, -SIZE / 2, SIZE, SIZE, color: @color)
131
+ end
132
+
133
+ # Emits motes on a cadence, either from a pool or by building each one, and the
134
+ # only difference between the two paths is the line that gets the node.
135
+ class Spawner < RGame::Engine::Node2D
136
+ POOLED = RGame::Util::Color.new(120, 210, 150)
137
+ FRESH = RGame::Util::Color.new(235, 120, 100)
138
+
139
+ def initialize(rng:, **)
140
+ super(**)
141
+ @rng = rng
142
+ @pooled = true
143
+ end
144
+
145
+ def pooled? = @pooled
146
+ def toggle = @pooled = !@pooled
147
+ # Children, not `@pool.size`: both paths attach the mote as a child of this
148
+ # node, and only one of them involves a pool. Counting the pool would make the
149
+ # bar vanish in fresh mode and hide the thing being compared.
150
+ def live = children.size
151
+
152
+ def on_add
153
+ @pool = add_component(RGame::Engine::Components::Pool.new { Mote.new })
154
+ add_component(RGame::Engine::Components::Timer.new(SPAWN_EVERY))
155
+ .on_timeout { PER_BURST.times { emit } }
156
+ end
157
+
158
+ private
159
+
160
+ def emit
161
+ if @pooled
162
+ # Acquire, initialise, attach — one call, and the reclaim is already
163
+ # arranged for.
164
+ @pool.spawn { |mote| place(mote) }
165
+ else
166
+ # The same three steps written out, and a new object every single time.
167
+ # Nothing else about the mote or the frame differs.
168
+ add_node(place(Mote.new))
169
+ end
170
+ end
171
+
172
+ def place(mote)
173
+ angle = @rng.rand * Math::PI * 2
174
+ speed = SPEED_MIN + (@rng.rand * (SPEED_MAX - SPEED_MIN))
175
+ mote.reset(0, 0, Math.cos(angle) * speed, Math.sin(angle) * speed,
176
+ @pooled ? POOLED : FRESH)
177
+ end
178
+ end
179
+
180
+ # Samples the allocation counter once a second and shows the difference.
181
+ #
182
+ # A per-frame readout is the one thing a cached Text cannot help with — a value
183
+ # that changes every frame has to be rebuilt every frame. Sampling on a slow
184
+ # timer makes it a value that changes once a second, which is exactly what the
185
+ # cache is for, and it is also the only way the number means anything: a single
186
+ # frame's allocations are noise.
187
+ class Meter < RGame::Engine::Node2D
188
+ INK = RGame::Util::Color.new(255, 255, 255)
189
+
190
+ def initialize(**)
191
+ super
192
+ @label = RGame::Engine::Text.new('hud.allocated', :count)
193
+ @count = 0
194
+ @last = 0
195
+ end
196
+
197
+ def on_add
198
+ @last = GC.stat(:total_allocated_objects)
199
+ add_component(RGame::Engine::Components::Timer.new(SAMPLE)).on_timeout { sample }
200
+ end
201
+
202
+ def on_draw(renderer, _view) = renderer.text(@label.with(count: @count), 0, 0, color: INK)
203
+
204
+ private
205
+
206
+ def sample
207
+ now = GC.stat(:total_allocated_objects)
208
+ @count = now - @last
209
+ @last = now
210
+ end
211
+ end
212
+
213
+ class Scene < RGame::Engine::Node2D
214
+ BACKDROP = RGame::Util::Color.new(26, 30, 38)
215
+ BAR = RGame::Util::Color.new(90, 100, 124)
216
+ BAR_X = 12
217
+ BAR_Y = 86
218
+ BAR_H = 14
219
+ BAR_SCALE = 2.4 # pixels per live mote
220
+
221
+ MODE = { true => RGame::Engine::Text.new('status.pooled'),
222
+ false => RGame::Engine::Text.new('status.fresh') }.freeze
223
+
224
+ def initialize
225
+ super
226
+ @rng = Random.new(ENV.fetch('RGAME_SEED', DEFAULT_SEED).to_i)
227
+ # Mounted outside the tree, so every DespawnOffscreen that attaches later
228
+ # finds it — the same ordering the wrap in `examples/velocity` depends on.
229
+ add_component(RGame::Engine::Components::World.new(width: WIDTH, height: HEIGHT))
230
+ end
231
+
232
+ def on_add
233
+ @spawner = add_node(Spawner.new(rng: @rng, x: WIDTH / 2, y: HEIGHT / 2))
234
+ @meter = add_node(Meter.new(x: 12, y: 34))
235
+ end
236
+
237
+ def on_control(actions)
238
+ @spawner.toggle if actions.pressed?(:fire)
239
+ end
240
+
241
+ def on_draw(renderer, view)
242
+ renderer.rect(0, 0, view.width, view.height, color: BACKDROP)
243
+ renderer.text(MODE.fetch(@spawner.pooled?), 12, 12)
244
+ # The live count as a bar rather than a number: it changes most frames, and
245
+ # a label rebuilt every frame is the allocation this example is about.
246
+ renderer.rect(BAR_X, BAR_Y, @spawner.live * BAR_SCALE, BAR_H, color: BAR)
247
+ end
248
+ end
249
+
250
+ game = RGame::Game.new(
251
+ root: Scene.new,
252
+ caption: 'Pooling',
253
+ width: WIDTH,
254
+ height: HEIGHT,
255
+ media_root: ASSETS,
256
+ locales: LOCALES
257
+ )
258
+
259
+ game.start
@@ -0,0 +1,16 @@
1
+ # The English table: every string this example draws.
2
+ en:
3
+ help:
4
+ wheel: Hold Tab (or LB), point with the arrows (or stick), let go to choose
5
+ status:
6
+ nothing: "Chosen: nothing yet"
7
+ chosen: "Chosen: %{item}"
8
+ items:
9
+ home: Home
10
+ settings: Settings
11
+ save: Save
12
+ favourite: Favourite
13
+ trophies: Trophies
14
+ sound: Sound
15
+ music: Music
16
+ locked: Locked