rgame 0.1.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 (265) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +228 -0
  3. data/README.md +142 -243
  4. data/docs/api/README.md +141 -90
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -166
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1045 -254
  10. data/docs/api/drawing.md +187 -119
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +165 -45
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +344 -85
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +503 -123
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +197 -64
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +400 -147
  23. data/docs/api/ui.md +927 -0
  24. data/docs/api/values.md +234 -19
  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/README.md +6 -5
  92. data/ext/rgame_core/app/app.c +215 -11
  93. data/ext/rgame_core/app/locale.c +67 -0
  94. data/ext/rgame_core/app/locale.h +28 -0
  95. data/ext/rgame_core/audio/audio.c +113 -2
  96. data/ext/rgame_core/example.rb +18 -56
  97. data/ext/rgame_core/extconf.rb +16 -113
  98. data/ext/rgame_core/graphics/canvas.c +45 -4
  99. data/ext/rgame_core/graphics/canvas.h +65 -10
  100. data/ext/rgame_core/graphics/clip.c +22 -13
  101. data/ext/rgame_core/include/rgame/core.h +151 -5
  102. data/ext/rgame_core/input/gamepad.c +57 -3
  103. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  104. data/ext/rgame_core/ruby/core_ext.c +46 -7
  105. data/ext/rgame_core/ruby/core_ext.h +3 -0
  106. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  107. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  108. data/ext/rgame_core/ruby/renderer_ext.c +42 -19
  109. data/ext/rgame_util/color_ext.c +12 -3
  110. data/ext/rgame_util/extconf.rb +2 -20
  111. data/ext/rgame_util/route_search.c +305 -0
  112. data/ext/rgame_util/route_search.h +86 -0
  113. data/ext/rgame_util/route_search_ext.c +150 -0
  114. data/ext/rgame_util/solid_grid.c +58 -0
  115. data/ext/rgame_util/solid_grid.h +49 -0
  116. data/ext/rgame_util/solid_grid_ext.c +161 -0
  117. data/ext/rgame_util/tile_sweep.c +164 -0
  118. data/ext/rgame_util/tile_sweep.h +62 -0
  119. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  120. data/ext/rgame_util/util_ext.c +3 -0
  121. data/ext/rgame_util/util_ext.h +15 -0
  122. data/lib/rgame/boot.rb +0 -10
  123. data/lib/rgame/cli/new_project.rb +139 -0
  124. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  125. data/lib/rgame/cli/templates/README.md.tt +93 -0
  126. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  127. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  128. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  129. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  130. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  131. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  132. data/lib/rgame/cli/templates/rspec.tt +2 -0
  133. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  134. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  135. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  136. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  137. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  138. data/lib/rgame/cli.rb +66 -0
  139. data/lib/rgame/core/app.rb +6 -42
  140. data/lib/rgame/core/asset_manager.rb +13 -31
  141. data/lib/rgame/core/audio.rb +37 -16
  142. data/lib/rgame/core/font.rb +0 -3
  143. data/lib/rgame/core/input.rb +35 -41
  144. data/lib/rgame/core/locale.rb +22 -0
  145. data/lib/rgame/core/nine_slice.rb +0 -21
  146. data/lib/rgame/core/recording.rb +3 -1
  147. data/lib/rgame/core/renderer.rb +75 -84
  148. data/lib/rgame/core/sprite_sheet.rb +0 -3
  149. data/lib/rgame/core/tile_map_renderer.rb +77 -65
  150. data/lib/rgame/core/ui_atlas.rb +28 -13
  151. data/lib/rgame/core.rb +1 -8
  152. data/lib/rgame/engine/actor_blockers.rb +131 -0
  153. data/lib/rgame/engine/animation_set.rb +1 -0
  154. data/lib/rgame/engine/audio_director.rb +36 -6
  155. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  156. data/lib/rgame/engine/camera.rb +55 -10
  157. data/lib/rgame/engine/circle_collider.rb +4 -2
  158. data/lib/rgame/engine/collision_box.rb +26 -1
  159. data/lib/rgame/engine/collision_system.rb +110 -22
  160. data/lib/rgame/engine/component.rb +35 -1
  161. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  162. data/lib/rgame/engine/components/animated_sprite.rb +31 -23
  163. data/lib/rgame/engine/components/box_collider.rb +99 -0
  164. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  165. data/lib/rgame/engine/components/character_body.rb +21 -41
  166. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  167. data/lib/rgame/engine/components/collision_world.rb +159 -31
  168. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  169. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  170. data/lib/rgame/engine/components/hop.rb +76 -0
  171. data/lib/rgame/engine/components/identity.rb +73 -0
  172. data/lib/rgame/engine/components/mover.rb +285 -0
  173. data/lib/rgame/engine/components/navigator.rb +145 -0
  174. data/lib/rgame/engine/components/path_follow.rb +123 -31
  175. data/lib/rgame/engine/components/player_controller.rb +5 -2
  176. data/lib/rgame/engine/components/pool.rb +1 -1
  177. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  178. data/lib/rgame/engine/components/sprite.rb +22 -6
  179. data/lib/rgame/engine/components/targeting.rb +9 -11
  180. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  181. data/lib/rgame/engine/components/tile_world.rb +70 -29
  182. data/lib/rgame/engine/components/timer.rb +1 -1
  183. data/lib/rgame/engine/components/velocity.rb +23 -7
  184. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  185. data/lib/rgame/engine/components/world.rb +133 -0
  186. data/lib/rgame/engine/contact_set.rb +74 -0
  187. data/lib/rgame/engine/culling.rb +45 -0
  188. data/lib/rgame/engine/debug_overlay.rb +23 -18
  189. data/lib/rgame/engine/i18n/plural.rb +45 -0
  190. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  191. data/lib/rgame/engine/i18n/template.rb +59 -0
  192. data/lib/rgame/engine/i18n.rb +276 -51
  193. data/lib/rgame/engine/input/action_mapper.rb +76 -22
  194. data/lib/rgame/engine/input/actions.rb +63 -12
  195. data/lib/rgame/engine/input/input_map.rb +196 -0
  196. data/lib/rgame/engine/layout.rb +82 -0
  197. data/lib/rgame/engine/nav_grid.rb +87 -0
  198. data/lib/rgame/engine/node2d.rb +364 -80
  199. data/lib/rgame/engine/path.rb +4 -6
  200. data/lib/rgame/engine/player.rb +69 -0
  201. data/lib/rgame/engine/player_layer.rb +70 -0
  202. data/lib/rgame/engine/players.rb +205 -0
  203. data/lib/rgame/engine/presentation.rb +171 -0
  204. data/lib/rgame/engine/scene/scene_stack.rb +29 -7
  205. data/lib/rgame/engine/sealed_privates.rb +54 -0
  206. data/lib/rgame/engine/spatial_hash.rb +53 -8
  207. data/lib/rgame/engine/text.rb +194 -0
  208. data/lib/rgame/engine/tile_blockers.rb +63 -0
  209. data/lib/rgame/engine/tile_map.rb +2 -3
  210. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  211. data/lib/rgame/engine/tileset.rb +2 -4
  212. data/lib/rgame/engine/timer.rb +2 -2
  213. data/lib/rgame/engine/ui/button.rb +248 -0
  214. data/lib/rgame/engine/ui/column.rb +20 -0
  215. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  216. data/lib/rgame/engine/ui/menu.rb +290 -0
  217. data/lib/rgame/engine/ui/navigation.rb +57 -0
  218. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  219. data/lib/rgame/engine/ui/option_button.rb +163 -0
  220. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  221. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  222. data/lib/rgame/engine/ui/pointing.rb +146 -0
  223. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  224. data/lib/rgame/engine/ui/ring.rb +55 -0
  225. data/lib/rgame/engine/ui/row.rb +21 -0
  226. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  227. data/lib/rgame/engine/ui/stack.rb +58 -0
  228. data/lib/rgame/engine/ui/stepping.rb +93 -0
  229. data/lib/rgame/engine/ui/text_button.rb +59 -0
  230. data/lib/rgame/engine/view.rb +76 -0
  231. data/lib/rgame/engine/viewports.rb +171 -0
  232. data/lib/rgame/engine/world_view.rb +71 -0
  233. data/lib/rgame/engine.rb +43 -25
  234. data/lib/rgame/game.rb +164 -22
  235. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  236. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  237. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  238. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  239. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  240. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  241. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  242. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  243. data/lib/rgame/rubocop/default.yml +39 -0
  244. data/lib/rgame/rubocop/plugin.rb +45 -0
  245. data/lib/rgame/rubocop.rb +11 -0
  246. data/lib/rgame/util/color.rb +20 -24
  247. data/lib/rgame/util/controls.rb +106 -44
  248. data/lib/rgame/util/route_search.rb +27 -0
  249. data/lib/rgame/util/save_file.rb +107 -0
  250. data/lib/rgame/util/solid_grid.rb +37 -0
  251. data/lib/rgame/util/tensor.rb +0 -9
  252. data/lib/rgame/util/tile_sweep.rb +36 -0
  253. data/lib/rgame/util/z.rb +123 -0
  254. data/lib/rgame/util.rb +5 -3
  255. data/lib/rgame/version.rb +1 -1
  256. data/lib/rgame.rb +0 -15
  257. metadata +176 -20
  258. data/lib/rgame/engine/actor.rb +0 -53
  259. data/lib/rgame/engine/body.rb +0 -49
  260. data/lib/rgame/engine/cached_label.rb +0 -33
  261. data/lib/rgame/engine/camera_view.rb +0 -28
  262. data/lib/rgame/engine/input/player_controller.rb +0 -14
  263. data/lib/rgame/engine/matrix.rb +0 -32
  264. data/lib/rgame/engine/resettable.rb +0 -67
  265. data/lib/rgame/engine/tile_collision.rb +0 -78
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A PathFollow that makes its own paths: `go_to(world_x, world_y)` plans a route
7
+ # over the scene's tile map to the cell containing that point, and walks it.
8
+ #
9
+ # Planning is three steps. The scene's TileWorld#nav_grid finds the cheapest route
10
+ # of cells; the route is **string-pulled** into as few straight segments as the
11
+ # node's own collider can travel; and those corners become the Engine::Path this
12
+ # component walks, exactly as a PathFollow walks one it was handed.
13
+ #
14
+ # ## The route is checked against what will stop the walk
15
+ #
16
+ # A segment is kept only if the map's own blocker source — TileWorld#blockers, the
17
+ # object a mover declaring `:tiles` resolves its steps against — lets the collider
18
+ # box travel it. A test on cells alone would keep a diagonal past a solid tile's
19
+ # corner that a point clears and a 12x6 feet box clips, and a PathFollow does not
20
+ # slide: the walker would stand at that corner.
21
+ #
22
+ # The question is TileBlockers#travel?, which holds for a walker stepping under a
23
+ # quarter tile at a time — 240 px/s at 60 ticks a second on 16 px tiles.
24
+ #
25
+ # Smoothing is greedy: from each corner it extends the segment cell by cell until the
26
+ # next cell would not be clear, and turns there. The next cell after a corner is taken
27
+ # without asking, which is sound only for a collider **no larger than a tile** on
28
+ # either axis — so `go_to` refuses a larger one rather than plan a route it may stall on.
29
+ #
30
+ # So what is walked is measured from the **anchor** — the centre of the sibling
31
+ # BoxCollider's box, or the node's origin on a node with none — and a route ends
32
+ # with the anchor on the centre of the target cell.
33
+ #
34
+ # ## What it does not do
35
+ #
36
+ # It does not replan. A route is planned against the map, and the map is all it
37
+ # knows: a walker declaring other colliders in `blocked_by` waits behind one on its
38
+ # route, the way any PathFollow waits, and a game that wants it to go round calls
39
+ # `go_to` again.
40
+ #
41
+ # The route is planned in world space and walked in the node's parent's space, which
42
+ # agree under an unrotated ancestor chain — the same limit a blocked Mover has.
43
+ class Navigator < PathFollow
44
+ # The cells of the last route `go_to` planned, start to target, before smoothing —
45
+ # `[[col, row], ...]`, or nil before the first one. For drawing; `path` is what is
46
+ # walked.
47
+ attr_reader :cells
48
+
49
+ def initialize(speed:, blocked_by: [])
50
+ super
51
+ @cells = nil
52
+ end
53
+
54
+ def on_attach
55
+ super
56
+ @world = node.system(TileWorld) ||
57
+ raise("#{mover_name} plans routes over the scene's TileWorld, and the scene " \
58
+ 'has none. Mount one, or use a PathFollow and hand it a path.')
59
+ @anchor = node.get_component(BoxCollider)
60
+ end
61
+
62
+ # Plan from where the node stands to the cell containing (world_x, world_y), and
63
+ # start walking it from the node's current position — so a walker already on a
64
+ # route turns onto the new one without a jump. Returns true.
65
+ #
66
+ # Returns false, and leaves whatever the node was doing alone, when there is no
67
+ # route: the target is solid, outside the map, or cut off from the node.
68
+ #
69
+ # Raises ArgumentError when the node's collider is wider or taller than a tile:
70
+ # pathfinding for such a collider is not supported.
71
+ #
72
+ # rubocop:disable Naming/PredicateMethod -- a command that reports whether it could be
73
+ # carried out, not a question; `go_to?` would read as "may I go there?".
74
+ def go_to(world_x, world_y)
75
+ unless @world
76
+ raise "#{mover_name}#go_to plans over the scene's TileWorld, so the node has to be " \
77
+ 'in the tree first.'
78
+ end
79
+
80
+ measure_anchor
81
+ refuse_a_box_larger_than_a_tile
82
+ cells = @world.nav_grid.find(*cell_at(@anchor_x, @anchor_y), *cell_at(world_x, world_y))
83
+ return false unless cells
84
+
85
+ @cells = cells
86
+ follow(Engine::Path.new(waypoints(corners(cells))))
87
+ true
88
+ end
89
+ # rubocop:enable Naming/PredicateMethod
90
+
91
+ private
92
+
93
+ def measure_anchor
94
+ box = @anchor&.box
95
+ @box_w = box ? box.width : 0
96
+ @box_h = box ? box.height : 0
97
+ @anchor_x = node.world_x + (box ? box.offset_x + (@box_w / 2.0) : 0.0)
98
+ @anchor_y = node.world_y + (box ? box.offset_y + (@box_h / 2.0) : 0.0)
99
+ end
100
+
101
+ def refuse_a_box_larger_than_a_tile
102
+ return if @box_w <= @world.tile_width && @box_h <= @world.tile_height
103
+
104
+ raise ArgumentError, "#{mover_name}#go_to plans for a collider no larger than a tile, and this " \
105
+ "node's is #{@box_w}x#{@box_h} over #{@world.tile_width}x" \
106
+ "#{@world.tile_height} tiles. Pathfinding for a larger collider is not supported."
107
+ end
108
+
109
+ def cell_at(x, y) = [(x / @world.tile_width).floor, (y / @world.tile_height).floor]
110
+
111
+ def centre_x(cell) = (cell[0] + 0.5) * @world.tile_width
112
+ def centre_y(cell) = (cell[1] + 0.5) * @world.tile_height
113
+
114
+ def corners(cells)
115
+ corners = [[@anchor_x, @anchor_y]]
116
+ turn = -1
117
+ while turn < cells.length - 1
118
+ turn = furthest_clear(*corners.last, cells, turn + 1)
119
+ corners << [centre_x(cells[turn]), centre_y(cells[turn])]
120
+ end
121
+ corners
122
+ end
123
+
124
+ def furthest_clear(x, y, cells, index)
125
+ index += 1 while index < cells.length - 1 &&
126
+ box_travels?(x, y, centre_x(cells[index + 1]), centre_y(cells[index + 1]))
127
+ index
128
+ end
129
+
130
+ def box_travels?(from_x, from_y, to_x, to_y)
131
+ @world.blockers.travel?(from_x - (@box_w / 2.0), from_y - (@box_h / 2.0), @box_w, @box_h,
132
+ to_x - from_x, to_y - from_y)
133
+ end
134
+
135
+ def waypoints(corners)
136
+ shift_x = node.x - @anchor_x
137
+ shift_y = node.y - @anchor_y
138
+ points = corners.map { |(x, y)| [x + shift_x, y + shift_y] }
139
+ points[0] = [node.x, node.y]
140
+ points
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -4,26 +4,58 @@ module RGame
4
4
  module Engine
