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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +228 -0
- data/README.md +142 -243
- data/docs/api/README.md +141 -90
- data/docs/api/app.md +125 -68
- data/docs/api/assets.md +203 -166
- data/docs/api/audio.md +130 -89
- data/docs/api/cli.md +259 -0
- data/docs/api/components.md +1045 -254
- data/docs/api/drawing.md +187 -119
- data/docs/api/examples.md +263 -0
- data/docs/api/game.md +165 -45
- data/docs/api/images.md +39 -39
- data/docs/api/input.md +344 -85
- data/docs/api/internals.md +241 -62
- data/docs/api/localization.md +285 -0
- data/docs/api/scene_graph.md +503 -123
- data/docs/api/signals.md +81 -76
- data/docs/api/systems.md +197 -64
- data/docs/api/text.md +58 -41
- data/docs/api/tile_maps.md +237 -0
- data/docs/api/toolbox.md +400 -147
- data/docs/api/ui.md +927 -0
- data/docs/api/values.md +234 -19
- data/examples/assets/README.md +322 -0
- data/examples/assets/blip.ogg +0 -0
- data/examples/assets/glyphs.json +5 -0
- data/examples/assets/glyphs.png +0 -0
- data/examples/assets/hero.json +12 -0
- data/examples/assets/hero.png +0 -0
- data/examples/assets/icons.json +13 -0
- data/examples/assets/icons.png +0 -0
- data/examples/assets/music.ogg +0 -0
- data/examples/assets/skills.json +10 -0
- data/examples/assets/skills.png +0 -0
- data/examples/assets/tileset.png +0 -0
- data/examples/assets/tileset.tsx +65 -0
- data/examples/assets/town.tmx +26 -0
- data/examples/assets/ui.json +11 -0
- data/examples/assets/ui.png +0 -0
- data/examples/collision/locales/en.yml +8 -0
- data/examples/collision/main.rb +316 -0
- data/examples/collision_tiles/locales/en.yml +9 -0
- data/examples/collision_tiles/main.rb +274 -0
- data/examples/fullscreen/locales/en.yml +10 -0
- data/examples/fullscreen/main.rb +216 -0
- data/examples/game_menu/locales/en.yml +8 -0
- data/examples/game_menu/main.rb +170 -0
- data/examples/input_glyphs/locales/en.yml +14 -0
- data/examples/input_glyphs/main.rb +213 -0
- data/examples/jump_topdown/locales/en.yml +9 -0
- data/examples/jump_topdown/main.rb +178 -0
- data/examples/localization/locales/de.yml +12 -0
- data/examples/localization/locales/en.yml +13 -0
- data/examples/localization/main.rb +158 -0
- data/examples/menu_navigation/locales/en.yml +23 -0
- data/examples/menu_navigation/main.rb +365 -0
- data/examples/music/locales/en.yml +7 -0
- data/examples/music/main.rb +134 -0
- data/examples/pathfinding/locales/en.yml +17 -0
- data/examples/pathfinding/main.rb +298 -0
- data/examples/pooling/locales/en.yml +7 -0
- data/examples/pooling/main.rb +259 -0
- data/examples/quick_wheel/locales/en.yml +16 -0
- data/examples/quick_wheel/main.rb +184 -0
- data/examples/radial_menu/locales/en.yml +16 -0
- data/examples/radial_menu/main.rb +184 -0
- data/examples/save_load/locales/en.yml +11 -0
- data/examples/save_load/main.rb +207 -0
- data/examples/save_load_ids/locales/en.yml +11 -0
- data/examples/save_load_ids/main.rb +322 -0
- data/examples/scroll_map/locales/en.yml +4 -0
- data/examples/scroll_map/main.rb +140 -0
- data/examples/signals/locales/en.yml +6 -0
- data/examples/signals/main.rb +278 -0
- data/examples/skill_bar/locales/en.yml +14 -0
- data/examples/skill_bar/main.rb +159 -0
- data/examples/sound/locales/en.yml +6 -0
- data/examples/sound/main.rb +122 -0
- data/examples/split_screen/locales/en.yml +9 -0
- data/examples/split_screen/main.rb +304 -0
- data/examples/sprite/locales/en.yml +8 -0
- data/examples/sprite/main.rb +180 -0
- data/examples/timer/locales/en.yml +12 -0
- data/examples/timer/main.rb +273 -0
- data/examples/velocity/locales/en.yml +6 -0
- data/examples/velocity/main.rb +196 -0
- data/examples/walk/locales/en.yml +4 -0
- data/examples/walk/main.rb +99 -0
- data/exe/rgame +9 -0
- data/ext/README.md +6 -5
- data/ext/rgame_core/app/app.c +215 -11
- data/ext/rgame_core/app/locale.c +67 -0
- data/ext/rgame_core/app/locale.h +28 -0
- data/ext/rgame_core/audio/audio.c +113 -2
- data/ext/rgame_core/example.rb +18 -56
- data/ext/rgame_core/extconf.rb +16 -113
- data/ext/rgame_core/graphics/canvas.c +45 -4
- data/ext/rgame_core/graphics/canvas.h +65 -10
- data/ext/rgame_core/graphics/clip.c +22 -13
- data/ext/rgame_core/include/rgame/core.h +151 -5
- data/ext/rgame_core/input/gamepad.c +57 -3
- data/ext/rgame_core/ruby/audio_ext.c +10 -5
- data/ext/rgame_core/ruby/core_ext.c +46 -7
- data/ext/rgame_core/ruby/core_ext.h +3 -0
- data/ext/rgame_core/ruby/locale_ext.c +44 -0
- data/ext/rgame_core/ruby/recording_ext.c +1 -1
- data/ext/rgame_core/ruby/renderer_ext.c +42 -19
- data/ext/rgame_util/color_ext.c +12 -3
- data/ext/rgame_util/extconf.rb +2 -20
- data/ext/rgame_util/route_search.c +305 -0
- data/ext/rgame_util/route_search.h +86 -0
- data/ext/rgame_util/route_search_ext.c +150 -0
- data/ext/rgame_util/solid_grid.c +58 -0
- data/ext/rgame_util/solid_grid.h +49 -0
- data/ext/rgame_util/solid_grid_ext.c +161 -0
- data/ext/rgame_util/tile_sweep.c +164 -0
- data/ext/rgame_util/tile_sweep.h +62 -0
- data/ext/rgame_util/tile_sweep_ext.c +155 -0
- data/ext/rgame_util/util_ext.c +3 -0
- data/ext/rgame_util/util_ext.h +15 -0
- data/lib/rgame/boot.rb +0 -10
- data/lib/rgame/cli/new_project.rb +139 -0
- data/lib/rgame/cli/templates/Gemfile.tt +23 -0
- data/lib/rgame/cli/templates/README.md.tt +93 -0
- data/lib/rgame/cli/templates/Rakefile.tt +9 -0
- data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
- data/lib/rgame/cli/templates/game.rb.tt +23 -0
- data/lib/rgame/cli/templates/gitignore.tt +12 -0
- data/lib/rgame/cli/templates/main.rb.tt +11 -0
- data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
- data/lib/rgame/cli/templates/rspec.tt +2 -0
- data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
- data/lib/rgame/cli/templates/ruby-version.tt +1 -0
- data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
- data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
- data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
- data/lib/rgame/cli.rb +66 -0
- data/lib/rgame/core/app.rb +6 -42
- data/lib/rgame/core/asset_manager.rb +13 -31
- data/lib/rgame/core/audio.rb +37 -16
- data/lib/rgame/core/font.rb +0 -3
- data/lib/rgame/core/input.rb +35 -41
- data/lib/rgame/core/locale.rb +22 -0
- data/lib/rgame/core/nine_slice.rb +0 -21
- data/lib/rgame/core/recording.rb +3 -1
- data/lib/rgame/core/renderer.rb +75 -84
- data/lib/rgame/core/sprite_sheet.rb +0 -3
- data/lib/rgame/core/tile_map_renderer.rb +77 -65
- data/lib/rgame/core/ui_atlas.rb +28 -13
- data/lib/rgame/core.rb +1 -8
- data/lib/rgame/engine/actor_blockers.rb +131 -0
- data/lib/rgame/engine/animation_set.rb +1 -0
- data/lib/rgame/engine/audio_director.rb +36 -6
- data/lib/rgame/engine/bounds_blockers.rb +74 -0
- data/lib/rgame/engine/camera.rb +55 -10
- data/lib/rgame/engine/circle_collider.rb +4 -2
- data/lib/rgame/engine/collision_box.rb +26 -1
- data/lib/rgame/engine/collision_system.rb +110 -22
- data/lib/rgame/engine/component.rb +35 -1
- data/lib/rgame/engine/components/action_trigger.rb +0 -1
- data/lib/rgame/engine/components/animated_sprite.rb +31 -23
- data/lib/rgame/engine/components/box_collider.rb +99 -0
- data/lib/rgame/engine/components/camera_follow.rb +45 -0
- data/lib/rgame/engine/components/character_body.rb +21 -41
- data/lib/rgame/engine/components/circle_collider.rb +47 -11
- data/lib/rgame/engine/components/collision_world.rb +159 -31
- data/lib/rgame/engine/components/despawn_offscreen.rb +24 -8
- data/lib/rgame/engine/components/feet_collider.rb +61 -0
- data/lib/rgame/engine/components/hop.rb +76 -0
- data/lib/rgame/engine/components/identity.rb +73 -0
- data/lib/rgame/engine/components/mover.rb +285 -0
- data/lib/rgame/engine/components/navigator.rb +145 -0
- data/lib/rgame/engine/components/path_follow.rb +123 -31
- data/lib/rgame/engine/components/player_controller.rb +5 -2
- data/lib/rgame/engine/components/pool.rb +1 -1
- data/lib/rgame/engine/components/screen_wrap.rb +33 -11
- data/lib/rgame/engine/components/sprite.rb +22 -6
- data/lib/rgame/engine/components/targeting.rb +9 -11
- data/lib/rgame/engine/components/thrust_controller.rb +1 -1
- data/lib/rgame/engine/components/tile_world.rb +70 -29
- data/lib/rgame/engine/components/timer.rb +1 -1
- data/lib/rgame/engine/components/velocity.rb +23 -7
- data/lib/rgame/engine/components/wander_controller.rb +6 -2
- data/lib/rgame/engine/components/world.rb +133 -0
- data/lib/rgame/engine/contact_set.rb +74 -0
- data/lib/rgame/engine/culling.rb +45 -0
- data/lib/rgame/engine/debug_overlay.rb +23 -18
- data/lib/rgame/engine/i18n/plural.rb +45 -0
- data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
- data/lib/rgame/engine/i18n/template.rb +59 -0
- data/lib/rgame/engine/i18n.rb +276 -51
- data/lib/rgame/engine/input/action_mapper.rb +76 -22
- data/lib/rgame/engine/input/actions.rb +63 -12
- data/lib/rgame/engine/input/input_map.rb +196 -0
- data/lib/rgame/engine/layout.rb +82 -0
- data/lib/rgame/engine/nav_grid.rb +87 -0
- data/lib/rgame/engine/node2d.rb +364 -80
- data/lib/rgame/engine/path.rb +4 -6
- data/lib/rgame/engine/player.rb +69 -0
- data/lib/rgame/engine/player_layer.rb +70 -0
- data/lib/rgame/engine/players.rb +205 -0
- data/lib/rgame/engine/presentation.rb +171 -0
- data/lib/rgame/engine/scene/scene_stack.rb +29 -7
- data/lib/rgame/engine/sealed_privates.rb +54 -0
- data/lib/rgame/engine/spatial_hash.rb +53 -8
- data/lib/rgame/engine/text.rb +194 -0
- data/lib/rgame/engine/tile_blockers.rb +63 -0
- data/lib/rgame/engine/tile_map.rb +2 -3
- data/lib/rgame/engine/tile_map_layer.rb +82 -0
- data/lib/rgame/engine/tileset.rb +2 -4
- data/lib/rgame/engine/timer.rb +2 -2
- data/lib/rgame/engine/ui/button.rb +248 -0
- data/lib/rgame/engine/ui/column.rb +20 -0
- data/lib/rgame/engine/ui/icon_button.rb +93 -0
- data/lib/rgame/engine/ui/menu.rb +290 -0
- data/lib/rgame/engine/ui/navigation.rb +57 -0
- data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
- data/lib/rgame/engine/ui/option_button.rb +163 -0
- data/lib/rgame/engine/ui/panel_button.rb +32 -0
- data/lib/rgame/engine/ui/panel_menu.rb +36 -0
- data/lib/rgame/engine/ui/pointing.rb +146 -0
- data/lib/rgame/engine/ui/radial_menu.rb +85 -0
- data/lib/rgame/engine/ui/ring.rb +55 -0
- data/lib/rgame/engine/ui/row.rb +21 -0
- data/lib/rgame/engine/ui/shape_style.rb +102 -0
- data/lib/rgame/engine/ui/stack.rb +58 -0
- data/lib/rgame/engine/ui/stepping.rb +93 -0
- data/lib/rgame/engine/ui/text_button.rb +59 -0
- data/lib/rgame/engine/view.rb +76 -0
- data/lib/rgame/engine/viewports.rb +171 -0
- data/lib/rgame/engine/world_view.rb +71 -0
- data/lib/rgame/engine.rb +43 -25
- data/lib/rgame/game.rb +164 -22
- data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
- data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
- data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
- data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
- data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
- data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
- data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
- data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
- data/lib/rgame/rubocop/default.yml +39 -0
- data/lib/rgame/rubocop/plugin.rb +45 -0
- data/lib/rgame/rubocop.rb +11 -0
- data/lib/rgame/util/color.rb +20 -24
- data/lib/rgame/util/controls.rb +106 -44
- data/lib/rgame/util/route_search.rb +27 -0
- data/lib/rgame/util/save_file.rb +107 -0
- data/lib/rgame/util/solid_grid.rb +37 -0
- data/lib/rgame/util/tensor.rb +0 -9
- data/lib/rgame/util/tile_sweep.rb +36 -0
- data/lib/rgame/util/z.rb +123 -0
- data/lib/rgame/util.rb +5 -3
- data/lib/rgame/version.rb +1 -1
- data/lib/rgame.rb +0 -15
- metadata +176 -20
- data/lib/rgame/engine/actor.rb +0 -53
- data/lib/rgame/engine/body.rb +0 -49
- data/lib/rgame/engine/cached_label.rb +0 -33
- data/lib/rgame/engine/camera_view.rb +0 -28
- data/lib/rgame/engine/input/player_controller.rb +0 -14
- data/lib/rgame/engine/matrix.rb +0 -32
- data/lib/rgame/engine/resettable.rb +0 -67
- 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
|
data/lib/rgame/engine/i18n.rb
CHANGED
|
@@ -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
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
88
|
+
locale = normalize(locale)
|
|
89
|
+
return if locale == @default
|
|
90
|
+
|
|
91
|
+
@default = locale
|
|
92
|
+
relink
|
|
21
93
|
end
|
|
22
94
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
#
|
|
31
|
-
#
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
@
|
|
143
|
+
@plural_rules[normalize(language)] = rule
|
|
144
|
+
@rule_for.clear
|
|
49
145
|
@generation += 1
|
|
146
|
+
self
|
|
50
147
|
end
|
|
51
148
|
|
|
52
|
-
|
|
149
|
+
# The locales that have a table, in load order.
|
|
150
|
+
def available = @tables.keys
|
|
53
151
|
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
return
|
|
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
|
-
|
|
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
|
|
6
|
-
#
|
|
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
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|