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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Points a camera at the node it is attached to.
7
+ #
8
+ # player_node.add_component(CameraFollow.new(camera: players.primary.camera))
9
+ #
10
+ # ## Ownership and behaviour are different questions
11
+ #
12
+ # The camera cannot be *owned* by a node in the world — with several
13
+ # viewers there are several cameras, and a world that holds one has to know
14
+ # how many times it is being drawn. But deciding *where a camera points* is
15
+ # exactly a per-node concern, so it belongs here: the player owns the
16
+ # camera, and a component in the world moves it.
17
+ #
18
+ # That also makes "player two's camera follows player two" nothing more
19
+ # than attaching this to their node with their camera.
20
+ #
21
+ # `offset_x` / `offset_y` shift the point being centred on, for a node
22
+ # whose origin is not what should be in the middle of the screen — a
23
+ # bottom-anchored sprite usually wants its feet, not its head.
24
+ class CameraFollow < Engine::Component
25
+ def initialize(camera:, offset_x: 0.0, offset_y: 0.0)
26
+ super()
27
+ @camera = camera
28
+ @offset_x = offset_x
29
+ @offset_y = offset_y
30
+ end
31
+
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
35
+ # pixels at walking speed). That is deliberate and uniform: everything
36
+ # drawn through this camera trails equally, so nothing drifts apart on
37
+ # screen. Reading it later — from the node's own hook, say — would put
38
+ # this component's ordering among its siblings on show instead.
39
+ def update(_dt)
40
+ @camera.center_on(node.world_x + @offset_x, node.world_y + @offset_y)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
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).
10
+ #
11
+ # The intent doubles as the mover's heading, which is what AnimatedSprite faces by, so
12
+ # a character is just CharacterBody + a controller + AnimatedSprite.
13
+ #
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
+ attr_reader :move_x, :move_y
22
+
23
+ def initialize(speed:, blocked_by: [])
24
+ super(blocked_by: blocked_by)
25
+ @speed = speed
26
+ @move_x = 0.0
27
+ @move_y = 0.0
28
+ end
29
+
30
+ # The heading is the intent as set, blocked or not.
31
+ def heading_x = @move_x
32
+ def heading_y = @move_y
33
+
34
+ # Set this step's movement intent; each axis is in -1..1.
35
+ def set_intent(intent_x, intent_y)
36
+ @move_x = intent_x
37
+ @move_y = intent_y
38
+ end
39
+
40
+ private
41
+
42
+ def take_step(dt)
43
+ return if @move_x.zero? && @move_y.zero?
44
+
45
+ apply_move(@move_x * @speed * dt, @move_y * @speed * dt)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A circular collision shape on a node. It registers itself with the scene's
7
+ # CollisionWorld system when it enters the tree and unregisters on leaving —
8
+ # the engine fires both hooks, so a spawned/despawned entity can't leak a
9
+ # registration. Its world centre is the node's world origin; the
10
+ # `layer` is an opaque tag the game reads in its on_hit handler to decide what a
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.
15
+ class CircleCollider < Engine::Component
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.
20
+ signal :on_hit, Engine::Signal.define(:other)
21
+ signal :on_separated, Engine::Signal.define(:other)
22
+
23
+ # radius is writable so a pooled entity (e.g. a multi-tier rock) can retune its
24
+ # shape on reset; CollisionWorld reads it fresh each frame, so no re-registration.
25
+ attr_accessor :radius
26
+ attr_reader :layer
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
+
32
+ def initialize(radius:, layer: :default)
33
+ super()
34
+ @radius = radius
35
+ @layer = layer
36
+ @contacts = Engine::ContactSet.new
37
+ end
38
+
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)
43
+ def on_detach = node.system(CollisionWorld)&.unregister(self)
44
+
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
68
+
69
+ # @api private
70
+ def overlap_box?(x, y, w, h)
71
+ Engine::CollisionBox.overlap_circle?(x, y, w, h, cx, cy, @radius)
72
+ end
73
+
74
+ # Called by CollisionWorld on each edge (the signals' emit is otherwise private).
75
+ def emit_hit(other) = on_hit_signal.emit(other)
76
+ def emit_separated(other) = on_separated_signal.emit(other)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Scene-scoped broadphase collision system: a Component that lives on the scene
7
+ # node (so it is born and torn down with the scene, and rides the normal update
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.
33
+ class CollisionWorld < Engine::Component
34
+ def initialize(cell_size:)
35
+ super()
36
+ @hash = Engine::SpatialHash.new(cell_size: cell_size)
37
+ @colliders = []
38
+ end
39
+
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.
50
+ def unregister(collider) = @colliders.delete(collider)
51
+
52
+ # Yield every registered collider whose centre lies within `r` of (x, y), using
53
+ # the spatial index built by the most recent #update. The narrowphase is a
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
56
+ # removal are skipped. As with SpatialHash#query a collider spanning several cells
57
+ # may be yielded more than once, so callers that *select* (e.g. #nearest) are
58
+ # written dup-insensitively. Layer-agnostic — filter by `collider.layer` in the
59
+ # block. Allocation-free.
60
+ def query_circle(x, y, r)
61
+ r2 = r * r
62
+ @hash.query_circle(x, y, r) do |collider|
63
+ next if collider.node.freed?
64
+
65
+ dx = collider.cx - x
66
+ dy = collider.cy - y
67
+ yield collider if (dx * dx) + (dy * dy) <= r2
68
+ end
69
+ end
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
+
110
+ # The registered collider nearest to (x, y) within range `r`, or nil when none
111
+ # qualifies. Restrict to a single `layer:` (the common case: a turret targeting only
112
+ # :enemy). Dup-safe — it keeps the running minimum, so #query_circle's possible
113
+ # multi-cell repeats don't matter. Allocation-free.
114
+ def nearest(x, y, r, layer: nil)
115
+ best = nil
116
+ best_d2 = nil
117
+ query_circle(x, y, r) do |collider|
118
+ next if layer && collider.layer != layer
119
+
120
+ dx = collider.cx - x
121
+ dy = collider.cy - y
122
+ d2 = (dx * dx) + (dy * dy)
123
+ next unless best_d2.nil? || d2 < best_d2
124
+
125
+ best = collider
126
+ best_d2 = d2
127
+ end
128
+ best
129
+ end
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.
171
+ def update(_dt)
172
+ @hash.clear
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
183
+
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
189
+ count = @colliders.size
190
+ i = 0
191
+ while i < count
192
+ a = @colliders[i]
193
+ i += 1
194
+ next if a.node.freed?
195
+
196
+ pair_up(a)
197
+ end
198
+ end
199
+
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)
206
+
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
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
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.
18
+ class DespawnOffscreen < Engine::Component
19
+ def initialize(width: nil, height: nil, margin: 0.0)
20
+ super()
21
+ @given_width = width
22
+ @given_height = height
23
+ @margin = margin
24
+ end
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
+
33
+ def update(_dt)
34
+ x = node.world_x
35
+ y = node.world_y
36
+ offscreen = x < -@margin || x > @width + @margin || y < -@margin || y > @height + @margin
37
+ node.queue_free if offscreen
38
+ end
39
+ end
40
+ end
41
+ end
42
+ 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
@@ -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