5
5
  module Components
6
6
  # Walks the owning node along an Engine::Path at a constant speed, segment by
7
- # segment, and emits `on_finished` once it reaches the final waypoint — the seam a
8
- # tower defense game uses to leak a life when an enemy reaches the base.
7
+ # segment, and emits `on_finished` once it reaches the final waypoint — the seam for
8
+ # whatever should happen when a walker arrives.
9
9
  #
10
10
  # The walk is allocation-free: it tracks the current segment and the distance into
11
11
  # it, advancing through as many segments as one step crosses (so a fast mover over
12
12
  # short segments still lands correctly), then interpolates the node's position from
13
13
  # the segment endpoints. Movement is driven purely by `speed * dt`; this is not a
14
14
  # Velocity integrator and ignores the node's angle.
15
- class PathFollow < Engine::Component
16
- signal :on_finished # emits no payload; the owning node identifies which follower finished
15
+ #
16
+ # ## Blocked, the walk waits
17
+ #
18
+ # A Mover, so `blocked_by:` stops it the way it stops a CharacterBody — see Mover's
19
+ # header. Declaring nothing keeps the walk exactly as described above: the node is
20
+ # *placed* on the path each step.
21
+ #
22
+ # Declaring something turns the placement into a move: the walk still works out
23
+ # where the node should be, and then gets there through `apply_move`. **A step that
24
+ # is stopped short does not advance the walk** — progress goes back to where the
25
+ # step began. The obvious alternative, letting progress run on and the node catch up,
26
+ # is wrong twice over: a follower held behind something for a second would race
27
+ # ahead to where it "should" be once let go, and `on_finished` could fire for a
28
+ # walker still standing in front of the obstacle.
29
+ #
30
+ # One consequence is easy to miss: **a held follower does not slide along what stopped
31
+ # it** the way a Velocity does. A step's free axis still moves, but the rewind aims the
32
+ # next step at the same point on the path, from a little closer, so a follower pressed
33
+ # diagonally against a wall creeps to rest instead of sliding to the corner. A walker
34
+ # meant to get round something replans its path rather than relying on the slide.
35
+ #
36
+ # `on_attach` still places the node on the first waypoint absolutely, blocked or
37
+ # not: where a walker starts is a placement, not a step.
38
+ #
39
+ # ## A new route, and none
40
+ #
41
+ # `path: nil` builds an idle follower, which moves nothing and never finishes, and
42
+ # `follow(path)` hands any follower a route to walk from its start — the same restart
43
+ # entering the tree gives, so a walker that finished one route walks the next and
44
+ # finishes again, and one still walking drops its old route at once.
45
+ #
46
+ # Its heading is the unit direction of the segment it is on, worked out when the walk
47
+ # crosses into a segment rather than on every read.
48
+ class PathFollow < Mover
49
+ signal :on_finished
17
50
 
