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,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The edges of the world as a blocker source: a box may not leave the region the
6
+ # scene's Components::WorldBounds describes. Pure — it is four numbers and the same
7
+ # snapping arithmetic every other source uses.
8
+ #
9
+ # It answers the blocker-source question Engine::CollisionSystem asks of every source
10
+ # it holds; see that class's header for the protocol.
11
+ #
12
+ # ## Why the world edge is declared rather than automatic
13
+ #
14
+ # Stopping at the edge is one of three responses to it. ScreenWrap and DespawnOffscreen
15
+ # are the other two, and a node may carry only one of them
16
+ # (Components::WorldBounds.one_response!). A clamp applied to every step would be a
17
+ # response nobody chose, contradicting a wrap or a despawn on every node that has one.
18
+ # So a game that wants the world edge to stop an actor says so, and gets an ordinary
19
+ # blocker with a layer like any other.
20
+ class BoundsBlockers
21
+ # What a BoundsBlockers reports as having stopped a step: one object for the life of
22
+ # the process, answering the same two questions a collider does, so a handler reads
23
+ # `by.layer` whatever stopped it. The world's edge has no node.
24
+ class Bounds
25
+ def layer = :bounds
26
+ def node = nil
27
+ end
28
+
29
+ BOUNDS = Bounds.new.freeze
30
+
31
+ # `bounds` is anything answering world_width / world_height — a Components::World or
32
+ # a Components::TileWorld. The two numbers are read once, here, because WorldBounds
33
+ # is documented immutable: a world that genuinely changes size is a new scene.
34
+ def initialize(bounds:)
35
+ @world_width = bounds.world_width
36
+ @world_height = bounds.world_height
37
+ @blocker = nil
38
+ end
39
+
40
+ # The world's edge stopped the last resolve on this axis, or nil when it did not.
41
+ attr_reader :blocker
42
+
43
+ # The far axis is nothing to a rectangle: a box leaves the world on one axis
44
+ # independently of where it is on the other, so `_y` and `_h` go unread here and
45
+ # `_x` and `_w` in the mirror. The five arguments are the protocol's, not this
46
+ # source's need.
47
+ def resolve_x(x, _y, w, _h, dx) = resolve(x + dx, w, @world_width, dx)
48
+ def resolve_y(_x, y, _w, h, dy) = resolve(y + dy, h, @world_height, dy)
49
+
50
+ # The world does not move, so there is nothing to re-index.
51
+ def moved(_actor, _from_x, _from_y, _w, _h) = nil
52
+
53
+ private
54
+
55
+ def resolve(landed, span, extent, delta)
56
+ @blocker = nil
57
+ return landed if delta.zero?
58
+
59
+ limit = extent - span
60
+ limit = 0.0 if limit.negative?
61
+
62
+ if landed.negative?
63
+ @blocker = BOUNDS
64
+ 0.0
65
+ elsif landed > limit
66
+ @blocker = BOUNDS
67
+ limit.to_f
68
+ else
69
+ landed
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A 2D camera: a point in the world to look at, plus the world bounds it may
6
+ # not show past. Pure; the offset it produces is applied as a draw-time
7
+ # transform, never baked into a node.
8
+ #
9
+ # camera = Camera.new(world_width: map.pixel_width, world_height: map.pixel_height)
10
+ # camera.center_on(player.world_x, player.world_y) # in update, every tick
11
+ # camera.resolve(view_width, view_height) # at draw, for one viewport
12
+ # camera.x, camera.y # the offset to translate by
13
+ #
14
+ # ## Why the viewport size is an argument and not state
15
+ #
16
+ # A camera used to be built with its viewport size and clamp against it in
17
+ # `center_on`. That cannot survive split-screen: the same world is drawn
18
+ # through several viewports whose rects come from the layout and change when
19
+ # a player joins or the window resizes. Clamping has to happen against the
20
+ # rect actually being drawn into, and the difference is visible rather than
21
+ # theoretical — near a world edge, the same target sits at a different place
22
+ # on screen in a half-width viewport than in a full-width one.
23
+ #
24
+ # So `center_on` records *intent* and `resolve` computes the offset. Nothing
25
+ # calls `resolve` by hand: the platform resolves each camera against the
26
+ # viewport it is about to draw, which is what keeps the two from drifting.
27
+ #
28
+ # ## The camera belongs to a player, not to a scene
29
+ #
30
+ # A scene may have any number of viewers, so it cannot own "the" camera.
31
+ # RGame::Engine::Player owns one; a scene sets its world bounds when it
32
+ # loads a map, and a CameraFollow component in the world points it.
33
+ class Camera
34
+ attr_reader :x, :y, :target_x, :target_y
35
+ attr_accessor :world_width, :world_height
36
+
37
+ # `world_width` / `world_height` bound what the camera may show. Left nil
38
+ # the camera is unbounded and follows its target exactly — which is the
39
+ # right default: a game that has not declared its world yet gets a camera
40
+ # that visibly works, rather than one silently pinned to the origin.
41
+ def initialize(world_width: nil, world_height: nil)
42
+ @world_width = world_width
43
+ @world_height = world_height
44
+ @target_x = 0.0
45
+ @target_y = 0.0
46
+ @x = 0.0
47
+ @y = 0.0
48
+ end
49
+
50
+ # Look at this world point. Records the target; the offset is worked out
51
+ # by #resolve, which is the only place that knows how big the view is.
52
+ def center_on(world_x, world_y)
53
+ @target_x = world_x
54
+ @target_y = world_y
55
+ self
56
+ end
57
+
58
+ # Work out the draw offset for a viewport of this size, clamped so the
59
+ # view never shows past the world's edges.
60
+ def resolve(view_width, view_height)
61
+ @x = clamp(@target_x - (view_width / 2.0), @world_width, view_width)
62
+ @y = clamp(@target_y - (view_height / 2.0), @world_height, view_height)
63
+ self
64
+ end
65
+
66
+ private
67
+
68
+ def clamp(value, world_size, view_size)
69
+ return value.to_f if world_size.nil?
70
+
71
+ max = world_size - view_size
72
+ return 0.0 if max <= 0
73
+
74
+ value.clamp(0.0, max.to_f)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A circular collision shape, the sibling of CollisionBox for entities that
6
+ # rotate (rocks, ship): a circle is rotation-invariant, so a spinning rock needs
7
+ # no per-frame box recompute. The collider owns only the radius; the entity owns
8
+ # its centre position. Pure logic; no graphics.
9
+ class CircleCollider
10
+ attr_reader :radius
11
+
12
+ # Squared-distance overlap test (no sqrt): true when two circles overlap in area.
13
+ # Circles that exactly graze are apart, the same half-open convention CollisionBox
14
+ # uses — one meaning of "contact" across both shapes and the pairs that mix them.
15
+ def self.overlap?(x1, y1, r1, x2, y2, r2)
16
+ dx = x2 - x1
17
+ dy = y2 - y1
18
+ sum = r1 + r2
19
+ (dx * dx) + (dy * dy) < sum * sum
20
+ end
21
+
22
+ def initialize(radius:)
23
+ @radius = radius
24
+ end
25
+
26
+ # Bounding AABB [x, y, w, h] for a circle centred at (cx, cy) — what the
27
+ # spatial hash buckets on.
28
+ def aabb(cx, cy)
29
+ d = @radius * 2
30
+ [cx - @radius, cy - @radius, d, d]
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A character's collision rectangle, expressed as an offset + size relative to
6
+ # the actor's top-left origin (the sprite's top-left). Decoupled from sprite
7
+ # size: a 32x32 sprite can have a small 16x16 box at its feet.
8
+ #
9
+ # It is also where rectangle geometry lives, the way CircleCollider owns circle
10
+ # geometry: the overlap tests below are class methods over plain numbers, so the
11
+ # per-frame collision path can call them without building a box first.
12
+ class CollisionBox
13
+ attr_reader :offset_x, :offset_y, :width, :height
14
+
15
+ # Centre the box horizontally and anchor it to the bottom of the sprite (feet).
16
+ def self.bottom_anchored(sprite_width:, sprite_height:, width:, height:)
17
+ new(
18
+ offset_x: (sprite_width - width) / 2,
19
+ offset_y: sprite_height - height,
20
+ width: width,
21
+ height: height
22
+ )
23
+ end
24
+
25
+ # Rect-vs-rect: true when two world-space AABBs overlap in *area*. A shape spans
26
+ # the half-open box [x, x + w), so shapes that merely share an edge are apart —
27
+ # the same convention SDL_HasIntersection uses, and the one thing that makes a
28
+ # grid work: pieces on neighbouring squares border each other constantly, and an
29
+ # inclusive test would report every one of those as a contact.
30
+ def self.overlap?(x1, y1, w1, h1, x2, y2, w2, h2)
31
+ x1 < x2 + w2 && x2 < x1 + w1 && y1 < y2 + h2 && y2 < y1 + h1
32
+ end
33
+
34
+ # Rect-vs-circle: true when the circle at (cx, cy) overlaps the rect. Clamping
35
+ # the centre into the rect gives the rect's nearest point to it, so the corner
36
+ # case and the edge case are the same two lines — no per-region analysis, and
37
+ # no sqrt (compare squared distances). Grazing is not overlapping, as above.
38
+ def self.overlap_circle?(x, y, w, h, cx, cy, r)
39
+ nx = cx.clamp(x, x + w)
40
+ ny = cy.clamp(y, y + h)
41
+ dx = cx - nx
42
+ dy = cy - ny
43
+ (dx * dx) + (dy * dy) < r * r
44
+ end
45
+
46
+ def initialize(width:, height:, offset_x: 0, offset_y: 0)
47
+ @offset_x = offset_x
48
+ @offset_y = offset_y
49
+ @width = width
50
+ @height = height
51
+ end
52
+
53
+ # World-space AABB [x, y, w, h] for an actor whose origin is at (x, y).
54
+ def aabb(x, y)
55
+ [x + @offset_x, y + @offset_y, @width, @height]
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The actor-facing collision system: moves any actor by a delta, resolving its
6
+ # *collision box* (not its sprite) against every blocker source it holds. Reusable by
7
+ # the player and any NPC.
8
+ #
9
+ # **Everything that can stop a step is a source**, including the edge of the world —
10
+ # see Engine::BoundsBlockers, and its header for why that used to be an unconditional
11
+ # clamp here and is not any more. Nothing holds an actor anywhere it did not ask to
12
+ # be held.
13
+ #
14
+ # ## What a blocker source is
15
+ #
16
+ # A blocker source answers one question, on one axis, over plain numbers:
17
+ #
18
+ # source.resolve_x(x, y, w, h, dx) # -> where the box's left edge lands moving dx
19
+ # source.resolve_y(x, y, w, h, dy) # -> where the box's top edge lands moving dy
20
+ #
21
+ # Engine::TileBlockers divides by the tile size and snaps flush against a solid tile;
22
+ # Engine::ActorBlockers queries a broadphase and snaps flush against another
23
+ # collider's edge. Neither needs to know the other exists, because this system takes
24
+ # the **most restrictive** answer on each axis: a box hemmed in by a wall on one side
25
+ # and something else nearer stops at whichever is nearer.
26
+ #
27
+ # Two more questions a source answers, and both are asked here so that no caller has
28
+ # anything to remember:
29
+ #
30
+ # source.blocker # -> what produced the edge the last resolve returned, or nil
31
+ # source.moved(actor, from_x, from_y, w, h) # -> the step has been written back
32
+ #
33
+ # `blocker` is how #blocked_x / #blocked_y name what stopped a step; `moved` is how a
34
+ # source over a moving index re-buckets the mover, with the box the step started from.
35
+ #
36
+ # A fourth question is optional, and only Engine::TileBlockers answers it today:
37
+ #
38
+ # source.travel?(x, y, w, h, dx, dy) # -> can the box move (dx, dy) without being stopped?
39
+ #
40
+ # "Stopped" means what it means here: a resolve landing *short* of where the step was
41
+ # heading. A landing past it is ignored below, so it does not stop a travel either. Read
42
+ # that way the question splits by source — a box travels past several sources exactly when
43
+ # it travels past each one alone — so no source needs to know the others exist for this
44
+ # question any more than for the other three. The shared example group "a blocker source
45
+ # answering travel?" states it.
46
+ #
47
+ # Holding the sources here rather than inside any one of them is what puts the
48
+ # axis-separated order — resolve X, then resolve Y fed the resolved X — in exactly
49
+ # one place. That order is what produces wall-sliding (a diagonal push into a wall
50
+ # keeps the component that is still free), so every source shares one feel rather
51
+ # than each implementing it.
52
+ class CollisionSystem
53
+ # What stopped the last #move on each axis, or nil when that axis was free. Whatever
54
+ # a source reports, so a handler reads `blocked_x.layer` without asking which kind
55
+ # of thing it was. Set by every #move, so read it straight after one.
56
+ attr_reader :blocked_x, :blocked_y
57
+
58
+ def initialize(blockers: [])
59
+ @blockers = Array(blockers)
60
+ @blocked_x = nil
61
+ @blocked_y = nil
62
+ end
63
+
64
+ # Move `actor` by (dx, dy), writing the resolved position back to it.
65
+ def move(actor, dx, dy)
66
+ box = actor.collision_box
67
+ from_x = actor.x + box.offset_x
68
+ from_y = actor.y + box.offset_y
69
+ bw = box.width
70
+ bh = box.height
71
+
72
+ bx = resolve_x(from_x, from_y, bw, bh, dx)
73
+ by = resolve_y(bx, from_y, bw, bh, dy)
74
+
75
+ actor.x = bx - box.offset_x
76
+ actor.y = by - box.offset_y
77
+
78
+ i = 0
79
+ while i < @blockers.size
80
+ @blockers[i].moved(actor, from_x, from_y, bw, bh)
81
+ i += 1
82
+ end
83
+ end
84
+
85
+ # Where the box's left edge lands moving dx, against every source at once. The most
86
+ # restrictive answer is the *smallest* landing for a rightward step and the largest
87
+ # for a leftward one; with no sources at all the box simply moves.
88
+ #
89
+ # A plain index loop rather than @blockers.map/min: this runs per actor per axis per
90
+ # frame, and both the block and the intermediate array would allocate.
91
+ def resolve_x(x, y, w, h, dx)
92
+ @blocked_x = nil
93
+ nx = x + dx
94
+ return nx if dx.zero?
95
+
96
+ winner = nil
97
+ i = 0
98
+ while i < @blockers.size
99
+ source = @blockers[i]
100
+ landed = source.resolve_x(x, y, w, h, dx)
101
+ if dx.positive? ? landed < nx : landed > nx
102
+ nx = landed
103
+ winner = source
104
+ end
105
+ i += 1
106
+ end
107
+ @blocked_x = winner&.blocker
108
+ nx
109
+ end
110
+
111
+ def resolve_y(x, y, w, h, dy)
112
+ @blocked_y = nil
113
+ ny = y + dy
114
+ return ny if dy.zero?
115
+
116
+ winner = nil
117
+ i = 0
118
+ while i < @blockers.size
119
+ source = @blockers[i]
120
+ landed = source.resolve_y(x, y, w, h, dy)
121
+ if dy.positive? ? landed < ny : landed > ny
122
+ ny = landed
123
+ winner = source
124
+ end
125
+ i += 1
126
+ end
127
+ @blocked_y = winner&.blocker
128
+ ny
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ class Component
6
+ extend Signal::DSL
7
+ extend SealedPrivates
8
+
9
+ attr_accessor :node
10
+
11
+ def context
12
+ @context ||= node.context
13
+ end
14
+
15
+ # Tree-lifecycle hooks. on_attach fires once the node is in the live tree, so
16
+ # node.root / node.scene and sibling systems are reachable — pull and register
17
+ # shared systems here, not in initialize (where the node has no anchors yet).
18
+ # on_detach mirrors it: release those registrations.
19
+ def on_attach; end
20
+ def on_detach; end
21
+
22
+ # The sibling component this one drives, or a raise naming both — for the
23
+ # `@body = require_sibling(CharacterBody)` line an `on_attach` opens with.
24
+ #
25
+ # Worth a helper rather than a bare `get_component` because the nil it returns
26
+ # is silent, and stays silent until the first frame calls a method on it: the
27
+ # error you see is a NoMethodError on nil, in `control`, naming neither the
28
+ # component that is missing nor the one that wanted it.
29
+ #
30
+ # And the cause is nearly always the same, which is why the message says it.
31
+ # A node assembled *outside* the tree collects every component before any
32
+ # on_attach runs (Node2D#enter_tree), so add order does not matter there. A
33
+ # node that adds components from its own `on_add` is already in the tree, so
34
+ # each one attaches as it arrives and can only see the ones before it. Same
35
+ # two lines, opposite outcome, depending on where they were written.
36
+ #
37
+ # A class several components answer to — `Components::Mover` has three — can match
38
+ # twice on one node, and then there is no telling which one was meant. That raises
39
+ # too, naming each match, rather than quietly taking the first.
40
+ def require_sibling(klass)
41
+ matches = node.components.grep(klass)
42
+ if matches.length > 1
43
+ raise ArgumentError, "#{self.class} reads one #{klass} on the same node, and this node has " \
44
+ "#{matches.length}: #{matches.map(&:class).join(', ')}. " \
45
+ 'Keep one of them on this node.'
46
+ end
47
+
48
+ matches.first ||
49
+ raise("#{self.class} needs a #{klass} on the same node, and there is none. If you did " \
50
+ 'add one, add it before this component: a node that is already in the tree ' \
51
+ 'attaches each component as it arrives, so a sibling added after this one is ' \
52
+ 'not there yet when this one attaches.')
53
+ end
54
+
55
+ def control(actions); end
56
+ def update(dt); end
57
+ def draw(renderer, view); end
58
+
59
+ # Container components (e.g. SceneStack) that hold nodes off the normal child
60
+ # list override this to forward the deferred-free sweep into them.
61
+ def sweep_freed; end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Maps held input actions to an `on_triggered(action)` signal, rate-limited by a
7
+ # per-action cooldown. One instance covers several actions (the engine allows
8
+ # only one component of a class per node), so it emits the action name and lets
9
+ # listeners filter — reusable for "fire" here, or "jump"/"fire" in a platformer.
10
+ #
11
+ # trigger = node.add_component(ActionTrigger.new(fire: 0.22, dash: 0.5))
12
+ # trigger.on_triggered { |action| fire if action == :fire }
13
+ #
14
+ # Semantics: while an action is held and its cooldown has elapsed, it fires and
15
+ # the cooldown restarts — i.e. auto-repeat at the cooldown rate.
16
+ class ActionTrigger < Engine::Component
17
+ signal :on_triggered, Engine::Signal.define(:action)
18
+
19
+ def initialize(cooldowns)
20
+ super()
21
+ @cooldowns = cooldowns
22
+ @timers = cooldowns.transform_values { 0.0 }
23
+ end
24
+
25
+ def update(dt)
26
+ @timers.each { |action, remaining| @timers[action] = remaining - dt if remaining.positive? }
27
+ end
28
+
29
+ def control(actions)
30
+ @cooldowns.each_key do |action|
31
+ next unless actions.held?(action) && @timers[action] <= 0.0
32
+
33
+ @timers[action] = @cooldowns[action]
34
+ on_triggered_signal.emit(action)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # Draws a sprite-sheet animation for a walking actor, picking the animation from its
7
+ # Mover sibling's heading: walk_left/right/up/down while moving, stand when still. The
8
+ # larger axis of the heading picks the direction and a tie goes horizontal, so a
9
+ # keyboard diagonal walks sideways and a route running mostly downhill walks down.
10
+ # Any mover will do — a CharacterBody faces its intent, a PathFollow the road it is
11
+ # on — and a node with two movers raises at attach, since there would be no telling
12
+ # which way it faces. Owns its Animator + the pure AnimationSet built from the sheet's
13
+ # animation table.
14
+ #
15
+ # Like Sprite, it passes NO angle and NO position: it draws at (0, 0), which
16
+ # Node2D#draw has already made mean "at this node, correctly rotated", and a
17
+ # WorldView ancestor has already made mean "through the camera". `z` is the
18
+ # render layer (kept as @layer, distinct from the node's transform z); it must
19
+ # sit between the tile map's ground and canopy z bands, so canopies draw in
20
+ # front.
21
+ #
22
+ # `sheet` is the asset's relative path. The component resolves it from the game's
23
+ # asset manager on attach — via node.root.context.assets (the platform seam) — to
24
+ # build its animation table and to size the node to the sprite's frame, so siblings
25
+ # like FeetCollider can read node.width/height. The renderer resolves the same
26
+ # symbol when drawing, so nothing is registered or passed in by hand.
27
+ class AnimatedSprite < Engine::Component
28
+ include Engine::Culling
29
+
30
+ def initialize(sheet:, z: 0)
31
+ super()
32
+ @sheet = sheet
33
+ @layer = z
34
+ end
35
+
36
+ def on_attach
37
+ sheet = context.assets.sheet(@sheet)
38
+ @animator = Engine::Animator.new(Engine::AnimationSet.new(sheet.animations))
39
+ node.width = sheet.frame_width
40
+ node.height = sheet.frame_height
41
+ @mover = require_sibling(Mover)
42
+ end
43
+
44
+ def update(dt)
45
+ @animator.play(walk_animation(@mover.heading_x, @mover.heading_y))
46
+ @animator.update(dt)
47
+ end
48
+
49
+ # Top-left anchored, sized by the sheet's frame and lifted by the node's
50
+ # elevation — so the footprint to cull against is the node's box, raised
51
+ # by the same amount the picture is.
52
+ def draw(renderer, view)
53
+ lift = node.elevation
54
+ return if culled?(view, node.world_x, node.world_y - lift, node.width, node.height)
55
+
56
+ renderer.sprite(@sheet, @animator.row, @animator.col, 0, -lift,
57
+ flip_x: @animator.flip_x, z: @layer)
58
+ end
59
+
60
+ private
61
+
62
+ def walk_animation(heading_x, heading_y)
63
+ if heading_x.zero? && heading_y.zero? then :stand
64
+ elsif heading_x.abs >= heading_y.abs then heading_x.negative? ? :walk_left : :walk_right
65
+ else heading_y.negative? ? :walk_up : :walk_down
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -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