rgame 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +135 -1
- data/README.md +67 -65
- data/docs/api/README.md +86 -82
- data/docs/api/app.md +125 -68
- data/docs/api/assets.md +203 -165
- data/docs/api/audio.md +130 -89
- data/docs/api/cli.md +259 -0
- data/docs/api/components.md +1032 -265
- data/docs/api/drawing.md +160 -160
- data/docs/api/examples.md +263 -0
- data/docs/api/game.md +149 -50
- data/docs/api/images.md +39 -39
- data/docs/api/input.md +226 -148
- data/docs/api/internals.md +241 -62
- data/docs/api/localization.md +285 -0
- data/docs/api/scene_graph.md +397 -244
- data/docs/api/signals.md +81 -76
- data/docs/api/systems.md +186 -73
- data/docs/api/text.md +58 -41
- data/docs/api/tile_maps.md +237 -0
- data/docs/api/toolbox.md +395 -146
- data/docs/api/ui.md +883 -54
- data/docs/api/values.md +216 -33
- 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/rgame_core/app/app.c +33 -3
- 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 +39 -2
- data/ext/rgame_core/example.rb +0 -49
- data/ext/rgame_core/extconf.rb +0 -125
- data/ext/rgame_core/include/rgame/core.h +38 -2
- data/ext/rgame_core/ruby/audio_ext.c +10 -5
- data/ext/rgame_core/ruby/core_ext.c +30 -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 +20 -20
- 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 -44
- 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/locale.rb +22 -0
- data/lib/rgame/core/nine_slice.rb +0 -21
- data/lib/rgame/core/renderer.rb +6 -63
- data/lib/rgame/core/sprite_sheet.rb +0 -3
- data/lib/rgame/core/tile_map_renderer.rb +2 -19
- 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 +3 -3
- 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 +34 -10
- data/lib/rgame/engine/components/action_trigger.rb +0 -1
- data/lib/rgame/engine/components/animated_sprite.rb +27 -25
- data/lib/rgame/engine/components/box_collider.rb +99 -0
- data/lib/rgame/engine/components/camera_follow.rb +6 -5
- data/lib/rgame/engine/components/character_body.rb +20 -61
- 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 +12 -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 +41 -13
- 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 +4 -6
- data/lib/rgame/engine/debug_overlay.rb +5 -11
- 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 +0 -26
- data/lib/rgame/engine/input/actions.rb +2 -8
- data/lib/rgame/engine/input/input_map.rb +39 -21
- data/lib/rgame/engine/nav_grid.rb +87 -0
- data/lib/rgame/engine/node2d.rb +248 -133
- data/lib/rgame/engine/path.rb +4 -6
- data/lib/rgame/engine/players.rb +6 -13
- data/lib/rgame/engine/presentation.rb +171 -0
- data/lib/rgame/engine/scene/scene_stack.rb +4 -4
- data/lib/rgame/engine/sealed_privates.rb +54 -0
- data/lib/rgame/engine/spatial_hash.rb +51 -19
- 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 +0 -2
- 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 +246 -71
- 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/viewports.rb +2 -5
- data/lib/rgame/engine/world_view.rb +5 -4
- data/lib/rgame/engine.rb +31 -25
- data/lib/rgame/game.rb +99 -27
- 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 +14 -28
- 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 +4 -14
- data/lib/rgame/util.rb +4 -3
- data/lib/rgame/version.rb +1 -1
- data/lib/rgame.rb +0 -15
- metadata +152 -11
- 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/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
- data/lib/rgame/engine/ui/menu_item.rb +0 -84
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
|
|
|
@@ -17,10 +17,6 @@ module RGame
|
|
|
17
17
|
# `down?(physical_id, device:)` and `axis(axis_id, device:)` — a spec passes
|
|
18
18
|
# a fake and a game passes RGame::Core::Input.
|
|
19
19
|
class ActionMapper
|
|
20
|
-
# A resting analog stick genuinely reports small non-zero values, so
|
|
21
|
-
# something has to ignore them. Here rather than in the game, because it
|
|
22
|
-
# is a property of the device, and here rather than in Core, because how
|
|
23
|
-
# much to ignore is a judgement rather than a fact about the hardware.
|
|
24
20
|
DEAD_ZONE = 0.15
|
|
25
21
|
|
|
26
22
|
attr_reader :map, :actions
|
|
@@ -31,10 +27,6 @@ module RGame
|
|
|
31
27
|
@device = device
|
|
32
28
|
@dead_zone = dead_zone
|
|
33
29
|
|
|
34
|
-
# One reusable snapshot: there is exactly one input state per tick per
|
|
35
|
-
# player, so these are mutated in place each poll instead of allocated.
|
|
36
|
-
# Seeded from the map's action list, so the hashes are warm before the
|
|
37
|
-
# first poll and steady-state polling allocates nothing at all.
|
|
38
30
|
@held = {}
|
|
39
31
|
@prev_held = {}
|
|
40
32
|
@axes = {}
|
|
@@ -47,8 +39,6 @@ module RGame
|
|
|
47
39
|
end
|
|
48
40
|
|
|
49
41
|
def poll(backend)
|
|
50
|
-
# Snapshot this frame's held state as "previous" before recomputing it
|
|
51
|
-
# (in-place copy: no allocation, the keys already exist).
|
|
52
42
|
@held.each { |name, down| @prev_held[name] = down }
|
|
53
43
|
|
|
54
44
|
return rest if @device.nil?
|
|
@@ -63,11 +53,6 @@ module RGame
|
|
|
63
53
|
|
|
64
54
|
private
|
|
65
55
|
|
|
66
|
-
# A player with no device — an empty seat waiting for a controller — reads
|
|
67
|
-
# as nothing held and every axis centred. Returning the snapshot rather
|
|
68
|
-
# than refusing to poll is what lets a game show "press a button to join"
|
|
69
|
-
# with no special case, and it releases anything that was held when the
|
|
70
|
-
# controller was unplugged mid-press.
|
|
71
56
|
def rest
|
|
72
57
|
@held.each_key { |name| @held[name] = false }
|
|
73
58
|
@axes.each_key { |name| @axes[name] = 0.0 }
|
|
@@ -79,11 +64,6 @@ module RGame
|
|
|
79
64
|
ids.any? { |id| backend.down?(id, device: @device) }
|
|
80
65
|
end
|
|
81
66
|
|
|
82
|
-
# A digital axis and an analog one can both be bound to the same action —
|
|
83
|
-
# arrows *and* the left stick — so the larger deflection wins. That needs
|
|
84
|
-
# no per-device branching: a keyboard reads 0.0 for every axis and a stick
|
|
85
|
-
# reads 0.0 for every key, so whichever device the player is on, the other
|
|
86
|
-
# source contributes nothing.
|
|
87
67
|
# hot-path
|
|
88
68
|
def axis_value(backend, binding)
|
|
89
69
|
digital = digital_axis(backend, binding)
|
|
@@ -93,9 +73,6 @@ module RGame
|
|
|
93
73
|
digital.abs >= analog.abs ? digital : analog
|
|
94
74
|
end
|
|
95
75
|
|
|
96
|
-
# The largest deflection across every pair bound to this axis. Several
|
|
97
|
-
# pairs is how the arrows, WASD and a d-pad all drive one action; a device
|
|
98
|
-
# with only one of them reads 0.0 for the rest, so the others cost nothing.
|
|
99
76
|
# hot-path
|
|
100
77
|
def digital_axis(backend, binding)
|
|
101
78
|
pairs = binding.pairs
|
|
@@ -110,9 +87,6 @@ module RGame
|
|
|
110
87
|
best
|
|
111
88
|
end
|
|
112
89
|
|
|
113
|
-
# Rescaled rather than merely cut off, so the value ramps from zero as the
|
|
114
|
-
# stick leaves the dead zone. Cutting off alone makes it jump to the dead
|
|
115
|
-
# zone's width the moment it starts reading, which is a visible twitch.
|
|
116
90
|
# hot-path
|
|
117
91
|
def dead_zoned(value)
|
|
118
92
|
magnitude = value.abs
|
|
@@ -53,6 +53,8 @@ module RGame
|
|
|
53
53
|
# `node.control(actions)` keep working unchanged — a tree with nobody
|
|
54
54
|
# claiming ownership, or a spec that has only one player in mind, passes
|
|
55
55
|
# the snapshot itself where a Players registry would otherwise go.
|
|
56
|
+
#
|
|
57
|
+
# @api private
|
|
56
58
|
# hot-path
|
|
57
59
|
def actions_for(_player) = self
|
|
58
60
|
|
|
@@ -81,18 +83,10 @@ module RGame
|
|
|
81
83
|
|
|
82
84
|
private
|
|
83
85
|
|
|
84
|
-
# A block rather than `fetch(name)`'s bare KeyError, for a message that
|
|
85
|
-
# says what to do about it. The block only runs on a miss, so the reading
|
|
86
|
-
# path stays one hash lookup with nothing allocated.
|
|
87
86
|
def undeclared(name)
|
|
88
87
|
raise KeyError, "no such action #{name.inspect} — declare it in the InputMap " \
|
|
89
88
|
"(this snapshot has #{declared.inspect})"
|
|
90
89
|
end
|
|
91
|
-
|
|
92
|
-
# `prev_held` is read with a default on purpose. It is one frame behind, so
|
|
93
|
-
# on the very first poll after an action is added it legitimately has no
|
|
94
|
-
# entry, and "was not held before" is the right answer rather than an
|
|
95
|
-
# error. The current-frame lookup above is what catches a typo.
|
|
96
90
|
end
|
|
97
91
|
end
|
|
98
92
|
end
|
|
@@ -67,27 +67,21 @@ module RGame
|
|
|
67
67
|
|
|
68
68
|
SOURCES = %i[buttons axis stick].freeze
|
|
69
69
|
|
|
70
|
-
# The universal set, merged into every map unless the game overrides it.
|
|
71
|
-
#
|
|
72
|
-
# The UI package navigates and activates through these, so a control can
|
|
73
|
-
# rely on them existing for *every* player without a game having declared
|
|
74
|
-
# them. They are prefixed rather than plain (`ui_up`, not `up`) so a game
|
|
75
|
-
# is free to use `:up` for something of its own.
|
|
76
|
-
#
|
|
77
|
-
# `ui_cancel` is Escape, which is why RGame::Game's quit key is F2: the
|
|
78
|
-
# button a player expects to back out of a menu belongs to the menu.
|
|
79
70
|
UI = {
|
|
80
71
|
ui_up: { buttons: [Controls::KEY_UP, Controls::PAD_DPAD_UP] },
|
|
81
72
|
ui_down: { buttons: [Controls::KEY_DOWN, Controls::PAD_DPAD_DOWN] },
|
|
82
73
|
ui_left: { buttons: [Controls::KEY_LEFT, Controls::PAD_DPAD_LEFT] },
|
|
83
74
|
ui_right: { buttons: [Controls::KEY_RIGHT, Controls::PAD_DPAD_RIGHT] },
|
|
84
75
|
ui_confirm: { buttons: [Controls::KEY_RETURN, Controls::KEY_SPACE, Controls::PAD_A] },
|
|
85
|
-
ui_cancel: { buttons: [Controls::KEY_ESCAPE, Controls::PAD_B] }
|
|
76
|
+
ui_cancel: { buttons: [Controls::KEY_ESCAPE, Controls::PAD_B] },
|
|
77
|
+
ui_radial_x: { axis: [[Controls::KEY_LEFT, Controls::KEY_RIGHT],
|
|
78
|
+
[Controls::PAD_DPAD_LEFT, Controls::PAD_DPAD_RIGHT]],
|
|
79
|
+
stick: Controls::AXIS_LEFT_X },
|
|
80
|
+
ui_radial_y: { axis: [[Controls::KEY_UP, Controls::KEY_DOWN],
|
|
81
|
+
[Controls::PAD_DPAD_UP, Controls::PAD_DPAD_DOWN]],
|
|
82
|
+
stick: Controls::AXIS_LEFT_Y }
|
|
86
83
|
}.freeze
|
|
87
84
|
|
|
88
|
-
# A playable starting point: eight-way movement on the arrows or the left
|
|
89
|
-
# stick, and a fire button. A game that wants exactly this declares
|
|
90
|
-
# nothing at all.
|
|
91
85
|
DEFAULT_ACTIONS = {
|
|
92
86
|
move_x: { axis: [[Controls::KEY_LEFT, Controls::KEY_RIGHT],
|
|
93
87
|
[Controls::KEY_A, Controls::KEY_D],
|
|
@@ -120,6 +114,38 @@ module RGame
|
|
|
120
114
|
|
|
121
115
|
def actions = @bindings.keys
|
|
122
116
|
|
|
117
|
+
# The button bound to `action` that `device` can actually press, or nil.
|
|
118
|
+
#
|
|
119
|
+
# This undoes what the section above describes. One entry lists a key and
|
|
120
|
+
# a pad button together so that polling needs no branch, and that is right
|
|
121
|
+
# for reading input and wrong for *showing* it: a prompt has to say "press
|
|
122
|
+
# A" or "press Space", never both, and which one depends on what the
|
|
123
|
+
# player last touched.
|
|
124
|
+
#
|
|
125
|
+
# map.button_for(:fire, Controls::KEYBOARD) # => KEY_SPACE
|
|
126
|
+
# map.button_for(:fire, Controls.gamepad(0)) # => PAD_A
|
|
127
|
+
#
|
|
128
|
+
# The first match wins, so the order an entry lists its ids in is the order
|
|
129
|
+
# a prompt prefers them — `ui_confirm` names Return before Space, and a
|
|
130
|
+
# prompt for it says Return.
|
|
131
|
+
#
|
|
132
|
+
# Nil for an action nobody bound, for one with no buttons at all (a stick
|
|
133
|
+
# or a digital axis is not a button and a prompt for one is a different
|
|
134
|
+
# picture), and for a device kind the entry does not cover. A caller
|
|
135
|
+
# showing a prompt has to handle that nil either way, because a rebinding
|
|
136
|
+
# screen can leave an action unbound.
|
|
137
|
+
#
|
|
138
|
+
# Allocation-free, so a HUD may call it per frame rather than caching a
|
|
139
|
+
# string it would then have to invalidate.
|
|
140
|
+
def button_for(action, device)
|
|
141
|
+
binding = @bindings[action]
|
|
142
|
+
buttons = binding&.buttons
|
|
143
|
+
return nil if buttons.nil?
|
|
144
|
+
|
|
145
|
+
pad = Controls.gamepad?(device)
|
|
146
|
+
buttons.find { |id| Controls.pad_button?(id) == pad }
|
|
147
|
+
end
|
|
148
|
+
|
|
123
149
|
# The entries in the shape they were declared in, so a map can be edited
|
|
124
150
|
# and rebuilt (a config screen) or merged.
|
|
125
151
|
def to_h
|
|
@@ -134,11 +160,6 @@ module RGame
|
|
|
134
160
|
|
|
135
161
|
private
|
|
136
162
|
|
|
137
|
-
# Malformed entries raise here rather than reading as "nothing is ever
|
|
138
|
-
# pressed" for the rest of the program. An action name misspelled at a
|
|
139
|
-
# *read* site is still silent — see Actions — but one misspelled in the
|
|
140
|
-
# map is the mistake that is actually easy to make, and this catches it at
|
|
141
|
-
# construction rather than at the first frame nobody can move.
|
|
142
163
|
def build(name, entry)
|
|
143
164
|
unknown = entry.keys - SOURCES
|
|
144
165
|
raise ArgumentError, "#{name}: unknown source #{unknown.first.inspect}" unless unknown.empty?
|
|
@@ -157,9 +178,6 @@ module RGame
|
|
|
157
178
|
ids.dup.freeze
|
|
158
179
|
end
|
|
159
180
|
|
|
160
|
-
# Accepts one `[negative, positive]` pair or a list of them. A bare pair
|
|
161
|
-
# is the common case and stays readable; a list is what lets the arrows,
|
|
162
|
-
# WASD and a d-pad all drive one axis.
|
|
163
181
|
def axis_pairs(name, axis)
|
|
164
182
|
return nil if axis.nil?
|
|
165
183
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# A walkability grid and the searches over it: region labels and A* routes, run in C by a
|
|
6
|
+
# Util::RouteSearch over a Util::SolidGrid.
|
|
7
|
+
#
|
|
8
|
+
# Where TileBlockers answers *what is in the way of a step*, this answers *what is in the
|
|
9
|
+
# way of a route*: the same fact about the same cells, asked over a graph instead of along
|
|
10
|
+
# a box's sweep. Components::TileWorld owns both, over one store.
|
|
11
|
+
#
|
|
12
|
+
# It is built one of two ways:
|
|
13
|
+
#
|
|
14
|
+
# - **`grid:`** — over a SolidGrid it shares. A cell changed in that grid is changed here
|
|
15
|
+
# at once, and region labels follow it on the next query with nothing rebuilt. This is
|
|
16
|
+
# how a TileWorld builds one, and the way to build one over solidity that will change.
|
|
17
|
+
# - **`width:`, `height:`, `solid:`** — from a callable, `solid.call(col, row) -> bool`,
|
|
18
|
+
# read once per cell into a private grid of its own. Nothing re-reads the callable, so a
|
|
19
|
+
# change behind it is never seen.
|
|
20
|
+
#
|
|
21
|
+
# Anything else — both, or neither — is an ArgumentError.
|
|
22
|
+
#
|
|
23
|
+
# Routes are cells, never pixels. Turning a cell into the point a node must reach depends
|
|
24
|
+
# on the node's collider, which a grid has no business knowing; Components::Navigator
|
|
25
|
+
# does that.
|
|
26
|
+
#
|
|
27
|
+
# The grid is 8-connected with octile costs (1 straight, √2 diagonal), and a diagonal step
|
|
28
|
+
# is allowed only when both orthogonal cells beside it are open, so a route never cuts the
|
|
29
|
+
# corner of a solid cell. Connected regions are labelled whenever the grid has changed
|
|
30
|
+
# since they last were, which makes an unreachable goal — the answer a search is slowest
|
|
31
|
+
# to give, because it has to exhaust the region first — a lookup whenever it lies in
|
|
32
|
+
# another region.
|
|
33
|
+
#
|
|
34
|
+
# Coordinates are Integers, and anything else is a TypeError. An Integer outside the
|
|
35
|
+
# grid, however large, is answered like any cell outside it.
|
|
36
|
+
#
|
|
37
|
+
# A search runs on demand, never per frame, and allocates its result; everything else it
|
|
38
|
+
# keeps is reused between searches. One grid is therefore not safe to search from two
|
|
39
|
+
# threads at once.
|
|
40
|
+
class NavGrid
|
|
41
|
+
def initialize(grid: nil, width: nil, height: nil, solid: nil)
|
|
42
|
+
@grid = grid ? shared(grid, width, height, solid) : private_grid(width, height, solid)
|
|
43
|
+
@search = Util::RouteSearch.new(@grid)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def width = @grid.width
|
|
47
|
+
def height = @grid.height
|
|
48
|
+
|
|
49
|
+
def walkable?(col, row)
|
|
50
|
+
!@grid.solid?(col, row) && col >= 0 && row >= 0 && col < @grid.width && row < @grid.height
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The connected region containing the cell, as an Integer label — two walkable cells
|
|
54
|
+
# share a label exactly when a route joins them — or nil for a solid cell or one
|
|
55
|
+
# outside the grid.
|
|
56
|
+
def region(col, row) = @search.region(col, row)
|
|
57
|
+
|
|
58
|
+
def reachable?(from_col, from_row, to_col, to_row)
|
|
59
|
+
from = @search.region(from_col, from_row)
|
|
60
|
+
to = @search.region(to_col, to_row)
|
|
61
|
+
!from.nil? && from == to
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The cheapest route from start to goal, both included, as `[[col, row], ...]` — or
|
|
65
|
+
# nil when either end is solid, outside the grid, or in another region. Start equal
|
|
66
|
+
# to goal is a route of one cell. Ties between equally cheap routes are broken the
|
|
67
|
+
# same way every time, so the same query always returns the same route.
|
|
68
|
+
def find(from_col, from_row, to_col, to_row) = @search.find(from_col, from_row, to_col, to_row)
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def shared(grid, width, height, solid)
|
|
73
|
+
return grid unless width || height || solid
|
|
74
|
+
|
|
75
|
+
raise ArgumentError, 'a NavGrid is built over a grid: or from width:, height: and solid:, not both'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def private_grid(width, height, solid)
|
|
79
|
+
unless width && height && solid
|
|
80
|
+
raise ArgumentError, 'a NavGrid needs a grid:, or all of width:, height: and solid:'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Util::SolidGrid.build(width, height) { |col, row| solid.call(col, row) }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|