rgame 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -1
  3. data/README.md +67 -65
  4. data/docs/api/README.md +86 -82
  5. data/docs/api/app.md +125 -68
  6. data/docs/api/assets.md +203 -165
  7. data/docs/api/audio.md +130 -89
  8. data/docs/api/cli.md +259 -0
  9. data/docs/api/components.md +1032 -265
  10. data/docs/api/drawing.md +160 -160
  11. data/docs/api/examples.md +263 -0
  12. data/docs/api/game.md +149 -50
  13. data/docs/api/images.md +39 -39
  14. data/docs/api/input.md +226 -148
  15. data/docs/api/internals.md +241 -62
  16. data/docs/api/localization.md +285 -0
  17. data/docs/api/scene_graph.md +397 -244
  18. data/docs/api/signals.md +81 -76
  19. data/docs/api/systems.md +186 -73
  20. data/docs/api/text.md +58 -41
  21. data/docs/api/tile_maps.md +237 -0
  22. data/docs/api/toolbox.md +395 -146
  23. data/docs/api/ui.md +883 -54
  24. data/docs/api/values.md +216 -33
  25. data/examples/assets/README.md +322 -0
  26. data/examples/assets/blip.ogg +0 -0
  27. data/examples/assets/glyphs.json +5 -0
  28. data/examples/assets/glyphs.png +0 -0
  29. data/examples/assets/hero.json +12 -0
  30. data/examples/assets/hero.png +0 -0
  31. data/examples/assets/icons.json +13 -0
  32. data/examples/assets/icons.png +0 -0
  33. data/examples/assets/music.ogg +0 -0
  34. data/examples/assets/skills.json +10 -0
  35. data/examples/assets/skills.png +0 -0
  36. data/examples/assets/tileset.png +0 -0
  37. data/examples/assets/tileset.tsx +65 -0
  38. data/examples/assets/town.tmx +26 -0
  39. data/examples/assets/ui.json +11 -0
  40. data/examples/assets/ui.png +0 -0
  41. data/examples/collision/locales/en.yml +8 -0
  42. data/examples/collision/main.rb +316 -0
  43. data/examples/collision_tiles/locales/en.yml +9 -0
  44. data/examples/collision_tiles/main.rb +274 -0
  45. data/examples/fullscreen/locales/en.yml +10 -0
  46. data/examples/fullscreen/main.rb +216 -0
  47. data/examples/game_menu/locales/en.yml +8 -0
  48. data/examples/game_menu/main.rb +170 -0
  49. data/examples/input_glyphs/locales/en.yml +14 -0
  50. data/examples/input_glyphs/main.rb +213 -0
  51. data/examples/jump_topdown/locales/en.yml +9 -0
  52. data/examples/jump_topdown/main.rb +178 -0
  53. data/examples/localization/locales/de.yml +12 -0
  54. data/examples/localization/locales/en.yml +13 -0
  55. data/examples/localization/main.rb +158 -0
  56. data/examples/menu_navigation/locales/en.yml +23 -0
  57. data/examples/menu_navigation/main.rb +365 -0
  58. data/examples/music/locales/en.yml +7 -0
  59. data/examples/music/main.rb +134 -0
  60. data/examples/pathfinding/locales/en.yml +17 -0
  61. data/examples/pathfinding/main.rb +298 -0
  62. data/examples/pooling/locales/en.yml +7 -0
  63. data/examples/pooling/main.rb +259 -0
  64. data/examples/quick_wheel/locales/en.yml +16 -0
  65. data/examples/quick_wheel/main.rb +184 -0
  66. data/examples/radial_menu/locales/en.yml +16 -0
  67. data/examples/radial_menu/main.rb +184 -0
  68. data/examples/save_load/locales/en.yml +11 -0
  69. data/examples/save_load/main.rb +207 -0
  70. data/examples/save_load_ids/locales/en.yml +11 -0
  71. data/examples/save_load_ids/main.rb +322 -0
  72. data/examples/scroll_map/locales/en.yml +4 -0
  73. data/examples/scroll_map/main.rb +140 -0
  74. data/examples/signals/locales/en.yml +6 -0
  75. data/examples/signals/main.rb +278 -0
  76. data/examples/skill_bar/locales/en.yml +14 -0
  77. data/examples/skill_bar/main.rb +159 -0
  78. data/examples/sound/locales/en.yml +6 -0
  79. data/examples/sound/main.rb +122 -0
  80. data/examples/split_screen/locales/en.yml +9 -0
  81. data/examples/split_screen/main.rb +304 -0
  82. data/examples/sprite/locales/en.yml +8 -0
  83. data/examples/sprite/main.rb +180 -0
  84. data/examples/timer/locales/en.yml +12 -0
  85. data/examples/timer/main.rb +273 -0
  86. data/examples/velocity/locales/en.yml +6 -0
  87. data/examples/velocity/main.rb +196 -0
  88. data/examples/walk/locales/en.yml +4 -0
  89. data/examples/walk/main.rb +99 -0
  90. data/exe/rgame +9 -0
  91. data/ext/rgame_core/app/app.c +33 -3
  92. data/ext/rgame_core/app/locale.c +67 -0
  93. data/ext/rgame_core/app/locale.h +28 -0
  94. data/ext/rgame_core/audio/audio.c +39 -2
  95. data/ext/rgame_core/example.rb +0 -49
  96. data/ext/rgame_core/extconf.rb +0 -125
  97. data/ext/rgame_core/include/rgame/core.h +38 -2
  98. data/ext/rgame_core/ruby/audio_ext.c +10 -5
  99. data/ext/rgame_core/ruby/core_ext.c +30 -7
  100. data/ext/rgame_core/ruby/core_ext.h +3 -0
  101. data/ext/rgame_core/ruby/locale_ext.c +44 -0
  102. data/ext/rgame_core/ruby/recording_ext.c +1 -1
  103. data/ext/rgame_core/ruby/renderer_ext.c +20 -20
  104. data/ext/rgame_util/extconf.rb +2 -20
  105. data/ext/rgame_util/route_search.c +305 -0
  106. data/ext/rgame_util/route_search.h +86 -0
  107. data/ext/rgame_util/route_search_ext.c +150 -0
  108. data/ext/rgame_util/solid_grid.c +58 -0
  109. data/ext/rgame_util/solid_grid.h +49 -0
  110. data/ext/rgame_util/solid_grid_ext.c +161 -0
  111. data/ext/rgame_util/tile_sweep.c +164 -0
  112. data/ext/rgame_util/tile_sweep.h +62 -0
  113. data/ext/rgame_util/tile_sweep_ext.c +155 -0
  114. data/ext/rgame_util/util_ext.c +3 -0
  115. data/ext/rgame_util/util_ext.h +15 -0
  116. data/lib/rgame/boot.rb +0 -10
  117. data/lib/rgame/cli/new_project.rb +139 -0
  118. data/lib/rgame/cli/templates/Gemfile.tt +23 -0
  119. data/lib/rgame/cli/templates/README.md.tt +93 -0
  120. data/lib/rgame/cli/templates/Rakefile.tt +9 -0
  121. data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
  122. data/lib/rgame/cli/templates/game.rb.tt +23 -0
  123. data/lib/rgame/cli/templates/gitignore.tt +12 -0
  124. data/lib/rgame/cli/templates/main.rb.tt +11 -0
  125. data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
  126. data/lib/rgame/cli/templates/rspec.tt +2 -0
  127. data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
  128. data/lib/rgame/cli/templates/ruby-version.tt +1 -0
  129. data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
  130. data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
  131. data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
  132. data/lib/rgame/cli.rb +66 -0
  133. data/lib/rgame/core/app.rb +6 -44
  134. data/lib/rgame/core/asset_manager.rb +13 -31
  135. data/lib/rgame/core/audio.rb +37 -16
  136. data/lib/rgame/core/font.rb +0 -3
  137. data/lib/rgame/core/locale.rb +22 -0
  138. data/lib/rgame/core/nine_slice.rb +0 -21
  139. data/lib/rgame/core/renderer.rb +6 -63
  140. data/lib/rgame/core/sprite_sheet.rb +0 -3
  141. data/lib/rgame/core/tile_map_renderer.rb +2 -19
  142. data/lib/rgame/core/ui_atlas.rb +28 -13
  143. data/lib/rgame/core.rb +1 -8
  144. data/lib/rgame/engine/actor_blockers.rb +131 -0
  145. data/lib/rgame/engine/animation_set.rb +1 -0
  146. data/lib/rgame/engine/audio_director.rb +36 -6
  147. data/lib/rgame/engine/bounds_blockers.rb +74 -0
  148. data/lib/rgame/engine/camera.rb +3 -3
  149. data/lib/rgame/engine/circle_collider.rb +4 -2
  150. data/lib/rgame/engine/collision_box.rb +26 -1
  151. data/lib/rgame/engine/collision_system.rb +110 -22
  152. data/lib/rgame/engine/component.rb +34 -10
  153. data/lib/rgame/engine/components/action_trigger.rb +0 -1
  154. data/lib/rgame/engine/components/animated_sprite.rb +27 -25
  155. data/lib/rgame/engine/components/box_collider.rb +99 -0
  156. data/lib/rgame/engine/components/camera_follow.rb +6 -5
  157. data/lib/rgame/engine/components/character_body.rb +20 -61
  158. data/lib/rgame/engine/components/circle_collider.rb +47 -11
  159. data/lib/rgame/engine/components/collision_world.rb +159 -31
  160. data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
  161. data/lib/rgame/engine/components/feet_collider.rb +61 -0
  162. data/lib/rgame/engine/components/hop.rb +76 -0
  163. data/lib/rgame/engine/components/identity.rb +73 -0
  164. data/lib/rgame/engine/components/mover.rb +285 -0
  165. data/lib/rgame/engine/components/navigator.rb +145 -0
  166. data/lib/rgame/engine/components/path_follow.rb +123 -31
  167. data/lib/rgame/engine/components/player_controller.rb +5 -2
  168. data/lib/rgame/engine/components/pool.rb +1 -1
  169. data/lib/rgame/engine/components/screen_wrap.rb +33 -11
  170. data/lib/rgame/engine/components/sprite.rb +12 -6
  171. data/lib/rgame/engine/components/targeting.rb +9 -11
  172. data/lib/rgame/engine/components/thrust_controller.rb +1 -1
  173. data/lib/rgame/engine/components/tile_world.rb +41 -13
  174. data/lib/rgame/engine/components/timer.rb +1 -1
  175. data/lib/rgame/engine/components/velocity.rb +23 -7
  176. data/lib/rgame/engine/components/wander_controller.rb +6 -2
  177. data/lib/rgame/engine/components/world.rb +133 -0
  178. data/lib/rgame/engine/contact_set.rb +74 -0
  179. data/lib/rgame/engine/culling.rb +4 -6
  180. data/lib/rgame/engine/debug_overlay.rb +5 -11
  181. data/lib/rgame/engine/i18n/plural.rb +45 -0
  182. data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
  183. data/lib/rgame/engine/i18n/template.rb +59 -0
  184. data/lib/rgame/engine/i18n.rb +276 -51
  185. data/lib/rgame/engine/input/action_mapper.rb +0 -26
  186. data/lib/rgame/engine/input/actions.rb +2 -8
  187. data/lib/rgame/engine/input/input_map.rb +39 -21
  188. data/lib/rgame/engine/nav_grid.rb +87 -0
  189. data/lib/rgame/engine/node2d.rb +248 -133
  190. data/lib/rgame/engine/path.rb +4 -6
  191. data/lib/rgame/engine/players.rb +6 -13
  192. data/lib/rgame/engine/presentation.rb +171 -0
  193. data/lib/rgame/engine/scene/scene_stack.rb +4 -4
  194. data/lib/rgame/engine/sealed_privates.rb +54 -0
  195. data/lib/rgame/engine/spatial_hash.rb +51 -19
  196. data/lib/rgame/engine/text.rb +194 -0
  197. data/lib/rgame/engine/tile_blockers.rb +63 -0
  198. data/lib/rgame/engine/tile_map.rb +2 -3
  199. data/lib/rgame/engine/tile_map_layer.rb +0 -2
  200. data/lib/rgame/engine/tileset.rb +2 -4
  201. data/lib/rgame/engine/timer.rb +2 -2
  202. data/lib/rgame/engine/ui/button.rb +248 -0
  203. data/lib/rgame/engine/ui/column.rb +20 -0
  204. data/lib/rgame/engine/ui/icon_button.rb +93 -0
  205. data/lib/rgame/engine/ui/menu.rb +246 -71
  206. data/lib/rgame/engine/ui/navigation.rb +57 -0
  207. data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
  208. data/lib/rgame/engine/ui/option_button.rb +163 -0
  209. data/lib/rgame/engine/ui/panel_button.rb +32 -0
  210. data/lib/rgame/engine/ui/panel_menu.rb +36 -0
  211. data/lib/rgame/engine/ui/pointing.rb +146 -0
  212. data/lib/rgame/engine/ui/radial_menu.rb +85 -0
  213. data/lib/rgame/engine/ui/ring.rb +55 -0
  214. data/lib/rgame/engine/ui/row.rb +21 -0
  215. data/lib/rgame/engine/ui/shape_style.rb +102 -0
  216. data/lib/rgame/engine/ui/stack.rb +58 -0
  217. data/lib/rgame/engine/ui/stepping.rb +93 -0
  218. data/lib/rgame/engine/ui/text_button.rb +59 -0
  219. data/lib/rgame/engine/viewports.rb +2 -5
  220. data/lib/rgame/engine/world_view.rb +5 -4
  221. data/lib/rgame/engine.rb +31 -25
  222. data/lib/rgame/game.rb +99 -27
  223. data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
  224. data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
  225. data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
  226. data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
  227. data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
  228. data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
  229. data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
  230. data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
  231. data/lib/rgame/rubocop/default.yml +39 -0
  232. data/lib/rgame/rubocop/plugin.rb +45 -0
  233. data/lib/rgame/rubocop.rb +11 -0
  234. data/lib/rgame/util/color.rb +20 -24
  235. data/lib/rgame/util/controls.rb +14 -28
  236. data/lib/rgame/util/route_search.rb +27 -0
  237. data/lib/rgame/util/save_file.rb +107 -0
  238. data/lib/rgame/util/solid_grid.rb +37 -0
  239. data/lib/rgame/util/tensor.rb +0 -9
  240. data/lib/rgame/util/tile_sweep.rb +36 -0
  241. data/lib/rgame/util/z.rb +4 -14
  242. data/lib/rgame/util.rb +4 -3
  243. data/lib/rgame/version.rb +1 -1
  244. data/lib/rgame.rb +0 -15
  245. metadata +152 -11
  246. data/lib/rgame/engine/actor.rb +0 -53
  247. data/lib/rgame/engine/body.rb +0 -49
  248. data/lib/rgame/engine/cached_label.rb +0 -33
  249. data/lib/rgame/engine/input/player_controller.rb +0 -14
  250. data/lib/rgame/engine/matrix.rb +0 -32
  251. data/lib/rgame/engine/resettable.rb +0 -67
  252. data/lib/rgame/engine/tile_collision.rb +0 -78
  253. data/lib/rgame/engine/ui/menu_item.rb +0 -84
