rgame 0.1.0 → 0.3.0

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 (265) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +228 -0
  3. data/README.md +142 -243
  4. data/docs/api/README.md +141 -90
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -166
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1045 -254
  10. data/docs/api/drawing.md +187 -119
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +165 -45
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +344 -85
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +503 -123
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +197 -64
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +400 -147
  23. data/docs/api/ui.md +927 -0
  24. data/docs/api/values.md +234 -19
  25. data/examples/assets/README.md +322 -0
  26. data/examples/assets/blip.ogg +0 -0
  27. data/examples/assets/glyphs.json +5 -0
  28. data/examples/assets/glyphs.png +0 -0
  29. data/examples/assets/hero.json +12 -0
  30. data/examples/assets/hero.png +0 -0
  31. data/examples/assets/icons.json +13 -0
  32. data/examples/assets/icons.png +0 -0
  33. data/examples/assets/music.ogg +0 -0
  34. data/examples/assets/skills.json +10 -0
  35. data/examples/assets/skills.png +0 -0
  36. data/examples/assets/tileset.png +0 -0
  37. data/examples/assets/tileset.tsx +65 -0
  38. data/examples/assets/town.tmx +26 -0
  39. data/examples/assets/ui.json +11 -0
  40. data/examples/assets/ui.png +0 -0
  41. data/examples/collision/locales/en.yml +8 -0
  42. data/examples/collision/main.rb +316 -0
  43. data/examples/collision_tiles/locales/en.yml +9 -0
  44. data/examples/collision_tiles/main.rb +274 -0
  45. data/examples/fullscreen/locales/en.yml +10 -0
  46. data/examples/fullscreen/main.rb +216 -0
  47. data/examples/game_menu/locales/en.yml +8 -0
  48. data/examples/game_menu/main.rb +170 -0
  49. data/examples/input_glyphs/locales/en.yml +14 -0
  50. data/examples/input_glyphs/main.rb +213 -0
  51. data/examples/jump_topdown/locales/en.yml +9 -0
  52. data/examples/jump_topdown/main.rb +178 -0
  53. data/examples/localization/locales/de.yml +12 -0
  54. data/examples/localization/locales/en.yml +13 -0
  55. data/examples/localization/main.rb +158 -0
  56. data/examples/menu_navigation/locales/en.yml +23 -0
  57. data/examples/menu_navigation/main.rb +365 -0
  58. data/examples/music/locales/en.yml +7 -0
  59. data/examples/music/main.rb +134 -0
  60. data/examples/pathfinding/locales/en.yml +17 -0
  61. data/examples/pathfinding/main.rb +298 -0
  62. data/examples/pooling/locales/en.yml +7 -0
  63. data/examples/pooling/main.rb +259 -0
  64. data/examples/quick_wheel/locales/en.yml +16 -0
  65. data/examples/quick_wheel/main.rb +184 -0
  66. data/examples/radial_menu/locales/en.yml +16 -0
  67. data/examples/radial_menu/main.rb +184 -0
  68. data/examples/save_load/locales/en.yml +11 -0
  69. data/examples/save_load/main.rb +207 -0
  70. data/examples/save_load_ids/locales/en.yml +11 -0
  71. data/examples/save_load_ids/main.rb +322 -0
  72. data/examples/scroll_map/locales/en.yml +4 -0
  73. data/examples/scroll_map/main.rb +140 -0
  74. data/examples/signals/locales/en.yml +6 -0
  75. data/examples/signals/main.rb +278 -0
  76. data/examples/skill_bar/locales/en.yml +14 -0
  77. data/examples/skill_bar/main.rb +159 -0
  78. data/examples/sound/locales/en.yml +6 -0
  79. data/examples/sound/main.rb +122 -0
  80. data/examples/split_screen/locales/en.yml +9 -0
  81. data/examples/split_screen/main.rb +304 -0
  82. data/examples/sprite/locales/en.yml +8 -0
  83. data/examples/sprite/main.rb +180 -0
  84. data/examples/timer/locales/en.yml +12 -0
  85. data/examples/timer/main.rb +273 -0
  86. data/examples/velocity/locales/en.yml +6 -0
  87. data/examples/velocity/main.rb +196 -0
  88. data/examples/walk/locales/en.yml +4 -0
  89. data/examples/walk/main.rb +99 -0
  90. data/exe/rgame +9 -0
  91. data/ext/README.md +6 -5
  92. data/ext/rgame_core/app/app.c +215 -11
  93. data/ext/rgame_core/app/locale.c +67 -0
  94. data/ext/rgame_core/app/locale.h +28 -0
  95. data/ext/rgame_core/audio/audio.c +113 -2
  96. data/ext/rgame_core/example.rb +18 -56
  97. data/ext/rgame_core/extconf.rb +16 -113
  98. data/ext/rgame_core/graphics/canvas.c +45 -4
  99. data/ext/rgame_core/graphics/canvas.h +65 -10
  100. data/ext/rgame_core/graphics/clip.c +22 -13
  101. data/ext/rgame_core/include/rgame/core.h +151 -5
  102. data/ext/rgame_core/input/gamepad.c +57 -3
  103. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  104. data/ext/rgame_core/ruby/core_ext.c +46 -7
  105. data/ext/rgame_core/ruby/core_ext.h +3 -0
  106. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  107. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  108. data/ext/rgame_core/ruby/renderer_ext.c +42 -19
  109. data/ext/rgame_util/color_ext.c +12 -3
  110. data/ext/rgame_util/extconf.rb +2 -20
  111. data/ext/rgame_util/route_search.c +305 -0
  112. data/ext/rgame_util/route_search.h +86 -0
  113. data/ext/rgame_util/route_search_ext.c +150 -0
  114. data/ext/rgame_util/solid_grid.c +58 -0
  115. data/ext/rgame_util/solid_grid.h +49 -0
  116. data/ext/rgame_util/solid_grid_ext.c +161 -0
  117. data/ext/rgame_util/tile_sweep.c +164 -0
  118. data/ext/rgame_util/tile_sweep.h +62 -0
  119. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  120. data/ext/rgame_util/util_ext.c +3 -0
  121. data/ext/rgame_util/util_ext.h +15 -0
  122. data/lib/rgame/boot.rb +0 -10
  123. data/lib/rgame/cli/new_project.rb +139 -0
  124. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  125. data/lib/rgame/cli/templates/README.md.tt +93 -0
  126. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  127. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  128. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  129. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  130. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  131. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  132. data/lib/rgame/cli/templates/rspec.tt +2 -0
  133. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  134. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  135. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  136. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  137. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  138. data/lib/rgame/cli.rb +66 -0
  139. data/lib/rgame/core/app.rb +6 -42
  140. data/lib/rgame/core/asset_manager.rb +13 -31
  141. data/lib/rgame/core/audio.rb +37 -16
  142. data/lib/rgame/core/font.rb +0 -3
  143. data/lib/rgame/core/input.rb +35 -41
  144. data/lib/rgame/core/locale.rb +22 -0
  145. data/lib/rgame/core/nine_slice.rb +0 -21
  146. data/lib/rgame/core/recording.rb +3 -1
  147. data/lib/rgame/core/renderer.rb +75 -84
  148. data/lib/rgame/core/sprite_sheet.rb +0 -3
  149. data/lib/rgame/core/tile_map_renderer.rb +77 -65
  150. data/lib/rgame/core/ui_atlas.rb +28 -13
  151. data/lib/rgame/core.rb +1 -8
  152. data/lib/rgame/engine/actor_blockers.rb +131 -0
  153. data/lib/rgame/engine/animation_set.rb +1 -0
  154. data/lib/rgame/engine/audio_director.rb +36 -6
  155. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  156. data/lib/rgame/engine/camera.rb +55 -10
  157. data/lib/rgame/engine/circle_collider.rb +4 -2
  158. data/lib/rgame/engine/collision_box.rb +26 -1
  159. data/lib/rgame/engine/collision_system.rb +110 -22
  160. data/lib/rgame/engine/component.rb +35 -1
  161. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  162. data/lib/rgame/engine/components/animated_sprite.rb +31 -23
  163. data/lib/rgame/engine/components/box_collider.rb +99 -0
  164. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  165. data/lib/rgame/engine/components/character_body.rb +21 -41
  166. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  167. data/lib/rgame/engine/components/collision_world.rb +159 -31
  168. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  169. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  170. data/lib/rgame/engine/components/hop.rb +76 -0
  171. data/lib/rgame/engine/components/identity.rb +73 -0
  172. data/lib/rgame/engine/components/mover.rb +285 -0
  173. data/lib/rgame/engine/components/navigator.rb +145 -0
  174. data/lib/rgame/engine/components/path_follow.rb +123 -31
  175. data/lib/rgame/engine/components/player_controller.rb +5 -2
  176. data/lib/rgame/engine/components/pool.rb +1 -1
  177. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  178. data/lib/rgame/engine/components/sprite.rb +22 -6
  179. data/lib/rgame/engine/components/targeting.rb +9 -11
  180. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  181. data/lib/rgame/engine/components/tile_world.rb +70 -29
  182. data/lib/rgame/engine/components/timer.rb +1 -1
  183. data/lib/rgame/engine/components/velocity.rb +23 -7
  184. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  185. data/lib/rgame/engine/components/world.rb +133 -0
  186. data/lib/rgame/engine/contact_set.rb +74 -0
  187. data/lib/rgame/engine/culling.rb +45 -0
  188. data/lib/rgame/engine/debug_overlay.rb +23 -18
  189. data/lib/rgame/engine/i18n/plural.rb +45 -0
  190. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  191. data/lib/rgame/engine/i18n/template.rb +59 -0
  192. data/lib/rgame/engine/i18n.rb +276 -51
  193. data/lib/rgame/engine/input/action_mapper.rb +76 -22
  194. data/lib/rgame/engine/input/actions.rb +63 -12
  195. data/lib/rgame/engine/input/input_map.rb +196 -0
  196. data/lib/rgame/engine/layout.rb +82 -0
  197. data/lib/rgame/engine/nav_grid.rb +87 -0
  198. data/lib/rgame/engine/node2d.rb +364 -80
  199. data/lib/rgame/engine/path.rb +4 -6
  200. data/lib/rgame/engine/player.rb +69 -0
  201. data/lib/rgame/engine/player_layer.rb +70 -0
  202. data/lib/rgame/engine/players.rb +205 -0
  203. data/lib/rgame/engine/presentation.rb +171 -0
  204. data/lib/rgame/engine/scene/scene_stack.rb +29 -7
  205. data/lib/rgame/engine/sealed_privates.rb +54 -0
  206. data/lib/rgame/engine/spatial_hash.rb +53 -8
  207. data/lib/rgame/engine/text.rb +194 -0
  208. data/lib/rgame/engine/tile_blockers.rb +63 -0
  209. data/lib/rgame/engine/tile_map.rb +2 -3
  210. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  211. data/lib/rgame/engine/tileset.rb +2 -4
  212. data/lib/rgame/engine/timer.rb +2 -2
  213. data/lib/rgame/engine/ui/button.rb +248 -0
  214. data/lib/rgame/engine/ui/column.rb +20 -0
  215. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  216. data/lib/rgame/engine/ui/menu.rb +290 -0
  217. data/lib/rgame/engine/ui/navigation.rb +57 -0
  218. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  219. data/lib/rgame/engine/ui/option_button.rb +163 -0
  220. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  221. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  222. data/lib/rgame/engine/ui/pointing.rb +146 -0
  223. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  224. data/lib/rgame/engine/ui/ring.rb +55 -0
  225. data/lib/rgame/engine/ui/row.rb +21 -0
  226. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  227. data/lib/rgame/engine/ui/stack.rb +58 -0
  228. data/lib/rgame/engine/ui/stepping.rb +93 -0
  229. data/lib/rgame/engine/ui/text_button.rb +59 -0
  230. data/lib/rgame/engine/view.rb +76 -0
  231. data/lib/rgame/engine/viewports.rb +171 -0
  232. data/lib/rgame/engine/world_view.rb +71 -0
  233. data/lib/rgame/engine.rb +43 -25
  234. data/lib/rgame/game.rb +164 -22
  235. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  236. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  237. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  238. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  239. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  240. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  241. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  242. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  243. data/lib/rgame/rubocop/default.yml +39 -0
  244. data/lib/rgame/rubocop/plugin.rb +45 -0
  245. data/lib/rgame/rubocop.rb +11 -0
  246. data/lib/rgame/util/color.rb +20 -24
  247. data/lib/rgame/util/controls.rb +106 -44
  248. data/lib/rgame/util/route_search.rb +27 -0
  249. data/lib/rgame/util/save_file.rb +107 -0
  250. data/lib/rgame/util/solid_grid.rb +37 -0
  251. data/lib/rgame/util/tensor.rb +0 -9
  252. data/lib/rgame/util/tile_sweep.rb +36 -0
  253. data/lib/rgame/util/z.rb +123 -0
  254. data/lib/rgame/util.rb +5 -3
  255. data/lib/rgame/version.rb +1 -1
  256. data/lib/rgame.rb +0 -15
  257. metadata +176 -20
  258. data/lib/rgame/engine/actor.rb +0 -53
  259. data/lib/rgame/engine/body.rb +0 -49
  260. data/lib/rgame/engine/cached_label.rb +0 -33
  261. data/lib/rgame/engine/camera_view.rb +0 -28
  262. data/lib/rgame/engine/input/player_controller.rb +0 -14
  263. data/lib/rgame/engine/matrix.rb +0 -32
  264. data/lib/rgame/engine/resettable.rb +0 -67
  265. data/lib/rgame/engine/tile_collision.rb +0 -78
