rgame 0.2.0 → 0.3.1

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 +144 -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 +3 -5
  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 +167 -12
  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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # How a game's own resolution is presented on a window that may be a
6
+ # different size. Pure arithmetic: a size, a mode, and the scale and offset
7
+ # they imply. No renderer, no window, nothing to draw with.
8
+ #
9
+ # presentation = Presentation.new(width: 640, height: 480, mode: :integer)
10
+ # presentation.fit(1920, 1080)
11
+ # presentation.scale_x # => 2.0
12
+ # presentation.offset_x # => 320 (1920 - 640*2, halved)
13
+ #
14
+ # ## The problem it exists for
15
+ #
16
+ # A game laid out against fixed numbers — a 500x500 board centred by writing
17
+ # `x: 150` in a 800x600 window — keeps those numbers when the window grows.
18
+ # It does not re-centre; it sits in the top-left corner with the new space
19
+ # piled up to its right. Fullscreen makes that happen to every game that has
20
+ # not thought about it, because fullscreen is the screen's resolution and
21
+ # never the one the game asked for.
22
+ #
23
+ # There are two honest answers, and this is the second one:
24
+ #
25
+ # - **Re-layout.** Read the size you are drawing into and place things
26
+ # relative to its edges. Right for a HUD or a menu, where extra space is
27
+ # extra room. `examples/fullscreen` does this.
28
+ # - **Scale.** Keep the design size, and map it onto whatever the window is.
29
+ # Right for a play area, where extra space should mean a bigger picture of
30
+ # the same thing rather than more world. This class.
31
+ #
32
+ # A game usually wants both at once, which works: the scale applies to
33
+ # everything, and nodes still read their view's size — that size is just the
34
+ # *logical* one now, so it stops changing.
35
+ #
36
+ # ## The modes
37
+ #
38
+ # | | |
39
+ # |---|---|
40
+ # | `:disabled` | No scaling. The logical size *is* the window size, so it changes as the window does. |
41
+ # | `:stretch` | Fill the window exactly, distorting if the aspect ratios differ. |
42
+ # | `:letterbox` | Largest scale that fits, equal on both axes, centred. Bars on two sides. |
43
+ # | `:integer` | The same, rounded down to a whole number. |
44
+ #
45
+ # `:integer` is the one pixel art wants. A sprite drawn at 1.875x with nearest
46
+ # filtering has some source pixels covering two screen pixels and some
47
+ # covering one, and the unevenness crawls as anything moves. A whole-number
48
+ # factor makes every source pixel exactly the same square, which is what
49
+ # "pixel perfect" means.
50
+ #
51
+ # **It costs screen, sometimes a lot of it.** The factor is bounded by the
52
+ # tighter axis, and rounding that down can throw away most of a window. A
53
+ # 640x480 design measured against real screens:
54
+ #
55
+ # | window | `:integer` | `:letterbox` |
56
+ # |---|---|---|
57
+ # | 1920x1080 | 2x, 320px border | 2.25x |
58
+ # | 2560x1440 | 3x, 320px border | 3x |
59
+ # | 1600x900 | **1x**, 480px border | 1.875x |
60
+ # | 1280x720 | **1x**, 320px border | 1.5x |
61
+ #
62
+ # The 16:9 rows are the ones to look at before choosing this mode: 900 and
63
+ # 720 pixels of height are both short of the 960 that 2x needs, so the whole
64
+ # window drops to 1x and the game sits in a small island. A design height
65
+ # that divides common screen heights — 360 rather than 480 — is what avoids
66
+ # that, and it is a decision about the game's art, not about this class.
67
+ #
68
+ # ## What `:integer` does when the window is too small
69
+ #
70
+ # It scales by 1 and lets the content overrun, rather than dropping below a
71
+ # whole number. So the design size is a **minimum**: shrink the window under
72
+ # it and you see part of the game rather than all of it, smaller.
73
+ #
74
+ # That is the same choice SDL makes, and the alternative is worse for the
75
+ # case the mode exists for — falling back to a fractional scale would
76
+ # reintroduce exactly the uneven pixels the game asked to avoid, at the
77
+ # moment nobody is looking. Nothing here enforces a minimum window size;
78
+ # that would need SDL, and this class is pure.
79
+ class Presentation
80
+ MODES = %i[disabled stretch letterbox integer].freeze
81
+
82
+ # The logical size a game draws in. Under `:disabled` these follow the
83
+ # window; under every other mode they are fixed and the window does not
84
+ # matter to anything above this class.
85
+ attr_reader :width, :height
86
+
87
+ attr_reader :mode, :scale_x, :scale_y, :offset_x, :offset_y
88
+
89
+ def initialize(width:, height:, mode: :disabled)
90
+ raise ArgumentError, "unknown scale mode #{mode.inspect} — one of #{MODES.inspect}" \
91
+ unless MODES.include?(mode)
92
+ raise ArgumentError, 'a logical size needs a positive width and height' \
93
+ unless width.positive? && height.positive?
94
+
95
+ @logical_width = width
96
+ @logical_height = height
97
+ @mode = mode
98
+ fit(width, height)
99
+ end
100
+
101
+ # Switches mode and refits against the window it last saw, so a settings
102
+ # screen can change this while the game runs.
103
+ #
104
+ # The window size is remembered for exactly this: a caller changing the
105
+ # mode knows which mode it wants and has no reason to also know how big
106
+ # the window currently is, and asking it to pass one would be asking it to
107
+ # keep a copy of something this object already has.
108
+ def mode=(mode)
109
+ raise ArgumentError, "unknown scale mode #{mode.inspect} — one of #{MODES.inspect}" \
110
+ unless MODES.include?(mode)
111
+
112
+ @mode = mode
113
+ fit(@window_width, @window_height)
114
+ end
115
+
116
+ # Whether anything is being scaled. `:disabled` means the caller can skip
117
+ # pushing a transform at all rather than pushing an identity one.
118
+ #
119
+ # @api private
120
+ def scaled? = @mode != :disabled
121
+
122
+ # Recomputes for a window of this size. Mutates rather than returning a
123
+ # new object: it is read every frame and recomputed only on a resize, so
124
+ # there is no reason to allocate.
125
+ def fit(window_width, window_height)
126
+ @window_width = window_width
127
+ @window_height = window_height
128
+ case @mode
129
+ when :disabled then fit_disabled(window_width, window_height)
130
+ when :stretch then fit_stretch(window_width, window_height)
131
+ else fit_uniform(window_width, window_height)
132
+ end
133
+ self
134
+ end
135
+
136
+ private
137
+
138
+ def fit_disabled(window_width, window_height)
139
+ @width = window_width
140
+ @height = window_height
141
+ @scale_x = @scale_y = 1.0
142
+ @offset_x = @offset_y = 0.0
143
+ end
144
+
145
+ def fit_stretch(window_width, window_height)
146
+ @width = @logical_width
147
+ @height = @logical_height
148
+ @scale_x = window_width.to_f / @logical_width
149
+ @scale_y = window_height.to_f / @logical_height
150
+ @offset_x = @offset_y = 0.0
151
+ end
152
+
153
+ def fit_uniform(window_width, window_height)
154
+ @width = @logical_width
155
+ @height = @logical_height
156
+
157
+ fitting = [window_width.to_f / @logical_width, window_height.to_f / @logical_height].min
158
+ scale = @mode == :integer ? integer_scale(fitting) : fitting
159
+
160
+ @scale_x = @scale_y = scale
161
+ @offset_x = ((window_width - (@logical_width * scale)) / 2.0).floor
162
+ @offset_y = ((window_height - (@logical_height * scale)) / 2.0).floor
163
+ end
164
+
165
+ def integer_scale(fitting)
166
+ floored = fitting.floor
167
+ floored < 1 ? 1.0 : floored.to_f
168
+ end
169
+ end
170
+ end
171
+ end
@@ -16,9 +16,9 @@ module RGame
16
16
 
