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,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A rectangular collision shape on a node — the sibling of CircleCollider for
7
+ # entities that are honestly box-shaped (a crate, a platform, a wall segment).
8
+ # It registers itself with the scene's CollisionWorld when it enters the tree
9
+ # and unregisters on leaving, so a spawned/despawned entity can't leak a
10
+ # registration. A scene with no world mounted leaves it a bare shape (see
11
+ # on_attach).
12
+ #
13
+ # The rectangle is an Engine::CollisionBox: an offset + size relative to the
14
+ # node's origin, so a 32x32 sprite can carry a small box at its feet —
15
+ # FeetCollider is the subclass that works that offset out for you. The `layer`
16
+ # is an opaque tag the game reads in its on_hit handler to decide what a contact
17
+ # means. See docs/api/systems.md.
18
+ #
19
+ # The box stays axis-aligned in world space: it does not rotate with the node.
20
+ # That is what an AABB buys — a spinning entity wants a CircleCollider, which
21
+ # is rotation-invariant, rather than a per-frame box recompute.
22
+ class BoxCollider < Engine::Component
23
+ # The two edges of a contact, fired by CollisionWorld: on_hit on the step this
24
+ # collider starts overlapping another, on_separated on the step it stops. Each
25
+ # fires once per pair, so a handler may count, play a sound or spend a life.
26
+ # The listener gets the other collider and reads its #layer / #node to react.
27
+ signal :on_hit, Engine::Signal.define(:other)
28
+ signal :on_separated, Engine::Signal.define(:other)
29
+
30
+ # box is writable so a pooled entity can retune its shape on reset — assign any
31
+ # CollisionBox, CollisionBox.bottom_anchored(...) included. CollisionWorld reads
32
+ # it fresh each frame, so no re-registration.
33
+ attr_accessor :box
34
+ attr_reader :layer
35
+
36
+ # CollisionWorld's per-collider bookkeeping: who this was touching this step and
37
+ # last. The world owns what goes in it; nothing else should write to it.
38
+ attr_reader :contacts
39
+
40
+ def initialize(width:, height:, offset_x: 0, offset_y: 0, layer: :default)
41
+ super()
42
+ @box = Engine::CollisionBox.new(width:, height:, offset_x:, offset_y:)
43
+ @layer = layer
44
+ @contacts = Engine::ContactSet.new
45
+ end
46
+
47
+ # A collider is a *shape*; a CollisionWorld is what turns shapes into contacts. So
48
+ # a scene with no world mounted leaves this a bare shape instead of raising, which
49
+ # is what a tile-only game wants: its character's feet box stops its steps (that is
50
+ # a Mover's `blocked_by`) and there are no pairs to report to anyone.
51
+ #
52
+ # The cost is that an on_hit handler in such a scene never fires and nothing says
53
+ # so. That is the trade taken deliberately: what declares "I expect to be stopped"
54
+ # is `blocked_by`, and that *does* raise for a system it cannot find.
55
+ def on_attach = node.system(CollisionWorld)&.register(self)
56
+ def on_detach = node.system(CollisionWorld)&.unregister(self)
57
+
58
+ # The broadphase AABB in world space, one component per call rather than
59
+ # CollisionBox#aabb's Array: CollisionWorld reads these for every collider
60
+ # every frame, and that path may not allocate.
61
+ #
62
+ # These go through `box` rather than @box so a subclass that builds its shape
63
+ # lazily — FeetCollider, which cannot know the node's size until the tree is
64
+ # live — is seen by the broadphase too. An attr_reader call allocates nothing.
65
+ def aabb_x = node.world_x + box.offset_x
66
+ def aabb_y = node.world_y + box.offset_y
67
+ def aabb_w = box.width
68
+ def aabb_h = box.height
69
+
70
+ # World-space centre of the box — what CollisionWorld's range queries measure
71
+ # from, so a box collider is targetable on the same terms as a circle. Note it
72
+ # is the box's centre, not the node's origin, which is where a circle's is.
73
+ def cx = aabb_x + (box.width / 2.0)
74
+ def cy = aabb_y + (box.height / 2.0)
75
+
76
+ # Narrowphase, first half of the double dispatch: hand this shape's numbers to
77
+ # the *other* collider and let it pick the test, so neither side has to ask what
78
+ # kind the other is. CircleCollider#overlap? is the mirror image.
79
+ def overlap?(other) = other.overlap_box?(aabb_x, aabb_y, aabb_w, aabb_h)
80
+
81
+ # Second half: the two tests another collider dispatches into.
82
+ #
83
+ # @api private
84
+ def overlap_box?(x, y, w, h)
85
+ Engine::CollisionBox.overlap?(aabb_x, aabb_y, aabb_w, aabb_h, x, y, w, h)
86
+ end
87
+
88
+ # @api private
89
+ def overlap_circle?(x, y, r)
90
+ Engine::CollisionBox.overlap_circle?(aabb_x, aabb_y, aabb_w, aabb_h, x, y, r)
91
+ end
92
+
93
+ # Called by CollisionWorld on each edge (the signals' emit is otherwise private).
94
+ def emit_hit(other) = on_hit_signal.emit(other)
95
+ def emit_separated(other) = on_separated_signal.emit(other)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -29,14 +29,15 @@ module RGame
29
29
  @offset_y = offset_y