@@ -3,20 +3,36 @@
3
3
  module RGame
4
4
  module Engine
5
5
  module Components
6
- # Queues the node for removal once it has fully left the bounds (plus margin).
7
- # From Body#offscreen?. Used by short-lived projectiles; removal is deferred via
8
- # queue_free so it is safe to trigger from inside the update traversal.
6
+ # Queues the node for removal once its origin is further than `margin` past an
7
+ # edge of the world bounds. Used by short-lived projectiles; removal is deferred
8
+ # via queue_free so it is safe to trigger from inside the update traversal.
9
+ #
10
+ # The margin is what stands in for the node's size: a node drawn centred on its
11
+ # origin has fully left once the margin is at least its half-extent.
12
+ #
13
+ # Bounds resolve the same way ScreenWrap's do: from the scene's world system,
14
+ # at attach time, with `width:`/`height:` as an override. And like ScreenWrap it
15
+ # tests the node's **world** position, so a projectile spawned as the child of an
16
+ # offset emitter leaves at the world's edge rather than at one shifted by the
17
+ # emitter.
9
18
  class DespawnOffscreen < Engine::Component
10
- def initialize(width:, height:, margin: 0.0)
19
+ def initialize(width: nil, height: nil, margin: 0.0)
11
20
  super()
12
- @width = width
13
- @height = height
21
+ @given_width = width
22
+ @given_height = height
14
23
  @margin = margin