@@ -1,93 +1,318 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
+ require_relative 'i18n/template'
5
+ require_relative 'i18n/plural'
6
+ require_relative 'i18n/plural_rules'
4
7
 
5
8
  module RGame
6
9
  module Engine
7
- # Minimal localization: per-locale translation tables (loaded from YAML or a Hash),
8
- # `t(key)` with `%{var}` interpolation and a fallback locale, and a `generation`
9
- # counter that ticks whenever the locale changes — so cached UI text knows when to
10
- # re-resolve without polling every frame. A global module (like EventDispatcher),
11
- # so `t` is reachable anywhere. Pure Ruby; YAML is the only (stdlib) dependency.
10
+ # Translation tables and the current language, as one global module so a
11
+ # node can resolve text from its constructor, before it is in any tree.
12
+ #
13
+ # Tables are read in Rails' YAML format the top-level key is the locale,
14
+ # nested keys below it and compiled at load into one flat Hash per locale,
15
+ # keyed by the dotted key, whose values are pre-parsed `%{var}` templates or,
16
+ # for a key whose nested keys are CLDR plural categories, a set of them.
17
+ # `generation` moves whenever what a key resolves to may have changed, so
18
+ # cached text compares one Integer instead of looking anything up.
19
+ #
20
+ # `I18n` parses Strings and never opens a file: finding and reading locale
21
+ # files is the asset manager's job.
12
22
  module I18n