18
51
  attr_accessor :speed
52
+ attr_reader :path, :heading_x, :heading_y
19
53
 
20
- def initialize(path:, speed:)
21
- super()
54
+ def initialize(speed:, path: nil, blocked_by: [])
55
+ super(blocked_by: blocked_by)
22
56
  @path = path
23
57
  @speed = speed
24
- @segment = 0 # walking from waypoint @segment to @segment + 1
25
- @distance = 0.0 # distance travelled into the current segment
26
- @finished = false
58
+ restart
27
59
  end
28
60
 
29
61
  def finished? = @finished
@@ -32,52 +64,112 @@ module RGame
32
64
  # progress cleared — so a pooled follower reacquired and re-added begins a fresh walk
33
65
  # rather than resuming (or staying finished) where its previous life ended.
34
66
  def on_attach
35
- @segment = 0
36
- @distance = 0.0
37
- @finished = false
38
- place_at(0)
67
+ super
68
+ restart
39
69
  end
40
70
 
41
- def update(dt)
42
- return if @finished
71
+ # Walk `path` from its first waypoint, where the node is placed, whatever this
72
+ # follower was doing — idle, finished, or halfway along another route. `nil` stops
73
+ # it where it stands.
74
+ def follow(path)
75
+ @path = path
76
+ restart
77
+ end
78
+
79
+ private
80
+
81
+ def take_step(dt)
82
+ return if @finished || @path.nil?
83
+
84
+ from_segment = @segment
85
+ from_distance = @distance
86
+
87
+ if advance_to_end?(@speed * dt)
88
+ last = @path.count - 1
89
+ return finish if moved_to?(@path.x_at(last), @path.y_at(last))
90
+
91
+ rewind(from_segment, from_distance)
92
+ else
93
+ seg_len = @path.segment_length(@segment)
94
+ t = seg_len.zero? ? 0.0 : @distance / seg_len
95
+ sx = @path.x_at(@segment)
96
+ sy = @path.y_at(@segment)
97
+ reached = moved_to?(sx + ((@path.x_at(@segment + 1) - sx) * t),
98
+ sy + ((@path.y_at(@segment + 1) - sy) * t))
99
+ rewind(from_segment, from_distance) unless reached
100
+ end
101
+ aim
102
+ end
43
103
 