17
17
  def push(scene)
18
18
  @stack.push(scene)
19
- scene.parent = node # so scene.root resolves up to the host
20
- scene.scene = scene # mark the scene as its subtree's scene boundary
21
- scene.enter_tree # cascades on_attach/on_add through the scene
19
+ scene.parent = node
20
+ scene.scene = scene
21
+ scene.enter_tree
22
22
  self
23
23
  end
24
24
 
@@ -26,7 +26,7 @@ module RGame
26
26
  scene = @stack.pop
27
27
  return self unless scene
28
28
 
29
- scene.exit_tree # cascades on_remove/on_detach, releasing systems
29
+ scene.exit_tree
30
30
  scene.scene = nil
31
31
  scene.parent = nil
32
32
  self
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The rule for the two classes a game subclasses, Node2D and Component: a
6
+ # non-public method whose name starts with `_` is the engine's machinery, and
7
+ # a subclass defining a method of the same name raises where it is defined.
8
+ #
9
+ # class Hop < RGame::Engine::Node2D
10
+ # def _draw_content(renderer, view) = ... # NameError: would replace Node2D#_draw_content
11
+ # def draw_children(renderer, view) = ... # fine: no underscore, meant to be overridden
12
+ # end
13
+ #
14
+ # `private` in Ruby says who may *call* a method, not who may *replace* one: a
15
+ # subclass method of the same name is found first, and the base class quietly
16
+ # stops doing what that method did — for that subclass only, with the failure
17
+ # somewhere else entirely. So the underscore is the opt-in. A private or
18
+ # protected method *without* one is a seam, meant to be overridden with
19
+ # `super`, and nothing guards it.
20
+ #
21
+ # Only the base class's own methods are sealed. A private hook an engine
22
+ # subclass writes for its own subclasses — Components::Mover's `take_step` —
23
+ # is not covered, and the underscore convention does not apply outside these
24
+ # two classes.
25
+ #
26
+ # What it cannot see: a method arriving through `include` or `prepend` of a
27
+ # module, which does not pass through `method_added`.
28
+ module SealedPrivates
29
+ def self.extended(base)
30
+ base.define_singleton_method(:sealed_base) { base }
31
+ base.singleton_class.send(:private, :sealed_base)
32
+ end
33
+
34
+ # Every method a subclass may not define: the base's non-public methods
35
+ # whose names start with `_`.
36
+ def sealed_methods
37
+ base = sealed_base
38
+ (base.private_instance_methods(false) + base.protected_instance_methods(false))
39
+ .select { |name| name.start_with?('_') }
40
+ end
41
+
42
+ def method_added(name)
43
+ super
44
+ base = sealed_base
45
+ return if equal?(base) || !name.start_with?('_')
46
+ return unless base.private_method_defined?(name, false) || base.protected_method_defined?(name, false)
47
+
48
+ raise NameError.new("#{self}##{name} would replace #{base}##{name}, which #{base} calls " \
49
+ "itself, and #{base} would silently stop doing its job for this class. " \
50
+ 'Give the method another name.', name)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -13,22 +13,6 @@ module RGame
13
13
  # rocks.each { |r| hash.insert(r, *r.aabb) }
