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
@@ -8,59 +8,100 @@ module RGame
8
8
  # everything an actor needs from it — collision against the solid tiles, the world
9
9
  # bounds, and drawing the map through the scene's camera.
10
10
  #
11
- # Collision reuses Engine::CollisionSystem (TileCollision + a world-bounds clamp);
12
- # the tile solidity is whatever the map's tileset reports (baked per-tile in Tiled).
11
+ # **It does not resolve a step.** What it owns is the map's solid tiles as a blocker
12
+ # source (#blockers, an Engine::TileBlockers); the actor that wants to be stopped by
13
+ # them borrows it and resolves against it — see Components::Mover, which
14
+ # builds its own Engine::CollisionSystem out of the sources its `blocked_by` names.
15
+ # A mover may be blocked by tiles, by other actors, or by both, and only the mover
16
+ # knows which, so the resolver is the mover's and the grid is this system's. The tile
17
+ # solidity itself is whatever the map's tileset reports (baked per-tile in Tiled).
18
+ # The same solidity, viewed as a graph for planning routes, is #nav_grid.
13
19
  #
14
- # Drawing splits into two z bands so actors can sit between them: the below band
15
- # (ground, same-level detail) at GROUND_Z and the above band (canopies, roofs) at
16
- # OVERLAY_Z. Actors draw at a z in between (the renderer sorts by z, so the
17
- # draw-call order doesn't matter). The camera is centred by the scene before any
18
- # drawing.
20
+ # **Solidity is read from the map once**, into one Util::SolidGrid, the first time
21
+ # anything asks and #blockers, #nav_grid and #solid? all read that store, never the
22
+ # map. So they cannot disagree about a cell, and a resolve costs a byte lookup rather
23
+ # than a walk through the map's layers and tileset.
19
24
  #
20
- # It also owns the map's **animation clock**. Nothing below reads a wall clock —
25
+ # **It does not draw.** Drawing the map is RGame::Engine::TileMapLayer, one
26
+ # node per Tiled layer, mounted inside the WorldView so the map is drawn
27
+ # once per viewport like the rest of the world. This stays a system — the
28
+ # thing actors ask about collision and bounds — and a system that also
29
+ # drew was always the odd part of it.
30
+ #
31
+ # It owns the map's **animation clock**. Nothing below reads a wall clock —
21
32
  # see CLAUDE.md, "`draw` renders state; time enters through `update`" — so the
22
33
  # elapsed seconds animated tiles run on are accumulated here and handed down at
23
34
  # draw time. Stop calling `update` and the water freezes, which is what pausing
24
35
  # should look like.
25
36
  class TileWorld < Engine::Component
26
- GROUND_Z = 0
27
- OVERLAY_Z = 20
37
+ include WorldBounds
38
+
39
+ attr_reader :tilemap_id, :elapsed
28
40
 
29
- def initialize(map:, tilemap_id:, camera:)
41
+ # `cameras` are the cameras this map bounds — every player's, normally.
42
+ # A camera may not show past the world's edges, and this is what knows
43
+ # how big the world is; the cameras themselves are owned by players.
44
+ def initialize(map:, tilemap_id:, cameras: [])
30
45
  super()
31
46
  @map = map
32
47
  @tilemap_id = tilemap_id
33
- @camera = camera
34
48
  @elapsed = 0.0
35
- @collision = Engine::CollisionSystem.new(
36
- tile_collision: Engine::TileCollision.new(
37
- tile_width: map.tile_width, tile_height: map.tile_height,
38
- solid: ->(col, row) { map.solid_tile?(col, row) }
39
- ),
40
- world_width: map.pixel_width, world_height: map.pixel_height
41
- )
49
+ Array(cameras).each { |camera| bound(camera) }
50
+ end
51
+
52
+ # The map's solid tiles as a blocker source, for a body that declared
53
+ # `blocked_by: [:tiles]`. The same source every time, so every body on the map
54
+ # shares one.
55
+ def blockers
56
+ @blockers ||= Engine::TileBlockers.new(grid: solid_grid, tile_width: @map.tile_width,
57
+ tile_height: @map.tile_height)
42
58
  end
