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
@@ -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
@@ -20,7 +20,7 @@ module RGame
20
20
  # this class (see CLAUDE.md, "The rule points both ways"). So `load` hands
21
21
  # back a path and stops there.
22
22
  class TileMap
23
- FLIP_MASK = 0x1FFFFFFF # strip Tiled's flip/rotation flags from a gid
23
+ FLIP_MASK = 0x1FFFFFFF
24
24
 
25
25
  attr_reader :width, :height, :tile_width, :tile_height,
26
26
  :pixel_width, :pixel_height, :tileset_source, :firstgid
@@ -71,7 +71,6 @@ module RGame
71
71
  )
72
72
  end
73
73
 
74
- # True if a layer carries a Tiled bool custom property `name` set to "true".
75
74
  def self.layer_flag?(layer_el, name)
76
75
  props = layer_el.elements['properties']
77
76
  return false unless props
@@ -81,6 +80,7 @@ module RGame
81
80
  end
82
81
  false
83
82
  end
83
+ private_class_method :layer_flag?
84
84
 
85
85
  def initialize(width:, height:, tile_width:, tile_height:, tileset_source:, firstgid:, layers:, above: [])
86
86
  @width = width
@@ -133,7 +133,6 @@ module RGame
133
133
 
134
134
  private
135
135
 
136
- # Pack the parsed per-layer gid rows into one flat Tensor ([col, row, layer]).
137
136
  def build_tiles(layers)
138
137
  tiles = Util::Tensor.new(@width, @height, layers.length, initial: 0)
139
138
  layers.each_with_index do |gids, layer|
@@ -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
@@ -15,7 +15,7 @@ module RGame
15
15
  Frame = Struct.new(:tile_id, :duration)
16
16
 
17
17
  attr_reader :firstgid, :columns, :tile_width, :tile_height, :image_source, :animations
18
- attr_accessor :solid_ids # Set of *local* tile ids treated as solid
18
+ attr_accessor :solid_ids
19
19
 
20
20
  def self.parse(tsx_string, firstgid:)
21
21
  root = REXML::Document.new(tsx_string).root
@@ -40,7 +40,6 @@ module RGame
40
40
  )
41
41
  end
42
42
 
43
- # Frames for an animated tile, or nil (compacted away) for a static one.
44
43
  def self.parse_animation(tile_el)
45
44
  anim = tile_el.elements['animation']
46
45
  return unless anim
@@ -50,12 +49,11 @@ module RGame
50
49
  end
51
50
  end
52
51
 
53
- # A tile is solid if it carries a Tiled collision shape — an `<objectgroup>`
54
- # with at least one object (the per-tile collision editor's output).
55
52
  def self.collision_shape?(tile_el)
56
53
  group = tile_el.elements['objectgroup']
57
54
  !group.nil? && !group.elements['object'].nil?
58
55
  end
56
+ private_class_method :parse_animation, :collision_shape?
59
57
 
60
58
  def initialize(firstgid:, columns:, tile_width:, tile_height:, image_source:, animations:, solid_ids: Set.new)
61
59
  @firstgid = firstgid
@@ -3,12 +3,12 @@
3
3
  module RGame
4
4
  module Engine
5
5
  # A repeating interval timer for periodic events that aren't driven by input — a
6
- # spawner emitting an enemy every N seconds, a tower's fire rate, a wave clock.
6
+ # spawner emitting an enemy every N seconds, a turret's fire rate, a wave clock.
7
7
  #
8
8
  # It only accumulates time; the owner decides what each elapsed interval means. That
9
9
  # split lets one primitive serve both styles: "act automatically" (consume every ready
10
10
  # interval) and "stay loaded until conditions allow" (check `ready?`, but `consume`
11
- # only when actually acting — so a tower with no target keeps its shot ready instead of
11
+ # only when actually acting — so a turret with no target keeps its shot ready instead of
12
12
  # wasting it). Pure and allocation-free, so it ticks on the per-frame path.