23
+ # Raised for a key no link of the chain has, under `missing = :raise`.
24
+ class MissingKey < StandardError
25
+ attr_reader :key, :chain
26
+
27
+ def initialize(key, chain)
28
+ @key = key
29
+ @chain = chain
30
+ super("no translation for #{key.inspect} in #{chain.join(', ')}")
31
+ end
32
+ end
33
+
34
+ # Raised under `missing = :raise` when a `Text` declares other variables
35
+ # than its translation uses: a placeholder the `Text` does not declare, or
36
+ # a declared name the translation never prints.
37
+ class VariableMismatch < StandardError
38
+ attr_reader :key
39
+
40
+ def initialize(key, message)
41
+ @key = key
42
+ super("#{key}: #{message}")
43
+ end
44
+ end
45
+
46
+ MISSING_POLICIES = %i[key raise].freeze
47
+
13
48
  class << self
49
+ # An Integer that moves on every `load`, every change of locale or
50
+ # default, every `plural_rule`, and every `reset`.
14
51
  attr_reader :generation
15
52
 
16
- # The locale `t` falls back to when the current locale lacks a key.
17
- def default = @fallback
53
+ attr_reader :locale, :default
54
+
55
+ # The locales a key is looked up in, in order: the current locale, each
56
+ # shorter prefix of it, then the default — `[:'de-AT', :de, :en]`.
57
+ # Rebuilt when the locale or the default changes, not per lookup.
58
+ attr_reader :chain
18
59
 