30
30
  end
31
31
 
32
- # Reads the absolute origin resolved at the top of this node's update,
33
- # so the camera trails the node's own movement by one step (a couple of
32
+ # A node runs its components before its own `on_update`, so this reads
33
+ # the node's world position from before whatever moves it this tick, and
34
+ # the camera trails the node's own movement by one step (a couple of
34
35
  # pixels at walking speed). That is deliberate and uniform: everything
35
36
  # drawn through this camera trails equally, so nothing drifts apart on
36
- # screen, and the alternativere-resolving here — would put this
37
- # component's ordering among its siblings on show.
37
+ # screen. Reading it laterfrom the node's own hook, say — would put
38
+ # this component's ordering among its siblings on show instead.
38
39
  def update(_dt)
39
- @camera.center_on(node.abs_x + @offset_x, node.abs_y + @offset_y)
40
+ @camera.center_on(node.world_x + @offset_x, node.world_y + @offset_y)
40
41
  end
41
42
  end
42
43
  end
@@ -3,48 +3,33 @@
3
3
  module RGame
4
4
  module Engine
5
5
  module Components
6
- # Walking movement for a tile-bound actor (player or NPC). A controller writes a
7
- # per-step movement intent — each axis in -1..1 — and this component turns it into
8
- # a real move each update, resolved against the scene's TileWorld so the actor
9
- # slides along walls and stays inside the map. Distinct from Velocity (which
10
- # integrates blindly): here every step is collision-checked.
6
+ # Direct, per-step movement for a walking actor (player or NPC). A controller writes a
7
+ # movement intent — each axis in -1..1 — and this component turns it into a real move
8
+ # each update, at a fixed speed and with no inertia (unlike Velocity, which integrates
9
+ # a velocity the controller sets, and ThrustController, which accelerates one).
11
10
  #
12
- # The intent doubles as the facing for AnimatedSprite (move_x / move_y readers), so
11
+ # The intent doubles as the mover's heading, which is what AnimatedSprite faces by, so
13
12
  # a character is just CharacterBody + a controller + AnimatedSprite.
14
13
  #
15
- # The feet box is built from the node's dimensions (which AnimatedSprite sets from the
16
- # sprite frame), not a passed-in sprite size `feet_width`/`feet_height` are the box,
17
- # centred horizontally and anchored to the node's bottom. It's built lazily on first
18
- # use (the first update, after every on_attach has run), so the order components are
19
- # added in doesn't matter. A body with no sprite must set the node's width/height.
20
- class CharacterBody < Engine::Component
14
+ # What may stop a step `blocked_by:`, `on_blocked` / `on_unblocked`, and the
15
+ # `apply_move` seam — is Mover's, and shared with every other component that moves a
16
+ # node; see its header. What is this class's own is the intent and the speed.
17
+ #
18
+ # CharacterBody.new(speed: 80) # walks wherever the intent points
19
+ # CharacterBody.new(speed: 80, blocked_by: %i[tiles npc]) # stopped by the map and by NPCs
20
+ class CharacterBody < Mover
21
21
  attr_reader :move_x, :move_y
