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,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require_relative 'i18n/template'
5
+ require_relative 'i18n/plural'
6
+ require_relative 'i18n/plural_rules'
7
+
8
+ module RGame
9
+ module Engine
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.
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
+
48
+ class << self
49
+ # An Integer that moves on every `load`, every change of locale or
50
+ # default, every `plural_rule`, and every `reset`.
51
+ attr_reader :generation
52
+
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
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.
87
+ def default=(locale)
88
+ locale = normalize(locale)
89
+ return if locale == @default
90
+
91
+ @default = locale
92
+ relink
93
+ end
94
+
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
104
+ end
105
+
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
116
+ end
117
+
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
124
+ end
125
+
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
135
+
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
142
+
143
+ @plural_rules[normalize(language)] = rule
144
+ @rule_for.clear
145
+ @generation += 1
146
+ self
147
+ end
148
+
149
+ # The locales that have a table, in load order.
150
+ def available = @tables.keys
151
+
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
160
+
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
199
+ end
200
+
201
+ private
202
+
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
256
+
257
+ def lookup(key)
258
+ @chain.each do |link|
259
+ entry = @tables[link]&.[](key)
260
+ return entry if entry
261
+ end
262
+ nil
263
+ end
264
+
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
278
+
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)]
284
+ end
285
+ end
286
+
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)
289
+
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
316
+ end
317
+ end
318
+
319
+ reset
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Polls one player's device through an InputMap and produces their Actions
6
+ # snapshot.
7
+ #
8
+ # mapper = ActionMapper.new(input_map, device: Controls.gamepad(0))
9
+ # actions = mapper.poll(input)
10
+ #
11
+ # **One of these per player.** The device is what makes that work: every
12
+ # query carries it, so two mappers over the same map read two different
13
+ # controllers, and each keeps its own previous-frame state so their edge
14
+ # queries are independent.
15
+ #
16
+ # Pure logic. The backend is duck-typed and the whole interface is
17
+ # `down?(physical_id, device:)` and `axis(axis_id, device:)` — a spec passes
18
+ # a fake and a game passes RGame::Core::Input.
19
+ class ActionMapper
20
+ DEAD_ZONE = 0.15
21
+
22
+ attr_reader :map, :actions
23
+ attr_accessor :device, :dead_zone
24
+
25
+ def initialize(map, device: RGame::Util::Controls::KEYBOARD, dead_zone: DEAD_ZONE)
26
+ @map = map
27
+ @device = device
28
+ @dead_zone = dead_zone
29
+
30
+ @held = {}
31
+ @prev_held = {}
32
+ @axes = {}
33
+ map.bindings.each_key do |name|
34
+ @held[name] = false
35
+ @prev_held[name] = false
36
+ @axes[name] = 0.0
37
+ end
38
+ @actions = Actions.new(held: @held, axes: @axes, prev_held: @prev_held)
39
+ end
40
+
41
+ def poll(backend)
42
+ @held.each { |name, down| @prev_held[name] = down }
43
+
44
+ return rest if @device.nil?
45
+
46
+ @map.bindings.each do |name, binding|
47
+ @held[name] = any_down?(backend, binding.buttons) if binding.buttons
48
+ @axes[name] = axis_value(backend, binding) if binding.pairs || binding.stick
49
+ end
50
+
51
+ @actions
52
+ end
53
+
54
+ private
55
+
56
+ def rest
57
+ @held.each_key { |name| @held[name] = false }
58
+ @axes.each_key { |name| @axes[name] = 0.0 }
59
+ @actions
60
+ end
61
+
62
+ # hot-path
63
+ def any_down?(backend, ids)
64
+ ids.any? { |id| backend.down?(id, device: @device) }
65
+ end
66
+
67
+ # hot-path
68
+ def axis_value(backend, binding)
69
+ digital = digital_axis(backend, binding)
70
+ return digital unless binding.stick
71
+
72
+ analog = dead_zoned(backend.axis(binding.stick, device: @device))
73
+ digital.abs >= analog.abs ? digital : analog
74
+ end
75
+
76
+ # hot-path
77
+ def digital_axis(backend, binding)
78
+ pairs = binding.pairs
79
+ return 0.0 if pairs.nil?
80
+
81
+ best = 0.0
82
+ pairs.each do |pair|
83
+ value = (backend.down?(pair[1], device: @device) ? 1.0 : 0.0) -
84
+ (backend.down?(pair[0], device: @device) ? 1.0 : 0.0)
85
+ best = value if value.abs > best.abs
86
+ end
87
+ best
88
+ end
89
+
90
+ # hot-path
91
+ def dead_zoned(value)
92
+ magnitude = value.abs
93
+ return 0.0 if magnitude <= @dead_zone
94
+
95
+ scaled = (magnitude - @dead_zone) / (1.0 - @dead_zone)
96
+ value.negative? ? -scaled : scaled
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # An immutable per-frame snapshot of abstract action state. Game logic reads
6
+ # this, never physical keys. Built by ActionMapper (or constructed directly
7
+ # in a spec).
8
+ #
9
+ # Edge queries (`pressed?`/`released?`) compare against the previous frame's
10
+ # held state, so a one-shot action (menu confirm, jump) fires exactly once
11
+ # per press rather than every frame it is held.
12
+ #
13
+ # ## Reading an action nobody declared raises
14
+ #
15
+ # A game declares its actions once, in an InputMap. Asking for one that is
16
+ # not in it is a typo, not a question with an answer, so it raises KeyError
17
+ # rather than reading `false` forever:
18
+ #
19
+ # actions.pressed?(:fyre) # KeyError: no such action :fyre
20
+ #
21
+ # This matters because the failure it replaces is silent and remote. A
22
+ # misspelled action reads as "never pressed", and what the player sees is a
23
+ # button that does nothing — a bug that looks like it lives in the code that
24
+ # *would* have run. RGame::Core::Input used to raise KeyError for an unbound
25
+ # action and no longer can: it takes physical ids now, and binding moved up
26
+ # to InputMap. This is where that guarantee went.
27
+ #
28
+ # **The hashes are the declaration.** ActionMapper seeds all three from its
29
+ # map at construction, so every action the map knows answers and nothing
30
+ # else does. A spec constructing one directly declares whatever it passes:
31
+ #
32
+ # Actions.new(axes: { move_x: 1.0, move_y: 0.0 }) # both answer
33
+ # Actions.new(axes: { move_x: 1.0 }).axis(:move_y) # KeyError
34
+ #
35
+ # That is stricter than it needs to be for a spec, and deliberately: a spec
36
+ # that has not said what the action set is cannot claim a component reads
37
+ # the right part of it.
38
+ class Actions
39
+ # `held`, `axes` and `prev_held` are mutable hashes the mapper updates in
40
+ # place each poll, so the snapshot stays a single reused, allocation-free
41
+ # object.
42
+ def initialize(held: {}, axes: {}, prev_held: {})
43
+ @held = held
44
+ @axes = axes
45
+ @prev_held = prev_held
46
+ end
47
+
48
+ # Every action this snapshot can answer for.
49
+ def declared = @held.keys | @axes.keys
50
+
51
+ # A snapshot is also a degenerate input *source*: it answers for whichever
52
+ # player is asking, because there is only one answer. That is what lets
53
+ # `node.control(actions)` keep working unchanged — a tree with nobody
54
+ # claiming ownership, or a spec that has only one player in mind, passes
55
+ # the snapshot itself where a Players registry would otherwise go.
56
+ #
57
+ # @api private
58
+ # hot-path
59
+ def actions_for(_player) = self
60
+
61
+ # hot-path
62
+ def held?(name)
63
+ @held.fetch(name) { undeclared(name) }
64
+ end
65
+
66
+ # True only on the frame the action transitions up→down.
67
+ # hot-path
68
+ def pressed?(name)
69
+ @held.fetch(name) { undeclared(name) } && !@prev_held.fetch(name, false)
70
+ end
71
+
72
+ # True only on the frame the action transitions down→up.
73
+ # hot-path
74
+ def released?(name)
75
+ !@held.fetch(name) { undeclared(name) } && @prev_held.fetch(name, false)
76
+ end
77
+
78
+ # Analog value in [-1.0, 1.0]; 0.0 for a declared action at rest.
79
+ # hot-path
80
+ def axis(name)
81
+ @axes.fetch(name) { undeclared(name) }
82
+ end
83
+
84
+ private
85
+
86
+ def undeclared(name)
87
+ raise KeyError, "no such action #{name.inspect} — declare it in the InputMap " \
88
+ "(this snapshot has #{declared.inspect})"
89
+ end
90
+ end
91
+ end
92
+ end