15
24
  end
16
25
 
26
+ # See ScreenWrap#on_attach: resolved per entry, so a recycled node is correct
27
+ # after a scene change.
28
+ def on_attach
29
+ WorldBounds.one_response!(node)
30
+ @width, @height = WorldBounds.resolve(node, @given_width, @given_height)
31
+ end
32
+
17
33
  def update(_dt)
18
- x = node.x
19
- y = node.y
34
+ x = node.world_x
35
+ y = node.world_y
20
36
  offscreen = x < -@margin || x > @width + @margin || y < -@margin || y > @height + @margin
21
37
  node.queue_free if offscreen
22
38
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A BoxCollider whose rectangle is the node's *feet*: horizontally centred in the
7
+ # node's dimensions and anchored to their bottom. That is the shape a top-down
8
+ # character collides with — a 16x22 hero standing on a floor occupies the 12x6
9
+ # patch under them, not the whole sprite, which is what stops their head from
10
+ # bumping into a wall a tile away.
11
+ #
12
+ # It is an ordinary BoxCollider in every other respect, so it registers with the
13
+ # scene's CollisionWorld, collides with circles and boxes alike, and
14
+ # `get_component(BoxCollider)` finds it. See docs/api/components.md.
15
+ #
16
+ # The box is derived from `node.width`/`node.height`, which AnimatedSprite sets
17
+ # from the sprite frame, rather than from a sprite size passed in — so the caller
18
+ # gives the feet box and nothing else, and the two can never disagree.
19
+ class FeetCollider < BoxCollider
20
+ def initialize(width:, height:, layer: :default)
21
+ super
22
+ @feet_width = width
23
+ @feet_height = height
24
+ @box = nil
25
+ end
26
+
27
+ # The feet box, derived from the node's sprite size and memoised.
28
+ #
29
+ # **Only valid once the node is in the tree**, and it says so rather than letting
30
+ # you find out later. The size comes from AnimatedSprite#on_attach, so a read from
31
+ # a constructor sees a 0x0 node and bakes a box anchored to nothing — permanently,
32
+ # because this memoises. The symptom is an actor that walks through walls it
33
+ # should not, a long way from the call that caused it. Guarding costs one
34
+ # comparison, once.
35
+ #
36
+ # Building it here rather than in on_attach is what makes the order components
37
+ # were added in irrelevant: the first read is a frame later, by which time every
38
+ # sibling has attached. Assigning `box =` still wins, since that leaves nothing
39
+ # to memoise.
40
+ def box
41
+ @box ||= build_box
42
+ end
43
+
44
+ private
45
+
46
+ def build_box
47
+ if node.width.zero? || node.height.zero?
48
+ raise "FeetCollider needs the node's sprite size, but it is " \
49
+ "#{node.width}x#{node.height}. AnimatedSprite sets that when it attaches, so " \
50
+ 'read this after the node is in the tree, not while building it.'
51
+ end
52
+
53
+ Engine::CollisionBox.bottom_anchored(
54
+ sprite_width: node.width, sprite_height: node.height,
55
+ width: @feet_width, height: @feet_height
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A jump in a top-down view. Pressing the action lifts the node along a
7
+ # parabola that peaks at `peak` pixels halfway through `duration` seconds,
8
+ # then puts it back on the ground.
9
+ #
10
+ # The height is written to Node2D#elevation, never to `y`. In a top-down view
11
+ # a jump is a *drawing* offset: the sprite arcs above the spot the character
12
+ # stands on, while that spot, and every collider and camera reading it, stays
13
+ # on the ground. So a hop does not carry anyone over a wall. What it
14
+ # crosses is the game's decision, made by reading `airborne?`.
15
+ #
16
+ # node.add_component(Engine::Components::Hop.new(peak: 18, duration: 0.5))
17
+ #
18
+ # The arc is a function of the time accumulated in `update`, not of a clock,
19
+ # so a paused node hangs in the air and a spec can ask for the height at 0.25s.
20
+ # It starts on the action's press edge, so holding the button hops once.
21
+ # `action: nil` leaves only #jump, for something that is not a player.
22
+ class Hop < Engine::Component
23
+ attr_reader :peak, :duration, :height
24
+
25
+ def initialize(peak:, duration:, action: :jump)
26
+ super()
27
+ raise ArgumentError, "peak must be positive, got #{peak.inspect}" unless peak.positive?
28
+ raise ArgumentError, "duration must be positive, got #{duration.inspect}" unless duration.positive?
29
+
30
+ @peak = peak
31
+ @duration = duration
32
+ @action = action
33
+ @height = 0.0
34
+ @elapsed = 0.0
35
+ @airborne = false
36
+ end
37
+
38
+ # Attaching lands the node, so a pooled node reused mid-hop starts on the ground.
39
+ def on_attach = land
40
+
41
+ def airborne? = @airborne
42
+
43
+ # Leave the ground. Does nothing while already off it; `airborne?` says which.
44
+ def jump
45
+ return if @airborne
46
+
47
+ @airborne = true
48
+ @elapsed = 0.0
49
+ end
50
+
51
+ def control(actions)
52
+ jump if @action && actions.pressed?(@action)
53
+ end
54
+
55
+ def update(dt)
56
+ return unless @airborne
57
+
58
+ @elapsed += dt
59
+ return land if @elapsed >= @duration
60
+
61
+ @height = 4.0 * @peak * @elapsed * (@duration - @elapsed) / (@duration * @duration)
62
+ node.elevation = @height
63
+ end
64
+
65
+ private
66
+
67
+ def land
68
+ @airborne = false
69
+ @elapsed = 0.0
70
+ @height = 0.0
71
+ node.elevation = 0
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # A stable name for one node, so something outside the tree can refer to it.
7
+ #
8
+ # sheep.add_component(Identity.new(id: 7))
9
+ # Identity.of(sheep) # => 7
10
+ #
11
+ # ## What it is for, and what does not need it
12
+ #
13
+ # Most saving needs no identity at all. A scene is a recipe and a save file
14
+ # is state: the scene rebuilds itself identically, and the save supplies the
15
+ # few facts that differ. A *singular* thing needs no id because the variable
16
+ # holding it is one — `@dog` is set where the dog is built and written
17
+ # straight to on load. *Interchangeable* things need none either, because
18
+ # their order will do: an array of positions restored in order is correct
19
+ # precisely when swapping two of them changes nothing observable.
20
+ #
21
+ # This is for the case those two do not cover:
22
+ #
23
+ # - **members of a collection that can die**, where each survivor keeps
24
+ # state of its own. An array index stops meaning anything the first time
25
+ # the middle of the list is removed;
26
+ # - **a reference from one saved thing to another** — a dog chasing a
27
+ # particular sheep. A node reference cannot be written to a file, and this
28
+ # is what it is written *as*.
29
+ #
30
+ # The second is the one that genuinely forces ids. A collection could always
31
+ # be respawned from its own records; a reference between two of them could
32
+ # not.
33
+ #
34
+ # ## The engine supplies the mechanism, a game supplies the meaning
35
+ #
36
+ # What gets an id, what the ids are, and how they are handed out are the
37
+ # game's business — the same division as `Timer`, which counts down without
38
+ # an opinion about what happens next. Two things follow from that, and both
39
+ # are the game's to get right:
40
+ #
41
+ # **Ids must be unique among the things that can refer to each other.** This
42
+ # component checks nothing; a duplicate is a save that restores the wrong
43
+ # object, silently.
44
+ #
45
+ # **The allocator belongs in the save.** A counter that restarts at 1 on
46
+ # load will reissue ids that the restored objects are already using, and the
47
+ # collision surfaces later as a reference pointing at the wrong thing. Save
48
+ # the next id alongside the objects and restore it too — it is one number,
49
+ # and forgetting it is the classic way this goes wrong.
50
+ class Identity < Engine::Component
51
+ attr_reader :id
52
+
53
+ def initialize(id:)
54
+ super()
55
+ raise ArgumentError, 'an identity needs an id' if id.nil?
56
+
57
+ @id = id
58
+ end
59
+
60
+ # The id of a node, or nil for one that carries no identity.
61
+ #
62
+ # This is the direction that matters at save time: a component holding a
63
+ # *node* — `Targeting#target` is the worked example — has to turn it into
64
+ # something writable, and it has only the node to go on. Going the other
65
+ # way is a game's own lookup, because only the game knows which
66
+ # collection to search.
67
+ def self.of(node)
68
+ node&.get_component(self)&.id
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # What every component that moves its node has in common: a step computed some way
7
+ # of its own, landing either straight on the node or against whatever may stop it.
8
+ # CharacterBody, Velocity and PathFollow are the three, and they are three classes
9
+ # because walking an intent, integrating a velocity and following a path are three
10
+ # different jobs. What they share is what happens *after* a step is computed, and
11
+ # that is this class.
12
+ #
13
+ # A mover fills in one private hook, `take_step(dt)`, and calls `apply_move(dx, dy)`
14
+ # from it. `update` is not for overriding: it opens the step, calls the hook and
15
+ # reports what stopped being in the way, so no mover can forget either edge.
16
+ #
17
+ # **Why a base class, and not a sibling component or a Node2D method.** A separate
18
+ # `Blocking` component that movers write through was tried, and it is order-dependent:
19
+ # closing the step has to happen after the mover's step, and a sibling can only do
20
+ # that from its own `update`, which runs wherever it sits in the component list — two
21
+ # add orders fired on_unblocked on two different ticks. A `Node2D#move_by` owning
22
+ # `blocked_by` is order-free, but puts collision into the base of every node, HUDs and
23
+ # menus included. A base class is order-free and touches only what moves.
24
+ #
25
+ # ## What stops a step is declared, not subclassed
26
+ #
27
+ # `blocked_by:` lists what a step may not pass through, and the default is nothing:
28
+ # the mover writes the node's position directly and needs no collider and no system
29
+ # on the scene. That free step is not quite free: `update` → `take_step` →
30
+ # `apply_move` is one dispatch more than a component writing `node.x` itself, which
31
+ # measured about 12% on a bare Velocity step (tens of nanoseconds). Inlining the free
32
+ # write into each subclass wins it back, at the cost of every mover copying
33
+ # `apply_move`'s free branch and reading this class's ivars — not taken.
34
+ #
35
+ # Velocity.new(vx: 120) # flies wherever it points
36
+ # Velocity.new(vx: 120, blocked_by: [:wall]) # stops flush against a :wall collider
37
+ # CharacterBody.new(speed: 80, blocked_by: [:tiles]) # slides along the map's solid tiles
38
+ # CharacterBody.new(speed: 80, blocked_by: %i[tiles npc]) # ...and does not walk through NPCs
39
+ # CharacterBody.new(speed: 80, blocked_by: %i[npc bounds]) # ...and cannot leave the world
40
+ #
41
+ # Two names are reserved: **`:tiles`** is the scene's TileWorld, and **`:bounds`** is the
42
+ # edge of the region the scene's WorldBounds describes. **Every other name is a collider
43
+ # layer**, resolved against the scene's CollisionWorld: a mover declaring `:npc` is
44
+ # stopped by any BoxCollider whose `layer` is `:npc`, flush against its edge, exactly the
45
+ # way a solid tile stops it. A layer that is empty, or whose colliders all leave, is not
46
+ # an error — the declaration says what *may* stop this mover, not what does.
47
+ #
48
+ # `:bounds` is declared rather than automatic, and a mover that does not declare it
49
+ # leaves the world. Stopping at the edge is one of three responses to it, beside
50
+ # ScreenWrap and DespawnOffscreen, and a node may carry only one — declaring `:bounds`
51
+ # beside either raises at attach (WorldBounds.one_response!).
52
+ #
53
+ # Blocking is box-versus-box: a CircleCollider on a declared layer reports its contacts
54
+ # as usual and stops nothing, and a mover that declares anything needs a BoxCollider of
55
+ # its own.
56
+ #
57
+ # The two are not alternatives. `blocked_by` and `on_hit` answer different questions —
58
+ # what may I pass through, and what am I touching — and a flush-blocked pair does not
59
+ # overlap, so a mover that must both stop and react needs both.
60
+ #
61
+ # A declaration this scene cannot honour raises at on_attach rather than quietly
62
+ # falling back to free movement — a mover passing through walls looks like a collision
63
+ # bug, and the cause would be a scene three files away that never mounted the system.
64
+ #
65
+ # ## A blocked step slides
66
+ #
67
+ # Where a step lands is decided by Engine::CollisionSystem, which resolves X and then Y,
68
+ # so a diagonal push into a wall keeps the component that is still free. That is the
69
+ # right feel for a character and the wrong one for a bullet — and a bullet does not
70
+ # need a different resolver for it, it needs to react: `on_blocked` fires on the step
71
+ # it hits, and a bullet that queue-frees itself there is gone before it slides anywhere.
72
+ # One that bounces reads which axis was stopped and turns that half of its velocity:
73
+ #
74
+ # velocity = Velocity.new(vx: 120, vy: 80, blocked_by: %i[wall bounds])
75
+ # velocity.on_blocked do |_by, axis|
76
+ # velocity.vx = -velocity.vx unless axis == :y
77
+ # velocity.vy = -velocity.vy unless axis == :x
78
+ # end
79
+ #
80
+ # ## The shape has one owner, and it is not this
81
+ #
82
+ # A blocked step is resolved against the node's **collider** box, read from the
83
+ # sibling BoxCollider (FeetCollider is the one a walking character wants). The box is
84
+ # therefore given once, to the component that *is* a shape, and the same rectangle both
85
+ # stops the step and reports contacts — there is nothing to hand from one component to
86
+ # the other and nothing to keep in sync.
87
+ class Mover < Engine::Component
88
+ # The two edges of being stopped: on_blocked on the step this mover starts being
89
+ # stopped by something, on_unblocked on the step it stops. Each fires once per
90
+ # blocker, so a handler may spend a life or play a sound — the spiky ball that
91
+ # both stops the player and hurts them is the two signals plus an on_hit.
92
+ #
93
+ # The listener gets whatever stopped the step and reads its #layer and #node, so
94
+ # one handler covers every kind: a collider answers its own layer and its owning
95
+ # node, the map's solid tiles answer :tiles and nil (Engine::TileBlockers::TILES),
96
+ # and the world's edge answers :bounds and nil.
97
+ #
98
+ # on_blocked also says which axis of the step it stopped — :x, :y, or :both when one
99
+ # blocker stopped the two at once — which is what a bounce branches on. A listener
100
+ # that names only the blocker, `{ |by| ... }`, never sees it: a block drops the
101
+ # arguments it does not name.
102
+ #
103
+ # on_unblocked has no axis, because what ends is a blocker stopping this mover, not
104
+ # an axis. A blocker that stopped x on one step and y on the next was in the way the
105
+ # whole time, and ends once.
106
+ signal :on_blocked, Engine::Signal.define(:by, :axis)
107
+ signal :on_unblocked, Engine::Signal.define(:by)
108
+
109
+ TILES = :tiles
110
+ BOUNDS = :bounds
111
+ RESERVED = [TILES, BOUNDS].freeze
112
+
113
+ def initialize(blocked_by: [])
114
+ super()
115
+ @blocked_by = Array(blocked_by)
116
+ @collider = nil
117
+ @collision = nil
118
+ @last_move_blocked = false
119
+ @stopped_by = Engine::ContactSet.new
120
+ end
121
+
122
+ # Resolve each declared blocker and build the resolver that runs them, once the node
123
+ # is in the tree and both the scene's systems and this node's other components are
124
+ # reachable.
125
+ #
126
+ # **The resolver is the mover's own**, rather than something borrowed off the scene.
127
+ # It has to be: a source over other colliders holds this mover's collider and this
128
+ # mover's layer list, so two movers declaring different `blocked_by` cannot share
129
+ # one — and a scene may mount a CollisionWorld with no TileWorld at all, so there is
130
+ # not always a scene-level resolver to borrow in the first place. What is shared is
131
+ # what can be: the TileWorld's own source is borrowed, not rebuilt.
132
+ #
133
+ # Nothing here runs on a frame. The list is built once, and a step only walks it.
134
+ #
135
+ # A subclass that needs its own attach work calls `super` first — PathFollow does,
136
+ # to place its node before walking.
137
+ def on_attach
138
+ @stopped_by.reset
139
+ return if @blocked_by.empty?
140
+
141
+ WorldBounds.one_response!(node) if blocked_by?(BOUNDS)
142
+
143
+ @collider = require_sibling(BoxCollider)
144
+ @collision = Engine::CollisionSystem.new(blockers: resolve_blockers)
145
+ end
146
+
147
+ # Take this step, then report what stopped being in the way.
148
+ #
149
+ # **The set advances once per update, not once per apply_move**, and that is what
150
+ # makes standing still an unblocking: a mover that stops pushing into something
151
+ # records nothing this step, so what it was pressing against ends and on_unblocked
152
+ # fires. It also keeps the bookkeeping where a subclass cannot lose it — a mover
153
+ # that resolves a step in several moves, or overrides apply_move, still opens the
154
+ # step once and still reports its edges.
155
+ def update(dt)
156
+ return take_step(dt) unless @collision
157
+
158
+ @stopped_by.begin_frame
159
+ take_step(dt)
160
+ @stopped_by.each_ended { on_unblocked_signal.emit(it) }
161
+ end
162
+
163
+ # Which way this mover's step is going, each axis in -1..1, and 0, 0 when it is not
164
+ # trying to move. A facing rather than a velocity: a mover pressed into a wall still
165
+ # heads into it, so an AnimatedSprite keeps walking against the wall rather than
166
+ # standing. Each subclass answers from what its step is computed out of.
167
+ def heading_x = 0.0
168
+ def heading_y = 0.0
169
+
170
+ # Whether `name` is one of the things this mover declared it may be stopped by.
171
+ def blocked_by?(name) = @blocked_by.include?(name)
172
+
173
+ # Where a step lands. Public, and kept separate from `take_step`, so a mover that
174
+ # resolves a step some other way — a platformer's CharacterBody, with gravity and a
175
+ # jump — inherits everything around it rather than restating it.
176
+ #
177
+ # The branch is on what was declared rather than on a subclass: an unblocked mover
178
+ # writes straight to the node, and a blocked one hands *itself* to its resolver as
179
+ # the actor being moved (see the adapter below).
180
+ def apply_move(dx, dy)
181
+ unless @collision
182
+ node.x += dx
183
+ node.y += dy
184
+ return
185
+ end
186
+
187
+ @collision.move(self, dx, dy)
188
+ blocked_x = @collision.blocked_x
189
+ blocked_y = @collision.blocked_y
190
+ @last_move_blocked = !(blocked_x.nil? && blocked_y.nil?)
191
+ if blocked_x.equal?(blocked_y)
192
+ record_blocker(blocked_x, :both)
193
+ else
194
+ record_blocker(blocked_x, :x)
195
+ record_blocker(blocked_y, :y)
196
+ end
197
+ end
198
+
199
+ # The actor adapter CollisionSystem#move drives: it reads x/y/collision_box, works
200
+ # out where the step lands, and writes the resolved position back. The box is the
201
+ # sibling collider's — the node's one shape, so retuning `collider.box` retunes
202
+ # what a step collides with.
203
+ #
204
+ # ## The adapter is in world space
205
+ #
206
+ # x and y are the node's **world** position, because that is the space everything
207
+ # else about collision is already in: the tile grid is a world-coordinate grid, and
208
+ # BoxCollider#aabb_x reports `node.world_x + box.offset_x`. A mover under an offset
209
+ # ancestor that reported its local position would resolve against a map shifted by
210
+ # the ancestor, and would be compared against other colliders in a different frame
211
+ # entirely.
212
+ #
213
+ # Writing goes back through Node2D#world_x= / #world_y=, which turn the resolved world
214
+ # position into the local one the node actually lives in.
215
+ #
216
+ # A mover under a rotated ancestor is still outside what an axis-aligned box supports
217
+ # (docs/api/components.md: a thing that spins wants a circle) — the position it lands
218
+ # at is exact, but the box it was resolved with does not turn with the frame.
219
+ def collision_box = @collider.box
220
+ def x = node.world_x
221
+ def y = node.world_y
222
+
223
+ def x=(value)
224
+ node.world_x = value
225
+ end
226
+
227
+ def y=(value)
228
+ node.world_y = value
229
+ end
230
+
231
+ private
232
+
233
+ def take_step(_dt) = nil
234
+
235
+ def blocking? = !@collision.nil?
236
+
237
+ def last_move_blocked? = @last_move_blocked
238
+
239
+ def record_blocker(by, axis)
240
+ return if by.nil? || @stopped_by.touching?(by)
241
+
242
+ started = @stopped_by.started?(by)
243
+ @stopped_by.add(by)
244
+ on_blocked_signal.emit(by:, axis:) if started
245
+ end
246
+
247
+ def resolve_blockers
248
+ sources = []
249
+ sources << tile_blockers if @blocked_by.include?(TILES)
250
+ sources << bounds_blockers if @blocked_by.include?(BOUNDS)
251
+ layers = @blocked_by.reject { RESERVED.include?(it) }
252
+ sources << actor_blockers(layers) unless layers.empty?
253
+ sources
254
+ end
255
+
256
+ def tile_blockers
257
+ world = node.system(TileWorld) ||
258
+ raise("#{mover_name} is blocked_by :tiles, and the scene has no TileWorld " \
259
+ 'system to resolve a step against. Mount one, or drop blocked_by for ' \
260
+ 'a mover with nothing to collide with.')
261
+ world.blockers
262
+ end
263
+
264
+ def bounds_blockers
265
+ bounds = node.system(WorldBounds) ||
266
+ raise("#{mover_name} is blocked_by :bounds, and the scene has no world " \
267
+ 'bounds to stop at. Mount a World (or a TileWorld, which is one), or ' \
268
+ 'drop :bounds for a mover that may leave the world.')
269
+ Engine::BoundsBlockers.new(bounds: bounds)
270
+ end
271
+
272
+ def actor_blockers(layers)
273
+ world = node.system(CollisionWorld) ||
274
+ raise("#{mover_name} is blocked_by #{layers.map(&:inspect).join(', ')}, which " \
275
+ 'names collider layers, and the scene has no CollisionWorld system to ' \
276
+ 'find them in. Mount one, or drop those names for a mover that only ' \
277
+ 'the map stops.')
278
+ Engine::ActorBlockers.new(world: world, owner: @collider, layers: layers)
279
+ end
280
+
281
+ def mover_name = self.class.name&.split('::')&.last || self.class.inspect
282
+ end
283
+ end
284
+ end
285
+ end