22
22
 
23
- def initialize(feet_width:, feet_height:, speed:)
24
- super()
25
- @feet_width = feet_width
26
- @feet_height = feet_height
23
+ def initialize(speed:, blocked_by: [])
24
+ super(blocked_by: blocked_by)
27
25
  @speed = speed
28
26
  @move_x = 0.0
29
27
  @move_y = 0.0
30
- @collision_box = nil
31
- end
32
-
33
- # The feet box, derived from the node's sprite size and memoised.
34
- #
35
- # **Only valid once the node is in the tree**, and it says so rather than
36
- # letting you find out later. The size comes from AnimatedSprite#on_attach,
37
- # so a read from a constructor sees a 0x0 node and bakes a box anchored to
38
- # nothing — permanently, because this memoises, and for the collision
39
- # system too, because it reads the same box. The symptom is an actor that
40
- # walks through walls it should not, a long way from the call that caused
41
- # it. Guarding costs one comparison, once.
42
- def collision_box
43
- @collision_box ||= build_collision_box
44
28
  end
45
29
 
46
- # The TileWorld is a scene-scoped system, reachable once we're in the tree.
47
- def on_attach = @world = node.system(TileWorld)
30
+ # The heading is the intent as set, blocked or not.
31
+ def heading_x = @move_x
32
+ def heading_y = @move_y
48
33
 
49
34
  # Set this step's movement intent; each axis is in -1..1.
50
35
  def set_intent(intent_x, intent_y)
@@ -52,38 +37,12 @@ module RGame
52
37
  @move_y = intent_y
53
38
  end
54
39
 
55
- def update(dt)
56
- return if @move_x.zero? && @move_y.zero?
57
-
58
- @world.move(self, @move_x * @speed * dt, @move_y * @speed * dt)
59
- end
60
-
61
- # CollisionSystem#move drives an "actor" through x/y/collision_box and writes the
62
- # resolved position back — back those by the owning node's transform.
63
- def x = node.x
64
- def y = node.y
65
-
66
- def x=(value)
67
- node.x = value
68
- end
69
-
70
- def y=(value)
71
- node.y = value
72
- end
73
-
74
40
  private
75
41
 
76
- def build_collision_box
77
- if node.width.zero? || node.height.zero?
78
- raise "collision_box needs the node's sprite size, but it is " \
79
- "#{node.width}x#{node.height}. AnimatedSprite sets that when it attaches, so " \
80
- 'read this after the node is in the tree, not while building it.'
81
- end
42
+ def take_step(dt)
43
+ return if @move_x.zero? && @move_y.zero?
82
44
 
83
- Engine::CollisionBox.bottom_anchored(
84
- sprite_width: node.width, sprite_height: node.height,
85
- width: @feet_width, height: @feet_height
86
- )
45
+ apply_move(@move_x * @speed * dt, @move_y * @speed * dt)
87
46
  end
88
47
  end
89
48
  end
@@ -6,38 +6,74 @@ module RGame
6
6
  # A circular collision shape on a node. It registers itself with the scene's
7
7
  # CollisionWorld system when it enters the tree and unregisters on leaving —
8
8
  # the engine fires both hooks, so a spawned/despawned entity can't leak a
9
- # registration. Its world centre is the node's resolved absolute origin; the
9
+ # registration. Its world centre is the node's world origin; the
10
10
  # `layer` is an opaque tag the game reads in its on_hit handler to decide what a