60
+ # What a key no link of the chain has resolves to: `:key` (the start)
61
+ # shows the key itself, `:raise` raises `MissingKey`, and a callable is
62
+ # called with the key and the chain and its return value is shown.
63
+ attr_reader :missing
64
+
65
+ # Merges a YAML document in Rails' format into the loaded tables. One
66
+ # document may hold several locales, and a locale loaded twice is merged
67
+ # key by key rather than replaced. `source` names the file in errors.
68
+ def load(yaml, source: nil)
69
+ merge(YAML.safe_load(yaml, aliases: true, filename: source), source || 'translations')
70
+ end
71
+
72
+ # `load` for a Hash already in memory: `load_hash(en: { menu: { title: 'Menu' } })`.
73
+ def load_hash(hash) = merge(hash, 'translations')
74
+
75
+ # Switches the language. `de_AT`, `'de-at'` and `:'de-AT'` name the same
76
+ # locale. A locale with no table of its own is allowed and resolves
77
+ # through its chain, so `:'de-CH'` reads the `de` table.
78
+ def locale=(locale)
79
+ locale = normalize(locale)
80
+ return if locale == @locale
81
+
82
+ @locale = locale
83
+ relink
84
+ end
85
+
86
+ # The last link of every chain, and the locale `choose` falls back to.
19
87
  def default=(locale)
20
- @fallback = locale.to_sym
88
+ locale = normalize(locale)
89
+ return if locale == @default
90
+
91
+ @default = locale
92
+ relink
21
93
  end
22
94
 
23
- def reset
24
- @locales = {}
25
- @current = :en
26
- @fallback = :en
27
- @generation = 0
95
+ # The canonical Symbol for a locale identifier: the language lowercase,
96
+ # a region uppercase, a script capitalized, joined by hyphens —
97
+ # `normalize('zh_hant_tw') # => :'zh-Hant-TW'`.
98
+ def normalize(locale)
99
+ language, *subtags = locale.to_s.split(/[-_]/)
100
+ raise ArgumentError, "not a locale: #{locale.inspect}" if language.nil? || language.empty?
101
+
102
+ subtags.map! { |subtag| subtag.length == 4 ? subtag.capitalize : subtag.upcase }
103
+ subtags.unshift(language.downcase).join('-').to_sym
28
104
  end
29
105
 
30
- # Register a locale's translations (nested Hashes allowed). Keys are symbolized
31
- # so YAML ("string keys") and inline symbol keys look the same to `t`.
32
- def load(locale, translations)
33
- @locales[locale.to_sym] = symbolize(translations)
34
- self
106
+ # The first of `preferred` — the player's languages, most wanted first
107
+ # that has a table for itself or a shorter prefix of itself, normalized
108
+ # but not shortened: `choose(['fr-CA', 'de-AT'])` with a `de` table is
109
+ # `:'de-AT'`. The default when none has.
110
+ def choose(preferred)
111
+ preferred.each do |candidate|
112
+ locale = normalize(candidate)
113
+ return locale if lineage(locale).any? { |link| @tables.key?(link) }
114
+ end
115
+ @default
35
116
  end
36
117
 
37
- def load_file(locale, path)
38
- load(locale, YAML.load_file(path))
118
+ def missing=(policy)
119
+ unless MISSING_POLICIES.include?(policy) || policy.respond_to?(:call)
120
+ raise ArgumentError, "missing must be :key, :raise or a callable, not #{policy.inspect}"
121
+ end
122
+
123
+ @missing = policy
39
124
  end
40
125
 
41
- def locale = @current
126
+ # The keys the default locale has that no table in `locale`'s own chain
127
+ # does, in the default table's order. A key `locale` gets from a parent
128
+ # (`de-AT` from `de`) is not missing; one it would get only from the
129
+ # default is.
130
+ def missing_keys(locale)
131
+ links = lineage(normalize(locale))
132
+ defaults = @tables.fetch(@default, {}).keys
133
+ defaults.reject { |key| links.any? { |link| @tables[link]&.key?(key) } }
134
+ end
42
135
 
43
- # Switching the locale bumps the generation so observers re-resolve their text.
44
- def locale=(locale)
45
- locale = locale.to_sym
46
- return if locale == @current
136
+ # Sets how `language` (or a regional locale, which then wins over its
137
+ # language) sorts a count into a plural category. The block receives
138
+ # the count and returns one of `Plural::CATEGORIES`. Replaces a built-in
139
+ # rule; lasts until `reset`.
140
+ def plural_rule(language, &rule)
141
+ raise ArgumentError, 'plural_rule needs a block' unless rule
47
142
 