44
- remaining = @speed * dt
104
+ def advance_to_end?(remaining)
45
105
  while remaining.positive?
46
106
  left = @path.segment_length(@segment) - @distance
47
107
  if remaining < left
48
108
  @distance += remaining
49
- break
109
+ return false
50
110
  end
51
- # Consume the rest of this segment and step onto the next waypoint.
52
111
  remaining -= left
53
112
  @segment += 1
54
113
  @distance = 0.0
55
- return finish if @segment >= @path.count - 1
114
+ return true if @segment >= @path.count - 1
56
115
  end
57
- place_along_segment
116
+ false
58
117
  end
59
118
 
60
- private
119
+ def moved_to?(x, y)
120
+ unless blocking?
121
+ node.x = x
122
+ node.y = y
123
+ return true
124
+ end
125
+
126
+ apply_move(x - node.x, y - node.y)
127
+ !last_move_blocked?
128
+ end
129
+
130
+ def rewind(segment, distance)
131
+ @segment = segment
132
+ @distance = distance
133
+ end
61
134
 
62
135
  def finish
63
- place_at(@path.count - 1)
64
136
  @finished = true
137
+ head_nowhere
65
138
  on_finished_signal.emit
66
139
  end
67
140
 
141
+ def restart
142
+ @segment = 0
143
+ @distance = 0.0
144
+ @finished = false
145
+ head_nowhere
146
+ return unless @path
147
+
148
+ aim
149
+ place_at(0) if node
150
+ end
151
+
152
+ def aim
153
+ return if @segment == @aimed_segment
154
+
155
+ @aimed_segment = @segment
156
+ length = @path.segment_length(@segment)
157
+ return head_nowhere if length.zero?
158
+
159
+ @heading_x = (@path.x_at(@segment + 1) - @path.x_at(@segment)) / length
160
+ @heading_y = (@path.y_at(@segment + 1) - @path.y_at(@segment)) / length
161
+ end
162
+
163
+ def head_nowhere
164
+ @aimed_segment = nil
165
+ @heading_x = 0.0
166
+ @heading_y = 0.0
167
+ end
168
+
68
169
  def place_at(waypoint)