11
- # contact means (bullet-vs-rock, etc.). See docs/api/systems.md.
11
+ # contact means. See docs/api/systems.md.
12
+ #
13
+ # BoxCollider is the rectangular sibling, and the two collide with each other:
14
+ # both answer the same broadphase (#aabb_*) and narrowphase (#overlap?) protocol.
12
15
  class CircleCollider < Engine::Component
13
- # Fired by CollisionWorld for each overlapping collider; the listener gets the
14
- # other collider and reads its #layer / #node to react.
16
+ # The two edges of a contact, fired by CollisionWorld: on_hit on the step this
17
+ # collider starts overlapping another, on_separated on the step it stops. Each
18
+ # fires once per pair, so a handler may count, play a sound or spend a life.
19
+ # The listener gets the other collider and reads its #layer / #node to react.
15
20
  signal :on_hit, Engine::Signal.define(:other)
21
+ signal :on_separated, Engine::Signal.define(:other)
16
22
 
17
23
  # radius is writable so a pooled entity (e.g. a multi-tier rock) can retune its
18
24
  # shape on reset; CollisionWorld reads it fresh each frame, so no re-registration.
19
25
  attr_accessor :radius
20
26
  attr_reader :layer
21
27
 
28
+ # CollisionWorld's per-collider bookkeeping: who this was touching this step and
29
+ # last. The world owns what goes in it; nothing else should write to it.
30
+ attr_reader :contacts
31
+
22
32
  def initialize(radius:, layer: :default)
23
33
  super()
24
34
  @radius = radius
25
35
  @layer = layer
36
+ @contacts = Engine::ContactSet.new
26
37
  end
27
38
 
28
- def on_attach = node.system(CollisionWorld).register(self)
39
+ # A collider is a *shape*; a CollisionWorld is what turns shapes into contacts.
40
+ # A scene with no world mounted therefore leaves this a bare shape rather than
41
+ # raising — BoxCollider#on_attach says why, and it is the same trade here.
42
+ def on_attach = node.system(CollisionWorld)&.register(self)
29
43
  def on_detach = node.system(CollisionWorld)&.unregister(self)
30
44
 
31
- # World-space centre — the node's resolved absolute origin.
32
- def cx = node.abs_x
33
- def cy = node.abs_y
45
+ # World-space centre — the node's own origin, in world coordinates.
46
+ def cx = node.world_x
47
+ def cy = node.world_y
48
+
49
+ # The circle's bounding box, one component per call rather than an Array:
50
+ # CollisionWorld reads these for every collider every frame, and that path may
51
+ # not allocate.
52
+ def aabb_x = cx - @radius
53
+ def aabb_y = cy - @radius
54
+ def aabb_w = @radius * 2
55
+ def aabb_h = @radius * 2
56
+
57
+ # Narrowphase, first half of the double dispatch: hand this shape's numbers to
58
+ # the *other* collider and let it pick the test, so neither side has to ask what
59
+ # kind the other is. BoxCollider#overlap? is the mirror image.
60
+ def overlap?(other) = other.overlap_circle?(cx, cy, @radius)
61
+
62
+ # Second half: the two tests another collider dispatches into.
63
+ #
64
+ # @api private
65
+ def overlap_circle?(x, y, r)
66
+ Engine::CircleCollider.overlap?(cx, cy, @radius, x, y, r)
67
+ end
34
68
 
35
- def overlap?(other)
36
- Engine::CircleCollider.overlap?(cx, cy, @radius, other.cx, other.cy, other.radius)
69
+ # @api private
70
+ def overlap_box?(x, y, w, h)
71
+ Engine::CollisionBox.overlap_circle?(x, y, w, h, cx, cy, @radius)
37
72
  end
38
73
 
39
- # Called by CollisionWorld on contact (the signal's emit is otherwise private).
74
+ # Called by CollisionWorld on each edge (the signals' emit is otherwise private).
40
75
  def emit_hit(other) = on_hit_signal.emit(other)
76
+ def emit_separated(other) = on_separated_signal.emit(other)
41
77
  end
42
78
  end
43
79
  end
