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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module I18n
6
+ # CLDR plural categories for whole numbers, one rule per language. A rule
7
+ # takes the `count` a caller passed and returns one of `Plural::CATEGORIES`.
8
+ # Every built-in rule answers `:other` for a count that is not an Integer,
9
+ # because fractional counts have categories of their own that no built-in
10
+ # table spells out.
11
+ #
12
+ # Rules are keyed by language, and `I18n.plural_rule` adds or replaces one;
13
+ # a language with no rule counts like English.
14
+ #
15
+ # Source: the CLDR plural rules, https://www.unicode.org/cldr/charts/latest/supplemental/language_plural_rules.html
16
+ module PluralRules
17
+ TWO_TO_FOUR = (2..4)
18
+ TWELVE_TO_FOURTEEN = (12..14)
19
+
20
+ def self.whole(&rule) = ->(count) { count.is_a?(Integer) ? rule.call(count.abs) : :other } # rubocop:disable Performance/RedundantBlockCall -- the rule runs after whole has returned, where yield has no block
21
+
22
+ def self.slavic_few?(count) = TWO_TO_FOUR.cover?(count % 10) && !TWELVE_TO_FOURTEEN.cover?(count % 100)
23
+
24
+ def self.million(count) = count.positive? && (count % 1_000_000).zero? ? :many : :other
25
+
26
+ private_class_method :whole, :slavic_few?, :million
27
+
28
+ ONE_OTHER = whole { |n| n == 1 ? :one : :other }
29
+ ONE_MILLION_OTHER = whole { |n| n == 1 ? :one : million(n) }
30
+ ZERO_ONE_MILLION_OTHER = whole { |n| n <= 1 ? :one : million(n) }
31
+
32
+ EAST_SLAVIC = whole do |n|
33
+ if n % 10 == 1 && n % 100 != 11 then :one
34
+ elsif slavic_few?(n) then :few
35
+ else :many
36
+ end
37
+ end
38
+
39
+ POLISH = whole do |n|
40
+ if n == 1 then :one
41
+ elsif slavic_few?(n) then :few
42
+ else :many
43
+ end
44
+ end
45
+
46
+ CZECH = whole do |n|
47
+ if n == 1 then :one
48
+ elsif TWO_TO_FOUR.cover?(n) then :few
49
+ else :other
50
+ end
51
+ end
52
+
53
+ ARABIC = whole do |n|
54
+ case n
55
+ when 0 then :zero
56
+ when 1 then :one
57
+ when 2 then :two
58
+ else
59
+ case n % 100
60
+ when 3..10 then :few
61
+ when 11..99 then :many
62
+ else :other
63
+ end
64
+ end
65
+ end
66
+
67
+ OTHER = ->(_count) { :other }
68
+
69
+ BUILT_IN = {
70
+ en: ONE_OTHER, de: ONE_OTHER, nl: ONE_OTHER, sv: ONE_OTHER, da: ONE_OTHER, nb: ONE_OTHER, fi: ONE_OTHER,
71
+ it: ONE_MILLION_OTHER, es: ONE_MILLION_OTHER,
72
+ fr: ZERO_ONE_MILLION_OTHER, pt: ZERO_ONE_MILLION_OTHER,
73
+ ru: EAST_SLAVIC, uk: EAST_SLAVIC, pl: POLISH, cs: CZECH,
74
+ ja: OTHER, zh: OTHER, ko: OTHER,
75
+ ar: ARABIC
76
+ }.freeze
77
+
78
+ DEFAULT = ONE_OTHER
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module I18n
6
+ # One translated string, compiled once at load: the literal runs and the
7
+ # `%{name}` placeholders between them, so rendering is an append per part
8
+ # rather than a parse. `%%{` in the source is a literal `%{`.
9
+ #
10
+ # Internal to `I18n`; a game reaches translations through `I18n.t` or a
11
+ # `Text`, never through a Template.
12
+ #
13
+ # @api private
14
+ class Template
15
+ PLACEHOLDER = /%%\{|%\{([A-Za-z_]\w*)\}/
16
+
17
+ # The variable names the source uses, each once, in order of appearance.
18
+ attr_reader :names
19
+
20
+ def self.compile(source)
21
+ parts = []
22
+ literal = +''
23
+ position = 0
24
+ while (match = PLACEHOLDER.match(source, position))
25
+ literal << source[position...match.begin(0)]
26
+ if match[1]
27
+ parts << literal.freeze unless literal.empty?
28
+ parts << match[1].to_sym
29
+ literal = +''
30
+ else
31
+ literal << '%{'
32
+ end
33
+ position = match.end(0)
34
+ end
35
+ literal << source[position..]
36
+ parts << literal.freeze unless literal.empty?
37
+ new(parts)
38
+ end
39
+
40
+ def initialize(parts)
41
+ @parts = parts.freeze
42
+ @names = parts.grep(Symbol).uniq.freeze
43
+ @constant = @names.empty? ? -parts.join : nil
44
+ end
45
+
46
+ # The string with each placeholder replaced by `vars[name].to_s`. A
47
+ # template without placeholders returns the same frozen String every
48
+ # time. Every name in `names` must be a key of `vars`.
49
+ def render(vars)
50
+ return @constant if @constant
51
+
52
+ @parts.each_with_object(+'') do |part, out|
53
+ out << (part.is_a?(Symbol) ? vars.fetch(part).to_s : part)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -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
 
@@ -2,45 +2,99 @@
2
2
 
3
3
  module RGame
4
4
  module Engine
5
- # Polls an InputBackend through a binding map and produces an Actions snapshot.
6
- # The map is `{ action_name => { axis: %i[neg pos] } | { button: %i[ids] } }`;
7
- # physical ids are decoupled from any backend's constants (the backend resolves
8
- # them), so remapping is just swapping this data.
5
+ # Polls one player's device through an InputMap and produces their Actions
6
+ # snapshot.
9
7
  #
10
- # Pure logic: the backend is duck-typed — the whole interface is
11
- # `down?(physical_id)` — so a test passes a fake and a game passes
12
- # `RGame::Core::Input`.
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.
13
19
  class ActionMapper
14
- attr_accessor :map
20
+ DEAD_ZONE = 0.15
21
+
22
+ attr_reader :map, :actions
23
+ attr_accessor :device, :dead_zone
15
24
 
16
- def initialize(map)
25
+ def initialize(map, device: RGame::Util::Controls::KEYBOARD, dead_zone: DEAD_ZONE)
17
26
  @map = map
18
- # One reusable snapshot: there's exactly one input state per tick, so we
19
- # mutate these in place each poll instead of allocating (after the first
20
- # poll warms the hash keys, steady-state polling allocates nothing).
21
- # `@prev_held` carries last frame's button state so Actions can answer edge
22
- # queries (pressed?/released?).
27
+ @device = device
28
+ @dead_zone = dead_zone
29
+
23
30
  @held = {}
24
31
  @prev_held = {}
25
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
26
38
  @actions = Actions.new(held: @held, axes: @axes, prev_held: @prev_held)
27
39
  end
28
40
 
29
41
  def poll(backend)
30
- # Snapshot this frame's held state as "previous" before recomputing it
31
- # (in-place copy: no allocation once the keys exist).
32
42
  @held.each { |name, down| @prev_held[name] = down }
33
43
 
34
- @map.each do |name, binding|
35
- if (axis = binding[:axis])
36
- neg, pos = axis
37
- @axes[name] = (backend.down?(pos) ? 1.0 : 0.0) - (backend.down?(neg) ? 1.0 : 0.0)
38
- end
39
- @held[name] = binding[:button].any? { |b| backend.down?(b) } if binding[:button]
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
40
49
  end
41
50
 
42
51
  @actions
43
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
44
98
  end
45
99
  end
46
100
  end