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/docs/api/internals.md
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
# Internal building blocks
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
`AnimatedSprite` plays through an `Animator`, a `CollisionWorld` indexes through a
|
|
7
|
-
`SpatialHash`) — but they are documented here because they carry the load-bearing
|
|
8
|
-
algorithms and are the seams the component tests drive. None `require "gosu"`.
|
|
3
|
+
**The engine's [components](components.md) and [systems](systems.md) are built on
|
|
4
|
+
these low-level, pure-Ruby classes.** A game author rarely constructs them. They
|
|
5
|
+
sit *behind* components:
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
- a blocked `Mover` resolves through `CollisionSystem`;
|
|
8
|
+
- an `AnimatedSprite` plays through an `Animator`;
|
|
9
|
+
- a `CollisionWorld` indexes through a `SpatialHash` and remembers contacts
|
|
10
|
+
through a `ContactSet`.
|
|
11
|
+
|
|
12
|
+
This page documents them because they hold the core algorithms, and component
|
|
13
|
+
specs drive them directly.
|
|
14
|
+
|
|
15
|
+
[Toolbox](toolbox.md) covers the helpers a game *does* use directly: pools,
|
|
16
|
+
the camera, collision boxes. [Localization](localization.md) covers `I18n`.
|
|
12
17
|
|
|
13
18
|
## `SpatialHash` — uniform-grid broadphase
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
cell
|
|
20
|
+
**`RGame::Engine::SpatialHash` (`rgame/engine/spatial_hash`) is a broadphase index
|
|
21
|
+
for collision.** It buckets colliders into fixed-size grid cells, then tests only
|
|
22
|
+
candidates that share a cell, not every pair. It is the index inside
|
|
18
23
|
[`CollisionWorld`](components.md#collisionworld).
|
|
19
24
|
|
|
20
25
|
```ruby
|
|
@@ -24,87 +29,261 @@ rocks.each { |r| hash.insert(r, *r.aabb) } # insert the static set
|
|
|
24
29
|
hash.query(*bullet.aabb) { |rock| ...narrowphase... }
|
|
25
30
|
```
|
|
26
31
|
|
|
27
|
-
|
|
28
|
-
around each moving collider.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
`
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
`
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
32
|
+
A typical frame calls `clear`, inserts every collider of one set, then runs
|
|
33
|
+
`query` around each moving collider. `insert` and `query` both take an AABB
|
|
34
|
+
(`x, y, w, h`).
|
|
35
|
+
|
|
36
|
+
`remove(item, x, y, w, h)` undoes one `insert`. Pass the box the item was
|
|
37
|
+
inserted at, not where it is now: the hash does not remember where it put
|
|
38
|
+
anything. Removing an item that is not there does nothing.
|
|
39
|
+
|
|
40
|
+
**`query` may yield an item more than once.** An item spanning several cells sits
|
|
41
|
+
in each of them. Removing repeats is the narrowphase caller's job, which spares the
|
|
42
|
+
hash a per-query visited set and keeps it allocation-free. `CollisionWorld` checks
|
|
43
|
+
each pair against the [`ContactSet`](#contactset--the-two-edges-of-a-contact) it
|
|
44
|
+
fills for the step, which it needs anyway. A caller that only *selects*, like
|
|
45
|
+
`nearest`, ignores repeats instead. The hash packs each cell key into one tagged
|
|
46
|
+
Fixnum, offset so negative cells stay non-negative. Keying therefore allocates
|
|
47
|
+
nothing either.
|
|
48
|
+
|
|
49
|
+
`query_circle(cx, cy, r, &)` is the radial form of `query`. It yields the items in
|
|
50
|
+
the cells the circle's bounding box covers, through the same cell walk. Use it for
|
|
51
|
+
range and nearest lookups. It remains a broadphase with the same may-yield-twice
|
|
52
|
+
contract. The caller refines candidates by true distance (see
|
|
53
|
+
[`CollisionWorld`](components.md#collisionworld)'s `query_circle` and `nearest`).
|
|
54
|
+
|
|
55
|
+
`cell_empty?(x, y)` asks whether the single cell *containing the point* `(x, y)`
|
|
56
|
+
is empty. It takes a point, not a region, so pass any coordinate inside the cell
|
|
57
|
+
you mean. It allocates nothing. Unlike a plain read of the bucket Hash, a miss
|
|
58
|
+
creates no bucket.
|
|
59
|
+
|
|
60
|
+
**The cell walk is half-open on the far edge.** The hash buckets an item by its
|
|
61
|
+
bounding box. A box ending exactly on a cell boundary stops at the cell before it.
|
|
62
|
+
A piece filling one cell therefore lands in that cell only. "Bucketed here" means
|
|
63
|
+
exactly "overlaps this cell's area", so `cell_empty?` tests occupancy, not
|
|
64
|
+
candidacy. `CollisionBox.overlap?` uses the same convention, and the two must
|
|
65
|
+
agree. Bucketing one cell too far would only cost extra candidates. Bucketing one
|
|
66
|
+
cell short would miss a real contact.
|
|
67
|
+
|
|
68
|
+
The index cannot know whether an occupant still counts.
|
|
69
|
+
[`CollisionWorld#cell_empty?`](components.md#collisionworld) therefore wraps this
|
|
70
|
+
method and skips colliders whose node is queued for removal.
|
|
71
|
+
|
|
72
|
+
## `ContactSet` — the two edges of a contact
|
|
73
|
+
|
|
74
|
+
**`RGame::Engine::ContactSet` (`rgame/engine/contact_set`) turns a per-step overlap
|
|
75
|
+
test into two edges**: the step a pair *starts* touching and the step it *stops*.
|
|
76
|
+
[`CollisionWorld`](components.md#collisionworld) owns one per registered collider
|
|
77
|
+
and is its only driver there. Nothing in the class concerns overlap, though, and
|
|
78
|
+
it has a second user. Every [`Mover`](components.md#mover) keeps one of what
|
|
79
|
+
stopped its step, and derives `on_blocked` / `on_unblocked` from it the same way.
|
|
80
|
+
Underneath its names, the class is "the set of things true this step and last".
|
|
49
81
|
|
|
50
82
|
```ruby
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
83
|
+
contacts.begin_frame # last step's list becomes the one to compare against
|
|
84
|
+
contacts.touching?(other) # already recorded this step? (the broadphase repeats)
|
|
85
|
+
contacts.started?(other) # not touching last step, so this is an on_hit
|
|
86
|
+
contacts.add(other) # record it for this step
|
|
87
|
+
contacts.each_ended { |o| ... } # touching last step, not now, so this is an on_separated
|
|
88
|
+
contacts.reset # forget both steps — a pooled collider registering again
|
|
55
89
|
```
|
|
56
90
|
|
|
57
|
-
It
|
|
58
|
-
|
|
91
|
+
**It uses two arrays, swapped each step, and checks membership by a linear scan
|
|
92
|
+
on identity.** Both choices serve the per-frame path. `Array#clear` keeps the
|
|
93
|
+
capacity the array grew to, so a lasting contact allocates nothing after the first
|
|
94
|
+
few steps. A Set or a Hash would allocate on every insert. A collider touching more
|
|
95
|
+
than a handful of things at once points to a design problem elsewhere; it is no
|
|
96
|
+
reason to index this.
|
|
97
|
+
|
|
98
|
+
`started?` reads only the previous step's list. It answers the same before and
|
|
99
|
+
after `add`, so the world can use it as a guard in step order.
|
|
59
100
|
|
|
60
|
-
## `
|
|
101
|
+
## `TileBlockers` — the tile grid as a blocker source
|
|
61
102
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
[`
|
|
65
|
-
|
|
103
|
+
**`RGame::Engine::TileBlockers` (`rgame/engine/tile_blockers`) resolves an
|
|
104
|
+
axis-aligned box against the solid tiles of a
|
|
105
|
+
[`Util::SolidGrid`](values.md#rgameutilsolidgrid)**, at a given tile size.
|
|
106
|
+
[`TileWorld`](components.md#tileworld) builds one over the grid it reads the map
|
|
107
|
+
into; a spec builds the grid itself. `TileBlockers` reads the grid and never copies
|
|
108
|
+
it, so a cell changed with `set_solid` stops the next step. It resolves each
|
|
109
|
+
axis on its own. [`CollisionSystem`](#collisionsystem--move-an-actor-against-its-blockers)
|
|
110
|
+
feeds one axis's result into the other.
|
|
66
111
|
|
|
67
112
|
```ruby
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
)
|
|
113
|
+
require 'rgame'
|
|
114
|
+
|
|
115
|
+
grid = RGame::Util::SolidGrid.build(20, 15) { |col, _row| col == 5 } # a wall at x 80..96
|
|
116
|
+
tiles = RGame::Engine::TileBlockers.new(grid: grid, tile_width: 16, tile_height: 16)
|
|
117
|
+
|
|
118
|
+
x, y, w, h = 58.0, 32.0, 12, 6
|
|
119
|
+
nx = tiles.resolve_x(x, y, w, h, 14.0) # => 68.0 — snapped flush against the wall's left edge
|
|
120
|
+
ny = tiles.resolve_y(nx, y, w, h, 4.0) # => 36.0
|
|
121
|
+
tiles.travel?(10.0, 32.0, w, h, 50.0, 0.0) # => true
|
|
122
|
+
tiles.travel?(10.0, 32.0, w, h, 90.0, 0.0) # => false — the wall is in the way
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
[`Util::TileSweep`](values.md#rgameutiltilesweep) does the arithmetic, in C;
|
|
126
|
+
`TileBlockers` presents it as a blocker source. Results are Floats. It assumes each
|
|
127
|
+
step moves less than a tile, so nothing tunnels. The engine's speeds meet that.
|
|
128
|
+
|
|
129
|
+
**`travel?(x, y, w, h, dx, dy)` answers whether the box can move `(dx, dy)`
|
|
130
|
+
without any resolve stopping it short.** It is the optional extra question of the
|
|
131
|
+
[blocker-source protocol](#collisionsystem--move-an-actor-against-its-blockers).
|
|
132
|
+
It sweeps the box in overlapping windows half a tile long and a quarter tile
|
|
133
|
+
apart. It resolves each window x-then-y and y-then-x. A `true` holds for a walker
|
|
134
|
+
that resolves its own steps against this source, as long as each step is under a
|
|
135
|
+
quarter tile: 240 px/s at 60 ticks a second on 16 px tiles. It may refuse a segment
|
|
136
|
+
such a walker could manage. It never clears one the walker could not.
|
|
137
|
+
[`Navigator`](components.md#navigator) smooths its routes with it.
|
|
138
|
+
|
|
139
|
+
`blocker` returns the sentinel `TileBlockers::TILES`: one object for the life of
|
|
140
|
+
the process, answering `layer` → `:tiles` and `node` → `nil`. `TileBlockers` holds
|
|
141
|
+
no per-step state, so every body on the map can share one.
|
|
142
|
+
[`TileWorld#blockers`](components.md#tileworld) hands out that shared instance.
|
|
143
|
+
|
|
144
|
+
## `ActorBlockers` — the registered colliders as a blocker source
|
|
145
|
+
|
|
146
|
+
**`RGame::Engine::ActorBlockers` (`rgame/engine/actor_blockers`) does the same
|
|
147
|
+
arithmetic, with the edge coming from another collider instead of a grid line.**
|
|
148
|
+
It asks a broadphase what lies near the swept box, and snaps flush against the
|
|
149
|
+
nearest candidate.
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
actors = RGame::Engine::ActorBlockers.new(world: collision_world, owner: my_collider,
|
|
153
|
+
layers: %i[npc hero])
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Unlike the other two sources, it belongs to one mover.** It holds that mover's
|
|
157
|
+
own collider, to exclude it by identity, and the layer list the mover declared.
|
|
158
|
+
Two movers with different `blocked_by` cannot share one. That is why a
|
|
159
|
+
[`Mover`](components.md#mover) builds its own `CollisionSystem` instead of
|
|
160
|
+
borrowing the scene's.
|
|
161
|
+
|
|
162
|
+
It does three things a grid does not need:
|
|
163
|
+
|
|
164
|
+
- **The nearest candidate wins**, tracked as a running minimum. The broadphase can
|
|
165
|
+
therefore offer one collider once per shared cell without harm.
|
|
166
|
+
- **It leaves an existing overlap unresolved.** A step is blocked only if it
|
|
167
|
+
*crosses* an edge the mover started on the near side of. A pair that starts
|
|
168
|
+
overlapping stays overlapping and is not teleported apart.
|
|
169
|
+
- **It handles boxes only.** It skips a `CircleCollider` on a declared layer.
|
|
170
|
+
|
|
171
|
+
Its `moved` passes the mover to
|
|
172
|
+
[`CollisionWorld#reindex`](components.md#collisionworld). That keeps a later query
|
|
173
|
+
in the same step exact.
|
|
174
|
+
|
|
175
|
+
## `BoundsBlockers` — the edge of the world as a blocker source
|
|
176
|
+
|
|
177
|
+
**`RGame::Engine::BoundsBlockers` (`rgame/engine/bounds_blockers`) stops a box from
|
|
178
|
+
leaving the region a [`WorldBounds`](components.md#world) describes.** It reads
|
|
179
|
+
the two numbers once at construction, because those bounds are immutable by
|
|
180
|
+
contract. Its `blocker` is the sentinel `BoundsBlockers::BOUNDS`, answering
|
|
181
|
+
`layer` → `:bounds`.
|
|
182
|
+
|
|
183
|
+
**A mover declares the edge as a source; the engine applies no automatic clamp.**
|
|
184
|
+
Stopping at the edge is one of three responses to it. `ScreenWrap` and
|
|
185
|
+
`DespawnOffscreen` are the others, and a node may carry only one
|
|
186
|
+
([`WorldBounds.one_response!`](components.md#world)). A mover that did not ask is
|
|
187
|
+
not held.
|
|
188
|
+
|
|
189
|
+
## `CollisionSystem` — move an actor against its blockers
|
|
190
|
+
|
|
191
|
+
**`RGame::Engine::CollisionSystem` (`rgame/engine/collision_system`) holds a list of
|
|
192
|
+
blocker sources and moves an actor against them.** A blocked
|
|
193
|
+
[`Mover`](components.md#mover) builds one at attach, from the sources its
|
|
194
|
+
`blocked_by:` names, and passes itself as the actor.
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
collision = RGame::Engine::CollisionSystem.new(blockers: [tiles, actors])
|
|
71
198
|
collision.move(actor, dx, dy) # actor responds to x / y / x= / y= / collision_box
|
|
72
199
|
```
|
|
73
200
|
|
|
74
|
-
`move` reads the actor's
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
box's offset from the sprite origin
|
|
201
|
+
`move` reads the actor's
|
|
202
|
+
[`CollisionBox`](toolbox.md#collisionbox--an-actors-feet-box) AABB and resolves it
|
|
203
|
+
on both axes. It writes the resolved position back to the actor, accounting for the
|
|
204
|
+
box's offset from the sprite origin. Finally it tells every source that the step
|
|
205
|
+
happened. **It has no clamp of its own.** Everything that can stop a step is a
|
|
206
|
+
source, the world's edge included.
|
|
207
|
+
|
|
208
|
+
A blocker source answers four questions. Only the first two involve arithmetic:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
source.resolve_x(x, y, w, h, dx) # -> where the box's left edge lands moving dx
|
|
212
|
+
source.resolve_y(x, y, w, h, dy) # -> where the box's top edge lands moving dy
|
|
213
|
+
source.blocker # -> what produced that edge, or nil
|
|
214
|
+
source.moved(actor, from_x, from_y, w, h) # -> the step has been written back
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
A fifth is optional. Of the three sources, only `TileBlockers` answers it:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
source.travel?(x, y, w, h, dx, dy) # -> can the box move (dx, dy) without being stopped?
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**"Stopped" means a resolve lands short of where the step was heading.** A landing
|
|
224
|
+
past it does not count: `move` ignores it, and it does not stop a travel either.
|
|
225
|
+
`BoundsBlockers` gives such a landing to a box that starts outside the world. Under
|
|
226
|
+
this reading, a box travels past several sources exactly when it travels past each
|
|
227
|
+
one alone. The shared example group `a blocker source answering travel?`
|
|
228
|
+
(`spec/support/shared_examples/`) states that meaning. It checks a source's answer
|
|
229
|
+
against a walker stepping through the source's own resolves.
|
|
230
|
+
|
|
231
|
+
The three sources are `TileBlockers`, `ActorBlockers` and `BoundsBlockers`. **The
|
|
232
|
+
system asks each source and takes the most restrictive answer on each axis**: the
|
|
233
|
+
smallest landing for a rightward or downward step, the largest for a leftward or
|
|
234
|
+
upward one. No source needs to know the others exist, and `blockers: []` means free
|
|
235
|
+
movement.
|
|
236
|
+
|
|
237
|
+
`blocked_x` and `blocked_y` hold what the winning source reported for each axis, or
|
|
238
|
+
nil when that axis was free. The system asks each winner during the resolve, not
|
|
239
|
+
afterwards, because a source's `blocker` is per axis and the next resolve overwrites
|
|
240
|
+
it. Every blocker answers `layer` and `node`, tiles and world edges included. A
|
|
241
|
+
caller reads `blocked_x.layer` without checking what kind of thing stopped it.
|
|
242
|
+
|
|
243
|
+
The system calls `moved` on every source with the box the step **started** from. A
|
|
244
|
+
source over a moving index can then re-bucket the mover, without anyone storing a
|
|
245
|
+
box for it; see `ActorBlockers` above.
|
|
246
|
+
|
|
247
|
+
Keeping the list in the system, not inside a source, has two consequences. First,
|
|
248
|
+
the axis-separated order exists once for every source: resolve x, then resolve y
|
|
249
|
+
with the resolved x. That order produces wall-sliding, where a diagonal push into a
|
|
250
|
+
wall keeps the component that is still free. Second, the loop walks an index
|
|
251
|
+
instead of using `map` or `min`. It runs per actor, per axis, per frame, and both a
|
|
252
|
+
block and an intermediate array would allocate.
|
|
78
253
|
|
|
79
254
|
## `AnimationSet` — pure frame maths
|
|
80
255
|
|
|
81
|
-
|
|
82
|
-
elapsed time into the sprite-sheet cell to show
|
|
256
|
+
**`RGame::Engine::AnimationSet` (`rgame/engine/animation_set`) turns an animation
|
|
257
|
+
table and an elapsed time into the sprite-sheet cell to show.** It needs no
|
|
258
|
+
renderer and no images, so specs test it fully.
|
|
83
259
|
|
|
84
260
|
```ruby
|
|
261
|
+
require 'rgame'
|
|
262
|
+
|
|
85
263
|
set = RGame::Engine::AnimationSet.new(
|
|
86
264
|
stand: { row: 0, col: 1, frames: 1, fps: 1 },
|
|
87
265
|
walk_right: { row: 1, frames: 3, fps: 6 }
|
|
88
266
|
)
|
|
89
|
-
set.frame(:walk_right,
|
|
267
|
+
set.frame(:walk_right, 0.4) # => [1, 2, false] — [row, col, flip_x]
|
|
90
268
|
```
|
|
91
269
|
|
|
92
270
|
Each animation is `{ row:, col: (start column, default 0), frames:, fps:, flip_x: }`.
|
|
93
|
-
`frame(name, elapsed)` advances `frames` columns from `col` at `fps`,
|
|
94
|
-
held animation cycles.
|
|
271
|
+
`frame(name, elapsed)` advances through `frames` columns from `col` at `fps`, and
|
|
272
|
+
wraps around. A held animation therefore cycles.
|
|
95
273
|
|
|
96
274
|
## `Animator` — animation playback state
|
|
97
275
|
|
|
98
|
-
|
|
99
|
-
`AnimationSet
|
|
100
|
-
[`AnimatedSprite`](components.md#animatedsprite) drives.
|
|
276
|
+
**`RGame::Engine::Animator` (`rgame/engine/animator`) owns playback state on top of
|
|
277
|
+
an `AnimationSet`**: the current animation name and its elapsed time.
|
|
278
|
+
[`AnimatedSprite`](components.md#animatedsprite) drives it.
|
|
101
279
|
|
|
102
280
|
```ruby
|
|
103
281
|
animator = RGame::Engine::Animator.new(set, initial: :stand)
|
|
104
282
|
animator.play(:walk_right) # switch (a no-op if already playing, so a walk keeps cycling)
|
|
105
283
|
animator.update(dt) # advance elapsed time
|
|
106
|
-
animator.frame # => [row, col, flip_x] for the current animation now
|
|
284
|
+
animator.frame # => [row, col, flip_x] — for the current animation now
|
|
107
285
|
```
|
|
108
286
|
|
|
109
|
-
`play`
|
|
110
|
-
current intent keeps a
|
|
287
|
+
`play` resets elapsed time only when the animation changes. Calling it
|
|
288
|
+
every frame with the current intent keeps a walk smooth instead of stuttering on
|
|
289
|
+
frame 0.
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# Localization
|
|
2
|
+
|
|
3
|
+
**Text a game draws is a translation key, and the words live in YAML tables.**
|
|
4
|
+
`RGame::Game` loads the tables and picks the player's language at startup. A
|
|
5
|
+
node draws an [`Engine::Text`](toolbox.md#text--the-string-a-node-draws) built
|
|
6
|
+
from a key. A UI button's `label:` [is a key too](ui.md#labels-are-translation-keys).
|
|
7
|
+
`RGame::Engine::I18n` (`rgame/engine/i18n`) holds the tables and the current
|
|
8
|
+
language, and every `Text` reads through it.
|
|
9
|
+
|
|
10
|
+
`examples/localization` shows all of it on one screen: plurals, a variable, a
|
|
11
|
+
fallback, a language menu and a saved choice. See [Examples](examples.md#localization).
|
|
12
|
+
|
|
13
|
+
## Where the tables go
|
|
14
|
+
|
|
15
|
+
**A game on `RGame::Game` keeps its tables under `locales/` in its media root**,
|
|
16
|
+
one or more `.yml` files in Rails' format. The top-level key is the locale, and
|
|
17
|
+
keys nest below it:
|
|
18
|
+
|
|
19
|
+
```yaml
|
|
20
|
+
# media/locales/en.yml
|
|
21
|
+
en:
|
|
22
|
+
title_menu:
|
|
23
|
+
play: Play
|
|
24
|
+
hud:
|
|
25
|
+
score: "Score: %{score}"
|
|
26
|
+
apples:
|
|
27
|
+
zero: No apples
|
|
28
|
+
one: "%{count} apple"
|
|
29
|
+
other: "%{count} apples"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```yaml
|
|
33
|
+
# media/locales/de.yml
|
|
34
|
+
de:
|
|
35
|
+
title_menu:
|
|
36
|
+
play: Spielen
|
|
37
|
+
hud:
|
|
38
|
+
score: "Punkte: %{score}"
|
|
39
|
+
apples:
|
|
40
|
+
one: "%{count} Apfel"
|
|
41
|
+
other: "%{count} Äpfel"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`Game.new` lists every `.yml` under `locales:` and loads each through the asset
|
|
45
|
+
manager, sorted by path. `locales:` defaults to `'locales'`. It is relative to
|
|
46
|
+
`media_root` unless it is absolute. A directory that does not exist loads
|
|
47
|
+
nothing, and every key then shows as itself. See
|
|
48
|
+
[Game](game.md#translations-and-the-players-language) for the loading order.
|
|
49
|
+
|
|
50
|
+
A project from `rgame new` keeps its tables in `assets/locales/`, which is its
|
|
51
|
+
media root plus the default. See
|
|
52
|
+
[The `rgame` command](cli.md#text-comes-from-a-translation-table).
|
|
53
|
+
|
|
54
|
+
One file may hold several locales, and several files may hold one. A second load
|
|
55
|
+
of a locale merges key by key into what is loaded. A key set twice takes the
|
|
56
|
+
later value.
|
|
57
|
+
|
|
58
|
+
## Drawing translated text
|
|
59
|
+
|
|
60
|
+
A node builds a `Text` once and reads it in `on_draw`. A read with unchanged
|
|
61
|
+
variables and an unchanged language returns the same String and allocates
|
|
62
|
+
nothing:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
class Hud < RGame::Engine::Node2D
|
|
66
|
+
def initialize(**)
|
|
67
|
+
super
|
|
68
|
+
@score = RGame::Engine::Text.new('hud.score', :score)
|
|
69
|
+
@apples = RGame::Engine::Text.new('hud.apples', :count)
|
|
70
|
+
@title = RGame::Engine::Text.new('hud.title')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def on_draw(renderer, _view)
|
|
74
|
+
renderer.text(@score.with(score: @points), 12, 10)
|
|
75
|
+
renderer.text(@apples.with(count: @apples_held), 12, 30)
|
|
76
|
+
renderer.text(@title, 12, 50)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A `Text` with no variables goes to `text` as it is, because it answers `to_str`.
|
|
82
|
+
|
|
83
|
+
A switch of `I18n.locale` renders every `Text` again on its next read. Nothing
|
|
84
|
+
subscribes to the switch. [`Text`](toolbox.md#text--the-string-a-node-draws)
|
|
85
|
+
covers variables, scopes, `Text.literal` for text that never translates, and
|
|
86
|
+
`Text.computed`.
|
|
87
|
+
|
|
88
|
+
`I18n` itself is a global module, so this works headless, in a spec or anywhere
|
|
89
|
+
else:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
require 'rgame'
|
|
93
|
+
|
|
94
|
+
i18n = RGame::Engine::I18n
|
|
95
|
+
i18n.load(<<~YAML, source: 'locales/game.yml')
|
|
96
|
+
en:
|
|
97
|
+
menu:
|
|
98
|
+
title: Main Menu
|
|
99
|
+
greeting: "Hello, %{name}"
|
|
100
|
+
apples:
|
|
101
|
+
zero: No apples
|
|
102
|
+
one: "%{count} apple"
|
|
103
|
+
other: "%{count} apples"
|
|
104
|
+
de:
|
|
105
|
+
menu:
|
|
106
|
+
title: Hauptmenü
|
|
107
|
+
apples:
|
|
108
|
+
one: "%{count} Apfel"
|
|
109
|
+
other: "%{count} Äpfel"
|
|
110
|
+
YAML
|
|
111
|
+
|
|
112
|
+
i18n.locale = 'de_AT'
|
|
113
|
+
i18n.chain # => [:"de-AT", :de, :en]
|
|
114
|
+
i18n.t('menu.title') # => "Hauptmenü" — from the de table
|
|
115
|
+
i18n.t('greeting', scope: 'menu', name: 'Ada') # => "Hello, Ada" — de lacks it, en has it
|
|
116
|
+
i18n.t('apples', count: 3) # => "3 Äpfel"
|
|
117
|
+
i18n.missing_keys(:de) # => ["menu.greeting"]
|
|
118
|
+
i18n.choose(%w[fr-CA de-CH]) # => :"de-CH" — the de table covers it
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Tables
|
|
122
|
+
|
|
123
|
+
`load(yaml, source:)` parses a String. `source:` names the file in error
|
|
124
|
+
messages. `load_hash` does the same for a Hash, with Symbol or String keys.
|
|
125
|
+
`I18n` never opens a file: `RGame::Game` reads the files and hands `load` their
|
|
126
|
+
text.
|
|
127
|
+
|
|
128
|
+
`load` uses `YAML.safe_load` with aliases allowed. It raises
|
|
129
|
+
`Psych::DisallowedClass` for an object tag and `Psych::SyntaxError` for broken
|
|
130
|
+
YAML. YAML reads an unquoted `on`, `off`, `yes`, `no`, `true`, `false` or `~` as
|
|
131
|
+
a boolean or `nil`. `load` raises `ArgumentError` for such a key or value, naming
|
|
132
|
+
where it is, so quote it. A number value becomes its text, and so does a
|
|
133
|
+
whole-number key. Any other key, such as `1.5`, raises `ArgumentError` too.
|
|
134
|
+
|
|
135
|
+
Each load compiles every value once. A String becomes a template with its
|
|
136
|
+
placeholders already found. `%%{` writes a literal `%{`. A key without
|
|
137
|
+
placeholders returns the same frozen String on every call.
|
|
138
|
+
|
|
139
|
+
`available` lists the locales that have a table, in load order.
|
|
140
|
+
|
|
141
|
+
## Variables and `t`
|
|
142
|
+
|
|
143
|
+
`%{name}` in a translation prints a variable. A `Text` declares its variables as
|
|
144
|
+
names and receives them as keywords to `with`.
|
|
145
|
+
|
|
146
|
+
`t(key, scope: nil, **vars)` looks `key` up, or `"scope.key"` when `scope:` is
|
|
147
|
+
given, and interpolates `vars`. It raises `ArgumentError` when the translation
|
|
148
|
+
uses a variable `vars` does not hold. Variables it does not use are ignored.
|
|
149
|
+
`t` allocates on every call, so keep it off the per-frame path.
|
|
150
|
+
|
|
151
|
+
`render(key, names, vars)` is what a `Text` reads through. It resolves `key` like
|
|
152
|
+
`t`, with `vars` holding exactly `names`. A translation whose placeholders are
|
|
153
|
+
not `names` goes to the [missing policy](#missing-keys), as
|
|
154
|
+
[`Text`](toolbox.md#when-it-renders-again) describes.
|
|
155
|
+
|
|
156
|
+
## Plurals
|
|
157
|
+
|
|
158
|
+
A key whose nested keys are all CLDR plural categories (`zero`, `one`, `two`,
|
|
159
|
+
`few`, `many`, `other`), with text values and `other` among them, is a plural.
|
|
160
|
+
Any other nested Hash is a level of keys. `t` needs `count:` for a plural and
|
|
161
|
+
raises `ArgumentError` without it. `count` is also available as `%{count}`. A
|
|
162
|
+
`Text` for a plural needs `:count` among its names.
|
|
163
|
+
|
|
164
|
+
`t` picks the form by these rules, in order:
|
|
165
|
+
|
|
166
|
+
1. An explicit `zero` form wins for a count of 0, in every language. A table
|
|
167
|
+
without one uses its language's rule: German reads `0 Äpfel` from `other`.
|
|
168
|
+
2. The plural rule sorts the count into a category. The rule belongs to the
|
|
169
|
+
table that supplied the key, not to the current locale. English text reached
|
|
170
|
+
through a fallback from `:pl` counts like English.
|
|
171
|
+
3. A category the table leaves out reads `other`.
|
|
172
|
+
|
|
173
|
+
`I18n::PluralRules::BUILT_IN` holds whole-number rules for en, de, nl, sv, da,
|
|
174
|
+
nb, fi, it, es, pt, fr, ru, uk, pl, cs, ja, zh, ko and ar. A built-in rule puts a
|
|
175
|
+
count that is not an Integer in `other`. A language without a rule counts like
|
|
176
|
+
English. `plural_rule(language) { |count| ... }` adds or replaces a rule; its
|
|
177
|
+
block returns a category Symbol. A rule for a regional locale, such as `'pt-PT'`,
|
|
178
|
+
wins over its language's rule for tables of that locale.
|
|
179
|
+
|
|
180
|
+
## Locales and the fallback chain
|
|
181
|
+
|
|
182
|
+
`locale=` switches the language, and `default=` sets the last locale every lookup
|
|
183
|
+
falls back to. Both start at `:en`. Both normalize what they are given:
|
|
184
|
+
`de_AT`, `'de-at'` and `:'de-AT'` all become `:'de-AT'`, and `normalize` is
|
|
185
|
+
public. A locale needs no table of its own.
|
|
186
|
+
|
|
187
|
+
`chain` lists where a lookup looks, in order: the locale, each shorter prefix of
|
|
188
|
+
it, then the default. `I18n` rebuilds it on a switch, not on every lookup. A key
|
|
189
|
+
the German table lacks therefore shows the English text in German, when English
|
|
190
|
+
is the default.
|
|
191
|
+
|
|
192
|
+
`choose(preferred)` takes the player's locales, most wanted first. It returns the
|
|
193
|
+
first whose own chain meets a table, normalized but not shortened. The default
|
|
194
|
+
does not count as a match, and with no match `choose` returns the default.
|
|
195
|
+
|
|
196
|
+
## The player's language
|
|
197
|
+
|
|
198
|
+
**`Game.new` sets `I18n.locale` to `I18n.choose(RGame::Core.preferred_locales)`**
|
|
199
|
+
after loading the tables: the first locale the operating system prefers that a
|
|
200
|
+
table covers, or the default. [App](app.md) describes what
|
|
201
|
+
`RGame::Core.preferred_locales` returns on each platform.
|
|
202
|
+
|
|
203
|
+
A language the player picked belongs between `new` and `start`, because `new`
|
|
204
|
+
has already chosen from the OS by then. `examples/localization` saves it with
|
|
205
|
+
[`Util::SaveFile`](values.md) and restores it this way:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
game = MyGame.new
|
|
209
|
+
saved = save.read[:language]
|
|
210
|
+
if saved.is_a?(String) && !saved.empty?
|
|
211
|
+
RGame::Engine::I18n.locale = RGame::Engine::I18n.choose([saved, *RGame::Core.preferred_locales])
|
|
212
|
+
end
|
|
213
|
+
game.start
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Passing the saved locale through `choose` ignores a saved language the game has
|
|
217
|
+
no table for. A menu that switches language sets `I18n.locale` and writes the
|
|
218
|
+
save. `I18n` holds one language for the whole process, so split-screen players
|
|
219
|
+
share it.
|
|
220
|
+
|
|
221
|
+
## Missing keys
|
|
222
|
+
|
|
223
|
+
A key is missing when no locale in the chain has it. `missing=` decides what
|
|
224
|
+
`t` returns then:
|
|
225
|
+
|
|
226
|
+
| `missing` | `t` on a missing key |
|
|
227
|
+
|---|---|
|
|
228
|
+
| `:key` (the start) | returns the key, with its scope |
|
|
229
|
+
| `:raise` | raises `I18n::MissingKey`, whose `key` and `chain` say where it looked |
|
|
230
|
+
| a callable | calls it with the key and the chain, and returns its result |
|
|
231
|
+
|
|
232
|
+
Any other value raises `ArgumentError`. A game on `RGame::Game` keeps `:key`, so
|
|
233
|
+
a missing key shows as itself on screen.
|
|
234
|
+
|
|
235
|
+
`missing_keys(locale)` lists the keys the default's table has and the locale's
|
|
236
|
+
own chain lacks, in the default table's order. It skips a key the locale gets
|
|
237
|
+
from a parent, such as `de-AT` from `de`.
|
|
238
|
+
|
|
239
|
+
## Specs
|
|
240
|
+
|
|
241
|
+
**Specs raise on a missing key and check every table against the default.** A
|
|
242
|
+
project from `rgame new` does both. Its `spec/spec_helper.rb` loads the tables
|
|
243
|
+
before every example:
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
LOCALES = Dir[File.expand_path('../assets/locales/**/*.yml', __dir__)]
|
|
247
|
+
.to_h { |path| [path, File.read(path)] }.freeze
|
|
248
|
+
|
|
249
|
+
RSpec.configure do |config|
|
|
250
|
+
config.before do
|
|
251
|
+
RGame::Engine::I18n.reset
|
|
252
|
+
LOCALES.each { |path, yaml| RGame::Engine::I18n.load(yaml, source: path) }
|
|
253
|
+
RGame::Engine::I18n.missing = :raise
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Its `spec/locales_spec.rb` expects `missing_keys` to be empty for every locale in
|
|
259
|
+
`available`. A key added to `en.yml` and not to `de.yml` then fails `rake`.
|
|
260
|
+
[The `rgame` command](cli.md#text-comes-from-a-translation-table) describes both
|
|
261
|
+
files.
|
|
262
|
+
|
|
263
|
+
## `generation`
|
|
264
|
+
|
|
265
|
+
**`generation` is an Integer that moves whenever what a key resolves to may have
|
|
266
|
+
changed.** It moves on every load, on a switch to a different locale or default,
|
|
267
|
+
on every `plural_rule`, and on `reset`. A switch to the locale already current leaves it alone. Cached
|
|
268
|
+
text compares `generation` with the value it last saw, and resolves again only
|
|
269
|
+
when it differs.
|
|
270
|
+
|
|
271
|
+
`reset` forgets every table and added plural rule. It restores `:en` as locale
|
|
272
|
+
and default, and `:key` as the missing policy. rgame's own headless suite calls
|
|
273
|
+
`reset` and sets `missing = :raise` before every example.
|
|
274
|
+
|
|
275
|
+
## What this is not
|
|
276
|
+
|
|
277
|
+
- **Text is not measured in the engine layer.** A button slot has a fixed width,
|
|
278
|
+
so choose one wide enough for the longest translation.
|
|
279
|
+
- **One language per process.** Split-screen players cannot read different
|
|
280
|
+
languages.
|
|
281
|
+
- **No right-to-left text or shaping.** The shipped font covers Latin, Greek and
|
|
282
|
+
Cyrillic; nothing selects a font per locale.
|
|
283
|
+
- **Only text translates.** Images, audio and other assets have no per-locale
|
|
284
|
+
variant.
|
|
285
|
+
- **No `default:`, lazy lookup, array values or symbol links** from Rails.
|