13
13
  #
14
14
  # timer = Engine::Timer.new(0.8)
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # Anything a Menu can hold: the state a button has — enabled, focused,
7
+ # pressed — and a signal for when it is chosen, with no look at all.
8
+ #
9
+ # class Swatch < UI::Button
10
+ # def initialize(color:, **)
11
+ # super(**)
12
+ # @color = color
13
+ # end
14
+ #
15
+ # def on_draw(renderer, _view)
16
+ # inset = state == :focused ? 0 : 4
17
+ # renderer.rect(inset, inset, width - (inset * 2), height - (inset * 2), color: @color)
18
+ # end
19
+ # end
20
+ #
21
+ # The look is a subclass's `on_draw`, and it reads `state` to decide what
22
+ # to draw. Everything the menu needs from a button — being told it is
23
+ # focused or pressed, being activated, being asked to `adjust` — lives
24
+ # here, so a subclass that only draws cannot leave any of it out.
25
+ #
26
+ # There is no hover, because there is no pointer: what a mouse-driven
27
+ # control would get from the cursor being over it, this gets from the Menu
28
+ # telling it it is the focused one.
29
+ #
30
+ # Its position is its own, resolved through the tree like any node's, so a
31
+ # Menu inside a PlayerLayer puts its buttons inside that player's region
32
+ # without either of them arranging it.
33
+ #
34
+ # A Button with no `on_draw` of its own draws nothing.
35
+ #
36
+ # ## The label is a translation key
37
+ #
38
+ # `label: 'play'` is a key, and the button holds it as an Engine::Text, so
39
+ # what is drawn follows `I18n.locale` without the button being rebuilt.
40
+ # Text that must not be translated — a player's name — is passed as one:
41
+ #
42
+ # UI::TextButton.new(label: 'play') # the key 'play'
43
+ # UI::TextButton.new(label: Engine::Text.literal(name)) # the name, in every language
44
+ #
45
+ # A label is drawn as it reads, so a `Text` with variables shows the values
46
+ # its last `with` was given. The node that owns them calls `with` in
47
+ # `update`, and the label follows:
48
+ #
49
+ # @continue = Engine::Text.new('continue', :saves)
50
+ # menu.add(UI::PanelButton.new(label: @continue))
51
+ # def on_update(_dt) = @continue.with(saves: @save_count)
52
+ #
53
+ # One given no `with` yet raises on the first draw, naming the keywords.
54
+ #
55
+ # `label_scope` puts a scope in front of a label given as a key, and
56
+ # UI::Menu sets it from its own `scope:` as the button is added. A label
57
+ # given as a `Text` is the caller's, scope and all, and nothing changes it.
58
+ #
59
+ # ## When a press activates
60
+ #
61
+ # `activate_on:` is `:release` by default: confirm draws the button pressed
62
+ # while it is held and activates it when it is let go, so a player who
63
+ # moves focus away while still holding cancels. `:press` activates on the
64
+ # way down — what a skill bar wants — and keeps the button drawn pressed
65
+ # for at least `PRESS_FEEDBACK` seconds, because a tap is otherwise a
66
+ # single frame of pressed that nobody sees. Its 0.1 s is Unity's, which
67
+ # holds a submitted button pressed that long; Godot holds a shortcut's for
68
+ # 0.2 s.
69
+ #
70
+ # Either way a button only acts on a press it saw start. A press whose
71
+ # release it did not see — its menu was paused or covered in between — is
72
+ # dropped rather than finished, so a menu that closes itself from
73
+ # `on_activated` and is opened again does not come back pressed. See
74
+ # UI::Menu for the other half, a menu that has not yet seen confirm up.
75
+ #
76
+ # ## A hotkey
77
+ #
78
+ # `hotkey:` names an action that presses this button whether or not it is
79
+ # focused, and without moving focus — a skill bar's number keys:
80
+ #
81
+ # UI::IconButton.new(image: :torch, hotkey: :skill3)
82
+ #
83
+ # A hotkey always activates on the press, whatever `activate_on:` says,
84
+ # and draws pressed for `PRESS_FEEDBACK` or while held. Its release
85
+ # activates nothing. The menu reads it, so the action has to be declared
86
+ # in the player's InputMap; an undeclared one raises on the first frame.
87
+ #
88
+ # **A press belongs to the source that started it.** The menu passes
89
+ # `:confirm` or `:hotkey` to `press`, `release` and `cancel_press`, and
90
+ # while one source holds the button a press from the other is ignored and
91
+ # its release ends nothing. So a player holding confirm on a `:release`
92
+ # button who taps that button's hotkey activates it once, when confirm is
93
+ # let go; and losing focus ends a confirm hold but not a hotkey one.
94
+ class Button < Node2D
95
+ signal :on_activated
96
+
97
+ PRESS_FEEDBACK = 0.1
98
+ STATES = %i[idle focused pressed disabled].freeze
99
+ ACTIVATE_ON = %i[release press].freeze
100
+ SOURCES = %i[confirm hotkey].freeze
101
+
102
+ attr_accessor :enabled
103
+ # The Engine::Text drawn for this button, or nil.
104
+ attr_reader :label
105
+ # The scope a label given as a key resolves under, or nil.
106
+ attr_reader :label_scope
107
+ # `hotkey` is an action name, or nil.
108
+ attr_reader :activate_on, :hotkey
109
+
110
+ def initialize(label: nil, enabled: true, activate_on: :release, hotkey: nil, **)
111
+ super(**)
112
+ unless ACTIVATE_ON.include?(activate_on)
113
+ raise ArgumentError, "activate_on: must be :release or :press, not #{activate_on.inspect}"
114
+ end
115
+
116
+ @label_scope = nil
117
+ self.label = label
118
+ @enabled = enabled
119
+ @activate_on = activate_on
120
+ @hotkey = hotkey
121
+ @focused = false
122
+ @holder = nil
123
+ @feedback = 0.0
124
+ end
125
+
126
+ # Sets the label: a key String or Symbol, which the button makes an
127
+ # Engine::Text of under `label_scope`; a `Text`, used as it is; or nil.
128
+ def label=(label)
129
+ @label = label && text_for(label)
130
+ @label_from_key = !(label.nil? || label.is_a?(Text))
131
+ end
132
+
133
+ # Sets the scope a label given as a key resolves under — `'title_menu'`
134
+ # makes the key `'play'` read `'title_menu.play'` — and resolves it again
135
+ # on the next draw.
136
+ def label_scope=(scope)
137
+ @label_scope = scope&.to_s
138
+ @label.scope = @label_scope if @label_from_key
139
+ end
140
+
141
+ def enabled? = @enabled
142
+ def focused? = @focused
143
+ def pressed? = !@holder.nil? || @feedback.positive?
144
+
145
+ # What to draw: `:disabled` whenever the button is disabled, whatever
146
+ # else is true; then `:pressed`, `:focused`, and otherwise `:idle`.
147
+ # Pressed does not require focus: a press can outlast the focus that
148
+ # started it by its `PRESS_FEEDBACK`.
149
+ def state
150
+ return :disabled unless @enabled
151
+ return :pressed if pressed?
152
+
153
+ @focused ? :focused : :idle
154
+ end
155
+
156
+ # Called by the Menu. Calls `on_focus_changed` when the value actually
157
+ # changes, and never for a repeated assignment, so a menu that reasserts
158
+ # focus every frame does not replay a focus sound every frame. Losing
159
+ # focus lets go of a confirm press without activating it; a hotkey press
160
+ # was never about focus, and is kept.
161
+ def focused=(value)
162
+ return if @focused == value
163
+
164
+ @focused = value
165
+ @holder = nil if !value && @holder == :confirm
166
+ on_focus_changed(value)
167
+ end
168
+
169
+ # Called by the Menu when a press from `source` — `:confirm` or
170
+ # `:hotkey` — starts on this button. A hotkey press, or any press under
171
+ # `activate_on: :press`, activates at once and returns what `activate`
172
+ # did; a confirm press under `:release` holds and returns nil. Ignored,
173
+ # returning nil, while disabled or while any source already holds it.
174
+ def press(source = :confirm)
175
+ unless SOURCES.include?(source)
176
+ raise ArgumentError, "source must be :confirm or :hotkey, not #{source.inspect}"
177
+ end
178
+ return nil unless @enabled && @holder.nil?
179
+
180
+ @holder = source
181
+ return nil unless source == :hotkey || @activate_on == :press
182
+
183
+ activate_with_feedback
184
+ end
185
+
186
+ # Called by the Menu when a press `source` started is let go. Activates
187
+ # a confirm press under `activate_on: :release`, and returns what
188
+ # `activate` did; ends nothing another source started.
189
+ def release(source = :confirm)
190
+ return nil unless @holder == source
191
+
192
+ @holder = nil
193
+ activate if source == :confirm && @activate_on == :release
194
+ end
195
+
196
+ # Called by the Menu for a press from `source` whose release this button
197
+ # never saw. Drops it, and the feedback with it, activating nothing.
198
+ def cancel_press(source = :confirm)
199
+ return unless @holder == source
200
+
201
+ @holder = nil
202
+ @feedback = 0.0
203
+ end
204
+
205
+ # The instant press: activates, and shows pressed for `PRESS_FEEDBACK`
206
+ # though nothing holds the button. What a hotkey press and a
207
+ # `:press` confirm do, available on its own for a press that ends
208
+ # somewhere other than on this button. Returns what `activate` did.
209
+ def activate_with_feedback
210
+ return nil unless @enabled
211
+
212
+ @feedback = PRESS_FEEDBACK
213
+ activate
214
+ end
215
+
216
+ # What the focused button does with `ui_left` / `ui_right`, which the
217
+ # menu hands down without knowing what kind of button it is talking to.
218
+ # A plain button has nothing to change, and answers nil the way a
219
+ # disabled `activate` does. UI::OptionButton is the one that overrides it.
220
+ def adjust(_delta) = nil
221
+
222
+ # Fires the signal and returns the button, or nil if it is disabled — so
223
+ # a caller never has to check first, and a disabled button cannot be
224
+ # activated by any route.
225
+ def activate
226
+ return nil unless @enabled
227
+
228
+ on_activated_signal.emit
229
+ self
230
+ end
231
+
232
+ # Counts down the pressed feedback, then does what every node does.
233
+ def update(dt)
234
+ @feedback -= dt if @feedback.positive? && !@paused
235
+ super
236
+ end
237
+
238
+ # Hook: override to react to gaining or losing focus — a sound, the
239
+ # start of an animation. Called only on a change.
240
+ def on_focus_changed(focused); end
241
+
242
+ private
243
+
244
+ def text_for(shown) = shown.is_a?(Text) ? shown : Text.new(shown, scope: @label_scope)
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A Menu layout: buttons stacked downwards from the menu's origin, all the
7
+ # same size, `spacing` pixels apart. A UI::Stack on the vertical axis, so
8
+ # UI::Stepping moves through it with `ui_up` and `ui_down`.
9
+ #
10
+ # UI::Menu.new(layout: UI::Column.new(item_width: 220, item_height: 44))
11
+ #
12
+ # It takes no `axis:` — a column that is not vertical is a UI::Row.
13
+ class Column < Stack
14
+ def initialize(item_width:, item_height:, spacing: 8)
15
+ super(axis: :vertical, item_width: item_width, item_height: item_height, spacing: spacing)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end