@@ -5,10 +5,31 @@ module RGame
5
5
  module Components
6
6
  # Scene-scoped broadphase collision system: a Component that lives on the scene
7
7
  # node (so it is born and torn down with the scene, and rides the normal update
8
- # traversal). CircleColliders register/unregister with it via their tree
9
- # lifecycle; each update it buckets them in a SpatialHash and fires on_hit on
10
- # every overlapping pair. It is layer-agnostic — it reports contacts and lets
11
- # the colliders' owners decide meaning. See docs/api/systems.md.
8
+ # traversal). Colliders register/unregister with it via their tree lifecycle;
9
+ # each update it buckets them in a SpatialHash and reports every overlapping
10
+ # pair. It is layer-agnostic — it reports contacts and lets the colliders'
11
+ # owners decide meaning. See docs/api/systems.md.
12
+ #
13
+ # **A contact is reported as two edges, not as a state.** `on_hit` fires on the
14
+ # step a pair starts overlapping and `on_separated` on the step it stops, each
15
+ # once, on both colliders. Nothing fires in between, so a handler is free to
16
+ # count, to play a sound, or to do anything else that must happen once — which
17
+ # is the whole reason the world keeps a ContactSet per collider rather than
18
+ # simply forwarding what the broadphase found.
19
+ #
20
+ # It is also *shape*-agnostic, which is what lets CircleCollider and BoxCollider
21
+ # share it (and collide with each other). A collider is anything answering:
22
+ #
23
+ # aabb_x, aabb_y, aabb_w, aabb_h its world-space bounding box, for bucketing
24
+ # cx, cy its centre, for the range queries below
25
+ # overlap?(other) the narrowphase, which the two colliders
26
+ # settle between themselves by double dispatch
27
+ # layer, node the tag and the owner
28
+ # contacts an Engine::ContactSet, which this drives
29
+ # emit_hit(other) the contact's two edges
30
+ # emit_separated(other)
31
+ #
32
+ # None of those may allocate: they run per collider per frame.
12
33
  class CollisionWorld < Engine::Component
13
34
  def initialize(cell_size:)
14
35
  super()
@@ -16,13 +37,22 @@ module RGame
16
37
  @colliders = []
17
38
  end
18
39
 
19
- def register(collider) = @colliders << collider
40
+ # Reset rather than merely add: the collider may be a pooled one coming back
41
+ # from the dead, still carrying the contacts it held when it was freed.
42
+ def register(collider)
43
+ collider.contacts.reset
44
+ @colliders << collider
45
+ end
46
+
47
+ # The partners of a collider that leaves are told on their next step, by the
48
+ # ordinary separation pass — it is gone from the index, so the pair no longer
49
+ # overlaps. Nothing has to be emitted here.
20
50
  def unregister(collider) = @colliders.delete(collider)
21
51
 
22
52
  # Yield every registered collider whose centre lies within `r` of (x, y), using
23
53
  # the spatial index built by the most recent #update. The narrowphase is a
24
- # centre-distance test — the query is a point + range (a tower's range ring), so
25
- # the collider's own radius isn't added in. Colliders whose node is queued for
54
+ # centre-distance test — the query is a point + range (a turret's range ring), so
55
+ # the collider's own size isn't added in. Colliders whose node is queued for
26
56
  # removal are skipped. As with SpatialHash#query a collider spanning several cells
27
57
  # may be yielded more than once, so callers that *select* (e.g. #nearest) are
28
58
  # written dup-insensitively. Layer-agnostic — filter by `collider.layer` in the
@@ -38,8 +68,47 @@ module RGame
38
68
  end
39
69
  end
40
70
 
