rgame 0.2.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 (253) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -1
  3. data/README.md +67 -65
  4. data/docs/api/README.md +86 -82
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -165
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1032 -265
  10. data/docs/api/drawing.md +160 -160
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +149 -50
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +226 -148
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +397 -244
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +186 -73
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +395 -146
  23. data/docs/api/ui.md +883 -54
  24. data/docs/api/values.md +216 -33
  25. data/examples/assets/README.md +322 -0
  26. data/examples/assets/blip.ogg +0 -0
  27. data/examples/assets/glyphs.json +5 -0
  28. data/examples/assets/glyphs.png +0 -0
  29. data/examples/assets/hero.json +12 -0
  30. data/examples/assets/hero.png +0 -0
  31. data/examples/assets/icons.json +13 -0
  32. data/examples/assets/icons.png +0 -0
  33. data/examples/assets/music.ogg +0 -0
  34. data/examples/assets/skills.json +10 -0
  35. data/examples/assets/skills.png +0 -0
  36. data/examples/assets/tileset.png +0 -0
  37. data/examples/assets/tileset.tsx +65 -0
  38. data/examples/assets/town.tmx +26 -0
  39. data/examples/assets/ui.json +11 -0
  40. data/examples/assets/ui.png +0 -0
  41. data/examples/collision/locales/en.yml +8 -0
  42. data/examples/collision/main.rb +316 -0
  43. data/examples/collision_tiles/locales/en.yml +9 -0
  44. data/examples/collision_tiles/main.rb +274 -0
  45. data/examples/fullscreen/locales/en.yml +10 -0
  46. data/examples/fullscreen/main.rb +216 -0
  47. data/examples/game_menu/locales/en.yml +8 -0
  48. data/examples/game_menu/main.rb +170 -0
  49. data/examples/input_glyphs/locales/en.yml +14 -0
  50. data/examples/input_glyphs/main.rb +213 -0
  51. data/examples/jump_topdown/locales/en.yml +9 -0
  52. data/examples/jump_topdown/main.rb +178 -0
  53. data/examples/localization/locales/de.yml +12 -0
  54. data/examples/localization/locales/en.yml +13 -0
  55. data/examples/localization/main.rb +158 -0
  56. data/examples/menu_navigation/locales/en.yml +23 -0
  57. data/examples/menu_navigation/main.rb +365 -0
  58. data/examples/music/locales/en.yml +7 -0
  59. data/examples/music/main.rb +134 -0
  60. data/examples/pathfinding/locales/en.yml +17 -0
  61. data/examples/pathfinding/main.rb +298 -0
  62. data/examples/pooling/locales/en.yml +7 -0
  63. data/examples/pooling/main.rb +259 -0
  64. data/examples/quick_wheel/locales/en.yml +16 -0
  65. data/examples/quick_wheel/main.rb +184 -0
  66. data/examples/radial_menu/locales/en.yml +16 -0
  67. data/examples/radial_menu/main.rb +184 -0
  68. data/examples/save_load/locales/en.yml +11 -0
  69. data/examples/save_load/main.rb +207 -0
  70. data/examples/save_load_ids/locales/en.yml +11 -0
  71. data/examples/save_load_ids/main.rb +322 -0
  72. data/examples/scroll_map/locales/en.yml +4 -0
  73. data/examples/scroll_map/main.rb +140 -0
  74. data/examples/signals/locales/en.yml +6 -0
  75. data/examples/signals/main.rb +278 -0
  76. data/examples/skill_bar/locales/en.yml +14 -0
  77. data/examples/skill_bar/main.rb +159 -0
  78. data/examples/sound/locales/en.yml +6 -0
  79. data/examples/sound/main.rb +122 -0
  80. data/examples/split_screen/locales/en.yml +9 -0
  81. data/examples/split_screen/main.rb +304 -0
  82. data/examples/sprite/locales/en.yml +8 -0
  83. data/examples/sprite/main.rb +180 -0
  84. data/examples/timer/locales/en.yml +12 -0
  85. data/examples/timer/main.rb +273 -0
  86. data/examples/velocity/locales/en.yml +6 -0
  87. data/examples/velocity/main.rb +196 -0
  88. data/examples/walk/locales/en.yml +4 -0
  89. data/examples/walk/main.rb +99 -0
  90. data/exe/rgame +9 -0
  91. data/ext/rgame_core/app/app.c +33 -3
  92. data/ext/rgame_core/app/locale.c +67 -0
  93. data/ext/rgame_core/app/locale.h +28 -0
  94. data/ext/rgame_core/audio/audio.c +39 -2
  95. data/ext/rgame_core/example.rb +0 -49
  96. data/ext/rgame_core/extconf.rb +0 -125
  97. data/ext/rgame_core/include/rgame/core.h +38 -2
  98. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  99. data/ext/rgame_core/ruby/core_ext.c +30 -7
  100. data/ext/rgame_core/ruby/core_ext.h +3 -0
  101. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  102. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  103. data/ext/rgame_core/ruby/renderer_ext.c +20 -20
  104. data/ext/rgame_util/extconf.rb +2 -20
  105. data/ext/rgame_util/route_search.c +305 -0
  106. data/ext/rgame_util/route_search.h +86 -0
  107. data/ext/rgame_util/route_search_ext.c +150 -0
  108. data/ext/rgame_util/solid_grid.c +58 -0
  109. data/ext/rgame_util/solid_grid.h +49 -0
  110. data/ext/rgame_util/solid_grid_ext.c +161 -0
  111. data/ext/rgame_util/tile_sweep.c +164 -0
  112. data/ext/rgame_util/tile_sweep.h +62 -0
  113. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  114. data/ext/rgame_util/util_ext.c +3 -0
  115. data/ext/rgame_util/util_ext.h +15 -0
  116. data/lib/rgame/boot.rb +0 -10
  117. data/lib/rgame/cli/new_project.rb +139 -0
  118. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  119. data/lib/rgame/cli/templates/README.md.tt +93 -0
  120. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  121. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  122. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  123. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  124. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  125. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  126. data/lib/rgame/cli/templates/rspec.tt +2 -0
  127. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  128. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  129. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  130. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  131. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  132. data/lib/rgame/cli.rb +66 -0
  133. data/lib/rgame/core/app.rb +6 -44
  134. data/lib/rgame/core/asset_manager.rb +13 -31
  135. data/lib/rgame/core/audio.rb +37 -16
  136. data/lib/rgame/core/font.rb +0 -3
  137. data/lib/rgame/core/locale.rb +22 -0
  138. data/lib/rgame/core/nine_slice.rb +0 -21
  139. data/lib/rgame/core/renderer.rb +6 -63
  140. data/lib/rgame/core/sprite_sheet.rb +0 -3
  141. data/lib/rgame/core/tile_map_renderer.rb +2 -19
  142. data/lib/rgame/core/ui_atlas.rb +28 -13
  143. data/lib/rgame/core.rb +1 -8
  144. data/lib/rgame/engine/actor_blockers.rb +131 -0
  145. data/lib/rgame/engine/animation_set.rb +1 -0
  146. data/lib/rgame/engine/audio_director.rb +36 -6
  147. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  148. data/lib/rgame/engine/camera.rb +3 -3
  149. data/lib/rgame/engine/circle_collider.rb +4 -2
  150. data/lib/rgame/engine/collision_box.rb +26 -1
  151. data/lib/rgame/engine/collision_system.rb +110 -22
  152. data/lib/rgame/engine/component.rb +34 -10
  153. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  154. data/lib/rgame/engine/components/animated_sprite.rb +27 -25
  155. data/lib/rgame/engine/components/box_collider.rb +99 -0
  156. data/lib/rgame/engine/components/camera_follow.rb +6 -5
  157. data/lib/rgame/engine/components/character_body.rb +20 -61
  158. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  159. data/lib/rgame/engine/components/collision_world.rb +159 -31
  160. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  161. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  162. data/lib/rgame/engine/components/hop.rb +76 -0
  163. data/lib/rgame/engine/components/identity.rb +73 -0
  164. data/lib/rgame/engine/components/mover.rb +285 -0
  165. data/lib/rgame/engine/components/navigator.rb +145 -0
  166. data/lib/rgame/engine/components/path_follow.rb +123 -31
  167. data/lib/rgame/engine/components/player_controller.rb +5 -2
  168. data/lib/rgame/engine/components/pool.rb +1 -1
  169. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  170. data/lib/rgame/engine/components/sprite.rb +12 -6
  171. data/lib/rgame/engine/components/targeting.rb +9 -11
  172. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  173. data/lib/rgame/engine/components/tile_world.rb +41 -13
  174. data/lib/rgame/engine/components/timer.rb +1 -1
  175. data/lib/rgame/engine/components/velocity.rb +23 -7
  176. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  177. data/lib/rgame/engine/components/world.rb +133 -0
  178. data/lib/rgame/engine/contact_set.rb +74 -0
  179. data/lib/rgame/engine/culling.rb +4 -6
  180. data/lib/rgame/engine/debug_overlay.rb +5 -11
  181. data/lib/rgame/engine/i18n/plural.rb +45 -0
  182. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  183. data/lib/rgame/engine/i18n/template.rb +59 -0
  184. data/lib/rgame/engine/i18n.rb +276 -51
  185. data/lib/rgame/engine/input/action_mapper.rb +0 -26
  186. data/lib/rgame/engine/input/actions.rb +2 -8
  187. data/lib/rgame/engine/input/input_map.rb +39 -21
  188. data/lib/rgame/engine/nav_grid.rb +87 -0
  189. data/lib/rgame/engine/node2d.rb +248 -133
  190. data/lib/rgame/engine/path.rb +4 -6
  191. data/lib/rgame/engine/players.rb +6 -13
  192. data/lib/rgame/engine/presentation.rb +171 -0
  193. data/lib/rgame/engine/scene/scene_stack.rb +4 -4
  194. data/lib/rgame/engine/sealed_privates.rb +54 -0
  195. data/lib/rgame/engine/spatial_hash.rb +51 -19
  196. data/lib/rgame/engine/text.rb +194 -0
  197. data/lib/rgame/engine/tile_blockers.rb +63 -0
  198. data/lib/rgame/engine/tile_map.rb +2 -3
  199. data/lib/rgame/engine/tile_map_layer.rb +0 -2
  200. data/lib/rgame/engine/tileset.rb +2 -4
  201. data/lib/rgame/engine/timer.rb +2 -2
  202. data/lib/rgame/engine/ui/button.rb +248 -0
  203. data/lib/rgame/engine/ui/column.rb +20 -0
  204. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  205. data/lib/rgame/engine/ui/menu.rb +246 -71
  206. data/lib/rgame/engine/ui/navigation.rb +57 -0
  207. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  208. data/lib/rgame/engine/ui/option_button.rb +163 -0
  209. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  210. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  211. data/lib/rgame/engine/ui/pointing.rb +146 -0
  212. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  213. data/lib/rgame/engine/ui/ring.rb +55 -0
  214. data/lib/rgame/engine/ui/row.rb +21 -0
  215. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  216. data/lib/rgame/engine/ui/stack.rb +58 -0
  217. data/lib/rgame/engine/ui/stepping.rb +93 -0
  218. data/lib/rgame/engine/ui/text_button.rb +59 -0
  219. data/lib/rgame/engine/viewports.rb +2 -5
  220. data/lib/rgame/engine/world_view.rb +5 -4
  221. data/lib/rgame/engine.rb +31 -25
  222. data/lib/rgame/game.rb +99 -27
  223. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  224. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  225. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  226. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  227. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  228. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  229. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  230. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  231. data/lib/rgame/rubocop/default.yml +39 -0
  232. data/lib/rgame/rubocop/plugin.rb +45 -0
  233. data/lib/rgame/rubocop.rb +11 -0
  234. data/lib/rgame/util/color.rb +20 -24
  235. data/lib/rgame/util/controls.rb +14 -28
  236. data/lib/rgame/util/route_search.rb +27 -0
  237. data/lib/rgame/util/save_file.rb +107 -0
  238. data/lib/rgame/util/solid_grid.rb +37 -0
  239. data/lib/rgame/util/tensor.rb +0 -9
  240. data/lib/rgame/util/tile_sweep.rb +36 -0
  241. data/lib/rgame/util/z.rb +4 -14
  242. data/lib/rgame/util.rb +4 -3
  243. data/lib/rgame/version.rb +1 -1
  244. data/lib/rgame.rb +0 -15
  245. metadata +152 -11
  246. data/lib/rgame/engine/actor.rb +0 -53
  247. data/lib/rgame/engine/body.rb +0 -49
  248. data/lib/rgame/engine/cached_label.rb +0 -33
  249. data/lib/rgame/engine/input/player_controller.rb +0 -14
  250. data/lib/rgame/engine/matrix.rb +0 -32
  251. data/lib/rgame/engine/resettable.rb +0 -67
  252. data/lib/rgame/engine/tile_collision.rb +0 -78
  253. data/lib/rgame/engine/ui/menu_item.rb +0 -84
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The registered colliders as a blocker source: the broadphase answers "what is near
6
+ # this box", and the same snapping arithmetic TileBlockers runs against a tile edge
7
+ # runs against a collider's edge instead. Pure — it needs no scene, no node and no
8
+ # tree, only something answering `query_box` and `reindex`.
9
+ #
10
+ # It answers the blocker-source question — "where does this box land moving dx" — that
11
+ # Engine::CollisionSystem asks of every source it holds; see that class's header for
12
+ # the protocol and for how the most restrictive answer wins across sources. This is
13
+ # the source that makes two actors stop each other the way a wall stops one.
14
+ #
15
+ # ## Per body, unlike the other sources
16
+ #
17
+ # A TileBlockers is the same grid for everybody, so one is shared by every actor on
18
+ # the map. This one takes an `owner` and a layer list, so it belongs to exactly one
19
+ # body: two actors declaring different `blocked_by` cannot share one. That is why
20
+ # Components::Mover builds its own resolver rather than borrowing the scene's.
21
+ #
22
+ # ## Three things a grid does not need
23
+ #
24
+ # - **The most restrictive candidate wins.** Several colliders may be in the way, so
25
+ # this keeps a running minimum (maximum, going the other way). That is
26
+ # dup-insensitive, which matters because the broadphase may offer the same collider
27
+ # once per cell the two share.
28
+ # - **An overlap that already exists is not resolved.** A step is blocked only if it
29
+ # *crosses* an edge the mover was on the near side of. Two actors that start
30
+ # overlapping stay overlapping and neither is teleported out — blocking stops the
31
+ # mover, it never moves the thing it hit.
32
+ # - **Boxes only.** A CircleCollider on a blocked layer is skipped, and still reports
33
+ # on_hit exactly as it does now. It is a documented limit rather than a raise at
34
+ # attach: layer membership is a runtime fact, so a check at attach would catch only
35
+ # the circles that already exist and quietly miss the ones spawned later.
36
+ class ActorBlockers
37
+ # `world` answers query_box/reindex (a Components::CollisionWorld); `owner` is the
38
+ # mover's own collider, excluded by identity so a body is never stopped by itself;
39
+ # `layers` is the set of collider layers that may stop it.
40
+ def initialize(world:, owner:, layers:)
41
+ @world = world
42
+ @owner = owner
43
+ @layers = Array(layers)
44
+ @blocker = nil
45
+ end
46
+
47
+ # Who produced the edge the last resolve_x / resolve_y returned, or nil when that
48
+ # axis was free. Per-move state, and safe because this source belongs to one body,
49
+ # which reads it inside its own update.
50
+ attr_reader :blocker
51
+
52
+ # Where the box's left edge lands moving dx, snapping flush against the nearest
53
+ # collider it would cross into.
54
+ def resolve_x(x, y, w, h, dx)
55
+ @blocker = nil
56
+ nx = x + dx
57
+ return nx if dx.zero?
58
+
59
+ sweep_x = dx.negative? ? nx : x
60
+ @best = nx
61
+ @world.query_box(sweep_x, y, w + dx.abs, h) do |other|
62
+ next unless candidate?(other)
63
+ next unless y < other.aabb_y + other.aabb_h && other.aabb_y < y + h
64
+
65
+ if dx.positive?
66
+ edge = other.aabb_x
67
+ take_min(edge - w, other) if x + w <= edge && nx + w > edge
68
+ else
69
+ edge = other.aabb_x + other.aabb_w
70
+ take_max(edge, other) if x >= edge && nx < edge
71
+ end
72
+ end
73
+ @best
74
+ end
75
+
76
+ # The mirror of resolve_x. CollisionSystem feeds it the resolved x, which is what
77
+ # makes a diagonal push into a wall slide along it.
78
+ def resolve_y(x, y, w, h, dy)
79
+ @blocker = nil
80
+ ny = y + dy
81
+ return ny if dy.zero?
82
+
83
+ sweep_y = dy.negative? ? ny : y
84
+ @best = ny
85
+ @world.query_box(x, sweep_y, w, h + dy.abs) do |other|
86
+ next unless candidate?(other)
87
+ next unless x < other.aabb_x + other.aabb_w && other.aabb_x < x + w
88
+
89
+ if dy.positive?
90
+ edge = other.aabb_y
91
+ take_min(edge - h, other) if y + h <= edge && ny + h > edge
92
+ else
93
+ edge = other.aabb_y + other.aabb_h
94
+ take_max(edge, other) if y >= edge && ny < edge
95
+ end
96
+ end
97
+ @best
98
+ end
99
+
100
+ # Called by CollisionSystem#move once the resolved position is written back, with
101
+ # the box the step started from. Re-bucketing the owner here is what keeps the index
102
+ # exact for whoever resolves next this step; see CollisionWorld#reindex.
103
+ def moved(_actor, from_x, from_y, from_w, from_h)
104
+ @world.reindex(@owner, from_x, from_y, from_w, from_h)
105
+ end
106
+
107
+ private
108
+
109
+ def take_min(landed, other)
110
+ return unless landed < @best
111
+
112
+ @best = landed
113
+ @blocker = other
114
+ end
115
+
116
+ def take_max(landed, other)
117
+ return unless landed > @best
118
+
119
+ @best = landed
120
+ @blocker = other
121
+ end
122
+
123
+ def candidate?(other)
124
+ !other.equal?(@owner) &&
125
+ other.is_a?(Components::BoxCollider) &&
126
+ @layers.include?(other.layer) &&
127
+ !other.node.freed?
128
+ end
129
+ end
130
+ end
131
+ end
@@ -15,6 +15,7 @@ module RGame
15
15
  # flip_x – draw mirrored horizontally
