rgame 0.4.0-x86_64-linux-gnu

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (239) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +251 -0
  3. data/LICENSE +26 -0
  4. data/README.md +325 -0
  5. data/docs/api/README.md +224 -0
  6. data/docs/api/app.md +249 -0
  7. data/docs/api/assets.md +463 -0
  8. data/docs/api/audio.md +249 -0
  9. data/docs/api/cli.md +259 -0
  10. data/docs/api/components.md +1112 -0
  11. data/docs/api/drawing.md +398 -0
  12. data/docs/api/examples.md +263 -0
  13. data/docs/api/game.md +219 -0
  14. data/docs/api/images.md +118 -0
  15. data/docs/api/input.md +476 -0
  16. data/docs/api/internals.md +289 -0
  17. data/docs/api/localization.md +285 -0
  18. data/docs/api/scene_graph.md +539 -0
  19. data/docs/api/signals.md +147 -0
  20. data/docs/api/systems.md +231 -0
  21. data/docs/api/text.md +133 -0
  22. data/docs/api/tile_maps.md +237 -0
  23. data/docs/api/toolbox.md +493 -0
  24. data/docs/api/ui.md +927 -0
  25. data/docs/api/values.md +316 -0
  26. data/examples/assets/README.md +322 -0
  27. data/examples/assets/blip.ogg +0 -0
  28. data/examples/assets/glyphs.json +5 -0
  29. data/examples/assets/glyphs.png +0 -0
  30. data/examples/assets/hero.json +12 -0
  31. data/examples/assets/hero.png +0 -0
  32. data/examples/assets/icons.json +13 -0
  33. data/examples/assets/icons.png +0 -0
  34. data/examples/assets/music.ogg +0 -0
  35. data/examples/assets/skills.json +10 -0
  36. data/examples/assets/skills.png +0 -0
  37. data/examples/assets/tileset.png +0 -0
  38. data/examples/assets/tileset.tsx +65 -0
  39. data/examples/assets/town.tmx +26 -0
  40. data/examples/assets/ui.json +11 -0
  41. data/examples/assets/ui.png +0 -0
  42. data/examples/collision/locales/en.yml +8 -0
  43. data/examples/collision/main.rb +316 -0
  44. data/examples/collision_tiles/locales/en.yml +9 -0
  45. data/examples/collision_tiles/main.rb +274 -0
  46. data/examples/fullscreen/locales/en.yml +10 -0
  47. data/examples/fullscreen/main.rb +216 -0
  48. data/examples/game_menu/locales/en.yml +8 -0
  49. data/examples/game_menu/main.rb +170 -0
  50. data/examples/input_glyphs/locales/en.yml +14 -0
  51. data/examples/input_glyphs/main.rb +213 -0
  52. data/examples/jump_topdown/locales/en.yml +9 -0
  53. data/examples/jump_topdown/main.rb +178 -0
  54. data/examples/localization/locales/de.yml +12 -0
  55. data/examples/localization/locales/en.yml +13 -0
  56. data/examples/localization/main.rb +158 -0
  57. data/examples/menu_navigation/locales/en.yml +23 -0
  58. data/examples/menu_navigation/main.rb +365 -0
  59. data/examples/music/locales/en.yml +7 -0
  60. data/examples/music/main.rb +134 -0
  61. data/examples/pathfinding/locales/en.yml +17 -0
  62. data/examples/pathfinding/main.rb +298 -0
  63. data/examples/pooling/locales/en.yml +7 -0
  64. data/examples/pooling/main.rb +259 -0
  65. data/examples/quick_wheel/locales/en.yml +16 -0
  66. data/examples/quick_wheel/main.rb +184 -0
  67. data/examples/radial_menu/locales/en.yml +16 -0
  68. data/examples/radial_menu/main.rb +184 -0
  69. data/examples/save_load/locales/en.yml +11 -0
  70. data/examples/save_load/main.rb +207 -0
  71. data/examples/save_load_ids/locales/en.yml +11 -0
  72. data/examples/save_load_ids/main.rb +322 -0
  73. data/examples/scroll_map/locales/en.yml +4 -0
  74. data/examples/scroll_map/main.rb +140 -0
  75. data/examples/signals/locales/en.yml +6 -0
  76. data/examples/signals/main.rb +278 -0
  77. data/examples/skill_bar/locales/en.yml +14 -0
  78. data/examples/skill_bar/main.rb +159 -0
  79. data/examples/sound/locales/en.yml +6 -0
  80. data/examples/sound/main.rb +122 -0
  81. data/examples/split_screen/locales/en.yml +9 -0
  82. data/examples/split_screen/main.rb +304 -0
  83. data/examples/sprite/locales/en.yml +8 -0
  84. data/examples/sprite/main.rb +180 -0
  85. data/examples/timer/locales/en.yml +12 -0
  86. data/examples/timer/main.rb +273 -0
  87. data/examples/velocity/locales/en.yml +6 -0
  88. data/examples/velocity/main.rb +196 -0
  89. data/examples/walk/locales/en.yml +4 -0
  90. data/examples/walk/main.rb +99 -0
  91. data/exe/rgame +9 -0
  92. data/lib/rgame/boot.rb +3 -0
  93. data/lib/rgame/cli/new_project.rb +139 -0
  94. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  95. data/lib/rgame/cli/templates/README.md.tt +93 -0
  96. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  97. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  98. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  99. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  100. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  101. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  102. data/lib/rgame/cli/templates/rspec.tt +2 -0
  103. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  104. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  105. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  106. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  107. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  108. data/lib/rgame/cli.rb +66 -0
  109. data/lib/rgame/core/app.rb +46 -0
  110. data/lib/rgame/core/asset_manager.rb +206 -0
  111. data/lib/rgame/core/audio.rb +145 -0
  112. data/lib/rgame/core/font.rb +46 -0
  113. data/lib/rgame/core/gamepad.rb +55 -0
  114. data/lib/rgame/core/image.rb +55 -0
  115. data/lib/rgame/core/input.rb +71 -0
  116. data/lib/rgame/core/locale.rb +22 -0
  117. data/lib/rgame/core/nine_slice.rb +142 -0
  118. data/lib/rgame/core/recording.rb +54 -0
  119. data/lib/rgame/core/renderer.rb +354 -0
  120. data/lib/rgame/core/sprite_sheet.rb +105 -0
  121. data/lib/rgame/core/tile_map_renderer.rb +172 -0
  122. data/lib/rgame/core/ui_atlas.rb +101 -0
  123. data/lib/rgame/core/virtual_gamepad.rb +26 -0
  124. data/lib/rgame/core.rb +18 -0
  125. data/lib/rgame/core_ext.so +0 -0
  126. data/lib/rgame/engine/actor_blockers.rb +131 -0
  127. data/lib/rgame/engine/animation_set.rb +50 -0
  128. data/lib/rgame/engine/animator.rb +44 -0
  129. data/lib/rgame/engine/audio_bus.rb +24 -0
  130. data/lib/rgame/engine/audio_director.rb +59 -0
  131. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  132. data/lib/rgame/engine/camera.rb +78 -0
  133. data/lib/rgame/engine/circle_collider.rb +34 -0
  134. data/lib/rgame/engine/collision_box.rb +59 -0
  135. data/lib/rgame/engine/collision_system.rb +132 -0
  136. data/lib/rgame/engine/component.rb +64 -0
  137. data/lib/rgame/engine/components/action_trigger.rb +40 -0
  138. data/lib/rgame/engine/components/animated_sprite.rb +71 -0
  139. data/lib/rgame/engine/components/box_collider.rb +99 -0
  140. data/lib/rgame/engine/components/camera_follow.rb +45 -0
  141. data/lib/rgame/engine/components/character_body.rb +50 -0
  142. data/lib/rgame/engine/components/circle_collider.rb +80 -0
  143. data/lib/rgame/engine/components/collision_world.rb +231 -0
  144. data/lib/rgame/engine/components/despawn_offscreen.rb +42 -0
  145. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  146. data/lib/rgame/engine/components/hop.rb +76 -0
  147. data/lib/rgame/engine/components/identity.rb +73 -0
  148. data/lib/rgame/engine/components/mover.rb +285 -0
  149. data/lib/rgame/engine/components/navigator.rb +145 -0
  150. data/lib/rgame/engine/components/path_follow.rb +176 -0
  151. data/lib/rgame/engine/components/player_controller.rb +27 -0
  152. data/lib/rgame/engine/components/pool.rb +53 -0
  153. data/lib/rgame/engine/components/screen_wrap.rb +49 -0
  154. data/lib/rgame/engine/components/sprite.rb +47 -0
  155. data/lib/rgame/engine/components/targeting.rb +52 -0
  156. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  157. data/lib/rgame/engine/components/tile_world.rb +109 -0
  158. data/lib/rgame/engine/components/timer.rb +75 -0
  159. data/lib/rgame/engine/components/velocity.rb +43 -0
  160. data/lib/rgame/engine/components/wander_controller.rb +64 -0
  161. data/lib/rgame/engine/components/world.rb +133 -0
  162. data/lib/rgame/engine/contact_set.rb +74 -0
  163. data/lib/rgame/engine/culling.rb +45 -0
  164. data/lib/rgame/engine/debug_overlay.rb +111 -0
  165. data/lib/rgame/engine/i18n/plural.rb +45 -0
  166. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  167. data/lib/rgame/engine/i18n/template.rb +59 -0
  168. data/lib/rgame/engine/i18n.rb +322 -0
  169. data/lib/rgame/engine/input/action_mapper.rb +100 -0
  170. data/lib/rgame/engine/input/actions.rb +92 -0
  171. data/lib/rgame/engine/input/input_map.rb +196 -0
  172. data/lib/rgame/engine/layout.rb +82 -0
  173. data/lib/rgame/engine/nav_grid.rb +87 -0
  174. data/lib/rgame/engine/node2d.rb +555 -0
  175. data/lib/rgame/engine/path.rb +76 -0
  176. data/lib/rgame/engine/player.rb +69 -0
  177. data/lib/rgame/engine/player_layer.rb +70 -0
  178. data/lib/rgame/engine/players.rb +205 -0
  179. data/lib/rgame/engine/pool.rb +51 -0
  180. data/lib/rgame/engine/presentation.rb +171 -0
  181. data/lib/rgame/engine/scene/scene_stack.rb +87 -0
  182. data/lib/rgame/engine/sealed_privates.rb +54 -0
  183. data/lib/rgame/engine/signal.rb +75 -0
  184. data/lib/rgame/engine/spatial_hash.rb +116 -0
  185. data/lib/rgame/engine/text.rb +194 -0
  186. data/lib/rgame/engine/tile_blockers.rb +63 -0
  187. data/lib/rgame/engine/tile_map.rb +147 -0
  188. data/lib/rgame/engine/tile_map_layer.rb +82 -0
  189. data/lib/rgame/engine/tileset.rb +99 -0
  190. data/lib/rgame/engine/timer.rb +51 -0
  191. data/lib/rgame/engine/ui/button.rb +248 -0
  192. data/lib/rgame/engine/ui/column.rb +20 -0
  193. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  194. data/lib/rgame/engine/ui/menu.rb +290 -0
  195. data/lib/rgame/engine/ui/navigation.rb +57 -0
  196. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  197. data/lib/rgame/engine/ui/option_button.rb +163 -0
  198. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  199. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  200. data/lib/rgame/engine/ui/pointing.rb +146 -0
  201. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  202. data/lib/rgame/engine/ui/ring.rb +55 -0
  203. data/lib/rgame/engine/ui/row.rb +21 -0
  204. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  205. data/lib/rgame/engine/ui/stack.rb +58 -0
  206. data/lib/rgame/engine/ui/stepping.rb +93 -0
  207. data/lib/rgame/engine/ui/text_button.rb +59 -0
  208. data/lib/rgame/engine/view.rb +76 -0
  209. data/lib/rgame/engine/viewports.rb +171 -0
  210. data/lib/rgame/engine/world_view.rb +71 -0
  211. data/lib/rgame/engine.rb +86 -0
  212. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  213. data/lib/rgame/fonts/OFL.txt +102 -0
  214. data/lib/rgame/game.rb +271 -0
  215. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  216. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  217. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  218. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  219. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  220. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  221. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  222. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  223. data/lib/rgame/rubocop/default.yml +39 -0
  224. data/lib/rgame/rubocop/plugin.rb +45 -0
  225. data/lib/rgame/rubocop.rb +11 -0
  226. data/lib/rgame/util/color.rb +23 -0
  227. data/lib/rgame/util/controls.rb +169 -0
  228. data/lib/rgame/util/route_search.rb +27 -0
  229. data/lib/rgame/util/save_file.rb +107 -0
  230. data/lib/rgame/util/solid_grid.rb +37 -0
  231. data/lib/rgame/util/tensor.rb +3 -0
  232. data/lib/rgame/util/tile_sweep.rb +36 -0
  233. data/lib/rgame/util/z.rb +123 -0
  234. data/lib/rgame/util.rb +10 -0
  235. data/lib/rgame/util_ext.so +0 -0
  236. data/lib/rgame/version.rb +12 -0
  237. data/lib/rgame.rb +5 -0
  238. data/licenses/SDL2/LICENSE.txt +18 -0
  239. metadata +309 -0
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module Signal
6
+ IDENTIFIER = /\A[a-z_][a-zA-Z0-9_]*\z/
7
+
8
+ # Class-level DSL for declaring signal "slots" so a host class need not hand-write
9
+ # the connect-forwarding boilerplate. `extend Engine::Signal::DSL`, then:
10
+ #
11
+ # signal :on_clicked # a no-arg signal
12
+ # signal :on_changed, ChangeSignal # a typed signal (Signal.define(:index, :value))
13
+ #
14
+ # For :on_clicked this generates:
15
+ # def on_clicked(&block) = on_clicked_signal.connect(&block) # public: subscribe, returns handle
16
+ # def on_clicked_signal = (@on_clicked ||= type.new) # private: the Signal, to emit on
17
+ #
18
+ # The Signal is built lazily on first use, so the host wires nothing in #initialize;
19
+ # emit from inside the host via the private `<name>_signal` reader.
20
+ module DSL
21
+ def signal(name, type = Signal.define)
22
+ ivar = :"@#{name}"
23
+ reader = :"#{name}_signal"
24
+
25
+ define_method(reader) do
26
+ instance_variable_get(ivar) || instance_variable_set(ivar, type.new)
27
+ end
28
+ private reader
29
+
30
+ define_method(name) { |&block| send(reader).connect(&block) }
31
+ end
32
+ end
33
+
34
+ def self.define(*fields)
35
+ fields.each do |field|
36
+ raise ArgumentError, "invalid field name: #{field.inspect}" unless field.to_s.match?(IDENTIFIER)
37
+ end
38
+
39
+ params = fields.size == 1 ? fields.first : fields.map { |f| "#{f}:" }.join(', ')
40
+ args = fields.join(', ')
41
+
42
+ Class.new do
43
+ class_eval(<<~RUBY, __FILE__, __LINE__ + 1)
44
+ # For fields [:x, :y] this generates:
45
+ # def initialize
46
+ # @listeners = []
47
+ # end
48
+ # def connect(&callback)
49
+ # @listeners << callback
50
+ # callback
51
+ # end
52
+ # def disconnect(handle) = @listeners.delete(handle)
53
+ # def emit(x:, y:)
54
+ # @listeners.each { it.call(x, y) }
55
+ # end
56
+
57
+ def initialize
58
+ @listeners = []
59
+ end
60
+
61
+ def connect(&callback)
62
+ @listeners << callback
63
+ callback
64
+ end
65
+
66
+ def disconnect(handle) = @listeners.delete(handle)
67
+ def emit(#{params})
68
+ @listeners.each { it.call(#{args}) }
69
+ end
70
+ RUBY
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A uniform-grid spatial hash for broadphase collision: bucket colliders into
6
+ # fixed-size cells, then test only candidates that share a cell instead of every
7
+ # pair. Pure logic; no graphics.
8
+ #
9
+ # Typical per-frame use: `clear`, `insert` every collider of the static set
10
+ # (here: rocks), then `query` around each moving collider (bullets, the ship).
11
+ #
12
+ # hash.clear
13
+ # rocks.each { |r| hash.insert(r, *r.aabb) }
14
+ # hash.query(*bullet.aabb) { |rock| ...narrowphase... }
15
+ class SpatialHash
16
+ OFFSET = 1 << 13
17
+ STRIDE = 1 << 14
18
+
19
+ def initialize(cell_size:)
20
+ @cell_size = cell_size
21
+ @buckets = Hash.new { |h, key| h[key] = [] }
22
+ end
23
+
24
+ # Reuse the bucket arrays across frames (clear contents, keep capacity).
25
+ def clear
26
+ @buckets.each_value(&:clear)
27
+ end
28
+
29
+ def insert(item, x, y, w, h)
30
+ each_cell(x, y, w, h) { |key| @buckets[key] << item }
31
+ end
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
+
51
+ # Yield every item whose buckets overlap the region. Dedup contract: an item
52
+ # spanning several cells may be yielded more than once. Narrowphase callers
53
+ # must already guard with `next if a.dead? || b.dead?` to make hits idempotent,
54
+ # so we skip a per-query visited set and stay allocation-free.
55
+ def query(x, y, w, h, &)
56
+ each_cell(x, y, w, h) do |key|
57
+ bucket = @buckets[key]
58
+ bucket.each(&) unless bucket.empty?
59
+ end
60
+ end
61
+
62
+ # Broadphase a circle: yield every item bucketed in a cell the circle's bounding
63
+ # box covers — the radial counterpart to #query, for range/nearest lookups. Same
64
+ # dedup contract (an item may be yielded more than once; the narrowphase caller
65
+ # refines by true distance). Allocation-free.
66
+ def query_circle(cx, cy, r, &)
67
+ d = r * 2
68
+ query(cx - r, cy - r, d, d, &)
69
+ end
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
+
90
+ private
91
+
92
+ def cell_key(col, row) = (col + OFFSET) * STRIDE + (row + OFFSET)
93
+
94
+ def each_cell(x, y, w, h)
95
+ col0 = (x / @cell_size).floor
96
+ row0 = (y / @cell_size).floor
97
+ col1 = last_cell(x + w, col0)
98
+ row1 = last_cell(y + h, row0)
99
+ row0.upto(row1) do |row|
100
+ col0.upto(col1) do |col|
101
+ yield cell_key(col, row)
102
+ end
103
+ end
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
114
+ end
115
+ end
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
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rexml/document'
4
+ require 'zlib'
5
+
6
+ require_relative '../util'
7
+ require_relative 'tileset'
8
+
9
+ module RGame
10
+ module Engine
11
+ # An orthogonal tile map parsed from a Tiled .tmx. Holds the per-layer gid
12
+ # arrays and geometry, and answers collision queries via its Tileset.
13
+ #
14
+ # `parse` takes a *string*, so the parsing itself needs no filesystem at all;
15
+ # `load` adds the file plumbing on top — reading the .tmx, following it to the
16
+ # .tsx it names, and working out where the tileset image sits relative to
17
+ # that. Both belong here. What does *not* is the image: a texture is a GPU
18
+ # handle, and the renderer that owns one lives a layer below and may not name
19
+ # this class (see CLAUDE.md, "The rule points both ways"). So `load` hands
20
+ # back a path and stops there.
21
+ class TileMap
22
+ FLIP_MASK = 0x1FFFFFFF
23
+
24
+ attr_reader :width, :height, :tile_width, :tile_height,
25
+ :pixel_width, :pixel_height, :tileset_source, :firstgid
26
+ attr_accessor :tileset
27
+
28
+ # Reads a .tmx and everything it points at, returning `[map, image_path]`.
29
+ #
30
+ # Two values rather than one because they are two kinds of thing: the map is
31
+ # the grid, and the path is where its pixels happen to live. Keeping the
32
+ # second off the map means a stand-in map in a spec has one less method to
33
+ # answer, and the renderer's protocol stays "things about the grid".
34
+ #
35
+ # Every path is resolved relative to the file that named it — the .tsx
36
+ # relative to the .tmx, the image relative to the .tsx — which is what Tiled
37
+ # itself writes and what lets a map be moved as a set.
38
+ def self.load(tmx_path)
39
+ map = parse(File.read(tmx_path))
40
+
41
+ tsx_path = File.join(File.dirname(tmx_path), map.tileset_source)
42
+ map.tileset = Tileset.parse(File.read(tsx_path), firstgid: map.firstgid)
43
+
44
+ [map, File.join(File.dirname(tsx_path), map.tileset.image_source)]
45
+ end
46
+
47
+ def self.parse(tmx_string)
48
+ root = REXML::Document.new(tmx_string).root
49
+ tileset_el = root.elements['tileset']
50
+
51
+ layers = []
52
+ above = []
53
+ root.each_element('layer') do |layer_el|
54
+ raw = layer_el.elements['data'].text.strip.unpack1('m')
55
+ gids = Zlib::Inflate.inflate(raw).unpack('V*')
56
+ gids.map! { |g| g & FLIP_MASK }
57
+ layers << gids
58
+ above << layer_flag?(layer_el, 'above')
59
+ end
60
+
61
+ new(
62
+ width: root.attributes['width'].to_i,
63
+ height: root.attributes['height'].to_i,
64
+ tile_width: root.attributes['tilewidth'].to_i,
65
+ tile_height: root.attributes['tileheight'].to_i,
66
+ tileset_source: tileset_el.attributes['source'],
67
+ firstgid: tileset_el.attributes['firstgid'].to_i,
68
+ layers: layers,
69
+ above: above
70
+ )
71
+ end
72
+
73
+ def self.layer_flag?(layer_el, name)
74
+ props = layer_el.elements['properties']
75
+ return false unless props
76
+
77
+ props.each_element('property') do |property|
78
+ return property.attributes['value'] == 'true' if property.attributes['name'] == name
79
+ end
80
+ false
81
+ end
82
+ private_class_method :layer_flag?
83
+
84
+ def initialize(width:, height:, tile_width:, tile_height:, tileset_source:, firstgid:, layers:, above: [])
85
+ @width = width
86
+ @height = height
87
+ @tile_width = tile_width
88
+ @tile_height = tile_height
89
+ @pixel_width = width * tile_width
90
+ @pixel_height = height * tile_height
91
+ @tileset_source = tileset_source
92
+ @firstgid = firstgid
93
+ @above = above
94
+ @tileset = nil
95
+ @tiles = build_tiles(layers)
96
+ end
97
+
98
+ def layer_count
99
+ @tiles.depth
100
+ end
101
+
102
+ # Whether a layer is drawn *above* the actors (a tree canopy, roof, etc.) rather
103
+ # than beneath them. Marked in Tiled with a bool layer property `above`; layers
104
+ # without it default to below. Purely a rendering distinction (collision still
105
+ # considers every layer).
106
+ def above_layer?(index)
107
+ @above[index] || false
108
+ end
109
+
110
+ def in_bounds?(col, row)
111
+ col >= 0 && row >= 0 && col < @width && row < @height
112
+ end
113
+
114
+ def gid(layer_index, col, row)
115
+ return 0 unless in_bounds?(col, row)
116
+
117
+ @tiles[col, row, layer_index]
118
+ end
119
+
120
+ # Solid if any layer has a solid tile at (col, row). Out of bounds is not solid
121
+ # — the camera/bounds clamp keeps the player inside the map.
122
+ def solid_tile?(col, row)
123
+ return false unless in_bounds?(col, row)
124
+
125
+ @tiles.depth.times { |layer| return true if @tileset.solid?(@tiles[col, row, layer]) }
126
+ false
127
+ end
128
+
129
+ def solid_at?(world_x, world_y)
130
+ solid_tile?((world_x / @tile_width).floor, (world_y / @tile_height).floor)
131
+ end
132
+
133
+ private
134
+
135
+ def build_tiles(layers)
136
+ tiles = Util::Tensor.new(@width, @height, layers.length, initial: 0)
137
+ layers.each_with_index do |gids, layer|
138
+ @height.times do |row|
139
+ base = row * @width
140
+ @width.times { |col| tiles[col, row, layer] = gids[base + col] }
141
+ end
142
+ end
143
+ tiles
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # One layer of the scene's tile map, drawn in world space, once per viewport.
6
+ #
7
+ # world = scene.add_node(WorldView.new)
8
+ # actors = TileMapLayer.mount(world)
9
+ # actors.add_node(player)
10
+ #
11
+ # A node per layer, and the layers Tiled lists are the layers you get. The
12
+ # scene tree is then what says what covers what: everything mounted before
13
+ # the actors draws under them, everything after draws over them, and the
14
+ # gap `mount` leaves is where the actors go.
15
+ #
16
+ # It belongs **inside a WorldView**, which is the whole point of it existing
17
+ # separately from Components::TileWorld. The map is world content: it
18
+ # scrolls under a camera and every player sees their own part of it, so it
19
+ # has to be drawn where the rest of the world is drawn rather than once for
20
+ # the frame.
21
+ #
22
+ # It carries no state. The map id and the animation clock come from the
23
+ # scene's TileWorld system, and the region worth drawing comes from the view.
24
+ #
25
+ # ## Why a node per layer, rather than two passes
26
+ #
27
+ # There used to be one of these, drawing a "below" band and an "above" band
28
+ # in one go and relying on a global z to slot the actors between them. Draw
29
+ # order is tree order now (see RGame::Util::Z), so a node's drawing is
30
+ # contiguous and "between them" has to mean "between two nodes".
31
+ #
32
+ # That turned out to be the better shape anyway. A designer already orders
33
+ # layers in Tiled and can see the result there; content can go between *any*
34
+ # two of them rather than at one flagged boundary; and the `above` property
35
+ # stops being something to remember on every layer — it is read once, by
36
+ # `mount`, to decide where the gap goes.
37
+ class TileMapLayer < Node2D
38
+ # Mounts one node per layer of the scene's map under `parent`, and returns
39
+ # an empty node sitting in the gap between them — what the scene hangs its
40
+ # actors on. Nothing here picks a z by hand, and neither does the caller.
41
+ #
42
+ # `under` names the first layer that should cover the actors, as a layer
43
+ # index. It defaults to the first layer the map flags `above` in Tiled, so
44
+ # a map that already marks its canopies needs nothing said; a map that
45
+ # marks none puts the actors on top of everything.
46
+ #
47
+ # `parent` must be inside a WorldView, like the nodes themselves.
48
+ def self.mount(parent, under: nil)
49
+ world = parent.system(Components::TileWorld)
50
+ gap = under || world.first_above_layer
51
+
52
+ world.layer_count.times do |index|
53
+ parent.add_node(new(layer: index, z: index < gap ? index : index + 1))
54
+ end
55
+ parent.add_node(Node2D.new(z: gap))
56
+ end
57
+
58
+ def initialize(layer:, **)
59
+ super(**)
60
+ @layer = layer
61
+ end
62
+
63
+ def on_add = @world = system(Components::TileWorld)
64
+
65
+ # The view supplies the cull rect: which part of the world this viewport
66
+ # can see. The map draws in world coordinates and the WorldView's
67
+ # translate puts it on screen, so nothing here does camera arithmetic —
68
+ # which is exactly what lets the same map serve every viewport.
69
+ #
70
+ # A screen-space view has no camera and nothing to cull against, so
71
+ # there is nothing sensible to draw; that is a misplaced layer rather than
72
+ # a state to handle, and it says so.
73
+ def on_draw(renderer, view)
74
+ camera = view.camera
75
+ raise 'TileMapLayer must be inside a WorldView — this view has no camera' if camera.nil?
76
+
77
+ renderer.tilemap(@world.tilemap_id, @layer, camera.x, camera.y,
78
+ view.width, view.height, elapsed: @world.elapsed)
79
+ end
80
+ end
81
+ end
82
+ end