48
- @current = locale
143
+ @plural_rules[normalize(language)] = rule
144
+ @rule_for.clear
49
145
  @generation += 1
146
+ self
50
147
  end
51
148
 
52
- def available = @locales.keys
149
+ # The locales that have a table, in load order.
150
+ def available = @tables.keys
53
151
 
54
- # Resolve a dotted key ("menu.title" or :menu_title) in the current locale, then
55
- # the fallback locale, then the key itself; interpolate %{var} from `vars`.
56
- #
57
- # Pass `count:` to pluralize: the key's value is then a table of forms
58
- # ({ one:, other:, optionally zero: }), and `count` is also exposed to
59
- # interpolation as %{count}. English/German use the one/other rule.
60
- def t(key, count: nil, **vars)
61
- value = lookup(@current, key) || lookup(@fallback, key)
62
- value = pluralize(value, count) if count && value.is_a?(Hash)
63
- return key.to_s unless value.is_a?(String)
152
+ # The translation of `key` (or `scope.key`) with `vars` interpolated. A
153
+ # pluralized key needs `count:`, and picks its form by the plural rule
154
+ # of the table that supplied it, which is not always the current locale.
155
+ # Allocates on every call: it is for code off the per-frame path.
156
+ def t(key, scope: nil, **vars)
157
+ key = scope ? "#{scope}.#{key}" : key.to_s
158
+ template = lookup(key)
159
+ return answer_missing(key) unless template
64
160
 
65
- vars = vars.merge(count: count) if count
66
- vars.empty? ? value : (value % vars)
161
+ template = pluralize(key, template, vars) if template.is_a?(Plural)
162
+
163
+ missing = template.names.find { |name| !vars.key?(name) }
164
+ raise ArgumentError, "#{key} needs %{#{missing}}" if missing
165
+
166
+ template.render(vars)
167
+ end
168
+
169
+ # What a `Text` declaring `names` shows for the dotted `key`, with `vars`
170
+ # — a Hash holding exactly those names — interpolated. It differs from
171
+ # `t` in one way: a translation whose placeholders are not the declared
172
+ # names is a bug in the table or the code, and the missing policy
173
+ # answers it, raising `VariableMismatch` under `:raise`.
174
+ def render(key, names, vars)
175
+ entry = lookup(key)
176
+ return answer_missing(key) unless entry
177
+
178
+ problem = mismatch(entry, names)
179
+ return answer_mismatch(key, problem) if problem
180
+
181
+ entry = pluralize(key, entry, vars) if entry.is_a?(Plural)
182
+ entry.render(vars)
183
+ end
184
+
185
+ # Forgets every table and plural rule added, and restores the starting
186
+ # locale and default, `:en`, and the `:key` missing policy. The
187
+ # generation moves rather than restarting, so text cached before a
188
+ # reset never mistakes itself for current.
189
+ def reset
190
+ @tables = {}
191
+ @sources = {}
192
+ @locale = :en
193
+ @default = :en
194
+ @generation = (@generation || 0) + 1
195
+ @chain = chain_for(@locale)
196
+ @plural_rules = PluralRules::BUILT_IN.dup
197
+ @rule_for = {}
198
+ @missing = :key
67
199
  end
68
200
 
69
201
  private
70
202
 
71
- def pluralize(forms, count)
72
- return forms[:zero] if count.zero? && forms.key?(:zero)
203
+ def relink
204
+ @chain = chain_for(@locale)
205
+ @generation += 1
206
+ end
207
+
208
+ def chain_for(locale) = (lineage(locale) << @default).uniq.freeze
209
+
210
+ def lineage(locale)
211
+ subtags = locale.name.split('-')
212
+ subtags.size.downto(1).map { |length| subtags.first(length).join('-').to_sym }
213
+ end
214
+
215
+ def answer_missing(key)
216
+ case @missing
217
+ when :key then key
218
+ when :raise then raise MissingKey.new(key, @chain)
219
+ else @missing.call(key, @chain)
220
+ end
221
+ end
222
+
223
+ def mismatch(entry, names)
224
+ undeclared = entry.names.find { |name| !names.include?(name) }
225
+ if undeclared == :count && entry.is_a?(Plural)
226
+ 'is pluralized, and the Text does not declare :count'
227
+ elsif undeclared
228
+ "uses %{#{undeclared}}, which the Text does not declare"
229
+ elsif (unused = names.find { |name| !entry.names.include?(name) })
230
+ "never uses :#{unused}, which the Text declares"
231
+ end
232
+ end
233
+
234
+ def answer_mismatch(key, problem)
235
+ case @missing
236
+ when :key then key
237
+ when :raise then raise VariableMismatch.new(key, problem)
238
+ else @missing.call(key, @chain)
239
+ end
240
+ end
241
+
242
+ def pluralize(key, plural, vars)
243
+ raise ArgumentError, "#{key} is pluralized and needs count:" unless vars.key?(:count)
244
+
245
+ count = vars[:count]
246
+ plural.template_for(count, plural_category(plural.locale, count))
247
+ end
248
+
249
+ def plural_category(locale, count)
250
+ rule = @rule_for[locale] ||= begin
251
+ language = lineage(locale).find { |link| @plural_rules.key?(link) }
252
+ @plural_rules.fetch(language, PluralRules::DEFAULT)
253
+ end
254
+ rule.call(count)
255
+ end
73
256
 
74
- forms[count == 1 ? :one : :other] || forms[:other]
257
+ def lookup(key)
258
+ @chain.each do |link|
259
+ entry = @tables[link]&.[](key)
260
+ return entry if entry
261
+ end
262
+ nil
75
263
  end