16
16
  class AnimationSet
17
17
  Anim = Struct.new(:row, :first_col, :frames, :frame_secs, :flip_x)
18
+ private_constant :Anim
18
19
 
19
20
  def initialize(animations)
20
21
  @anims = animations.each_with_object({}) do |(name, a), table|
@@ -15,13 +15,43 @@ module RGame
15
15
  @audio = audio
16
16
  end
17
17
 
18
- # Subscribe to the audio events on a hub (the global EventDispatcher by default,
19
- # but any object with #on). Returns self so callers can build-and-subscribe in one
20
- # line. The director lives for the whole app, so nothing unsubscribes it.
18
+ # Subscribe to the audio events on a hub (the global AudioBus by default, but
19
+ # any object exposing the same three signals). Returns self, so a caller can
20
+ # build and subscribe in one expression.
21
+ #
22
+ # **The handles are kept, so `unsubscribe` can take them back off.** AudioBus
23
+ # is a module — one hub for the whole process — and it holds its listeners
24
+ # until something removes them. A connected director is therefore reachable
25
+ # from a global, and it keeps the audio device alive, along with the asset
26
+ # manager that device resolves paths through and the App that manager loads
27
+ # images for, down to the window.
28
+ #
29
+ # A game never notices, because its App lives as long as the process does.
30
+ # Anywhere an App is created and discarded — a spec, a tool,
31
+ # `tools/drive_test_project.rb` — it is the difference between a window
32
+ # closing and a window leaking. RGame::Game subscribes a director when it
33
+ # starts and releases it when the loop ends, which is why a game calls
34
+ # neither method itself.
21
35
  def subscribe(audio_bus = Engine::AudioBus)