71
+ # Yield every registered collider bucketed in a cell the region covers, skipping
72
+ # those whose node is queued for removal. The rectangular counterpart to
73
+ # #query_circle, and what a blocker source asks when it resolves a step: "what is
74
+ # near this box".
75
+ #
76
+ # It inherits #query_circle's dedup contract — a collider spanning several cells
77
+ # may be yielded more than once — so a caller that *selects* is written
78
+ # dup-insensitively, the way #nearest and Engine::ActorBlockers both are. Unlike
79
+ # #query_circle there is no narrowphase here at all: the bucket walk is the whole
80
+ # answer, and refining it is the caller's business. Layer-agnostic; filter by
81
+ # `collider.layer` in the block. Allocation-free.
82
+ def query_box(x, y, w, h)
83
+ @hash.query(x, y, w, h) do |collider|
84
+ next if collider.node.freed?
85
+
86
+ yield collider
87
+ end
88
+ end
89
+
90
+ # Re-bucket a collider that has moved since #update built the index, so a query
91
+ # later in the same step still finds it where it now is. `from_*` is the box it
92
+ # was bucketed at — the caller knows it, which is what lets the index keep no
93
+ # per-item state of its own.
94
+ #
95
+ # This is what makes a mid-step query exact rather than nearly right. Buckets are
96
+ # filled once per step and a collider that moves afterwards is still bucketed
97
+ # where it was, so a query over cells it has left does not reach it. Measured over
98
+ # 60,000 queries at two hundred actors: 116 misses left stale, 0 re-indexed, and
99
+ # padding the query instead is not exact at any pad — it compensates for the
100
+ # *other* actor's staleness by inflating the *mover's* step. Engine::CollisionSystem
101
+ # calls this through a blocker source's #moved, so nothing a game writes has to
102
+ # remember it; anything else that moves a collider mid-step may call it directly.
103
+ #
104
+ # Allocation-free, so a resolver may call it every step.
105
+ def reindex(collider, from_x, from_y, from_w, from_h)
106
+ @hash.remove(collider, from_x, from_y, from_w, from_h)
107
+ insert(collider)
108
+ end
109
+
41
110
  # The registered collider nearest to (x, y) within range `r`, or nil when none
42
- # qualifies. Restrict to a single `layer:` (the common case: a tower targeting only
111
+ # qualifies. Restrict to a single `layer:` (the common case: a turret targeting only
43
112
  # :enemy). Dup-safe — it keeps the running minimum, so #query_circle's possible
44
113
  # multi-cell repeats don't matter. Allocation-free.
45
114
  def nearest(x, y, r, layer: nil)
@@ -59,15 +128,64 @@ module RGame
59
128
  best
60
129
  end
61
130
 
131
+ # Is the cell containing the *world* point (x, y) free — "may a pickup spawn on
132
+ # this square?" A point, not a region: pass any coordinate inside the square you
133
+ # mean. The cells are the broadphase's own, so set `cell_size` to the game's
134
+ # square and the two grids line up.
135
+ #
136
+ # Two coordinate gotchas, both easy to get wrong from a node that has neither in
137
+ # mind:
138
+ #
139
+ # - The index holds **world** coordinates, because that is what a collider
140
+ # reports. A node whose own grid starts somewhere else adds its world origin
141
+ # before asking (`node.world_x + col * cell_size`) — and puts that origin on a
142
+ # multiple of `cell_size`, since the cells are the hash's own lattice anchored
143
+ # at the world origin. A board off that lattice has each square straddling two
144
+ # cells, and both read occupied.
145
+ # - It answers about the index the most recent #update built, exactly as
146
+ # #query_circle and #nearest do. Before the first update every cell is empty.
147
+ #
148
+ # The bucket is the answer, geometry and all: the broadphase's cell walk is
149
+ # half-open exactly like CollisionBox.overlap?, so a collider is bucketed in a
150
+ # cell if and only if it overlaps that cell's area. All this adds is the one
151
+ # thing the index cannot know — that a collider queued for removal no longer
152
+ # occupies anything, the same rule the queries above follow, so a corpse cannot
153
+ # reserve a square.
154
+ #
155
+ # Allocation-free, including the miss: SpatialHash#cell_empty? is asked first
156
+ # because it is the only way to look at a cell without materialising a bucket for
157
+ # it, and a caller scanning a board for a free square asks mostly about empty
158
+ # ones.
159
+ def cell_empty?(x, y)
160
+ return true if @hash.cell_empty?(x, y)
161
+
162
+ free = true
163
+ @hash.query(x, y, 0, 0) { |collider| free &&= collider.node.freed? }
164
+ free
165
+ end
166
+
167
+ # Rebuild the index, then report the step's two kinds of edge. Starting edges
168
+ # come first and every one of them for the step is emitted before the first
169
+ # ending one, because a separation is only knowable once every pair has been
170
+ # looked at.
62
171
  def update(_dt)