76
264
 
77
- def lookup(locale, key)
78
- node = @locales[locale]
79
- key.to_s.split('.').each do |segment|
80
- return nil unless node.is_a?(Hash)
265
+ def merge(hash, source)
266
+ raise ArgumentError, "#{source}: expected a Hash of locales" unless hash.is_a?(Hash)
267
+
268
+ hash.each do |locale, entries|
269
+ raise ArgumentError, "#{source}: #{locale} must hold a Hash of keys" unless entries.is_a?(Hash)
270
+
271
+ locale = normalize(locale)
272
+ @sources[locale] = deep_merge(@sources.fetch(locale, {}), stringify(entries, locale.to_s, source))
273
+ @tables[locale] = compile(@sources[locale], locale)
274
+ end
275
+ @generation += 1
276
+ self
277
+ end
81
278
 
82
- node = node[segment.to_sym]
279
+ def stringify(entries, path, source)
280
+ entries.to_h do |name, value|
281
+ name = scalar_name(name, path, source)
282
+ here = "#{path}.#{name}"
283
+ [name, value.is_a?(Hash) ? stringify(value, here, source) : scalar_value(value, here, source)]
83
284
  end
84
- node
85
285
  end
86
286
 
87
- def symbolize(value)
88
- return value unless value.is_a?(Hash)
287
+ def scalar_name(name, path, source)
288
+ return name.to_s if name.is_a?(String) || name.is_a?(Symbol) || name.is_a?(Integer)
89
289
 
90
- value.to_h { |k, v| [k.to_sym, symbolize(v)] }
290
+ raise ArgumentError, "#{source}: #{path} has the key #{name.inspect}; " \
291
+ 'YAML reads yes/no/on/off/true/false/~ unquoted, so quote it'
292
+ end
293
+
294
+ def scalar_value(value, path, source)
295
+ return value.to_s if value.is_a?(String) || value.is_a?(Numeric)
296
+
297
+ raise ArgumentError, "#{source}: #{path} is #{value.inspect}, not text; " \
298
+ 'quote it if it is meant as a string'
299
+ end
300
+
301
+ def deep_merge(into, from)
302
+ into.merge(from) do |_name, old, new|
303
+ old.is_a?(Hash) && new.is_a?(Hash) ? deep_merge(old, new) : new
304
+ end
305
+ end
306
+
307
+ def compile(entries, locale, prefix = nil, table = {})
308
+ entries.each do |name, value|
309
+ key = prefix ? "#{prefix}.#{name}" : name
310
+ if !value.is_a?(Hash) then table[key.freeze] = Template.compile(value)
311
+ elsif Plural.forms?(value) then table[key.freeze] = Plural.new(locale, value)
312
+ else compile(value, locale, key, table)
313
+ end
314
+ end
315
+ prefix ? table : table.freeze
91
316
  end
92
317
  end
93
318
 
@@ -17,10 +17,6 @@ module RGame
17
17
  # `down?(physical_id, device:)` and `axis(axis_id, device:)` — a spec passes
18
18
  # a fake and a game passes RGame::Core::Input.
19
19
  class ActionMapper
20
- # A resting analog stick genuinely reports small non-zero values, so
21
- # something has to ignore them. Here rather than in the game, because it
22
- # is a property of the device, and here rather than in Core, because how
23
- # much to ignore is a judgement rather than a fact about the hardware.
24
20
  DEAD_ZONE = 0.15
25
21
 
26
22
  attr_reader :map, :actions
@@ -31,10 +27,6 @@ module RGame
31
27
  @device = device
32
28
  @dead_zone = dead_zone
33
29
 
34
- # One reusable snapshot: there is exactly one input state per tick per
35
- # player, so these are mutated in place each poll instead of allocated.
36
- # Seeded from the map's action list, so the hashes are warm before the
37
- # first poll and steady-state polling allocates nothing at all.
38
30
  @held = {}
39
31
  @prev_held = {}
40
32
  @axes = {}
@@ -47,8 +39,6 @@ module RGame
47
39
  end
48
40
 
49
41
  def poll(backend)
50
- # Snapshot this frame's held state as "previous" before recomputing it
51
- # (in-place copy: no allocation, the keys already exist).
52
42
  @held.each { |name, down| @prev_held[name] = down }
53
43
 
54
44
  return rest if @device.nil?
@@ -63,11 +53,6 @@ module RGame
63
53
 
64
54
  private
65
55
 
66
- # A player with no device — an empty seat waiting for a controller — reads
67
- # as nothing held and every axis centred. Returning the snapshot rather
68
- # than refusing to poll is what lets a game show "press a button to join"
69
- # with no special case, and it releases anything that was held when the
70
- # controller was unplugged mid-press.
71
56
  def rest
72
57
  @held.each_key { |name| @held[name] = false }
73
58
  @axes.each_key { |name| @axes[name] = 0.0 }
@@ -79,11 +64,6 @@ module RGame
79
64
  ids.any? { |id| backend.down?(id, device: @device) }
80
65
  end
81
66
 
82
- # A digital axis and an analog one can both be bound to the same action —
83
- # arrows *and* the left stick — so the larger deflection wins. That needs
84
- # no per-device branching: a keyboard reads 0.0 for every axis and a stick
85
- # reads 0.0 for every key, so whichever device the player is on, the other
86
- # source contributes nothing.
87
67
  # hot-path
88
68
  def axis_value(backend, binding)
89
69
  digital = digital_axis(backend, binding)
@@ -93,9 +73,6 @@ module RGame
93
73
  digital.abs >= analog.abs ? digital : analog
94
74
  end
95
75
 