43
59
 
44
60
  def world_width = @map.pixel_width
45
61
  def world_height = @map.pixel_height
46
62
 
47
- # Move an actor (responds to x/y/collision_box) by (dx, dy), sliding along solids
48
- # and clamped inside the world. Delegates to the reused collision system.
49
- def move(actor, dx, dy) = @collision.move(actor, dx, dy)
63
+ # The size of one cell, in pixels what turns a world position into the cell
64
+ # #nav_grid plans over.
65
+ def tile_width = @map.tile_width
66
+ def tile_height = @map.tile_height
67
+
68
+ def layer_count = @map.layer_count
69
+
70
+ # The first layer Tiled flags `above`, or the layer count if none is —
71
+ # which is where TileMapLayer.mount leaves the gap for the actors, so a
72
+ # map with no flag puts them over everything. Read once at mount rather
73
+ # than per frame: which layers cover the actors is a fact about the
74
+ # scene's arrangement, and the arrangement is made once.
75
+ def first_above_layer
76
+ layer_count.times.find { |index| @map.above_layer?(index) } || layer_count
77
+ end
78
+
79
+ # Clamp a camera to this map's edges. Called for each camera the scene
80
+ # hands over, and again for one that arrives later (a player joining).
81
+ def bound(camera)
82
+ camera.world_width = @map.pixel_width
83
+ camera.world_height = @map.pixel_height
84
+ camera
85
+ end
50
86
 
51
- def solid?(col, row) = @map.solid_tile?(col, row)
87
+ def solid?(col, row) = solid_grid.solid?(col, row)
88
+
89
+ # The map's solid tiles as an Engine::NavGrid, for planning a route rather than
90
+ # resolving a step — over the same store #blockers reads, as a second view. Built on
91
+ # first ask, and the same grid every time after.
92
+ def nav_grid
93
+ @nav_grid ||= Engine::NavGrid.new(grid: solid_grid)
94
+ end
52
95
 
53
96
  # Advances the tile animations. Seconds, like every other duration here.
54
97
  def update(dt)
55
98
  @elapsed += dt
56
99
  end
57
100
 
58
- def draw(renderer)
59
- renderer.tilemap(@tilemap_id, @camera.x, @camera.y,
60
- @camera.viewport_width, @camera.viewport_height, elapsed: @elapsed)
61
- renderer.tilemap_overlay(@tilemap_id, @camera.x, @camera.y,
62
- @camera.viewport_width, @camera.viewport_height,
63
- z: OVERLAY_Z, elapsed: @elapsed)
101
+ private
102
+
103
+ def solid_grid
104
+ @solid_grid ||= Util::SolidGrid.build(@map.width, @map.height) { |col, row| @map.solid_tile?(col, row) }
64
105
  end
65
106
  end
66
107
  end
@@ -26,7 +26,7 @@ module RGame
26
26
  # When a node needs more than one (a spawn cadence and a wave cadence), give them
27
27
  # distinct `as:` names — a node holds one component per slot.
28
28
  class Timer < Engine::Component
29
- signal :on_timeout # emits no payload
29
+ signal :on_timeout
30
30
 
31
31
  def initialize(interval, repeating: true)
32
32
  super()
@@ -5,20 +5,36 @@ module RGame
5
5
  module Components
6
6
  # Integrates linear and angular velocity into the node's transform each step.
7
7
  # Free-moving entities (rocks, bullets) use it alone; a controller component