63
172
  @hash.clear
64
- @colliders.each { |c| insert(c) }
173
+ @colliders.each do |collider|
174
+ collider.contacts.begin_frame
175
+ insert(collider)
176
+ end
177
+
178
+ report_contacts
179
+ report_separations
180
+ end
181
+
182
+ private
65
183
 
66
- # Index-bounded over the count at frame start: an on_hit handler may spawn
67
- # entities (a rock splitting), which `register`s new colliders mid-loop; those
68
- # appended ones are skipped this frame (processed next) rather than mutating
69
- # the array being iterated. The hash was built before the loop, so they're
70
- # absent from queries too — consistent.
184
+ def insert(collider)
185
+ @hash.insert(collider, collider.aabb_x, collider.aabb_y, collider.aabb_w, collider.aabb_h)
186
+ end
187
+
188
+ def report_contacts
71
189
  count = @colliders.size
72
190
  i = 0
73
191
  while i < count
@@ -75,27 +193,37 @@ module RGame
75
193
  i += 1
76
194
  next if a.node.freed?
77
195
 
78
- r = a.radius
79
- d = r * 2
80
- @hash.query(a.cx - r, a.cy - r, d, d) do |b|
81
- # object_id ordering visits each unordered pair once (and skips self);
82
- # the freed? guards skip nodes already queued for removal, so a dead
83
- # entity stops colliding and duplicate (multi-cell) yields are ignored.
84
- next if a.node.freed? || b.node.freed? || a.object_id >= b.object_id
85
- next unless a.overlap?(b)
86
-
87
- a.emit_hit(b)
88
- b.emit_hit(a)
89
- end
196
+ pair_up(a)
90
197
  end
91
198
  end
92
199
 
93
- private
200
+ def pair_up(a)
201
+ contacts = a.contacts
202
+ @hash.query(a.aabb_x, a.aabb_y, a.aabb_w, a.aabb_h) do |b|
203
+ next if a.node.freed? || b.node.freed? || a.object_id >= b.object_id
204
+ next if contacts.touching?(b)
205
+ next unless a.overlap?(b)
94
206
 
95
- def insert(collider)
96
- r = collider.radius
97
- d = r * 2
98
- @hash.insert(collider, collider.cx - r, collider.cy - r, d, d)
207
+ started = contacts.started?(b)
208
+ contacts.add(b)
209
+ b.contacts.add(a)
210
+ next unless started
211
+
212
+ a.emit_hit(b)
213
+ b.emit_hit(a)
214
+ end
215
+ end
216
+
217
+ def report_separations
218
+ count = @colliders.size
219
+ i = 0
220
+ while i < count
221
+ collider = @colliders[i]
222
+ i += 1
223
+ next if collider.node.freed?
224
+
225
+ collider.contacts.each_ended { |other| collider.emit_separated(other) }
226
+ end
99
227
  end
100
228
  end
101
229
  end
@@ -3,20 +3,36 @@
3
3
  module RGame
4
4
  module Engine
5
5
  module Components