96
- # The largest deflection across every pair bound to this axis. Several
97
- # pairs is how the arrows, WASD and a d-pad all drive one action; a device
98
- # with only one of them reads 0.0 for the rest, so the others cost nothing.
99
76
  # hot-path
100
77
  def digital_axis(backend, binding)
101
78
  pairs = binding.pairs
@@ -110,9 +87,6 @@ module RGame
110
87
  best
111
88
  end
112
89
 
113
- # Rescaled rather than merely cut off, so the value ramps from zero as the
114
- # stick leaves the dead zone. Cutting off alone makes it jump to the dead
115
- # zone's width the moment it starts reading, which is a visible twitch.
116
90
  # hot-path
117
91
  def dead_zoned(value)
118
92
  magnitude = value.abs
@@ -53,6 +53,8 @@ module RGame
53
53
  # `node.control(actions)` keep working unchanged — a tree with nobody
54
54
  # claiming ownership, or a spec that has only one player in mind, passes
55
55
  # the snapshot itself where a Players registry would otherwise go.
56
+ #
57
+ # @api private
56
58
  # hot-path
57
59
  def actions_for(_player) = self
58
60
 
@@ -81,18 +83,10 @@ module RGame
81
83
 
82
84
  private
83
85
 
84
- # A block rather than `fetch(name)`'s bare KeyError, for a message that
85
- # says what to do about it. The block only runs on a miss, so the reading
86
- # path stays one hash lookup with nothing allocated.
87
86
  def undeclared(name)
88
87
  raise KeyError, "no such action #{name.inspect} — declare it in the InputMap " \
89
88
  "(this snapshot has #{declared.inspect})"
90
89
  end
91
-
92
- # `prev_held` is read with a default on purpose. It is one frame behind, so
93
- # on the very first poll after an action is added it legitimately has no
94
- # entry, and "was not held before" is the right answer rather than an
95
- # error. The current-frame lookup above is what catches a typo.
96
90
  end
97
91
  end
98
92
  end
@@ -67,27 +67,21 @@ module RGame
67
67
 
68
68
  SOURCES = %i[buttons axis stick].freeze
69
69
 
70
- # The universal set, merged into every map unless the game overrides it.
71
- #
72
- # The UI package navigates and activates through these, so a control can
73
- # rely on them existing for *every* player without a game having declared
74
- # them. They are prefixed rather than plain (`ui_up`, not `up`) so a game
75
- # is free to use `:up` for something of its own.
76
- #
77
- # `ui_cancel` is Escape, which is why RGame::Game's quit key is F2: the
78
- # button a player expects to back out of a menu belongs to the menu.
79
70
  UI = {
80
71
  ui_up: { buttons: [Controls::KEY_UP, Controls::PAD_DPAD_UP] },
81
72
  ui_down: { buttons: [Controls::KEY_DOWN, Controls::PAD_DPAD_DOWN] },
82
73
  ui_left: { buttons: [Controls::KEY_LEFT, Controls::PAD_DPAD_LEFT] },
83
74
  ui_right: { buttons: [Controls::KEY_RIGHT, Controls::PAD_DPAD_RIGHT] },
84
75
  ui_confirm: { buttons: [Controls::KEY_RETURN, Controls::KEY_SPACE, Controls::PAD_A] },
85
- ui_cancel: { buttons: [Controls::KEY_ESCAPE, Controls::PAD_B] }
76
+ ui_cancel: { buttons: [Controls::KEY_ESCAPE, Controls::PAD_B] },
77
+ ui_radial_x: { axis: [[Controls::KEY_LEFT, Controls::KEY_RIGHT],
78
+ [Controls::PAD_DPAD_LEFT, Controls::PAD_DPAD_RIGHT]],
79
+ stick: Controls::AXIS_LEFT_X },
80
+ ui_radial_y: { axis: [[Controls::KEY_UP, Controls::KEY_DOWN],
81
+ [Controls::PAD_DPAD_UP, Controls::PAD_DPAD_DOWN]],
82
+ stick: Controls::AXIS_LEFT_Y }
86
83
  }.freeze
87
84
 
