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,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A jump in a top-down view. Pressing the action lifts the node along a
7
+ # parabola that peaks at `peak` pixels halfway through `duration` seconds,
8
+ # then puts it back on the ground.
9
+ #
10
+ # The height is written to Node2D#elevation, never to `y`. In a top-down view
11
+ # a jump is a *drawing* offset: the sprite arcs above the spot the character
12
+ # stands on, while that spot, and every collider and camera reading it, stays
13
+ # on the ground. So a hop does not carry anyone over a wall. What it
14
+ # crosses is the game's decision, made by reading `airborne?`.
15
+ #
16
+ # node.add_component(Engine::Components::Hop.new(peak: 18, duration: 0.5))
17
+ #
18
+ # The arc is a function of the time accumulated in `update`, not of a clock,
19
+ # so a paused node hangs in the air and a spec can ask for the height at 0.25s.
20
+ # It starts on the action's press edge, so holding the button hops once.
21
+ # `action: nil` leaves only #jump, for something that is not a player.
22
+ class Hop < Engine::Component
23
+ attr_reader :peak, :duration, :height
24
+
25
+ def initialize(peak:, duration:, action: :jump)
26
+ super()
27
+ raise ArgumentError, "peak must be positive, got #{peak.inspect}" unless peak.positive?
28
+ raise ArgumentError, "duration must be positive, got #{duration.inspect}" unless duration.positive?
29
+
30
+ @peak = peak
31
+ @duration = duration
32
+ @action = action
33
+ @height = 0.0
34
+ @elapsed = 0.0
35
+ @airborne = false
36
+ end
37
+
38
+ # Attaching lands the node, so a pooled node reused mid-hop starts on the ground.
39
+ def on_attach = land
40
+
41
+ def airborne? = @airborne
42
+
43
+ # Leave the ground. Does nothing while already off it; `airborne?` says which.
44
+ def jump
45
+ return if @airborne
46
+
47
+ @airborne = true
48
+ @elapsed = 0.0
49
+ end
50
+
51
+ def control(actions)
52
+ jump if @action && actions.pressed?(@action)
53
+ end
54
+
55
+ def update(dt)
56
+ return unless @airborne
57
+
58
+ @elapsed += dt
59
+ return land if @elapsed >= @duration
60
+
61
+ @height = 4.0 * @peak * @elapsed * (@duration - @elapsed) / (@duration * @duration)
62
+ node.elevation = @height
63
+ end
64
+
65
+ private
66
+
67
+ def land
68
+ @airborne = false
69
+ @elapsed = 0.0
70
+ @height = 0.0
71
+ node.elevation = 0
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A stable name for one node, so something outside the tree can refer to it.
7
+ #
8
+ # sheep.add_component(Identity.new(id: 7))
9
+ # Identity.of(sheep) # => 7
10
+ #
11
+ # ## What it is for, and what does not need it
12
+ #
13
+ # Most saving needs no identity at all. A scene is a recipe and a save file
14
+ # is state: the scene rebuilds itself identically, and the save supplies the
15
+ # few facts that differ. A *singular* thing needs no id because the variable
16
+ # holding it is one — `@dog` is set where the dog is built and written
17
+ # straight to on load. *Interchangeable* things need none either, because
18
+ # their order will do: an array of positions restored in order is correct
19
+ # precisely when swapping two of them changes nothing observable.
20
+ #
21
+ # This is for the case those two do not cover:
22
+ #
23
+ # - **members of a collection that can die**, where each survivor keeps
24
+ # state of its own. An array index stops meaning anything the first time
25
+ # the middle of the list is removed;
26
+ # - **a reference from one saved thing to another** — a dog chasing a
27
+ # particular sheep. A node reference cannot be written to a file, and this
28
+ # is what it is written *as*.
29
+ #
30
+ # The second is the one that genuinely forces ids. A collection could always
31
+ # be respawned from its own records; a reference between two of them could
32
+ # not.
33
+ #
34
+ # ## The engine supplies the mechanism, a game supplies the meaning
35
+ #
36
+ # What gets an id, what the ids are, and how they are handed out are the
37
+ # game's business — the same division as `Timer`, which counts down without
38
+ # an opinion about what happens next. Two things follow from that, and both
39
+ # are the game's to get right:
40
+ #
41
+ # **Ids must be unique among the things that can refer to each other.** This
42
+ # component checks nothing; a duplicate is a save that restores the wrong
43
+ # object, silently.
44
+ #
45
+ # **The allocator belongs in the save.** A counter that restarts at 1 on
46
+ # load will reissue ids that the restored objects are already using, and the
47
+ # collision surfaces later as a reference pointing at the wrong thing. Save
48
+ # the next id alongside the objects and restore it too — it is one number,
49
+ # and forgetting it is the classic way this goes wrong.
50
+ class Identity < Engine::Component
51
+ attr_reader :id
52
+
53
+ def initialize(id:)
54
+ super()
55
+ raise ArgumentError, 'an identity needs an id' if id.nil?
56
+
57
+ @id = id
58
+ end
59
+
60
+ # The id of a node, or nil for one that carries no identity.
61
+ #
62
+ # This is the direction that matters at save time: a component holding a
63
+ # *node* — `Targeting#target` is the worked example — has to turn it into
64
+ # something writable, and it has only the node to go on. Going the other
65
+ # way is a game's own lookup, because only the game knows which
66
+ # collection to search.
67
+ def self.of(node)
68
+ node&.get_component(self)&.id
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -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