8
- # (or the node's own control hook) writes vx/vy/spin as intent. From Body#integrate.
9
- class Velocity < Engine::Component
8
+ # (or the node's own control hook) writes vx/vy/spin as intent.
9
+ #
10
+ # A Mover, so `blocked_by:` stops it the way it stops a CharacterBody — see Mover's
11
+ # header. A blocked step keeps `vx`/`vy` as they were: they are the intent, and what
12
+ # a stop should do to them (nothing, zero them, bounce) is the game's call, made in
13
+ # an `on_blocked` handler.
14
+ class Velocity < Mover
10
15
  attr_accessor :vx, :vy, :spin
11
16
 
12
- def initialize(vx: 0.0, vy: 0.0, spin: 0.0)
13
- super()
17
+ def initialize(vx: 0.0, vy: 0.0, spin: 0.0, blocked_by: [])
18
+ super(blocked_by: blocked_by)
14
19
  @vx = vx
15
20
  @vy = vy
16
21
  @spin = spin
17
22
  end
18
23
 
19
- def update(dt)
20
- node.x += @vx * dt
21
- node.y += @vy * dt
24
+ # The velocity scaled so its larger axis is ±1 — the direction, without the square
25
+ # root a unit vector would cost.
26
+ def heading_x = scale_to_heading(@vx)
27
+ def heading_y = scale_to_heading(@vy)
28
+
29
+ private
30
+
31
+ def scale_to_heading(axis)
32
+ larger = [@vx.abs, @vy.abs].max
33
+ larger.zero? ? 0.0 : axis / larger.to_f
34
+ end
35
+
36
+ def take_step(dt)
37
+ apply_move(@vx * dt, @vy * dt)
22
38
  node.angle += @spin * dt
23
39
  end
24
40
  end
@@ -7,6 +7,10 @@ module RGame
7
7
  # direction (one of eight, or idle) and holds it until a timer elapses — or until a
8
8
  # wall blocks it, at which point it re-rolls early instead of pushing into the wall.
9
9
  # The RNG is injected so behaviour is deterministic in tests.
10
+ #
11
+ # "Blocked" is measured as *the node did not move while intending to*, not asked of
12
+ # a collision world — so this works over any CharacterBody, and simply never fires
13
+ # for one whose steps always land.
10
14
  class WanderController < Engine::Component
11
15
  MOVED_EPS = 1e-6