14
14
  # hash.query(*bullet.aabb) { |rock| ...narrowphase... }
15
15
  class SpatialHash
16
- # Cell (col, row) → one integer key. The offset keeps negative cells (objects
17
- # off-screen / mid-wrap) non-negative; the stride keeps pairs unique. Both are
18
- # sized to keep every packed key inside a tagged Fixnum, so keying allocates
19
- # nothing on the query path (CLAUDE.md: never allocate on the per-frame path).
20
- #
21
- # The ceiling that sizes them is Windows, not Linux/macOS: CRuby's immediate
22
- # Fixnum range comes from a C `long`, which is 64 bits on LP64 (Linux, macOS)
23
- # but stays 32 bits on Windows' LLP64 even in a 64-bit process — so a value
24
- # comfortably inside Fixnum range on Linux (this packing used to run up to
25
- # ~2**42) silently becomes a heap-allocated Bignum on Windows instead, one
26
- # allocation per cell per query. OFFSET/STRIDE here keep the largest possible
27
- # packed key (both coordinates at the far corner) under 2**28 — well inside
28
- # Windows' ~2**30 Fixnum ceiling — while still allowing cell coordinates out
29
- # to +/-8192, i.e. a world some sixteen million pixels wide at this file's own
30
- # cell_size: 64 example. A game whose world exceeds that wraps into a
31
- # neighbouring cell's key instead of raising; see #each_cell below.
32
16
  OFFSET = 1 << 13
33
17
  STRIDE = 1 << 14
34
18
 
@@ -46,6 +30,24 @@ module RGame
46
30
  each_cell(x, y, w, h) { |key| @buckets[key] << item }
47
31
  end
48
32
 
