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,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Wraps the node's position toroidally within the world bounds (plus margin), so
7
+ # an entity leaving one edge reappears on the opposite one.
8
+ #
9
+ # The bounds come from the scene's world system — anything answering the
10
+ # WorldBounds contract, so Components::World or Components::TileWorld — and are
11
+ # resolved when the node enters the tree. Passing `width:`/`height:` overrides
12
+ # that for a node whose wrap region is not the whole world.
13
+ #
14
+ # The node is tested and placed in **world space**, which is the frame WorldBounds
15
+ # is stated in: a node under an offset container wraps at the world's edge, not at
16
+ # an edge shifted by wherever the container sits. The write goes through
17
+ # Node2D#world_x=, so the node's local position is what actually changes.
18
+ #
19
+ # A wrap is a placement, not a step: it does not ask a sibling Mover's `blocked_by`
20
+ # whether the far edge is free, so a node wrapped onto a blocker is left pressed
21
+ # against it.
22
+ class ScreenWrap < Engine::Component
23
+ def initialize(width: nil, height: nil, margin: 0.0)
24
+ super()
25
+ @given_width = width
26
+ @given_height = height
27
+ @margin = margin
28
+ end
29
+
30
+ # Re-resolved on every entry rather than cached from the first, so a pooled
31
+ # entity recycled into a differently sized scene wraps against that scene.
32
+ def on_attach
33
+ WorldBounds.one_response!(node)
34
+ @width, @height = WorldBounds.resolve(node, @given_width, @given_height)
35
+ end
36
+
37
+ def update(_dt)
38
+ x = node.world_x
39
+ node.world_x = @width + @margin if x < -@margin
40
+ node.world_x = -@margin if x > @width + @margin
41
+
42
+ y = node.world_y
43
+ node.world_y = @height + @margin if y < -@margin
44
+ node.world_y = -@margin if y > @height + @margin
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Draws a single registered image centred on the node's own origin. It passes
7
+ # NO angle and NO position to the renderer: Node2D#draw has already pushed the
8
+ # node's transform, so drawing at (0, 0) *is* drawing at the node, correctly
9
+ # rotated. Passing either would apply it a second time.
10
+ #
11
+ # `scale` is writable so a pooled entity (e.g. a multi-tier rock) can retune it on
12
+ # reset. `z` is the render layer (NOT the node's transform z / abs_z) — kept under
13
+ # @layer to say so. For an animated sprite (sheet + frame index driving
14
+ # renderer.sprite), add a sibling AnimatedSprite component later.
15
+ class Sprite < Engine::Component
16
+ include Engine::Culling
17
+
18
+ attr_accessor :scale
19
+
20
+ def initialize(id:, scale: 1.0, z: 0)
21
+ super()
22
+ @id = id
23
+ @scale = scale
24
+ @layer = z
25
+ end
26
+
27
+ # Centred on the node's origin and scaled, so the footprint to cull
28
+ # against is the node's box scaled and offset back by half of itself. A
29
+ # node that never set a size is never culled — see Culling.
30
+ #
31
+ # Culling is the one thing here still stated in **world** coordinates:
32
+ # it compares against the camera, which is nowhere near this node's
33
+ # local space. Drawing is local, culling is world, and the two are
34
+ # deliberately different arguments.
35
+ def draw(renderer, view)
36
+ width = node.width * @scale
37
+ height = node.height * @scale
38
+ lift = node.elevation
39
+ return if culled?(view, node.world_x - (width / 2.0), node.world_y - lift - (height / 2.0),
40
+ width, height)
41
+
42
+ renderer.image(@id, 0, -lift, scale: @scale, z: @layer)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Picks a node for the owning node to aim at: each update it queries the scene's
7
+ # CollisionWorld around the node's world origin and exposes the chosen target via
8
+ # #target (the candidate's node, or nil). The owner reads it to act — Targeting only
9
+ # selects, it never moves or shoots.
10
+ #
11
+ # The CollisionWorld it queries is the same broadphase every collider registers with,
12
+ # so targeting needs no candidate list of its own. The `layer` restricts candidates
13
+ # (pass :enemy to pick only colliders on that layer, not allies or projectiles).
14
+ #
15
+ # Policies (how to choose among the candidates in range):
16
+ # - :nearest — the closest candidate (the default; one broadphase nearest-lookup).
17
+ # More policies (e.g. furthest-along-path) arrive with the state they need.
18
+ class Targeting < Engine::Component
19
+ POLICIES = %i[nearest].freeze
20
+
21
+ def initialize(range:, policy: :nearest, layer: nil)
22
+ super()
23
+ raise ArgumentError, "unknown targeting policy #{policy.inspect}" unless POLICIES.include?(policy)
24
+
25
+ @range = range
26
+ @policy = policy
27
+ @layer = layer
28
+ @target = nil
29
+ end
30
+
31
+ # The chosen node, or nil when nothing is in range. Refreshed every update.
32
+ attr_reader :target
33
+
34
+ # Pull the broadphase once it's reachable (scene scope, resolved up the tree).
35
+ def on_attach = @world = node.system(CollisionWorld)
36
+
37
+ def update(_dt)
38
+ collider = pick
39
+ @target = collider&.node
40
+ end
41
+
42
+ private
43
+
44
+ def pick
45
+ case @policy
46
+ when :nearest then @world.nearest(node.world_x, node.world_y, @range, layer: @layer)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Inertial "ship" flight on top of a Velocity sibling: a turn axis rotates the
7
+ # node (angular velocity) and a thrust axis accelerates it along its heading,
8
+ # with optional drag and a top-speed clamp. Reads intent in `control` and
9
+ # integrates it in `update`, so it composes with the normal phase order.
10
+ #
11
+ # Heading convention: angle 0 points along +x ("right"), so forward is
12
+ # (cos θ, sin θ) — consistent with the renderer's clockwise rotation under a y-down screen
13
+ # (e.g. +90° faces down). Firing is intentionally NOT here — see ActionTrigger +
14
+ # the owning node, which knows its muzzle geometry.
15
+ class ThrustController < Engine::Component
16
+ def initialize(turn_speed:, accel:, max_speed:, drag: 0.0,
17
+ turn_action: :turn, thrust_action: :thrust)
18
+ super()
19
+ @turn_speed = turn_speed
20
+ @accel = accel
21
+ @max_speed = max_speed
22
+ @drag = drag
23
+ @turn_action = turn_action
24
+ @thrust_action = thrust_action
25
+ @thrust = 0.0
26
+ end
27
+
28
+ # The Velocity sibling is only guaranteed present once attached to a node.
29
+ def on_attach = @velocity = require_sibling(Velocity)
30
+
31
+ def control(actions)
32
+ @velocity.spin = actions.axis(@turn_action) * @turn_speed
33
+ @thrust = actions.axis(@thrust_action)
34
+ end
35
+
36
+ def update(dt)
37
+ if @thrust != 0.0
38
+ @velocity.vx += Math.cos(node.angle) * @accel * @thrust * dt
39
+ @velocity.vy += Math.sin(node.angle) * @accel * @thrust * dt
40
+ end
41
+ apply_drag(dt) if @drag.positive?
42
+ clamp_speed
43
+ end
44
+
45
+ private
46
+
47
+ def apply_drag(dt)
48
+ factor = 1.0 - (@drag * dt)
49
+ factor = 0.0 if factor.negative?
50
+ @velocity.vx *= factor
51
+ @velocity.vy *= factor
52
+ end
53
+
54
+ def clamp_speed
55
+ speed = Math.hypot(@velocity.vx, @velocity.vy)
56
+ return unless speed > @max_speed
57
+
58
+ scale = @max_speed / speed
59
+ @velocity.vx *= scale
60
+ @velocity.vy *= scale
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # The scene-scoped tile world: a system component (it lives on the scene node and
7
+ # is found with node.system(TileWorld)) that owns the parsed Engine::TileMap and
8
+ # everything an actor needs from it — collision against the solid tiles, the world
9
+ # bounds, and drawing the map through the scene's camera.
10
+ #
11
+ # **It does not resolve a step.** What it owns is the map's solid tiles as a blocker
12
+ # source (#blockers, an Engine::TileBlockers); the actor that wants to be stopped by
13
+ # them borrows it and resolves against it — see Components::Mover, which
14
+ # builds its own Engine::CollisionSystem out of the sources its `blocked_by` names.
15
+ # A mover may be blocked by tiles, by other actors, or by both, and only the mover
16
+ # knows which, so the resolver is the mover's and the grid is this system's. The tile
17
+ # solidity itself is whatever the map's tileset reports (baked per-tile in Tiled).
18
+ # The same solidity, viewed as a graph for planning routes, is #nav_grid.
19
+ #
20
+ # **Solidity is read from the map once**, into one Util::SolidGrid, the first time
21
+ # anything asks — and #blockers, #nav_grid and #solid? all read that store, never the
22
+ # map. So they cannot disagree about a cell, and a resolve costs a byte lookup rather
23
+ # than a walk through the map's layers and tileset.
24
+ #
25
+ # **It does not draw.** Drawing the map is RGame::Engine::TileMapLayer, one
26
+ # node per Tiled layer, mounted inside the WorldView so the map is drawn
27
+ # once per viewport like the rest of the world. This stays a system — the
28
+ # thing actors ask about collision and bounds — and a system that also
29
+ # drew was always the odd part of it.
30
+ #
31
+ # It owns the map's **animation clock**. Nothing below reads a wall clock —
32
+ # see CLAUDE.md, "`draw` renders state; time enters through `update`" — so the
33
+ # elapsed seconds animated tiles run on are accumulated here and handed down at
34
+ # draw time. Stop calling `update` and the water freezes, which is what pausing
35
+ # should look like.
36
+ class TileWorld < Engine::Component
37
+ include WorldBounds
38
+
39
+ attr_reader :tilemap_id, :elapsed
40
+
41
+ # `cameras` are the cameras this map bounds — every player's, normally.
42
+ # A camera may not show past the world's edges, and this is what knows
43
+ # how big the world is; the cameras themselves are owned by players.
44
+ def initialize(map:, tilemap_id:, cameras: [])
45
+ super()
46
+ @map = map
47
+ @tilemap_id = tilemap_id
48
+ @elapsed = 0.0
49
+ Array(cameras).each { |camera| bound(camera) }
50
+ end
51
+
52
+ # The map's solid tiles as a blocker source, for a body that declared
53
+ # `blocked_by: [:tiles]`. The same source every time, so every body on the map
54
+ # shares one.
55
+ def blockers
56
+ @blockers ||= Engine::TileBlockers.new(grid: solid_grid, tile_width: @map.tile_width,
57
+ tile_height: @map.tile_height)
58
+ end
59
+
60
+ def world_width = @map.pixel_width
61
+ def world_height = @map.pixel_height
62
+
63
+ # The size of one cell, in pixels — what turns a world position into the cell
64
+ # #nav_grid plans over.
65
+ def tile_width = @map.tile_width
66
+ def tile_height = @map.tile_height
67
+
68
+ def layer_count = @map.layer_count
69
+
70
+ # The first layer Tiled flags `above`, or the layer count if none is —
71
+ # which is where TileMapLayer.mount leaves the gap for the actors, so a
72
+ # map with no flag puts them over everything. Read once at mount rather
73
+ # than per frame: which layers cover the actors is a fact about the
74
+ # scene's arrangement, and the arrangement is made once.
75
+ def first_above_layer
76
+ layer_count.times.find { |index| @map.above_layer?(index) } || layer_count
77
+ end
78
+
79
+ # Clamp a camera to this map's edges. Called for each camera the scene
80
+ # hands over, and again for one that arrives later (a player joining).
81
+ def bound(camera)
82
+ camera.world_width = @map.pixel_width
83
+ camera.world_height = @map.pixel_height
84
+ camera
85
+ end
86
+
87
+ def solid?(col, row) = solid_grid.solid?(col, row)
88
+
89
+ # The map's solid tiles as an Engine::NavGrid, for planning a route rather than
90
+ # resolving a step — over the same store #blockers reads, as a second view. Built on
91
+ # first ask, and the same grid every time after.
92
+ def nav_grid
93
+ @nav_grid ||= Engine::NavGrid.new(grid: solid_grid)
94
+ end
95
+
96
+ # Advances the tile animations. Seconds, like every other duration here.
97
+ def update(dt)
98
+ @elapsed += dt
99
+ end
100
+
101
+ private
102
+
103
+ def solid_grid
104
+ @solid_grid ||= Util::SolidGrid.build(@map.width, @map.height) { |col, row| @map.solid_tile?(col, row) }
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A node-driven interval timer: it rides the node's update tick — so nothing can
7
+ # forget to advance it — and emits `on_timeout` each time a whole interval elapses.
8
+ # It wraps the pure Engine::Timer, reusing its carry-forward `consume` (the remainder
9
+ # rolls over, so the cadence doesn't drift); this only adds the per-frame drive and
10
+ # the signal.
11
+ #
12
+ # timer = node.add_component(Engine::Components::Timer.new(0.8), as: :spawn)
13
+ # timer.on_timeout { spawn_enemy }
14
+ #
15
+ # `repeating: false` makes it a one-shot: it fires `on_timeout` exactly once and then
16
+ # goes inert — the fixed-board despawn case (a projectile that vanishes after N
17
+ # seconds, where DespawnOffscreen doesn't apply):
18
+ #
19
+ # life = node.add_component(Engine::Components::Timer.new(2.0, repeating: false), as: :despawn)
20
+ # life.on_timeout { node.queue_free }
21
+ #
22
+ # The countdown restarts in `on_attach`, so a pooled node reacquired and re-added
23
+ # starts fresh — a recycled projectile gets its full interval (and a one-shot can fire
24
+ # again) rather than inheriting the previous life's elapsed time.
25
+ #
26
+ # When a node needs more than one (a spawn cadence and a wave cadence), give them
27
+ # distinct `as:` names — a node holds one component per slot.
28
+ class Timer < Engine::Component
29
+ signal :on_timeout
30
+
31
+ def initialize(interval, repeating: true)
32
+ super()
33
+ @timer = Engine::Timer.new(interval)
34
+ @repeating = repeating
35
+ @done = false
36
+ end
37
+
38
+ def interval = @timer.interval
39
+
40
+ def interval=(seconds)
41
+ @timer.interval = seconds
42
+ end
43
+
44
+ # Start the countdown fresh whenever the node (re-)enters the tree, so a pooled node
45
+ # never inherits a previous life's accumulated time or spent one-shot.
46
+ def on_attach = reset
47
+
48
+ # Advance one step and fire on_timeout once per whole interval that elapsed — so a
49
+ # single long step still emits the right number of times (catch-up, not drift). A
50
+ # one-shot (`repeating: false`) fires once and then stops accumulating.
51
+ def update(dt)
52
+ return if @done
53
+
54
+ @timer.update(dt)
55
+ while @timer.ready?
56
+ @timer.consume
57
+ on_timeout_signal.emit
58
+ unless @repeating
59
+ @done = true
60
+ break
61
+ end
62
+ end
63
+ end
64
+
65
+ # Back to a fresh timer: drop accumulated time and re-arm a spent one-shot (e.g.
66
+ # after retuning the interval, or when a pooled node is reused). Returns self.
67
+ def reset
68
+ @timer.reset
69
+ @done = false
70
+ self
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Integrates linear and angular velocity into the node's transform each step.
7
+ # Free-moving entities (rocks, bullets) use it alone; a controller component
8
+ # (or the node's own control hook) writes vx/vy/spin as intent.
9
+ #
10
+ # A Mover, so `blocked_by:` stops it the way it stops a CharacterBody — see Mover's
11
+ # header. A blocked step keeps `vx`/`vy` as they were: they are the intent, and what
12
+ # a stop should do to them (nothing, zero them, bounce) is the game's call, made in
13
+ # an `on_blocked` handler.
14
+ class Velocity < Mover
15
+ attr_accessor :vx, :vy, :spin
16
+
17
+ def initialize(vx: 0.0, vy: 0.0, spin: 0.0, blocked_by: [])
18
+ super(blocked_by: blocked_by)
19
+ @vx = vx
20
+ @vy = vy
21
+ @spin = spin
22
+ end
23
+
24
+ # The velocity scaled so its larger axis is ±1 — the direction, without the square
25
+ # root a unit vector would cost.
26
+ def heading_x = scale_to_heading(@vx)
27
+ def heading_y = scale_to_heading(@vy)
28
+
29
+ private
30
+
31
+ def scale_to_heading(axis)
32
+ larger = [@vx.abs, @vy.abs].max
33
+ larger.zero? ? 0.0 : axis / larger.to_f
34
+ end
35
+
36
+ def take_step(dt)
37
+ apply_move(@vx * dt, @vy * dt)
38
+ node.angle += @spin * dt
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A simple AI driver for a CharacterBody sibling: every so often it rolls a new
7
+ # direction (one of eight, or idle) and holds it until a timer elapses — or until a
8
+ # wall blocks it, at which point it re-rolls early instead of pushing into the wall.
9
+ # The RNG is injected so behaviour is deterministic in tests.
10
+ #
11
+ # "Blocked" is measured as *the node did not move while intending to*, not asked of
12
+ # a collision world — so this works over any CharacterBody, and simply never fires
13
+ # for one whose steps always land.
14
+ class WanderController < Engine::Component
15
+ MOVED_EPS = 1e-6
16
+ DIRECTIONS = [
17
+ [-1, 0], [1, 0], [0, -1], [0, 1],
18
+ [-1, -1], [1, -1], [-1, 1], [1, 1]
19
+ ].freeze
20
+
21
+ def initialize(rng: Random.new, change_interval: 1.0..3.0, idle_chance: 0.25)
22
+ super()
23
+ @rng = rng
24
+ @change_interval = change_interval
25
+ @idle_chance = idle_chance
26
+ @timer = 0.0
27
+ end
28
+
29
+ def on_attach
30
+ @body = require_sibling(CharacterBody)
31
+ @last_x = node.x
32
+ @last_y = node.y
33
+ end
34
+
35
+ def update(dt)
36
+ blocked = intending_to_move? && !moved_since_last?
37
+ @last_x = node.x
38
+ @last_y = node.y
39
+
40
+ @timer -= dt
41
+ reroll if blocked || @timer <= 0.0
42
+ end
43
+
44
+ private
45
+
46
+ def intending_to_move? = !@body.move_x.zero? || !@body.move_y.zero?
47
+
48
+ def moved_since_last?
49
+ (node.x - @last_x).abs > MOVED_EPS || (node.y - @last_y).abs > MOVED_EPS
50
+ end
51
+
52
+ def reroll
53
+ if @rng.rand < @idle_chance
54
+ @body.set_intent(0.0, 0.0)
55
+ else
56
+ dx, dy = DIRECTIONS.sample(random: @rng)
57
+ @body.set_intent(dx.to_f, dy.to_f)
58
+ end
59
+ @timer = @rng.rand(@change_interval)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # How big the world is — the contract, not an implementation.
7
+ #
8
+ # Two systems answer it: `World` below, which is nothing *but* the answer,
9
+ # and `TileWorld`, which derives it from the map it parsed. A component
10
+ # that needs bounds asks for the contract — `node.system(WorldBounds)` —
11
+ # and gets whichever of the two the scene mounted. That works because
12
+ # Node2D#get_component matches with `is_a?`, which matches an included
13
+ # module as readily as a class.
14
+ #
15
+ # Naming the contract is what keeps the two from drifting: ScreenWrap does
16
+ # not know which kind of world it is wrapping inside, and must not need to.
17
+ #
18
+ # ## The bounds are in world coordinates
19
+ #
20
+ # The world runs from (0, 0) to (world_width, world_height) in **world**
21
+ # space — the space `Node2D#world_x` answers in, and the one the tile grid,
22
+ # the collision world and the camera already use. So everything that
23
+ # compares a node against these bounds compares `world_x`/`world_y`, never
24
+ # the node's local `x`/`y`: an entity grouped under an offset container is
25
+ # still inside the world when it is, whatever its position in the container.
26
+ # spec/support/shared_examples/a_world_edge_response.rb holds every reader to it.
27
+ module WorldBounds
28
+ def world_width = raise(NotImplementedError, "#{self.class} must define #world_width")
29
+ def world_height = raise(NotImplementedError, "#{self.class} must define #world_height")
30
+
31
+ # The bounds a component should use: whatever it was handed, falling back
32
+ # to the world system on `node` for either axis left nil. The single place
33
+ # that fallback is written, so the components sharing it cannot drift.
34
+ #
35
+ # Call it from `on_attach`, not `initialize` — a node has no scene to ask
36
+ # until it is in the tree, and a pooled entity is built long before it is.
37
+ def self.resolve(node, width, height)
38
+ return [width, height] if width && height
39
+
40
+ bounds = node.system(self)
41
+ if bounds.nil?
42
+ raise 'no world bounds in scope: mount a World (or TileWorld) system on the scene, ' \
43
+ 'or pass explicit width:/height:'
44
+ end
45
+
46
+ [width || bounds.world_width, height || bounds.world_height]
47
+ end
48
+
49
+ # Refuse a node carrying more than one response to the edge of the world:
50
+ # ScreenWrap, DespawnOffscreen, or a Mover declaring `blocked_by: [:bounds]`.
51
+ #
52
+ # Stopping at the edge, wrapping past it and being freed beyond it are three
53
+ # answers to one question, and no two of them mean anything together. Stop and
54
+ # wrap disagree about where the node ends up; wrap and despawn race on which
55
+ # margin is reached first; and the stop works on the collision box while the
56
+ # other two test the node's origin, so a box with an offset holds the origin
57
+ # just past the edge and the other response fires on a node that was held.
58
+ # Rather than a rule each game has to remember, it raises.
59
+ #
60
+ # Folding wrap and despawn into Mover as one `at_edge:` option would make two
61
+ # responses inexpressible too, but they act on the node however it moved — a reset,
62
+ # an ancestor, a direct write — and every option there is a branch on the free
63
+ # step's path. A refusal at attach costs no frame anything. It is also the place
64
+ # to change if a game wants wrapping on one axis and stopping on the other: the
65
+ # rule then becomes one response per axis.
66
+ #
67
+ # Every response calls this from its own on_attach, which is what makes it
68
+ # order-free: a node assembled outside the tree already holds all its
69
+ # components when the first attaches, and on a live node each attaches on
70
+ # arrival, so the second response always finds the first.
71
+ #
72
+ # A pooled node re-attaches on every spawn, so this walks the components
73
+ # without allocating, and builds a message only when it raises.
74
+ def self.one_response!(node)
75
+ first = nil
76
+ node.components.each do |component|
77
+ next unless edge_response?(component)
78
+ next first = component if first.nil?
79
+
80
+ raise "#{node.class} has two responses to the edge of the world: " \
81
+ "#{describe_response(first)} and #{describe_response(component)}. " \
82
+ 'A node can stop at the edge, wrap past it or be freed beyond it, but ' \
83
+ 'only one of those — keep the one this node is for.'
84
+ end
85
+ end
86
+
87
+ def self.edge_response?(component)
88
+ component.is_a?(ScreenWrap) || component.is_a?(DespawnOffscreen) ||
89
+ (component.is_a?(Mover) && component.blocked_by?(Mover::BOUNDS))
90
+ end
91
+ private_class_method :edge_response?
92
+
93
+ def self.describe_response(component)
94
+ name = component.class.name&.split('::')&.last || component.class.inspect
95
+ component.is_a?(Mover) ? "#{name} (blocked_by :bounds)" : name
96
+ end
97
+ private_class_method :describe_response
98
+ end
99
+
100
+ # The scene-scoped world: how big it is, and nothing else yet.
101
+ #
102
+ # A game whose world is a plain rectangle mounts one on its scene, and the
103
+ # components that need bounds — ScreenWrap, DespawnOffscreen — find it
104
+ # rather than having the numbers threaded through every constructor between
105
+ # the scene and the entity. That threading is what this replaces, and it was
106
+ # worse than it looks: a pooled entity is built outside the tree, so its
107
+ # factory had to close over the size, and every scene above the factory had
108
+ # to carry the size in order to build it.
109
+ #
110
+ # It is deliberately **not** the window size. The two coincide in a
111
+ # single-screen game, which is exactly what makes the mistake easy to make
112
+ # and hard to see: bind wrapping to the viewport and the world silently
113
+ # changes shape when the window is resized, or when the screen is split and
114
+ # each half is its own viewport. Ask `Viewports` (or the `View` a draw is
115
+ # handed) how big the *window* is; ask this how big the *world* is.
116
+ #
117
+ # The bounds are immutable, which is what lets the components resolve them
118
+ # once at attach rather than re-reading them every frame. A world that
119
+ # genuinely changes size is a new scene.
120
+ class World < Engine::Component
121
+ include WorldBounds
122
+
123
+ attr_reader :world_width, :world_height
124
+
125
+ def initialize(width:, height:)
126
+ super()
127
+ @world_width = width
128
+ @world_height = height
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end