rgame 0.4.0-x86_64-linux-gnu

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 (239) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +251 -0
  3. data/LICENSE +26 -0
  4. data/README.md +325 -0
  5. data/docs/api/README.md +224 -0
  6. data/docs/api/app.md +249 -0
  7. data/docs/api/assets.md +463 -0
  8. data/docs/api/audio.md +249 -0
  9. data/docs/api/cli.md +259 -0
  10. data/docs/api/components.md +1112 -0
  11. data/docs/api/drawing.md +398 -0
  12. data/docs/api/examples.md +263 -0
  13. data/docs/api/game.md +219 -0
  14. data/docs/api/images.md +118 -0
  15. data/docs/api/input.md +476 -0
  16. data/docs/api/internals.md +289 -0
  17. data/docs/api/localization.md +285 -0
  18. data/docs/api/scene_graph.md +539 -0
  19. data/docs/api/signals.md +147 -0
  20. data/docs/api/systems.md +231 -0
  21. data/docs/api/text.md +133 -0
  22. data/docs/api/tile_maps.md +237 -0
  23. data/docs/api/toolbox.md +493 -0
  24. data/docs/api/ui.md +927 -0
  25. data/docs/api/values.md +316 -0
  26. data/examples/assets/README.md +322 -0
  27. data/examples/assets/blip.ogg +0 -0
  28. data/examples/assets/glyphs.json +5 -0
  29. data/examples/assets/glyphs.png +0 -0
  30. data/examples/assets/hero.json +12 -0
  31. data/examples/assets/hero.png +0 -0
  32. data/examples/assets/icons.json +13 -0
  33. data/examples/assets/icons.png +0 -0
  34. data/examples/assets/music.ogg +0 -0
  35. data/examples/assets/skills.json +10 -0
  36. data/examples/assets/skills.png +0 -0
  37. data/examples/assets/tileset.png +0 -0
  38. data/examples/assets/tileset.tsx +65 -0
  39. data/examples/assets/town.tmx +26 -0
  40. data/examples/assets/ui.json +11 -0
  41. data/examples/assets/ui.png +0 -0
  42. data/examples/collision/locales/en.yml +8 -0
  43. data/examples/collision/main.rb +316 -0
  44. data/examples/collision_tiles/locales/en.yml +9 -0
  45. data/examples/collision_tiles/main.rb +274 -0
  46. data/examples/fullscreen/locales/en.yml +10 -0
  47. data/examples/fullscreen/main.rb +216 -0
  48. data/examples/game_menu/locales/en.yml +8 -0
  49. data/examples/game_menu/main.rb +170 -0
  50. data/examples/input_glyphs/locales/en.yml +14 -0
  51. data/examples/input_glyphs/main.rb +213 -0
  52. data/examples/jump_topdown/locales/en.yml +9 -0
  53. data/examples/jump_topdown/main.rb +178 -0
  54. data/examples/localization/locales/de.yml +12 -0
  55. data/examples/localization/locales/en.yml +13 -0
  56. data/examples/localization/main.rb +158 -0
  57. data/examples/menu_navigation/locales/en.yml +23 -0
  58. data/examples/menu_navigation/main.rb +365 -0
  59. data/examples/music/locales/en.yml +7 -0
  60. data/examples/music/main.rb +134 -0
  61. data/examples/pathfinding/locales/en.yml +17 -0
  62. data/examples/pathfinding/main.rb +298 -0
  63. data/examples/pooling/locales/en.yml +7 -0
  64. data/examples/pooling/main.rb +259 -0
  65. data/examples/quick_wheel/locales/en.yml +16 -0
  66. data/examples/quick_wheel/main.rb +184 -0
  67. data/examples/radial_menu/locales/en.yml +16 -0
  68. data/examples/radial_menu/main.rb +184 -0
  69. data/examples/save_load/locales/en.yml +11 -0
  70. data/examples/save_load/main.rb +207 -0
  71. data/examples/save_load_ids/locales/en.yml +11 -0
  72. data/examples/save_load_ids/main.rb +322 -0
  73. data/examples/scroll_map/locales/en.yml +4 -0
  74. data/examples/scroll_map/main.rb +140 -0
  75. data/examples/signals/locales/en.yml +6 -0
  76. data/examples/signals/main.rb +278 -0
  77. data/examples/skill_bar/locales/en.yml +14 -0
  78. data/examples/skill_bar/main.rb +159 -0
  79. data/examples/sound/locales/en.yml +6 -0
  80. data/examples/sound/main.rb +122 -0
  81. data/examples/split_screen/locales/en.yml +9 -0
  82. data/examples/split_screen/main.rb +304 -0
  83. data/examples/sprite/locales/en.yml +8 -0
  84. data/examples/sprite/main.rb +180 -0
  85. data/examples/timer/locales/en.yml +12 -0
  86. data/examples/timer/main.rb +273 -0
  87. data/examples/velocity/locales/en.yml +6 -0
  88. data/examples/velocity/main.rb +196 -0
  89. data/examples/walk/locales/en.yml +4 -0
  90. data/examples/walk/main.rb +99 -0
  91. data/exe/rgame +9 -0
  92. data/lib/rgame/boot.rb +3 -0
  93. data/lib/rgame/cli/new_project.rb +139 -0
  94. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  95. data/lib/rgame/cli/templates/README.md.tt +93 -0
  96. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  97. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  98. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  99. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  100. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  101. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  102. data/lib/rgame/cli/templates/rspec.tt +2 -0
  103. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  104. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  105. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  106. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  107. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  108. data/lib/rgame/cli.rb +66 -0
  109. data/lib/rgame/core/app.rb +46 -0
  110. data/lib/rgame/core/asset_manager.rb +206 -0
  111. data/lib/rgame/core/audio.rb +145 -0
  112. data/lib/rgame/core/font.rb +46 -0
  113. data/lib/rgame/core/gamepad.rb +55 -0
  114. data/lib/rgame/core/image.rb +55 -0
  115. data/lib/rgame/core/input.rb +71 -0
  116. data/lib/rgame/core/locale.rb +22 -0
  117. data/lib/rgame/core/nine_slice.rb +142 -0
  118. data/lib/rgame/core/recording.rb +54 -0
  119. data/lib/rgame/core/renderer.rb +354 -0
  120. data/lib/rgame/core/sprite_sheet.rb +105 -0
  121. data/lib/rgame/core/tile_map_renderer.rb +172 -0
  122. data/lib/rgame/core/ui_atlas.rb +101 -0
  123. data/lib/rgame/core/virtual_gamepad.rb +26 -0
  124. data/lib/rgame/core.rb +18 -0
  125. data/lib/rgame/core_ext.so +0 -0
  126. data/lib/rgame/engine/actor_blockers.rb +131 -0
  127. data/lib/rgame/engine/animation_set.rb +50 -0
  128. data/lib/rgame/engine/animator.rb +44 -0
  129. data/lib/rgame/engine/audio_bus.rb +24 -0
  130. data/lib/rgame/engine/audio_director.rb +59 -0
  131. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  132. data/lib/rgame/engine/camera.rb +78 -0
  133. data/lib/rgame/engine/circle_collider.rb +34 -0
  134. data/lib/rgame/engine/collision_box.rb +59 -0
  135. data/lib/rgame/engine/collision_system.rb +132 -0
  136. data/lib/rgame/engine/component.rb +64 -0
  137. data/lib/rgame/engine/components/action_trigger.rb +40 -0
  138. data/lib/rgame/engine/components/animated_sprite.rb +71 -0
  139. data/lib/rgame/engine/components/box_collider.rb +99 -0
  140. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  141. data/lib/rgame/engine/components/character_body.rb +50 -0
  142. data/lib/rgame/engine/components/circle_collider.rb +80 -0
  143. data/lib/rgame/engine/components/collision_world.rb +231 -0
  144. data/lib/rgame/engine/components/despawn_offscreen.rb +42 -0
  145. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  146. data/lib/rgame/engine/components/hop.rb +76 -0
  147. data/lib/rgame/engine/components/identity.rb +73 -0
  148. data/lib/rgame/engine/components/mover.rb +285 -0
  149. data/lib/rgame/engine/components/navigator.rb +145 -0
  150. data/lib/rgame/engine/components/path_follow.rb +176 -0
  151. data/lib/rgame/engine/components/player_controller.rb +27 -0
  152. data/lib/rgame/engine/components/pool.rb +53 -0
  153. data/lib/rgame/engine/components/screen_wrap.rb +49 -0
  154. data/lib/rgame/engine/components/sprite.rb +47 -0
  155. data/lib/rgame/engine/components/targeting.rb +52 -0
  156. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  157. data/lib/rgame/engine/components/tile_world.rb +109 -0
  158. data/lib/rgame/engine/components/timer.rb +75 -0
  159. data/lib/rgame/engine/components/velocity.rb +43 -0
  160. data/lib/rgame/engine/components/wander_controller.rb +64 -0
  161. data/lib/rgame/engine/components/world.rb +133 -0
  162. data/lib/rgame/engine/contact_set.rb +74 -0
  163. data/lib/rgame/engine/culling.rb +45 -0
  164. data/lib/rgame/engine/debug_overlay.rb +111 -0
  165. data/lib/rgame/engine/i18n/plural.rb +45 -0
  166. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  167. data/lib/rgame/engine/i18n/template.rb +59 -0
  168. data/lib/rgame/engine/i18n.rb +322 -0
  169. data/lib/rgame/engine/input/action_mapper.rb +100 -0
  170. data/lib/rgame/engine/input/actions.rb +92 -0
  171. data/lib/rgame/engine/input/input_map.rb +196 -0
  172. data/lib/rgame/engine/layout.rb +82 -0
  173. data/lib/rgame/engine/nav_grid.rb +87 -0
  174. data/lib/rgame/engine/node2d.rb +555 -0
  175. data/lib/rgame/engine/path.rb +76 -0
  176. data/lib/rgame/engine/player.rb +69 -0
  177. data/lib/rgame/engine/player_layer.rb +70 -0
  178. data/lib/rgame/engine/players.rb +205 -0
  179. data/lib/rgame/engine/pool.rb +51 -0
  180. data/lib/rgame/engine/presentation.rb +171 -0
  181. data/lib/rgame/engine/scene/scene_stack.rb +87 -0
  182. data/lib/rgame/engine/sealed_privates.rb +54 -0
  183. data/lib/rgame/engine/signal.rb +75 -0
  184. data/lib/rgame/engine/spatial_hash.rb +116 -0
  185. data/lib/rgame/engine/text.rb +194 -0
  186. data/lib/rgame/engine/tile_blockers.rb +63 -0
  187. data/lib/rgame/engine/tile_map.rb +147 -0
  188. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  189. data/lib/rgame/engine/tileset.rb +99 -0
  190. data/lib/rgame/engine/timer.rb +51 -0
  191. data/lib/rgame/engine/ui/button.rb +248 -0
  192. data/lib/rgame/engine/ui/column.rb +20 -0
  193. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  194. data/lib/rgame/engine/ui/menu.rb +290 -0
  195. data/lib/rgame/engine/ui/navigation.rb +57 -0
  196. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  197. data/lib/rgame/engine/ui/option_button.rb +163 -0
  198. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  199. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  200. data/lib/rgame/engine/ui/pointing.rb +146 -0
  201. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  202. data/lib/rgame/engine/ui/ring.rb +55 -0
  203. data/lib/rgame/engine/ui/row.rb +21 -0
  204. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  205. data/lib/rgame/engine/ui/stack.rb +58 -0
  206. data/lib/rgame/engine/ui/stepping.rb +93 -0
  207. data/lib/rgame/engine/ui/text_button.rb +59 -0
  208. data/lib/rgame/engine/view.rb +76 -0
  209. data/lib/rgame/engine/viewports.rb +171 -0
  210. data/lib/rgame/engine/world_view.rb +71 -0
  211. data/lib/rgame/engine.rb +86 -0
  212. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  213. data/lib/rgame/fonts/OFL.txt +102 -0
  214. data/lib/rgame/game.rb +271 -0
  215. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  216. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  217. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  218. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  219. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  220. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  221. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  222. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  223. data/lib/rgame/rubocop/default.yml +39 -0
  224. data/lib/rgame/rubocop/plugin.rb +45 -0
  225. data/lib/rgame/rubocop.rb +11 -0
  226. data/lib/rgame/util/color.rb +23 -0
  227. data/lib/rgame/util/controls.rb +169 -0
  228. data/lib/rgame/util/route_search.rb +27 -0
  229. data/lib/rgame/util/save_file.rb +107 -0
  230. data/lib/rgame/util/solid_grid.rb +37 -0
  231. data/lib/rgame/util/tensor.rb +3 -0
  232. data/lib/rgame/util/tile_sweep.rb +36 -0
  233. data/lib/rgame/util/z.rb +123 -0
  234. data/lib/rgame/util.rb +10 -0
  235. data/lib/rgame/util_ext.so +0 -0
  236. data/lib/rgame/version.rb +12 -0
  237. data/lib/rgame.rb +5 -0
  238. data/licenses/SDL2/LICENSE.txt +18 -0
  239. metadata +309 -0
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # What every component that moves its node has in common: a step computed some way
7
+ # of its own, landing either straight on the node or against whatever may stop it.
8
+ # CharacterBody, Velocity and PathFollow are the three, and they are three classes
9
+ # because walking an intent, integrating a velocity and following a path are three
10
+ # different jobs. What they share is what happens *after* a step is computed, and
11
+ # that is this class.
12
+ #
13
+ # A mover fills in one private hook, `take_step(dt)`, and calls `apply_move(dx, dy)`
14
+ # from it. `update` is not for overriding: it opens the step, calls the hook and
15
+ # reports what stopped being in the way, so no mover can forget either edge.
16
+ #
17
+ # **Why a base class, and not a sibling component or a Node2D method.** A separate
18
+ # `Blocking` component that movers write through was tried, and it is order-dependent:
19
+ # closing the step has to happen after the mover's step, and a sibling can only do
20
+ # that from its own `update`, which runs wherever it sits in the component list — two
21
+ # add orders fired on_unblocked on two different ticks. A `Node2D#move_by` owning
22
+ # `blocked_by` is order-free, but puts collision into the base of every node, HUDs and
23
+ # menus included. A base class is order-free and touches only what moves.
24
+ #
25
+ # ## What stops a step is declared, not subclassed
26
+ #
27
+ # `blocked_by:` lists what a step may not pass through, and the default is nothing:
28
+ # the mover writes the node's position directly and needs no collider and no system
29
+ # on the scene. That free step is not quite free: `update` → `take_step` →
30
+ # `apply_move` is one dispatch more than a component writing `node.x` itself, which
31
+ # measured about 12% on a bare Velocity step (tens of nanoseconds). Inlining the free
32
+ # write into each subclass wins it back, at the cost of every mover copying
33
+ # `apply_move`'s free branch and reading this class's ivars — not taken.
34
+ #
35
+ # Velocity.new(vx: 120) # flies wherever it points
36
+ # Velocity.new(vx: 120, blocked_by: [:wall]) # stops flush against a :wall collider
37
+ # CharacterBody.new(speed: 80, blocked_by: [:tiles]) # slides along the map's solid tiles
38
+ # CharacterBody.new(speed: 80, blocked_by: %i[tiles npc]) # ...and does not walk through NPCs
39
+ # CharacterBody.new(speed: 80, blocked_by: %i[npc bounds]) # ...and cannot leave the world
40
+ #
41
+ # Two names are reserved: **`:tiles`** is the scene's TileWorld, and **`:bounds`** is the
42
+ # edge of the region the scene's WorldBounds describes. **Every other name is a collider
43
+ # layer**, resolved against the scene's CollisionWorld: a mover declaring `:npc` is
44
+ # stopped by any BoxCollider whose `layer` is `:npc`, flush against its edge, exactly the
45
+ # way a solid tile stops it. A layer that is empty, or whose colliders all leave, is not
46
+ # an error — the declaration says what *may* stop this mover, not what does.
47
+ #
48
+ # `:bounds` is declared rather than automatic, and a mover that does not declare it
49
+ # leaves the world. Stopping at the edge is one of three responses to it, beside
50
+ # ScreenWrap and DespawnOffscreen, and a node may carry only one — declaring `:bounds`
51
+ # beside either raises at attach (WorldBounds.one_response!).
52
+ #
53
+ # Blocking is box-versus-box: a CircleCollider on a declared layer reports its contacts
54
+ # as usual and stops nothing, and a mover that declares anything needs a BoxCollider of
55
+ # its own.
56
+ #
57
+ # The two are not alternatives. `blocked_by` and `on_hit` answer different questions —
58
+ # what may I pass through, and what am I touching — and a flush-blocked pair does not
59
+ # overlap, so a mover that must both stop and react needs both.
60
+ #
61
+ # A declaration this scene cannot honour raises at on_attach rather than quietly
62
+ # falling back to free movement — a mover passing through walls looks like a collision
63
+ # bug, and the cause would be a scene three files away that never mounted the system.
64
+ #
65
+ # ## A blocked step slides
66
+ #
67
+ # Where a step lands is decided by Engine::CollisionSystem, which resolves X and then Y,
68
+ # so a diagonal push into a wall keeps the component that is still free. That is the
69
+ # right feel for a character and the wrong one for a bullet — and a bullet does not
70
+ # need a different resolver for it, it needs to react: `on_blocked` fires on the step
71
+ # it hits, and a bullet that queue-frees itself there is gone before it slides anywhere.
72
+ # One that bounces reads which axis was stopped and turns that half of its velocity:
73
+ #
74
+ # velocity = Velocity.new(vx: 120, vy: 80, blocked_by: %i[wall bounds])
75
+ # velocity.on_blocked do |_by, axis|
76
+ # velocity.vx = -velocity.vx unless axis == :y
77
+ # velocity.vy = -velocity.vy unless axis == :x
78
+ # end
79
+ #
80
+ # ## The shape has one owner, and it is not this
81
+ #
82
+ # A blocked step is resolved against the node's **collider** box, read from the
83
+ # sibling BoxCollider (FeetCollider is the one a walking character wants). The box is
84
+ # therefore given once, to the component that *is* a shape, and the same rectangle both
85
+ # stops the step and reports contacts — there is nothing to hand from one component to
86
+ # the other and nothing to keep in sync.
87
+ class Mover < Engine::Component
88
+ # The two edges of being stopped: on_blocked on the step this mover starts being
89
+ # stopped by something, on_unblocked on the step it stops. Each fires once per
90
+ # blocker, so a handler may spend a life or play a sound — the spiky ball that
91
+ # both stops the player and hurts them is the two signals plus an on_hit.
92
+ #
93
+ # The listener gets whatever stopped the step and reads its #layer and #node, so
94
+ # one handler covers every kind: a collider answers its own layer and its owning
95
+ # node, the map's solid tiles answer :tiles and nil (Engine::TileBlockers::TILES),
96
+ # and the world's edge answers :bounds and nil.
97
+ #
98
+ # on_blocked also says which axis of the step it stopped — :x, :y, or :both when one
99
+ # blocker stopped the two at once — which is what a bounce branches on. A listener
100
+ # that names only the blocker, `{ |by| ... }`, never sees it: a block drops the
101
+ # arguments it does not name.
102
+ #
103
+ # on_unblocked has no axis, because what ends is a blocker stopping this mover, not
104
+ # an axis. A blocker that stopped x on one step and y on the next was in the way the
105
+ # whole time, and ends once.
106
+ signal :on_blocked, Engine::Signal.define(:by, :axis)
107
+ signal :on_unblocked, Engine::Signal.define(:by)
108
+
109
+ TILES = :tiles
110
+ BOUNDS = :bounds
111
+ RESERVED = [TILES, BOUNDS].freeze
112
+
113
+ def initialize(blocked_by: [])
114
+ super()
115
+ @blocked_by = Array(blocked_by)
116
+ @collider = nil
117
+ @collision = nil
118
+ @last_move_blocked = false
119
+ @stopped_by = Engine::ContactSet.new
120
+ end
121
+
122
+ # Resolve each declared blocker and build the resolver that runs them, once the node
123
+ # is in the tree and both the scene's systems and this node's other components are
124
+ # reachable.
125
+ #
126
+ # **The resolver is the mover's own**, rather than something borrowed off the scene.
127
+ # It has to be: a source over other colliders holds this mover's collider and this
128
+ # mover's layer list, so two movers declaring different `blocked_by` cannot share
129
+ # one — and a scene may mount a CollisionWorld with no TileWorld at all, so there is
130
+ # not always a scene-level resolver to borrow in the first place. What is shared is
131
+ # what can be: the TileWorld's own source is borrowed, not rebuilt.
132
+ #
133
+ # Nothing here runs on a frame. The list is built once, and a step only walks it.
134
+ #
135
+ # A subclass that needs its own attach work calls `super` first — PathFollow does,
136
+ # to place its node before walking.
137
+ def on_attach
138
+ @stopped_by.reset
139
+ return if @blocked_by.empty?
140
+
141
+ WorldBounds.one_response!(node) if blocked_by?(BOUNDS)
142
+
143
+ @collider = require_sibling(BoxCollider)
144
+ @collision = Engine::CollisionSystem.new(blockers: resolve_blockers)
145
+ end
146
+
147
+ # Take this step, then report what stopped being in the way.
148
+ #
149
+ # **The set advances once per update, not once per apply_move**, and that is what
150
+ # makes standing still an unblocking: a mover that stops pushing into something
151
+ # records nothing this step, so what it was pressing against ends and on_unblocked
152
+ # fires. It also keeps the bookkeeping where a subclass cannot lose it — a mover
153
+ # that resolves a step in several moves, or overrides apply_move, still opens the
154
+ # step once and still reports its edges.
155
+ def update(dt)
156
+ return take_step(dt) unless @collision
157
+
158
+ @stopped_by.begin_frame
159
+ take_step(dt)
160
+ @stopped_by.each_ended { on_unblocked_signal.emit(it) }
161
+ end
162
+
163
+ # Which way this mover's step is going, each axis in -1..1, and 0, 0 when it is not
164
+ # trying to move. A facing rather than a velocity: a mover pressed into a wall still
165
+ # heads into it, so an AnimatedSprite keeps walking against the wall rather than
166
+ # standing. Each subclass answers from what its step is computed out of.
167
+ def heading_x = 0.0
168
+ def heading_y = 0.0
169
+
170
+ # Whether `name` is one of the things this mover declared it may be stopped by.
171
+ def blocked_by?(name) = @blocked_by.include?(name)
172
+
173
+ # Where a step lands. Public, and kept separate from `take_step`, so a mover that
174
+ # resolves a step some other way — a platformer's CharacterBody, with gravity and a
175
+ # jump — inherits everything around it rather than restating it.
176
+ #
177
+ # The branch is on what was declared rather than on a subclass: an unblocked mover
178
+ # writes straight to the node, and a blocked one hands *itself* to its resolver as
179
+ # the actor being moved (see the adapter below).
180
+ def apply_move(dx, dy)
181
+ unless @collision
182
+ node.x += dx
183
+ node.y += dy
184
+ return
185
+ end
186
+
187
+ @collision.move(self, dx, dy)
188
+ blocked_x = @collision.blocked_x
189
+ blocked_y = @collision.blocked_y
190
+ @last_move_blocked = !(blocked_x.nil? && blocked_y.nil?)
191
+ if blocked_x.equal?(blocked_y)
192
+ record_blocker(blocked_x, :both)
193
+ else
194
+ record_blocker(blocked_x, :x)
195
+ record_blocker(blocked_y, :y)
196
+ end
197
+ end
198
+
199
+ # The actor adapter CollisionSystem#move drives: it reads x/y/collision_box, works
200
+ # out where the step lands, and writes the resolved position back. The box is the
201
+ # sibling collider's — the node's one shape, so retuning `collider.box` retunes
202
+ # what a step collides with.
203
+ #
204
+ # ## The adapter is in world space
205
+ #
206
+ # x and y are the node's **world** position, because that is the space everything
207
+ # else about collision is already in: the tile grid is a world-coordinate grid, and
208
+ # BoxCollider#aabb_x reports `node.world_x + box.offset_x`. A mover under an offset
209
+ # ancestor that reported its local position would resolve against a map shifted by
210
+ # the ancestor, and would be compared against other colliders in a different frame
211
+ # entirely.
212
+ #
213
+ # Writing goes back through Node2D#world_x= / #world_y=, which turn the resolved world
214
+ # position into the local one the node actually lives in.
215
+ #
216
+ # A mover under a rotated ancestor is still outside what an axis-aligned box supports
217
+ # (docs/api/components.md: a thing that spins wants a circle) — the position it lands
218
+ # at is exact, but the box it was resolved with does not turn with the frame.
219
+ def collision_box = @collider.box
220
+ def x = node.world_x
221
+ def y = node.world_y
222
+
223
+ def x=(value)
224
+ node.world_x = value
225
+ end
226
+
227
+ def y=(value)
228
+ node.world_y = value
229
+ end
230
+
231
+ private
232
+
233
+ def take_step(_dt) = nil
234
+
235
+ def blocking? = !@collision.nil?
236
+
237
+ def last_move_blocked? = @last_move_blocked
238
+
239
+ def record_blocker(by, axis)
240
+ return if by.nil? || @stopped_by.touching?(by)
241
+
242
+ started = @stopped_by.started?(by)
243
+ @stopped_by.add(by)
244
+ on_blocked_signal.emit(by:, axis:) if started
245
+ end
246
+
247
+ def resolve_blockers
248
+ sources = []
249
+ sources << tile_blockers if @blocked_by.include?(TILES)
250
+ sources << bounds_blockers if @blocked_by.include?(BOUNDS)
251
+ layers = @blocked_by.reject { RESERVED.include?(it) }
252
+ sources << actor_blockers(layers) unless layers.empty?
253
+ sources
254
+ end
255
+
256
+ def tile_blockers
257
+ world = node.system(TileWorld) ||
258
+ raise("#{mover_name} is blocked_by :tiles, and the scene has no TileWorld " \
259
+ 'system to resolve a step against. Mount one, or drop blocked_by for ' \
260
+ 'a mover with nothing to collide with.')
261
+ world.blockers
262
+ end
263
+
264
+ def bounds_blockers
265
+ bounds = node.system(WorldBounds) ||
266
+ raise("#{mover_name} is blocked_by :bounds, and the scene has no world " \
267
+ 'bounds to stop at. Mount a World (or a TileWorld, which is one), or ' \
268
+ 'drop :bounds for a mover that may leave the world.')
269
+ Engine::BoundsBlockers.new(bounds: bounds)
270
+ end
271
+
272
+ def actor_blockers(layers)
273
+ world = node.system(CollisionWorld) ||
274
+ raise("#{mover_name} is blocked_by #{layers.map(&:inspect).join(', ')}, which " \
275
+ 'names collider layers, and the scene has no CollisionWorld system to ' \
276
+ 'find them in. Mount one, or drop those names for a mover that only ' \
277
+ 'the map stops.')
278
+ Engine::ActorBlockers.new(world: world, owner: @collider, layers: layers)
279
+ end
280
+
281
+ def mover_name = self.class.name&.split('::')&.last || self.class.inspect
282
+ end
283
+ end
284
+ end
285
+ end
@@ -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
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
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 for
8
+ # whatever should happen when a walker arrives.
9
+ #
10
+ # The walk is allocation-free: it tracks the current segment and the distance into
11
+ # it, advancing through as many segments as one step crosses (so a fast mover over
12
+ # short segments still lands correctly), then interpolates the node's position from
13
+ # the segment endpoints. Movement is driven purely by `speed * dt`; this is not a
14
+ # Velocity integrator and ignores the node's angle.
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
50
+
51
+ attr_accessor :speed
52
+ attr_reader :path, :heading_x, :heading_y
53
+
54
+ def initialize(speed:, path: nil, blocked_by: [])
55
+ super(blocked_by: blocked_by)
56
+ @path = path
57
+ @speed = speed
58
+ restart
59
+ end
60
+
61
+ def finished? = @finished
62
+
63
+ # Restart the walk as the node enters the tree — back to the first waypoint, with
64
+ # progress cleared — so a pooled follower reacquired and re-added begins a fresh walk
65
+ # rather than resuming (or staying finished) where its previous life ended.
66
+ def on_attach
67
+ super
68
+ restart
69
+ end
70
+
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
103
+
104
+ def advance_to_end?(remaining)
105
+ while remaining.positive?
106
+ left = @path.segment_length(@segment) - @distance
107
+ if remaining < left
108
+ @distance += remaining
109
+ return false
110
+ end
111
+ remaining -= left
112
+ @segment += 1
113
+ @distance = 0.0
114
+ return true if @segment >= @path.count - 1
115
+ end
116
+ false
117
+ end
118
+
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
134
+
135
+ def finish
136
+ @finished = true
137
+ head_nowhere
138
+ on_finished_signal.emit
139
+ end
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
+
169
+ def place_at(waypoint)
170
+ node.x = @path.x_at(waypoint)
171
+ node.y = @path.y_at(waypoint)
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Drives a CharacterBody sibling from two input axes — direct 8-way walking, no
7
+ # inertia (unlike ThrustController). Each frame it copies the axis snapshot into
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.
12
+ class PlayerController < Engine::Component
13
+ def initialize(x_axis: :move_x, y_axis: :move_y)
14
+ super()
15
+ @x_axis = x_axis
16
+ @y_axis = y_axis
17
+ end
18
+
19
+ def on_attach = @body = require_sibling(CharacterBody)
20
+
21
+ def control(actions)
22
+ @body.set_intent(actions.axis(@x_axis), actions.axis(@y_axis))
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Wraps an Engine::Pool of nodes and folds the tree bookkeeping into the frame tick:
7
+ # `spawn` takes a node from the pool (recycled or freshly built) and adds it as a
8
+ # child of the owner, and `update` returns freed nodes to the pool — so a game only
9
+ # ever writes `pool.spawn` and the ordinary `node.queue_free`, never a hand-written
10
+ # acquire/add/reclaim bridge. Pooled nodes are normal children, so the scene's usual
11
+ # traversal updates and draws them; this component only manages their pool membership.
12
+ #
13
+ # pool = add_component(Engine::Components::Pool.new { Enemy.new(path: @path) })
14
+ # pool.spawn # acquire + add as a child
15
+ # pool.spawn { |b| b.reset(x, y) } # reset before it enters the tree (projectiles)
16
+ # enemy.queue_free # → reclaimed back to the pool automatically
17
+ #
18
+ # Add it named (`as:`) like any component when a node needs more than one pool.
19
+ class Pool < Engine::Component
20
+ def initialize(&)
21
+ super()
22
+ @pool = Engine::Pool.new(&)
23
+ end
24
+
25
+ # Take a node from the pool, let the caller re-initialise it before it goes live
26
+ # (optional block — runs before on_attach), and add it as a child of the owner.
27
+ def spawn
28
+ child = @pool.acquire
29
+ yield child if block_given?
30
+ node.add_node(child)
31
+ child
32
+ end
33
+
34
+ # Ride the tick: return every freed pooled node to the free list, detaching any that
35
+ # are still in the tree — so a queue_free elsewhere recycles with no game-side wiring.
36
+ def update(_dt)
37
+ @pool.reclaim_if do |child|
38
+ next false unless child.freed?
39
+
40
+ node.remove_node(child)
41
+ true
42
+ end
43
+ end
44
+
45
+ def size = @pool.size
46
+
47
+ # True when no spawned node is currently live (all reclaimed) — a scene reads this to
48
+ # tell when a wave/round has been fully cleared.
49
+ def empty? = @pool.empty?
50
+ end
51
+ end
52
+ end
53
+ end