22
- audio_bus.on_play_sound.connect { @audio.play_sound(it) }
23
- audio_bus.on_play_music.connect { @audio.play_music(it) }
24
- audio_bus.on_stop_music.connect { @audio.stop_music }
36
+ @bus = audio_bus
37
+ @handles = [
38
+ audio_bus.on_play_sound.connect { @audio.play_sound(it) },
39
+ audio_bus.on_play_music.connect { @audio.play_music(it) },
40
+ audio_bus.on_stop_music.connect { @audio.stop_music }
41
+ ]
42
+ self
43
+ end
44
+
45
+ # Takes this director's listeners back off the hub. Safe to call twice, and
46
+ # safe on a director that was never subscribed — releasing something is not
47
+ # a good place to be strict about how many times it happens.
48
+ def unsubscribe
49
+ return self if @handles.nil?
50
+
51
+ @bus.on_play_sound.disconnect(@handles[0])
52
+ @bus.on_play_music.disconnect(@handles[1])
53
+ @bus.on_stop_music.disconnect(@handles[2])
54
+ @handles = nil
25
55
  self
26
56
  end
27
57
  end
@@ -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
@@ -7,7 +7,7 @@ module RGame
7
7
  # transform, never baked into a node.
8
8
  #
9
9
  # camera = Camera.new(world_width: map.pixel_width, world_height: map.pixel_height)