12
16
  DIRECTIONS = [
@@ -19,11 +23,11 @@ module RGame
19
23
  @rng = rng
20
24
  @change_interval = change_interval
21
25
  @idle_chance = idle_chance
22
- @timer = 0.0 # rolls a direction on the first update
26
+ @timer = 0.0
23
27
  end
24
28
 
25
29
  def on_attach
26
- @body = node.get_component(CharacterBody)
30
+ @body = require_sibling(CharacterBody)
27
31
  @last_x = node.x
28
32
  @last_y = node.y
29
33
  end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Components
6
+ # How big the world is — the contract, not an implementation.
7
+ #
8
+ # Two systems answer it: `World` below, which is nothing *but* the answer,
9
+ # and `TileWorld`, which derives it from the map it parsed. A component
10
+ # that needs bounds asks for the contract — `node.system(WorldBounds)` —
11
+ # and gets whichever of the two the scene mounted. That works because
12
+ # Node2D#get_component matches with `is_a?`, which matches an included
13
+ # module as readily as a class.
14
+ #
15
+ # Naming the contract is what keeps the two from drifting: ScreenWrap does
16
+ # not know which kind of world it is wrapping inside, and must not need to.
17
+ #
18
+ # ## The bounds are in world coordinates
19
+ #
20
+ # The world runs from (0, 0) to (world_width, world_height) in **world**
21
+ # space — the space `Node2D#world_x` answers in, and the one the tile grid,
22
+ # the collision world and the camera already use. So everything that
23
+ # compares a node against these bounds compares `world_x`/`world_y`, never
24
+ # the node's local `x`/`y`: an entity grouped under an offset container is
25
+ # still inside the world when it is, whatever its position in the container.
26
+ # spec/support/shared_examples/a_world_edge_response.rb holds every reader to it.
27
+ module WorldBounds
28
+ def world_width = raise(NotImplementedError, "#{self.class} must define #world_width")
29
+ def world_height = raise(NotImplementedError, "#{self.class} must define #world_height")
30
+
31
+ # The bounds a component should use: whatever it was handed, falling back
32
+ # to the world system on `node` for either axis left nil. The single place
33
+ # that fallback is written, so the components sharing it cannot drift.
34
+ #
35
+ # Call it from `on_attach`, not `initialize` — a node has no scene to ask
36
+ # until it is in the tree, and a pooled entity is built long before it is.
37
+ def self.resolve(node, width, height)
38
+ return [width, height] if width && height
39
+
40
+ bounds = node.system(self)
41
+ if bounds.nil?
42
+ raise 'no world bounds in scope: mount a World (or TileWorld) system on the scene, ' \
43
+ 'or pass explicit width:/height:'
44
+ end
45
+
46
+ [width || bounds.world_width, height || bounds.world_height]
47
+ end
48
+
49
+ # Refuse a node carrying more than one response to the edge of the world:
50
+ # ScreenWrap, DespawnOffscreen, or a Mover declaring `blocked_by: [:bounds]`.
51
+ #
52
+ # Stopping at the edge, wrapping past it and being freed beyond it are three
53
+ # answers to one question, and no two of them mean anything together. Stop and
54
+ # wrap disagree about where the node ends up; wrap and despawn race on which
55
+ # margin is reached first; and the stop works on the collision box while the
56
+ # other two test the node's origin, so a box with an offset holds the origin
57
+ # just past the edge and the other response fires on a node that was held.
58
+ # Rather than a rule each game has to remember, it raises.
59
+ #
60
+ # Folding wrap and despawn into Mover as one `at_edge:` option would make two
61
+ # responses inexpressible too, but they act on the node however it moved — a reset,
62
+ # an ancestor, a direct write — and every option there is a branch on the free
63
+ # step's path. A refusal at attach costs no frame anything. It is also the place
64
+ # to change if a game wants wrapping on one axis and stopping on the other: the
65
+ # rule then becomes one response per axis.
66
+ #
67
+ # Every response calls this from its own on_attach, which is what makes it
68
+ # order-free: a node assembled outside the tree already holds all its
69
+ # components when the first attaches, and on a live node each attaches on
70
+ # arrival, so the second response always finds the first.
71
+ #
72
+ # A pooled node re-attaches on every spawn, so this walks the components
73
+ # without allocating, and builds a message only when it raises.
74
+ def self.one_response!(node)
75
+ first = nil
76
+ node.components.each do |component|
77
+ next unless edge_response?(component)
78
+ next first = component if first.nil?
79
+
80
+ raise "#{node.class} has two responses to the edge of the world: " \
81
+ "#{describe_response(first)} and #{describe_response(component)}. " \
82
+ 'A node can stop at the edge, wrap past it or be freed beyond it, but ' \
83
+ 'only one of those — keep the one this node is for.'
84
+ end
85
+ end
86
+
87
+ def self.edge_response?(component)
88
+ component.is_a?(ScreenWrap) || component.is_a?(DespawnOffscreen) ||
89
+ (component.is_a?(Mover) && component.blocked_by?(Mover::BOUNDS))
90
+ end
91
+ private_class_method :edge_response?
92
+
93
+ def self.describe_response(component)
94
+ name = component.class.name&.split('::')&.last || component.class.inspect
95
+ component.is_a?(Mover) ? "#{name} (blocked_by :bounds)" : name
96
+ end
97
+ private_class_method :describe_response
98
+ end
99
+
100
+ # The scene-scoped world: how big it is, and nothing else yet.
101
+ #
102
+ # A game whose world is a plain rectangle mounts one on its scene, and the
103
+ # components that need bounds — ScreenWrap, DespawnOffscreen — find it
104
+ # rather than having the numbers threaded through every constructor between
105
+ # the scene and the entity. That threading is what this replaces, and it was
106
+ # worse than it looks: a pooled entity is built outside the tree, so its
107
+ # factory had to close over the size, and every scene above the factory had
108
+ # to carry the size in order to build it.
109
+ #
110
+ # It is deliberately **not** the window size. The two coincide in a
111
+ # single-screen game, which is exactly what makes the mistake easy to make
112
+ # and hard to see: bind wrapping to the viewport and the world silently
113
+ # changes shape when the window is resized, or when the screen is split and
114
+ # each half is its own viewport. Ask `Viewports` (or the `View` a draw is
115
+ # handed) how big the *window* is; ask this how big the *world* is.
116
+ #
117
+ # The bounds are immutable, which is what lets the components resolve them
118
+ # once at attach rather than re-reading them every frame. A world that
119
+ # genuinely changes size is a new scene.
120
+ class World < Engine::Component
121
+ include WorldBounds
122
+
123
+ attr_reader :world_width, :world_height
124
+
125
+ def initialize(width:, height:)
126
+ super()
127
+ @world_width = width
128
+ @world_height = height
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The colliders one collider is touching, this step and last. CollisionWorld owns
6
+ # one of these per registered collider and drives it, and together they are what
7
+ # turns a per-step overlap test into the two edges a game actually wants: the step
8
+ # a contact starts and the step it ends. Pure logic; no graphics.
9
+ #
10
+ # Nothing here is about overlapping, though, and it has a second user:
11
+ # every Components::Mover keeps one of *what stopped its step*, and gets on_blocked
12
+ # and on_unblocked out of it on exactly the same terms. What this class is, underneath
13
+ # its names, is "the set of things that were true this step and last".
14
+ #
15
+ # Two arrays, swapped rather than reallocated. `begin_frame` makes this step's list
16
+ # into last step's and empties the other one for refilling, so after the first few
17
+ # frames nothing here allocates — `Array#clear` keeps the capacity it grew to, the
18
+ # same trick SpatialHash#clear relies on.
19
+ #
20
+ # Membership is a linear scan by identity, and deliberately so. A Set or a Hash
21
+ # allocates on insert, and both are slower than scanning a handful of entries; a
22
+ # collider touching more than a handful of things at once is a design problem
23
+ # somewhere else, not a reason to index this.
24
+ class ContactSet
25
+ def initialize
26
+ @current = []
27
+ @previous = []
28
+ end
29
+
30
+ # Start a step: this step's contacts become the ones to compare against, and the
31
+ # list being filled is emptied. Swapped through a temporary rather than by
32
+ # `@current, @previous = @previous, @current`, which would allocate an Array every
33
+ # step — see .rubocop.yml on Style/SwapValues.
34
+ def begin_frame
35
+ filled = @current
36
+ @current = @previous
37
+ @previous = filled
38
+ @current.clear
39
+ end
40
+
41
+ # Forget everything, both steps. CollisionWorld calls this when a collider
42
+ # registers, so a pooled entity that died mid-contact cannot come back still
43
+ # holding the contacts it had in its previous life — which would otherwise
44
+ # surface as one spurious `on_separated` on its first step.
45
+ def reset
46
+ @current.clear
47
+ @previous.clear
48
+ end
49
+
50
+ # Record a contact for this step.
51
+ def add(other) = @current << other
52
+
53
+ # Already recorded this step? The broadphase offers a pair once per cell the two
54
+ # share, so this is what keeps one overlap from being counted twice.
55
+ def touching?(other) = @current.include?(other)
56
+
57
+ # Is this the *start* of a contact — a pair that was not touching last step? It
58
+ # reads only last step's list, so it gives the same answer either side of #add,
59
+ # and asking before recording is what reads as a guard.
60
+ def started?(other) = !@previous.include?(other)
61
+
62
+ # Yield every collider that was in contact last step and is not in contact now —
63
+ # the ending edge. Call it once every pair for the step has been recorded.
64
+ def each_ended
65
+ i = 0
66
+ while i < @previous.size
67
+ other = @previous[i]
68
+ i += 1
69
+ yield other unless @current.include?(other)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Skipping a drawable that the viewport cannot show.
6
+ #
7
+ # Mixed into the components that draw a node's own footprint, which are the
8
+ # ones that know both where they put it and how big it is.
9
+ #
10
+ # ## Why it is worth doing at all
11
+ #
12
+ # It was not, while the world was drawn once: a handful of skipped draws
13
+ # against a frame's worth of work. Split-screen changes the arithmetic —
14
+ # the world is walked once per viewport, and an actor at one player's end of
15
+ # the map is off the *other* player's view every frame. What used to be a
16
+ # small saving becomes a saving multiplied by the number of people playing.
17
+ #
18
+ # ## Conservative on purpose
19
+ #
20
+ # Culling one frame too eagerly is a sprite popping in at the edge of the
21
+ # screen, which is worse than the draw it saved. So two rules:
22
+ #
23
+ # - **No size means no culling.** `node.width`/`height` are what a drawable's
24
+ # footprint is measured from, and a node that never set them (nothing
25
+ # requires it) reads as 0×0, which would otherwise cull everything
26
+ # instantly. Unknown means draw.
27
+ # - **A rotated node is measured generously.** `Node2D#draw` rotates about the
28
+ # node's own origin, so a rotated footprint can reach further than its
29
+ # box in any direction. The margin is `width + height`, which is always at
30
+ # least the diagonal `hypot(width, height)` and costs no square root on a
31
+ # path that runs once per drawable per viewport.
32
+ module Culling
33
+ private
34
+
35
+ # hot-path
36
+ def culled?(view, x, y, width, height)
37
+ return false if width.zero? || height.zero?
38
+ return !view.visible?(x, y, width, height) if node.world_angle.zero?
39
+
40
+ margin = width + height
41
+ !view.visible?(x - margin, y - margin, width + (margin * 2), height + (margin * 2))
42
+ end
43
+ end
44
+ end
45
+ end
@@ -35,27 +35,33 @@ module RGame
35
35
  OBJ_LABEL = 'OBJ'
36
36
  DELTA_LABEL = 'Δ/f'
37
37
 
38
- COLOR = [80, 255, 120].freeze # frozen so the renderer caches the resolved colour
39
- Z = 1_000_000 # above any scene content
40
- PAD = 8 # margin from the screen edge
41
- GAP = 8 # space between a label and its number
38
+ COLOR = [80, 255, 120].freeze
39
+ PAD = 8
40
+ GAP = 8
42
41
 
43
42
  def initialize
44
43
  @visible = false
45
44
  @prev_allocated = 0
46
- @digit_widths = Array.new(10) # measured once, then reused
47
- @label_widths = {} # measured once per label, then reused
45
+ @digit_widths = Array.new(10)
46
+ @label_widths = {}
48
47
  end
49
48
 
50
49
  def visible? = @visible
51
50
 
52
51
  def toggle
53
52
  @visible = !@visible
54
- # Baseline the counter on reveal so the first Δ/f isn't the whole run's history.
55
53
  @prev_allocated = GC.stat(:total_allocated_objects) if @visible
56
54
  end
57
55
 
58
- def draw(renderer, width, height, fps)
56
+ # Laid out against the view it is drawn into rather than against the
57
+ # window, so it stays in the corner of whatever region it is given. It
58
+ # takes `fps` rather than reading it, for the same reason nothing here
59
+ # reads a clock: the number is measured by the shell that owns the loop
60
+ # and handed down. See CLAUDE.md, "`draw` renders state".
61
+ # Its own `:debug` band, which is the last one, so this lands over every
62
+ # other thing in the frame however the scene is arranged. Not a node, so
63
+ # it opens its own layer rather than being given one by the traversal.
64
+ def draw(renderer, view, fps)
59
65
  return unless @visible
60
66
 
61
67
  allocated = GC.stat(:total_allocated_objects)
@@ -63,24 +69,23 @@ module RGame
63
69
  @prev_allocated = allocated
64
70
 
65
71
  line_h = renderer.text_height
66
- right_x = width - PAD
67
- top_y = height - PAD - (line_h * 3)
72
+ right_x = view.width - PAD
73
+ top_y = view.height - PAD - (line_h * 3)
68
74
 
69
- draw_line(renderer, FPS_LABEL, fps, right_x, top_y)
70
- draw_line(renderer, OBJ_LABEL, allocated, right_x, top_y + line_h)
71
- draw_line(renderer, DELTA_LABEL, delta, right_x, top_y + (line_h * 2))
75
+ renderer.layered(:debug) do
76
+ draw_line(renderer, FPS_LABEL, fps, right_x, top_y)
77
+ draw_line(renderer, OBJ_LABEL, allocated, right_x, top_y + line_h)
78
+ draw_line(renderer, DELTA_LABEL, delta, right_x, top_y + (line_h * 2))
79
+ end
72
80
  end
73
81
 
74
82
  private
75
83
 
76
- # A right-aligned "label number" row ending at right_x.
77
84
  def draw_line(renderer, label, value, right_x, y)
78
85
  number_left = draw_uint(renderer, value, right_x, y)
79
- renderer.text(label, number_left - GAP - label_width(renderer, label), y, z: Z, color: COLOR)
86
+ renderer.text(label, number_left - GAP - label_width(renderer, label), y, color: COLOR)
80
87
  end
81
88
 
82
- # Draw a non-negative integer right-aligned ending at right_x, digit by digit so no
83
- # String is built per frame. Returns the x of the leftmost digit.
84
89
  def draw_uint(renderer, value, right_x, y)
85
90
  x = right_x
86
91
  more = true
@@ -88,7 +93,7 @@ module RGame
88
93
  digit = value % 10
89
94
  value /= 10
90
95
  x -= digit_width(renderer, digit)
91
- renderer.text(DIGITS[digit], x, y, z: Z, color: COLOR)
96
+ renderer.text(DIGITS[digit], x, y, color: COLOR)
92
97
  more = value.positive?
93
98
  end
94
99
  x
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module I18n
6
+ # A translation that varies with `count`: one `Template` per CLDR category
7
+ # the table spells out, and the locale of the table it came from, whose
8
+ # language decides which category a count falls into.
9
+ #
10
+ # Internal to `I18n`. A nested Hash in a table compiles to a Plural when
11
+ # every key is a category, every value is text, and `other` is among them;
12
+ # any other Hash is a level of nesting.
13
+ #
14
+ # @api private
15
+ class Plural
16
+ CATEGORIES = %i[zero one two few many other].freeze
17
+ CATEGORY_NAMES = CATEGORIES.map(&:name).freeze
18
+
19
+ attr_reader :locale
20
+
21
+ # The variable names any form uses, each once, and `count`, which every
22
+ # form is chosen by whether it prints it or not.
23
+ attr_reader :names
24
+
25
+ def self.forms?(entries)
26
+ entries.key?('other') &&
27
+ entries.all? { |name, value| CATEGORY_NAMES.include?(name) && value.is_a?(String) }
28
+ end
29
+
30
+ def initialize(locale, entries)
31
+ @locale = locale
32
+ @forms = entries.to_h { |name, source| [name.to_sym, Template.compile(source)] }.freeze
33
+ @names = @forms.values.flat_map(&:names).push(:count).uniq.freeze
34
+ end
35
+
36
+ # The template for `count`, whose category under this table's language
37
+ # is `category`. An explicit `zero` form wins for 0 in every language,
38
+ # and a category the table leaves out reads `other`.
39
+ def template_for(count, category)
40
+ (count == 0 && @forms[:zero]) || @forms[category] || @forms.fetch(:other) # rubocop:disable Style/NumericPredicate -- count may be any object
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end