88
- # A playable starting point: eight-way movement on the arrows or the left
89
- # stick, and a fire button. A game that wants exactly this declares
90
- # nothing at all.
91
85
  DEFAULT_ACTIONS = {
92
86
  move_x: { axis: [[Controls::KEY_LEFT, Controls::KEY_RIGHT],
93
87
  [Controls::KEY_A, Controls::KEY_D],
@@ -120,6 +114,38 @@ module RGame
120
114
 
121
115
  def actions = @bindings.keys
122
116
 
117
+ # The button bound to `action` that `device` can actually press, or nil.
118
+ #
119
+ # This undoes what the section above describes. One entry lists a key and
120
+ # a pad button together so that polling needs no branch, and that is right
121
+ # for reading input and wrong for *showing* it: a prompt has to say "press
122
+ # A" or "press Space", never both, and which one depends on what the
123
+ # player last touched.
124
+ #
125
+ # map.button_for(:fire, Controls::KEYBOARD) # => KEY_SPACE
126
+ # map.button_for(:fire, Controls.gamepad(0)) # => PAD_A
127
+ #
128
+ # The first match wins, so the order an entry lists its ids in is the order
129
+ # a prompt prefers them — `ui_confirm` names Return before Space, and a
130
+ # prompt for it says Return.
131
+ #
132
+ # Nil for an action nobody bound, for one with no buttons at all (a stick
133
+ # or a digital axis is not a button and a prompt for one is a different
134
+ # picture), and for a device kind the entry does not cover. A caller
135
+ # showing a prompt has to handle that nil either way, because a rebinding
136
+ # screen can leave an action unbound.
137
+ #
138
+ # Allocation-free, so a HUD may call it per frame rather than caching a
139
+ # string it would then have to invalidate.
140
+ def button_for(action, device)
141
+ binding = @bindings[action]
142
+ buttons = binding&.buttons
143
+ return nil if buttons.nil?
144
+
145
+ pad = Controls.gamepad?(device)
146
+ buttons.find { |id| Controls.pad_button?(id) == pad }
147
+ end
148
+
123
149
  # The entries in the shape they were declared in, so a map can be edited
124
150
  # and rebuilt (a config screen) or merged.
125
151
  def to_h
@@ -134,11 +160,6 @@ module RGame
134
160
 
135
161
  private
136
162
 
137
- # Malformed entries raise here rather than reading as "nothing is ever
138
- # pressed" for the rest of the program. An action name misspelled at a
139
- # *read* site is still silent — see Actions — but one misspelled in the
140
- # map is the mistake that is actually easy to make, and this catches it at
141
- # construction rather than at the first frame nobody can move.
142
163
  def build(name, entry)
143
164
  unknown = entry.keys - SOURCES
144
165
  raise ArgumentError, "#{name}: unknown source #{unknown.first.inspect}" unless unknown.empty?
@@ -157,9 +178,6 @@ module RGame
157
178
  ids.dup.freeze
158
179
  end
159
180
 
160
- # Accepts one `[negative, positive]` pair or a list of them. A bare pair
161
- # is the common case and stays readable; a list is what lets the arrows,
162
- # WASD and a d-pad all drive one axis.
163
181
  def axis_pairs(name, axis)
164
182
  return nil if axis.nil?
165
183
 
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # A walkability grid and the searches over it: region labels and A* routes, run in C by a
6
+ # Util::RouteSearch over a Util::SolidGrid.
7
+ #
8
+ # Where TileBlockers answers *what is in the way of a step*, this answers *what is in the
9
+ # way of a route*: the same fact about the same cells, asked over a graph instead of along
10
+ # a box's sweep. Components::TileWorld owns both, over one store.
11
+ #
12
+ # It is built one of two ways:
13
+ #
14
+ # - **`grid:`** — over a SolidGrid it shares. A cell changed in that grid is changed here
15
+ # at once, and region labels follow it on the next query with nothing rebuilt. This is
16
+ # how a TileWorld builds one, and the way to build one over solidity that will change.
17
+ # - **`width:`, `height:`, `solid:`** — from a callable, `solid.call(col, row) -> bool`,
18
+ # read once per cell into a private grid of its own. Nothing re-reads the callable, so a
19
+ # change behind it is never seen.
20
+ #
21
+ # Anything else — both, or neither — is an ArgumentError.
22
+ #
23
+ # Routes are cells, never pixels. Turning a cell into the point a node must reach depends
24
+ # on the node's collider, which a grid has no business knowing; Components::Navigator
25
+ # does that.
26
+ #
27
+ # The grid is 8-connected with octile costs (1 straight, √2 diagonal), and a diagonal step
28
+ # is allowed only when both orthogonal cells beside it are open, so a route never cuts the
29
+ # corner of a solid cell. Connected regions are labelled whenever the grid has changed
30
+ # since they last were, which makes an unreachable goal — the answer a search is slowest
31
+ # to give, because it has to exhaust the region first — a lookup whenever it lies in
32
+ # another region.
33
+ #
34
+ # Coordinates are Integers, and anything else is a TypeError. An Integer outside the
35
+ # grid, however large, is answered like any cell outside it.
36
+ #
37
+ # A search runs on demand, never per frame, and allocates its result; everything else it
38
+ # keeps is reused between searches. One grid is therefore not safe to search from two
39
+ # threads at once.
40
+ class NavGrid
41
+ def initialize(grid: nil, width: nil, height: nil, solid: nil)
42
+ @grid = grid ? shared(grid, width, height, solid) : private_grid(width, height, solid)
43
+ @search = Util::RouteSearch.new(@grid)
44
+ end
45
+
46
+ def width = @grid.width
47
+ def height = @grid.height
48
+
49
+ def walkable?(col, row)
50
+ !@grid.solid?(col, row) && col >= 0 && row >= 0 && col < @grid.width && row < @grid.height
51
+ end
52
+
53
+ # The connected region containing the cell, as an Integer label — two walkable cells
54
+ # share a label exactly when a route joins them — or nil for a solid cell or one
55
+ # outside the grid.
56
+ def region(col, row) = @search.region(col, row)
57
+
58
+ def reachable?(from_col, from_row, to_col, to_row)
59
+ from = @search.region(from_col, from_row)
60
+ to = @search.region(to_col, to_row)
61
+ !from.nil? && from == to
62
+ end
63
+
64
+ # The cheapest route from start to goal, both included, as `[[col, row], ...]` — or
65
+ # nil when either end is solid, outside the grid, or in another region. Start equal
66
+ # to goal is a route of one cell. Ties between equally cheap routes are broken the
67
+ # same way every time, so the same query always returns the same route.
68
+ def find(from_col, from_row, to_col, to_row) = @search.find(from_col, from_row, to_col, to_row)
69
+
70
+ private
71
+
72
+ def shared(grid, width, height, solid)
73
+ return grid unless width || height || solid
74
+
75
+ raise ArgumentError, 'a NavGrid is built over a grid: or from width:, height: and solid:, not both'
76
+ end
77
+
78
+ def private_grid(width, height, solid)
79
+ unless width && height && solid
80
+ raise ArgumentError, 'a NavGrid needs a grid:, or all of width:, height: and solid:'
81
+ end
82
+
83
+ Util::SolidGrid.build(width, height) { |col, row| solid.call(col, row) }
84
+ end
85
+ end
86
+ end
87
+ end