6
- # Queues the node for removal once it has fully left the bounds (plus margin).
7
- # From Body#offscreen?. Used by short-lived projectiles; removal is deferred via
8
- # queue_free so it is safe to trigger from inside the update traversal.
6
+ # Queues the node for removal once its origin is further than `margin` past an
7
+ # edge of the world bounds. Used by short-lived projectiles; removal is deferred
8
+ # via queue_free so it is safe to trigger from inside the update traversal.
9
+ #
10
+ # The margin is what stands in for the node's size: a node drawn centred on its
11
+ # origin has fully left once the margin is at least its half-extent.
12
+ #
13
+ # Bounds resolve the same way ScreenWrap's do: from the scene's world system,
14
+ # at attach time, with `width:`/`height:` as an override. And like ScreenWrap it
15
+ # tests the node's **world** position, so a projectile spawned as the child of an
16
+ # offset emitter leaves at the world's edge rather than at one shifted by the
17
+ # emitter.
9
18
  class DespawnOffscreen < Engine::Component
10
- def initialize(width:, height:, margin: 0.0)
19
+ def initialize(width: nil, height: nil, margin: 0.0)
11
20
  super()
12
- @width = width
13
- @height = height
21
+ @given_width = width
22
+ @given_height = height
14
23
  @margin = margin
15
24
  end
16
25
 
26
+ # See ScreenWrap#on_attach: resolved per entry, so a recycled node is correct
27
+ # after a scene change.
28
+ def on_attach
29
+ WorldBounds.one_response!(node)
30
+ @width, @height = WorldBounds.resolve(node, @given_width, @given_height)
31
+ end
32
+
17
33
  def update(_dt)
18
- x = node.x
19
- y = node.y
34
+ x = node.world_x
35
+ y = node.world_y
20
36
  offscreen = x < -@margin || x > @width + @margin || y < -@margin || y > @height + @margin
21
37
  node.queue_free if offscreen
22
38
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A BoxCollider whose rectangle is the node's *feet*: horizontally centred in the
7
+ # node's dimensions and anchored to their bottom. That is the shape a top-down
8
+ # character collides with — a 16x22 hero standing on a floor occupies the 12x6
9
+ # patch under them, not the whole sprite, which is what stops their head from
10
+ # bumping into a wall a tile away.
11
+ #
12
+ # It is an ordinary BoxCollider in every other respect, so it registers with the
13
+ # scene's CollisionWorld, collides with circles and boxes alike, and
14
+ # `get_component(BoxCollider)` finds it. See docs/api/components.md.
15
+ #
16
+ # The box is derived from `node.width`/`node.height`, which AnimatedSprite sets
17
+ # from the sprite frame, rather than from a sprite size passed in — so the caller
18
+ # gives the feet box and nothing else, and the two can never disagree.
19
+ class FeetCollider < BoxCollider
20
+ def initialize(width:, height:, layer: :default)
21
+ super
22
+ @feet_width = width
23
+ @feet_height = height
24
+ @box = nil
25
+ end
26
+
27
+ # The feet box, derived from the node's sprite size and memoised.
28
+ #
29
+ # **Only valid once the node is in the tree**, and it says so rather than letting
30
+ # you find out later. The size comes from AnimatedSprite#on_attach, so a read from
31
+ # a constructor sees a 0x0 node and bakes a box anchored to nothing — permanently,
32
+ # because this memoises. The symptom is an actor that walks through walls it
33
+ # should not, a long way from the call that caused it. Guarding costs one
34
+ # comparison, once.
35
+ #
36
+ # Building it here rather than in on_attach is what makes the order components
37
+ # were added in irrelevant: the first read is a frame later, by which time every
38
+ # sibling has attached. Assigning `box =` still wins, since that leaves nothing
39
+ # to memoise.
40
+ def box
41
+ @box ||= build_box
42
+ end
43
+
44
+ private
45
+
46
+ def build_box
47
+ if node.width.zero? || node.height.zero?
48
+ raise "FeetCollider needs the node's sprite size, but it is " \
49
+ "#{node.width}x#{node.height}. AnimatedSprite sets that when it attaches, so " \
50
+ 'read this after the node is in the tree, not while building it.'
51
+ end
52
+
53
+ Engine::CollisionBox.bottom_anchored(
54
+ sprite_width: node.width, sprite_height: node.height,
55
+ width: @feet_width, height: @feet_height
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end