69
170
  node.x = @path.x_at(waypoint)
70
171
  node.y = @path.y_at(waypoint)
71
172
  end
72
-
73
- def place_along_segment
74
- seg_len = @path.segment_length(@segment)
75
- t = seg_len.zero? ? 0.0 : @distance / seg_len
76
- sx = @path.x_at(@segment)
77
- sy = @path.y_at(@segment)
78
- node.x = sx + ((@path.x_at(@segment + 1) - sx) * t)
79
- node.y = sy + ((@path.y_at(@segment + 1) - sy) * t)
80
- end
81
173
  end
82
174
  end
83
175
  end
@@ -5,7 +5,10 @@ module RGame
5
5
  module Components
6
6
  # Drives a CharacterBody sibling from two input axes — direct 8-way walking, no
7
7
  # inertia (unlike ThrustController). Each frame it copies the axis snapshot into
8
- # the body's movement intent; the body does the collision-checked move.
8
+ # the body's movement intent; what the body then does with it is the body's
9
+ # business — an unblocked CharacterBody moves the node, a blocked one resolves
10
+ # the step against the map. The lookup names the base class and get_component
11
+ # matches by ancestry, so this needs to know nothing about which is there.
9
12
  class PlayerController < Engine::Component
10
13
  def initialize(x_axis: :move_x, y_axis: :move_y)