33
+ # Un-bucket an item from the cells the given box covers — the exact inverse of
34
+ # #insert, so the caller passes the box the item was **inserted at**, not where it
35
+ # is now. That is what keeps this stateless: nothing here remembers where anything
36
+ # was bucketed, and a mover that knows where it started can undo its own insert.
37
+ #
38
+ # An item that was never inserted (or a box that covers none of its cells) is a
39
+ # no-op rather than an error — removing something twice, or removing something that
40
+ # left the tree between two steps, is ordinary rather than a mistake.
41
+ #
42
+ # Allocation-free: `fetch` sidesteps the bucket Hash's default block, which would
43
+ # *create* an empty bucket for every cell walked.
44
+ def remove(item, x, y, w, h)
45
+ each_cell(x, y, w, h) do |key|
46
+ bucket = @buckets.fetch(key, nil)
47
+ bucket&.delete(item)
48
+ end
49
+ end
50
+
49
51
  # Yield every item whose buckets overlap the region. Dedup contract: an item
50
52
  # spanning several cells may be yielded more than once. Narrowphase callers
51
53
  # must already guard with `next if a.dead? || b.dead?` to make hits idempotent,
@@ -66,19 +68,49 @@ module RGame
66
68
  query(cx - r, cy - r, d, d, &)
67
69
  end
68
70
 
71
+ # Is the one cell containing the point (x, y) holding nothing? A point, not a
72
+ # region: pass any coordinate inside the cell you mean.
73
+ #
74
+ # An item is bucketed by its *bounding box*, and the cell walk is half-open on the
75
+ # far edge (see #last_cell), so "bucketed here" means "overlaps this cell's area"
76
+ # exactly: a box filling one cell fills that cell's bucket and no other, and one
77
+ # covering four cells fills four. So this answers occupancy, not just candidacy —
78
+ # it is true when nothing overlaps the cell at all. What it cannot know is whether
79
+ # an occupant still counts; `CollisionWorld#cell_empty?` adds that, skipping
80
+ # colliders whose node is queued for removal.
81
+ #
82
+ # Reads whatever the most recent inserts left behind: after `clear` every cell is
83
+ # empty until something is inserted again. Allocation-free — `fetch` deliberately
84
+ # sidesteps the bucket Hash's default block, which would *create* the bucket.
85
+ def cell_empty?(x, y)
86
+ bucket = @buckets.fetch(cell_key((x / @cell_size).floor, (y / @cell_size).floor), nil)
87
+ bucket.nil? || bucket.empty?
88
+ end
89
+
69
90
  private
70
91
 
92
+ def cell_key(col, row) = (col + OFFSET) * STRIDE + (row + OFFSET)
93
+
71
94
  def each_cell(x, y, w, h)
72
95
  col0 = (x / @cell_size).floor
73
96
  row0 = (y / @cell_size).floor
74
- col1 = ((x + w) / @cell_size).floor
75
- row1 = ((y + h) / @cell_size).floor
97
+ col1 = last_cell(x + w, col0)
98
+ row1 = last_cell(y + h, row0)
76
99
  row0.upto(row1) do |row|
77
100
  col0.upto(col1) do |col|
78
- yield (col + OFFSET) * STRIDE + (row + OFFSET)
101
+ yield cell_key(col, row)
79
102
  end
80
103
  end
81
104
  end
105
+
106
+ def last_cell(edge, first)
107
+ cell = (edge / @cell_size).floor
108
+ cell -= 1 if (edge % @cell_size).zero?
109
+ # rubocop:disable Style/MinMaxComparison -- the [cell, first].max it asks for is
110
+ # what the project's own Game/NoNeedlessAllocation rejects on a per-frame path.
111
+ cell < first ? first : cell
112
+ # rubocop:enable Style/MinMaxComparison
113
+ end
82
114
  end
83
115
  end
84
116
  end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'i18n'