10
- # camera.center_on(player.abs_x, player.abs_y) # in update, every tick
10
+ # camera.center_on(player.world_x, player.world_y) # in update, every tick
11
11
  # camera.resolve(view_width, view_height) # at draw, for one viewport
12
12
  # camera.x, camera.y # the offset to translate by
13
13
  #
@@ -66,10 +66,10 @@ module RGame
66
66
  private
67
67
 
68
68
  def clamp(value, world_size, view_size)
69
- return value.to_f if world_size.nil? # unbounded: follow exactly
69
+ return value.to_f if world_size.nil?
70
70
 
71
71
  max = world_size - view_size
72
- return 0.0 if max <= 0 # world smaller than the view -> pin to origin
72
+ return 0.0 if max <= 0
73
73
 
74
74
  value.clamp(0.0, max.to_f)
75
75
  end
@@ -9,12 +9,14 @@ module RGame
9
9
  class CircleCollider
10
10
  attr_reader :radius
11
11
 
12
- # Squared-distance overlap test (no sqrt): true when two circles touch.
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.
13
15
  def self.overlap?(x1, y1, r1, x2, y2, r2)
14
16
  dx = x2 - x1
15
17
  dy = y2 - y1
16
18
  sum = r1 + r2
17
- (dx * dx) + (dy * dy) <= sum * sum
19
+ (dx * dx) + (dy * dy) < sum * sum
18
20
  end