11
14
  super()
@@ -13,7 +16,7 @@ module RGame
13
16
  @y_axis = y_axis
14
17
  end
15
18
 
16
- def on_attach = @body = node.get_component(CharacterBody)
19
+ def on_attach = @body = require_sibling(CharacterBody)
17
20
 
18
21
  def control(actions)
19
22
  @body.set_intent(actions.axis(@x_axis), actions.axis(@y_axis))
@@ -37,7 +37,7 @@ module RGame
37
37
  @pool.reclaim_if do |child|
38
38
  next false unless child.freed?
39
39
 
40
- node.remove_node(child) # idempotent if the deferred-free sweep already detached it
40
+ node.remove_node(child)
41
41
  true
42
42
  end
43
43
  end
@@ -3,23 +3,45 @@
3
3
  module RGame
4
4
  module Engine
5
5
  module Components
6
- # Wraps the node's position toroidally within a rectangle (plus margin), so an
7
- # entity leaving one edge reappears on the opposite one. From Body#wrap!. The
8
- # bounds are passed in; in a scrolling game they'd come from a scene-scoped
9
- # World system instead.
6
+ # Wraps the node's position toroidally within the world bounds (plus margin), so
7
+ # an entity leaving one edge reappears on the opposite one.
8
+ #
9
+ # The bounds come from the scene's world system — anything answering the
10
+ # WorldBounds contract, so Components::World or Components::TileWorld — and are
11
+ # resolved when the node enters the tree. Passing `width:`/`height:` overrides
12
+ # that for a node whose wrap region is not the whole world.
13
+ #
14
+ # The node is tested and placed in **world space**, which is the frame WorldBounds
15
+ # is stated in: a node under an offset container wraps at the world's edge, not at
16
+ # an edge shifted by wherever the container sits. The write goes through
17
+ # Node2D#world_x=, so the node's local position is what actually changes.
18
+ #
19
+ # A wrap is a placement, not a step: it does not ask a sibling Mover's `blocked_by`
20
+ # whether the far edge is free, so a node wrapped onto a blocker is left pressed
21
+ # against it.
10
22
  class ScreenWrap < Engine::Component