4
+
5
+ module RGame
6
+ module Engine
7
+ # The string a node draws: a translation key, resolved through `I18n` and
8
+ # kept until one of its inputs changes — a variable, or the language.
9
+ #
10
+ # @score = Engine::Text.new('hud.score', :score) # built once, e.g. in initialize
11
+ # renderer.text(@score.with(score: @points), 12, 10) # every frame: cached, no allocation
12
+ #
13
+ # The names a `Text` is built with become the keywords of its `with`, so a
14
+ # forgotten or misspelt variable is Ruby's own `ArgumentError`. `to_s` reads
15
+ # a `Text` with the values its last `with` was given — so whatever draws it,
16
+ # a button's label among them, needs no values of its own — and raises
17
+ # before the first `with`. A `Text` with no names is read with `to_s`
18
+ # alone. Reading one whose variables and
19
+ # `I18n.generation` are unchanged returns the same frozen String and
20
+ # allocates nothing; otherwise it renders again. A `Text` never subscribes to
21
+ # `I18n`: it compares one Integer, so it holds nothing that could keep it
22
+ # alive.
23
+ #
24
+ # A `Text` answers `to_str`, so a renderer takes one where it takes a String,
25
+ # and it is `==` to the String it reads.
26
+ #
27
+ # `Text.literal` is a string that is never translated, and `Text.computed` a
28
+ # string a block builds. Both answer `with` and `to_s` the same way, so
29
+ # whatever draws a `Text` never asks which kind it holds.
30
+ class Text
31
+ NAME = /\A[a-z_][A-Za-z0-9_]*\z/
32
+ private_constant :NAME
33
+
34
+ class << self
35
+ # A `Text` that shows `string` in every language.
36
+ def literal(string) = Literal.new(string)
37
+
38
+ # A `Text` whose string the block builds from the keywords `names`, for
39
+ # text that is formatted rather than looked up. The block runs when a
40
+ # keyword or `I18n.generation` changes, never on an unchanged read, so a
41
+ # block that calls `I18n.t` follows the language too:
42
+ #
43
+ # @clock = Engine::Text.computed(:seconds) { |seconds:| format_clock(seconds) }
44
+ def computed(*names, &block) = Computed.new(names, block)
45
+ end
46
+
47
+ # Generates the module that gives a `Text` its `with` for one list of
48
+ # names, the first time that list is seen, and shares it after.
49
+ module Accessors
50
+ @cache = {}
51
+
52
+ def self.for(names)
53
+ @cache[names] ||= generate(names)
54
+ end
55
+
56
+ class << self
57
+ private
58
+
59
+ def generate(names)
60
+ names.each do |name|
61
+ raise ArgumentError, "#{name.inspect} cannot be a variable name" unless NAME.match?(name)
62
+ end
63
+
64
+ Module.new.tap { |mod| mod.module_eval(accessor_source(names), __FILE__, __LINE__) }
65
+ rescue SyntaxError
66
+ raise ArgumentError, "#{names.inspect} cannot all be variable names"
67
+ end
68
+
69
+ def accessor_source(names)
70
+ return <<~RUBY if names.empty?
71
+ def with = to_s
72
+
73
+ def to_s
74
+ return @string if @generation == ::RGame::Engine::I18n.generation
75
+
76
+ refresh({})
77
+ end
78
+ RUBY
79
+
80
+ <<~RUBY
81
+ def with(#{names.map { "#{it}:" }.join(', ')})
82
+ if @generation == ::RGame::Engine::I18n.generation && #{names.map { "@_var_#{it} == #{it}" }.join(' && ')}
83
+ return @string
84
+ end
85
+
86
+ #{names.map { "@_var_#{it} = #{it}" }.join('; ')}
87
+ refresh({ #{names.map { "#{it}: #{it}" }.join(', ')} })
88
+ end
89
+
90
+ def to_s
91
+ return @string if @generation == ::RGame::Engine::I18n.generation
92
+ unless defined?(@_var_#{names.first})
93
+ raise ArgumentError, "\#{describe} needs #{names.map { "#{it}:" }.join(', ')}; call with first"
94
+ end
95
+
96
+ refresh({ #{names.map { "#{it}: @_var_#{it}" }.join(', ')} })
97
+ end
98
+ RUBY
99
+ end
100
+ end
101
+ end
102
+ private_constant :Accessors
103
+
104
+ # The key as given, without its scope.
105
+ attr_reader :key
106
+
107
+ # The variable names `with` takes, sorted.
108
+ attr_reader :names
109
+
110
+ attr_reader :scope
111
+
112
+ # A `Text` for `key`, whose translation prints the variables `names`.
113
+ # `scope: 'title_menu'` with key `'play'` resolves `'title_menu.play'`.
114
+ def initialize(key, *names, scope: nil)
115
+ @key = key.to_s.freeze
116
+ self.scope = scope
117
+ declare(names)
118
+ end
119
+
120
+ # Changes the scope the key resolves under; the next read resolves again.
121
+ def scope=(scope)
122
+ @scope = scope&.to_s&.freeze
123
+ @path = (@scope ? "#{@scope}.#{@key}" : @key).freeze
124
+ @generation = nil
125
+ end
126
+
127
+ # The same String as `to_s`, so a `Text` goes wherever a String is
128
+ # expected: `renderer.text(@title, 12, 10)`. Before the first `with`, a
129
+ # `Text` with names raises `ArgumentError` here too.
130
+ def to_str = to_s
131
+
132
+ # Equal to a String that reads the same, from either side, so a spy that
133
+ # recorded a `Text` matches the String it drew. Against anything else,
134
+ # including another `Text`, it compares by identity.
135
+ def ==(other) = other.is_a?(String) ? to_s == other : super
136
+
137
+ private
138
+
139
+ def declare(names)
140
+ @names = names.map(&:to_sym).sort.freeze
141
+ raise ArgumentError, "#{describe} declares a variable twice" if @names.uniq.size != @names.size
142
+
143
+ extend(Accessors.for(@names))
144
+ end
145
+
146
+ def refresh(vars)
147
+ generation = I18n.generation
148
+ @string = render(vars)
149
+ @generation = generation
150
+ @string
151
+ end
152
+
153
+ def render(vars) = I18n.render(@path, @names, vars).freeze
154
+
155
+ def describe = "Text #{@path.inspect}"
156
+
157
+ # A `Text` that is not translated: the same `with` and `to_s`, answering
158
+ # with one String.
159
+ class Literal < Text
160
+ def initialize(string) # rubocop:disable Lint/MissingSuper -- a literal has no key to resolve
161
+ @string = -string.to_s
162
+ @names = [].freeze
163
+ end
164
+
165
+ def with = @string
166
+ def to_s = @string
167
+
168
+ # A literal ignores a scope: there is no key to put one in front of.
169
+ def scope=(_scope); end
170
+ end
171
+ private_constant :Literal
172
+
173
+ # A `Text` built by a block from its keywords instead of from a key.
174
+ class Computed < Text
175
+ def initialize(names, block) # rubocop:disable Lint/MissingSuper -- a computed text has no key to resolve
176
+ raise ArgumentError, 'Text.computed needs a block' unless block
177
+
178
+ @block = block
179
+ declare(names)
180
+ end
181
+
182
+ # A computed text ignores a scope: there is no key to put one in front of.
183
+ def scope=(_scope); end
184
+
185
+ private
186
+
187
+ def render(vars) = @block.call(**vars)
188
+
189
+ def describe = 'Text.computed'
190
+ end
191
+ private_constant :Computed
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # The tile grid as a blocker source: axis-separated AABB-vs-tile collision
6
+ # resolution over a Util::SolidGrid, at a tile size. Assumes per-step movement smaller
7
+ # than a tile (no tunneling), which holds for our speeds.
8
+ #
9
+ # It answers the blocker-source question — "where does this box land moving dx" —
10
+ # which CollisionSystem asks of every source it holds; see its header for the
11
+ # protocol. Resolving X then Y (with the X result) is what gives wall-sliding, and
12
+ # that ordering lives in the system rather than here, so every source shares it.
13
+ #
14
+ # It also answers the protocol's optional fourth question, #travel? — whether a box can
15
+ # move along a segment without any resolve falling short — which is how a route is
16
+ # checked against the resolver that will stop the walker on it. It is the only source
17
+ # that answers it today.
18
+ #
19
+ # The arithmetic for both is Util::TileSweep's, in C: this class is its blocker-source
20
+ # face, adding what the protocol asks beyond the numbers. The grid is read, never copied,
21
+ # so a cell changed with SolidGrid#set_solid stops the next step.
22
+ class TileBlockers
23
+ # What a TileBlockers reports as having stopped a step. One object for the life of
24
+ # the process, answering the same two questions a collider does — so a handler reads
25
+ # `by.layer` whatever stopped it and never branches on what kind of thing it was. A
26
+ # tile has no node, so `node` is nil.
27
+ #
28
+ # It holds no state at all, which is what makes one TileBlockers safe to share
29
+ # between every body on the map: there is nothing here for two of them to race over.
30
+ class Tiles
31
+ def layer = :tiles
32
+ def node = nil
33
+ end
34
+
35
+ TILES = Tiles.new.freeze
36
+
37
+ def initialize(grid:, tile_width:, tile_height:)
38
+ @sweep = Util::TileSweep.new(grid, tile_width, tile_height)
39
+ end
40
+
41
+ def grid = @sweep.grid
42
+
43
+ # Move an AABB (top-left x, y; size w, h) by dx, snapping flush against solids. A Float.
44
+ def resolve_x(x, y, w, h, dx) = @sweep.resolve_x(x, y, w, h, dx)
45
+
46
+ def resolve_y(x, y, w, h, dy) = @sweep.resolve_y(x, y, w, h, dy)
47
+
48
+ # Whether the box travels (dx, dy) without any resolve along the way falling short of
49
+ # where it was heading — so a walker resolving its own steps against this source, each
50
+ # under a quarter tile, is never stopped on that segment. A landing *past* the intended
51
+ # one does not count as stopped, which is how CollisionSystem reads every source.
52
+ def travel?(x, y, w, h, dx, dy) = @sweep.travel?(x, y, w, h, dx, dy)
53
+
54
+ # The grid stopped it, and the grid is the same for everybody — so this is a
55
+ # constant rather than a record of the last step. See TILES.
56
+ def blocker = TILES
57
+
58
+ # A grid does not move, so there is nothing to re-index. Part of the blocker-source
59
+ # protocol Engine::CollisionSystem calls after every step; see its header.
60
+ def moved(_actor, _from_x, _from_y, _w, _h) = nil
61
+ end
62
+ end
63
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rexml/document'
4
- require 'base64'
5
4
  require 'zlib'
6
5
 
7
6
  require_relative '../util'
@@ -20,7 +19,7 @@ module RGame
20
19
  # this class (see CLAUDE.md, "The rule points both ways"). So `load` hands
21
20
  # back a path and stops there.
22
21
  class TileMap
23
- FLIP_MASK = 0x1FFFFFFF # strip Tiled's flip/rotation flags from a gid
22
+ FLIP_MASK = 0x1FFFFFFF
24
23
 
25
24
  attr_reader :width, :height, :tile_width, :tile_height,
26
25
  :pixel_width, :pixel_height, :tileset_source, :firstgid
@@ -52,7 +51,7 @@ module RGame
52
51
  layers = []
53
52
  above = []
54
53
  root.each_element('layer') do |layer_el|
55
- raw = Base64.decode64(layer_el.elements['data'].text.strip)
54
+ raw = layer_el.elements['data'].text.strip.unpack1('m')
56
55
  gids = Zlib::Inflate.inflate(raw).unpack('V*')
57
56
  gids.map! { |g| g & FLIP_MASK }
58
57
  layers << gids
@@ -71,7 +70,6 @@ module RGame
71
70
  )
72
71
  end
73
72
 
74
- # True if a layer carries a Tiled bool custom property `name` set to "true".
75
73
  def self.layer_flag?(layer_el, name)
76
74
  props = layer_el.elements['properties']
77
75
  return false unless props
@@ -81,6 +79,7 @@ module RGame
81
79
  end
82
80
  false
83
81
  end
82
+ private_class_method :layer_flag?
84
83
 
85
84
  def initialize(width:, height:, tile_width:, tile_height:, tileset_source:, firstgid:, layers:, above: [])
86
85
  @width = width
@@ -133,7 +132,6 @@ module RGame
133
132
 
134
133
  private
135
134
 
136
- # Pack the parsed per-layer gid rows into one flat Tensor ([col, row, layer]).
137
135
  def build_tiles(layers)
138
136
  tiles = Util::Tensor.new(@width, @height, layers.length, initial: 0)
139
137
  layers.each_with_index do |gids, layer|
@@ -50,8 +50,6 @@ module RGame
50
50
  gap = under || world.first_above_layer
51
51
 
52
52
  world.layer_count.times do |index|
53
- # A layer at or past the gap sits above the actors; z is only ever
54
- # compared to a sibling's, so the +1 is a gap, not a magnitude.
55
53
  parent.add_node(new(layer: index, z: index < gap ? index : index + 1))
56
54
  end
57
55
  parent.add_node(Node2D.new(z: gap))