19
21
 
20
22
  def initialize(radius:)
@@ -5,6 +5,10 @@ module RGame
5
5
  # A character's collision rectangle, expressed as an offset + size relative to
6
6
  # the actor's top-left origin (the sprite's top-left). Decoupled from sprite
7
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.
8
12
  class CollisionBox
9
13
  attr_reader :offset_x, :offset_y, :width, :height
10
14
 
@@ -18,7 +22,28 @@ module RGame
18
22
  )
19
23
  end
20
24
 
21
- def initialize(offset_x:, offset_y:, width:, height:)
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)
22
47
  @offset_x = offset_x
23
48
  @offset_y = offset_y
24
49
  @width = width
@@ -3,41 +3,129 @@
3
3
  module RGame
4
4
  module Engine
5
5
  # The actor-facing collision system: moves any actor by a delta, resolving its
6
- # *collision box* (not its sprite) against the tiles via TileCollision, then
7
- # clamping the box inside the world as a backstop. Reusable by the player and
8
- # any NPC.
6
+ # *collision box* (not its sprite) against every blocker source it holds. Reusable by
7
+ # the player and any NPC.
9
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.
10
52
  class CollisionSystem
11
- def initialize(tile_collision:, world_width:, world_height:)
12
- @tiles = tile_collision
13
- @world_width = world_width
14
- @world_height = world_height
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
15
62
  end