11
- def initialize(width:, height:, margin: 0.0)
23
+ def initialize(width: nil, height: nil, margin: 0.0)
12
24
  super()
13
- @width = width
14
- @height = height
25
+ @given_width = width
26
+ @given_height = height
15
27
  @margin = margin
16
28
  end
17
29
 
30
+ # Re-resolved on every entry rather than cached from the first, so a pooled
31
+ # entity recycled into a differently sized scene wraps against that scene.
32
+ def on_attach
33
+ WorldBounds.one_response!(node)
34
+ @width, @height = WorldBounds.resolve(node, @given_width, @given_height)
35
+ end
36
+
18
37
  def update(_dt)
19
- node.x = @width + @margin if node.x < -@margin
20
- node.x = -@margin if node.x > @width + @margin
21
- node.y = @height + @margin if node.y < -@margin
22
- node.y = -@margin if node.y > @height + @margin
38
+ x = node.world_x
39
+ node.world_x = @width + @margin if x < -@margin
40
+ node.world_x = -@margin if x > @width + @margin
41
+
42
+ y = node.world_y
43
+ node.world_y = @height + @margin if y < -@margin
44
+ node.world_y = -@margin if y > @height + @margin
23
45
  end
24
46
  end
25
47
  end
@@ -3,16 +3,18 @@
3
3
  module RGame
4
4
  module Engine
5
5
  module Components
6
- # Draws a single registered image centered on the node's absolute origin. It
7
- # passes NO angle to the renderer: Node2D#draw already wraps a node's own draws
8
- # in renderer.rotated(abs_angle, abs_x, abs_y), so the node's rotation orients
9
- # the sprite — passing an angle here too would rotate it twice.
6
+ # Draws a single registered image centred on the node's own origin. It passes
7
+ # NO angle and NO position to the renderer: Node2D#draw has already pushed the
8
+ # node's transform, so drawing at (0, 0) *is* drawing at the node, correctly
9
+ # rotated. Passing either would apply it a second time.
10
10
  #
11
11
  # `scale` is writable so a pooled entity (e.g. a multi-tier rock) can retune it on
12
12
  # reset. `z` is the render layer (NOT the node's transform z / abs_z) — kept under
13
13
  # @layer to say so. For an animated sprite (sheet + frame index driving
14
14
  # renderer.sprite), add a sibling AnimatedSprite component later.