16
63
 
17
64
  # Move `actor` by (dx, dy), writing the resolved position back to it.
18
65
  def move(actor, dx, dy)
19
66
  box = actor.collision_box
20
- # Read the box AABB into locals directly rather than via box.aabb, which would
21
- # allocate an Array on this per-frame path.
22
- bx = actor.x + box.offset_x
23
- by = actor.y + box.offset_y
67
+ from_x = actor.x + box.offset_x
68
+ from_y = actor.y + box.offset_y
24
69
  bw = box.width
25
70
  bh = box.height
26
71
 
27
- bx = @tiles.resolve_x(bx, by, bw, bh, dx)
28
- by = @tiles.resolve_y(bx, by, bw, bh, dy)
29
-
30
- # Clamp the box inside the world. Floor each upper bound at 0 without a [span, 0]
31
- # array (this runs per actor per frame).
32
- max_x = @world_width - bw
33
- max_x = 0 if max_x.negative?
34
- max_y = @world_height - bh
35
- max_y = 0 if max_y.negative?
36
- bx = bx.clamp(0.0, max_x.to_f)
37
- by = by.clamp(0.0, max_y.to_f)
72
+ bx = resolve_x(from_x, from_y, bw, bh, dx)
73
+ by = resolve_y(bx, from_y, bw, bh, dy)
38
74
 
39
75
  actor.x = bx - box.offset_x
40
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
41
129
  end
42
130
  end
43
131
  end
@@ -4,6 +4,7 @@ module RGame
4
4
  module Engine
5
5
  class Component
6
6
  extend Signal::DSL
7
+ extend SealedPrivates
7
8
 
8
9
  attr_accessor :node
9
10
 
@@ -18,6 +19,39 @@ module RGame
18
19
  def on_attach; end
19
20
  def on_detach; end
20
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
+
21
55
  def control(actions); end
22
56
  def update(dt); end
23
57
  def draw(renderer, view); end
@@ -25,16 +59,6 @@ module RGame
25
59
  # Container components (e.g. SceneStack) that hold nodes off the normal child
26
60
  # list override this to forward the deferred-free sweep into them.
27
61
  def sweep_freed; end
28
-
29
- # `control` above receives **one player's** Actions — the actions of
30
- # whoever owns this component's node — which is what a component wants,
31
- # since it belongs to exactly one node.
32
- #
33
- # A container component holding a whole subtree needs the input *source*
34
- # instead, so the nodes inside it can each resolve their own owner. There
35
- # is no hook for that on purpose: it is a system lookup like any other, so
36
- # such a component pulls `node.system(Players)` in `on_attach` and passes
37
- # that down. SceneStack is the worked example.
38
62
  end