15
15
  class Sprite < Engine::Component
16
+ include Engine::Culling
17
+
16
18
  attr_accessor :scale
17
19
 
18
20
  def initialize(id:, scale: 1.0, z: 0)
@@ -22,8 +24,22 @@ module RGame
22
24
  @layer = z
23
25
  end
24
26
 
25
- def draw(renderer)
26
- renderer.image(@id, node.abs_x, node.abs_y, scale: @scale, z: @layer)
27
+ # Centred on the node's origin and scaled, so the footprint to cull
28
+ # against is the node's box scaled and offset back by half of itself. A
29
+ # node that never set a size is never culled — see Culling.
30
+ #
31
+ # Culling is the one thing here still stated in **world** coordinates:
32
+ # it compares against the camera, which is nowhere near this node's
33
+ # local space. Drawing is local, culling is world, and the two are
34
+ # deliberately different arguments.
35
+ def draw(renderer, view)
36
+ width = node.width * @scale
37
+ height = node.height * @scale
38
+ lift = node.elevation
39
+ return if culled?(view, node.world_x - (width / 2.0), node.world_y - lift - (height / 2.0),
40
+ width, height)
41
+
42
+ renderer.image(@id, 0, -lift, scale: @scale, z: @layer)
27
43
  end
28
44
  end
29
45
  end
@@ -3,17 +3,17 @@
3
3
  module RGame
4
4
  module Engine
5
5
  module Components
6
- # Picks an enemy for the owning node (a tower) to aim at: each update it queries the
7
- # scene's CollisionWorld around the node's world origin and exposes the chosen target
8
- # via #target (the enemy node, or nil). The owner reads it to fire — Targeting only
6
+ # Picks a node for the owning node to aim at: each update it queries the scene's
7
+ # CollisionWorld around the node's world origin and exposes the chosen target via
8
+ # #target (the candidate's node, or nil). The owner reads it to act — Targeting only
9
9
  # selects, it never moves or shoots.
10
10
  #
11
- # The CollisionWorld it queries is the same broadphase enemies register with through
12
- # their CircleCollider, so targeting needs no enemy list of its own. The `layer`
13
- # restricts candidates (a tower wants :enemy, not other towers or projectiles).
11
+ # The CollisionWorld it queries is the same broadphase every collider registers with,
12
+ # so targeting needs no candidate list of its own. The `layer` restricts candidates
13
+ # (pass :enemy to pick only colliders on that layer, not allies or projectiles).
14
14
  #
15
15
  # Policies (how to choose among the candidates in range):
16
- # - :nearest — the closest enemy (the default; one broadphase nearest-lookup).
16
+ # - :nearest — the closest candidate (the default; one broadphase nearest-lookup).
17
17
  # More policies (e.g. furthest-along-path) arrive with the state they need.
18
18
  class Targeting < Engine::Component
19
19
  POLICIES = %i[nearest].freeze
@@ -28,7 +28,7 @@ module RGame
28
28
  @target = nil
29
29
  end
30
30
 
31
- # The chosen enemy node, or nil when nothing is in range. Refreshed every update.
31
+ # The chosen node, or nil when nothing is in range. Refreshed every update.
32
32
  attr_reader :target
33
33
 
34
34
  # Pull the broadphase once it's reachable (scene scope, resolved up the tree).
@@ -41,11 +41,9 @@ module RGame
41
41
 
42
42
  private
43
43
 
44
- # Allocation-free: a symbol dispatch over a known-small policy set, each delegating
45
- # to an allocation-free CollisionWorld query.
46
44
  def pick
47
45
  case @policy
48
- when :nearest then @world.nearest(node.abs_x, node.abs_y, @range, layer: @layer)
46
+ when :nearest then @world.nearest(node.world_x, node.world_y, @range, layer: @layer)
49
47
  end
50
48
  end
51
49
  end
@@ -26,7 +26,7 @@ module RGame
26
26
  end
27
27
 
28
28
  # The Velocity sibling is only guaranteed present once attached to a node.
29
- def on_attach = @velocity = node.get_component(Velocity)
29
+ def on_attach = @velocity = require_sibling(Velocity)
30
30
 
31
31
  def control(actions)
32
32
  @velocity.spin = actions.axis(@turn_action) * @turn_speed