39
63
  end
40
64
  end
@@ -19,7 +19,6 @@ module RGame
19
19
  def initialize(cooldowns)
20
20
  super()
21
21
  @cooldowns = cooldowns
22
- # action => seconds remaining until it may fire again (0 = ready).
23
22
  @timers = cooldowns.transform_values { 0.0 }
24
23
  end
25
24
 
@@ -3,21 +3,26 @@
3
3
  module RGame
4
4
  module Engine
5
5
  module Components
6
- # Draws a sprite-sheet animation for a walking actor, picking the animation from a
7
- # CharacterBody sibling's movement intent: walk_left/right/up/down while moving
8
- # (horizontal wins on a diagonal), stand when still. Owns its Animator + the pure
9
- # AnimationSet built from the sheet's animation table.
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.
10
14
  #
11
- # Like Sprite, it passes NO angle and draws at the node's *world* origin
12
- # (node.abs_x/abs_y): a WorldView ancestor wraps the draw in renderer.translated to
13
- # map world screen, so this component never touches the camera. `z` is the render
14
- # layer (kept as @layer, distinct from the node's transform z); it must sit between
15
- # the tile map's ground and canopy z bands, so canopies draw in front.
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.
16
21
  #
17
22
  # `sheet` is the asset's relative path. The component resolves it from the game's
18
23
  # asset manager on attach — via node.root.context.assets (the platform seam) — to
19
24
  # build its animation table and to size the node to the sprite's frame, so siblings
20
- # like CharacterBody can read node.width/height. The renderer resolves the same
25
+ # like FeetCollider can read node.width/height. The renderer resolves the same
21
26
  # symbol when drawing, so nothing is registered or passed in by hand.
22
27
  class AnimatedSprite < Engine::Component
23
28
  include Engine::Culling
@@ -33,34 +38,31 @@ module RGame
33
38
  @animator = Engine::Animator.new(Engine::AnimationSet.new(sheet.animations))
34
39
  node.width = sheet.frame_width
35
40
  node.height = sheet.frame_height
36
- @body = node.get_component(CharacterBody)
41
+ @mover = require_sibling(Mover)
37
42
  end
38
43
 
39
44
  def update(dt)
40
- @animator.play(walk_animation(@body.move_x, @body.move_y))
45
+ @animator.play(walk_animation(@mover.heading_x, @mover.heading_y))
41
46
  @animator.update(dt)
42
47
  end
43
48
 
44
- # Top-left anchored, and sized by the sheet's frame so the footprint
45
- # to cull against is exactly the node's box.
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.
46
52
  def draw(renderer, view)
47
- return if culled?(view, node.abs_x, node.abs_y, node.width, node.height)
53
+ lift = node.elevation
54
+ return if culled?(view, node.world_x, node.world_y - lift, node.width, node.height)
48
55
 
49
- # Read row/col/flip_x separately (not @animator.frame, which allocates an Array
50
- # every call) to keep the draw path allocation-free.
51
- renderer.sprite(@sheet, @animator.row, @animator.col, node.abs_x, node.abs_y,
56
+ renderer.sprite(@sheet, @animator.row, @animator.col, 0, -lift,
52
57
  flip_x: @animator.flip_x, z: @layer)
53
58
  end
54
59
 
55
60
  private
56
61
 
57
- # Horizontal movement wins over vertical, so a diagonal walk faces left/right.
58
- def walk_animation(move_x, move_y)
59
- if move_x.negative? then :walk_left
60
- elsif move_x.positive? then :walk_right
61
- elsif move_y.negative? then :walk_up
62
- elsif move_y.positive? then :walk_down
63
- else :stand
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
64
66
  end
65
67
  end
66
68
  end