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/components.md
CHANGED
|
@@ -1,325 +1,1005 @@
|
|
|
1
1
|
# Components
|
|
2
2
|
|
|
3
|
-
A
|
|
4
|
-
|
|
5
|
-
`node
|
|
6
|
-
`RGame::Engine::Components
|
|
7
|
-
|
|
8
|
-
components
|
|
3
|
+
**A component is a reusable piece of behaviour attached to a `Node2D`**, instead
|
|
4
|
+
of being built into a node subclass. A node composes several components. Each
|
|
5
|
+
knows its owning `node`, and the node's tick drives it. Components live in
|
|
6
|
+
`engine/components/`, under `RGame::Engine::Components`, and subclass
|
|
7
|
+
`RGame::Engine::Component`. [Scene graph](scene_graph.md) explains how nodes drive
|
|
8
|
+
components. [Systems & shared resources](systems.md) covers components that serve
|
|
9
|
+
a whole scene or program.
|
|
10
|
+
|
|
11
|
+
`examples/walk` shows the smallest character in one file: `AnimatedSprite`,
|
|
12
|
+
`CharacterBody` and `PlayerController`.
|
|
13
|
+
|
|
14
|
+
## When what you want is not a component
|
|
15
|
+
|
|
16
|
+
**A component is behaviour on the node's tick.** It overrides `control`, `update`
|
|
17
|
+
or `draw`, and the node drives it. Much of what a game needs is not that. Searching
|
|
18
|
+
this page for it leads to worse, hand-made answers.
|
|
19
|
+
|
|
20
|
+
Those helpers live in the [Toolbox](toolbox.md). A game constructs them directly,
|
|
21
|
+
attached to nothing, and that is ordinary use. `examples/sound` emits on `AudioBus`
|
|
22
|
+
and reads a `Text` within ten lines.
|
|
23
|
+
|
|
24
|
+
| Looking for | Reach for | |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| a label from a value that changes, with no `String` per frame | `Text` | [→](toolbox.md#text--the-string-a-node-draws) |
|
|
27
|
+
| to say *what happened* without naming a sound device | `AudioBus` | [→](toolbox.md#audiobus--decoupled-audio-facts) |
|
|
28
|
+
| a point to follow, clamped to the world | `Camera` | [→](toolbox.md#camera--follow-a-point-clamp-to-the-world) |
|
|
29
|
+
| text in the player's language | `I18n` | [→](localization.md) |
|
|
30
|
+
| an ordered route to walk | `Path` | [→](toolbox.md#path--a-walkable-polyline) |
|
|
31
|
+
| the cheapest route between two tiles | `NavGrid`, from `TileWorld#nav_grid` | [→](toolbox.md#navgrid--routes-over-a-tile-grid) |
|
|
32
|
+
| a node that walks itself to a point, around the map | `Components::Navigator` | [→](#navigator) |
|
|
33
|
+
|
|
34
|
+
**Per-frame work earns a component.** Two toolbox classes have component wrappers,
|
|
35
|
+
for that reason only. A timer must advance every tick, and a pool must reclaim
|
|
36
|
+
freed nodes every tick. `Components::Timer` and `Components::Pool` put that work in
|
|
37
|
+
the traversal, where nothing can forget it. Without per-frame work there is no
|
|
38
|
+
wrapper. A `Text` is read when something draws it. A component that
|
|
39
|
+
overrode no hook would be a component in name only. The one-per-slot rule would
|
|
40
|
+
even make a second label on one node harder.
|
|
9
41
|
|
|
10
42
|
## The `Component` base
|
|
11
43
|
|
|
12
|
-
`RGame::Engine::Component` (`rgame/engine/component`) gives every component a
|
|
13
|
-
|
|
14
|
-
nodes,
|
|
44
|
+
`RGame::Engine::Component` (`rgame/engine/component`) gives every component a
|
|
45
|
+
`node` back-link and hooks the node calls. Override the hooks you need; the rest
|
|
46
|
+
do nothing. Like nodes, components extend the signal DSL, so they can declare and
|
|
47
|
+
emit signals.
|
|
15
48
|
|
|
16
|
-
Per-tick hooks
|
|
17
|
-
before its children
|
|
49
|
+
**Per-tick hooks.** In each phase a node runs its components before its own hook
|
|
50
|
+
and before its children.
|
|
18
51
|
|
|
19
|
-
- `control(actions)`
|
|
20
|
-
|
|
52
|
+
- `control(actions)` reads intent from the per-tick action snapshot. The snapshot
|
|
53
|
+
belongs to whoever [owns the node](scene_graph.md#who-a-node-answers-to), so a
|
|
21
54
|
component never learns there is more than one player.
|
|
22
|
-
- `update(dt)`
|
|
23
|
-
- `draw(renderer, view)`
|
|
24
|
-
[viewport being drawn](scene_graph.md#viewports-and-views). Most components
|
|
25
|
-
view
|
|
55
|
+
- `update(dt)` advances state over the timestep.
|
|
56
|
+
- `draw(renderer, view)` renders through the renderer interface into the
|
|
57
|
+
[viewport being drawn](scene_graph.md#viewports-and-views). Most components
|
|
58
|
+
ignore `view`. It serves layout against the region's edges, and culling.
|
|
59
|
+
|
|
60
|
+
**Tree-lifecycle hooks.** The engine fires these when the node enters or leaves
|
|
61
|
+
the live tree. Anchors and sibling systems are reachable here, so wire across
|
|
62
|
+
nodes here, not in `initialize`.
|
|
63
|
+
|
|
64
|
+
- `on_attach`: the node entered the tree. Look up shared systems and register
|
|
65
|
+
with them.
|
|
66
|
+
- `on_detach`: the node is leaving. Release those registrations.
|
|
67
|
+
|
|
68
|
+
`sweep_freed` serves container components that hold nodes outside the normal
|
|
69
|
+
child list. The default does nothing; see
|
|
70
|
+
[deferred free](scene_graph.md#deferred-free).
|
|
71
|
+
|
|
72
|
+
**`require_sibling(klass)` opens the `on_attach` of a component that drives a
|
|
73
|
+
sibling**: `@body = require_sibling(CharacterBody)`. It returns the component, or
|
|
74
|
+
raises naming both. A plain `nil` would stay silent until the first frame called a
|
|
75
|
+
method on it. When it raises, the cause is nearly always add order; see
|
|
76
|
+
[Where to add a component](#where-to-add-a-component) below. It also raises,
|
|
77
|
+
naming each one, when *several* siblings match `klass`, such as two
|
|
78
|
+
[`Mover`](#mover)s under one [`AnimatedSprite`](#animatedsprite). It never quietly
|
|
79
|
+
takes whichever came first.
|
|
80
|
+
|
|
81
|
+
**A node holds at most one component per slot.** The slot defaults to the
|
|
82
|
+
component's class, so by default a node holds one per class, and `add_component`
|
|
83
|
+
raises on a taken slot. Pass `as: :name` to keep several of one type, such as a
|
|
84
|
+
spawn timer and a wave timer. Look a component up with `get_component(key)`. The
|
|
85
|
+
key is a class, matched by ancestry and raising if several share the type, or a
|
|
86
|
+
Symbol name.
|
|
87
|
+
|
|
88
|
+
## Where to add a component
|
|
89
|
+
|
|
90
|
+
**Assemble a node before it enters the tree.** Two shapes do that. Which one fits
|
|
91
|
+
depends on whether the node has a class of its own:
|
|
92
|
+
|
|
93
|
+
- **A `Node2D` subclass adds components in `initialize`.** This default covers most
|
|
94
|
+
entities.
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
class Bullet < RGame::Engine::Node2D
|
|
98
|
+
def initialize(x:, y:, vx:, vy:)
|
|
99
|
+
super(x: x, y: y)
|
|
100
|
+
add_component(RGame::Engine::Components::Velocity.new(vx: vx, vy: vy))
|
|
101
|
+
add_component(RGame::Engine::Components::DespawnOffscreen.new)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
- **A plain `Node2D` built from components gets a builder method** that returns
|
|
107
|
+
the assembled node. Use it when the node is nothing but its components, and a
|
|
108
|
+
subclass would add no behaviour. `examples/save_load` builds its walkers this
|
|
109
|
+
way.
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
def build_player
|
|
113
|
+
node = RGame::Engine::Node2D.new(x: spawn_x, y: spawn_y)
|
|
114
|
+
node.add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: PLAYER_SHEET))
|
|
115
|
+
node.add_component(RGame::Engine::Components::FeetCollider.new(width: 10, height: 8))
|
|
116
|
+
node.add_component(RGame::Engine::Components::CharacterBody.new(
|
|
117
|
+
speed: PLAYER_SPEED, blocked_by: [:tiles]
|
|
118
|
+
))
|
|
119
|
+
node.add_component(RGame::Engine::Components::PlayerController.new)
|
|
120
|
+
node
|
|
121
|
+
end
|
|
122
|
+
```
|
|
26
123
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
124
|
+
**Both shapes work for the same reason.** The node is not in the tree yet, so
|
|
125
|
+
`add_component` only appends. No `on_attach` fires until the whole set is present
|
|
126
|
+
and the node enters. **Add order is therefore free.** `build_player` above adds an
|
|
127
|
+
`AnimatedSprite` *before* the `CharacterBody` it faces by, and that works.
|
|
30
128
|
|
|
31
|
-
|
|
32
|
-
- `on_detach` — the node is leaving; release those registrations.
|
|
129
|
+
### Adding from `on_add`, and when you must
|
|
33
130
|
|
|
34
|
-
|
|
35
|
-
|
|
131
|
+
**A node running `on_add` is already in the tree.** Each `add_component` attaches
|
|
132
|
+
at once and sees only the components added before it. The same two lines in the
|
|
133
|
+
other order raise (see [`require_sibling`](#the-component-base) above). Prefer
|
|
134
|
+
`initialize` or a builder. Use `on_add` only when the component cannot be built
|
|
135
|
+
earlier, because its constructor needs something only the tree can answer:
|
|
36
136
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
137
|
+
```ruby
|
|
138
|
+
def on_add
|
|
139
|
+
# Both arguments are cross-tree lookups: the asset manager hangs off the root's
|
|
140
|
+
# context, and the player registry is a system. Neither exists at construction.
|
|
141
|
+
add_component(RGame::Engine::Components::TileWorld.new(
|
|
142
|
+
map: root.context.assets.tilemap(MAP_KEY).map,
|
|
143
|
+
tilemap_id: MAP_KEY,
|
|
144
|
+
cameras: root.system(RGame::Engine::Players).map(&:camera)
|
|
145
|
+
))
|
|
146
|
+
end
|
|
147
|
+
```
|
|
42
148
|
|
|
43
|
-
|
|
149
|
+
**Apply one test: does the constructor need the tree?** A `World` built from
|
|
150
|
+
numbers the scene already has does not. Nor does a `CollisionWorld` built from a
|
|
151
|
+
constant. `examples/collision` mounts both in `initialize`. There they are
|
|
152
|
+
guaranteed to precede every entity the scene spawns later. A `TileWorld` parsed
|
|
153
|
+
from the asset manager needs the tree, so it waits.
|
|
44
154
|
|
|
45
|
-
|
|
155
|
+
The opposite exception is a component added **deliberately** after entry, because
|
|
156
|
+
it depends on state that exists only once the node is live. An example is a
|
|
157
|
+
`CameraFollow` whose offset comes from the sibling body's resolved
|
|
158
|
+
`collision_box`. That is a later decision, not assembly, and `on_add` suits it.
|
|
46
159
|
|
|
47
|
-
|
|
160
|
+
## Available components
|
|
48
161
|
|
|
49
|
-
|
|
50
|
-
- **State:** `vx`, `vy`, `spin` are read/write accessors — a controller (or the node's
|
|
51
|
-
own `control` hook) writes them as movement intent.
|
|
52
|
-
- **Phase:** `update(dt)` adds `vx*dt`/`vy*dt` to `node.x`/`node.y` and `spin*dt` to
|
|
53
|
-
`node.angle`.
|
|
162
|
+
### `ActionTrigger`
|
|
54
163
|
|
|
55
|
-
|
|
164
|
+
**Maps held input actions to an `on_triggered(action)` signal, limited by a
|
|
165
|
+
per-action cooldown.** One instance covers several actions and emits the action
|
|
166
|
+
name, so listeners filter. The same component serves "fire" in a shooter or
|
|
167
|
+
"jump" and "fire" in a platformer.
|
|
56
168
|
|
|
57
|
-
|
|
169
|
+
- **Construct:** `ActionTrigger.new(cooldowns)`, where `cooldowns` is
|
|
170
|
+
`{ action => seconds }`, e.g. `ActionTrigger.new(fire: 0.22)`.
|
|
171
|
+
- **Signal:** `on_triggered` fires with the action name:
|
|
172
|
+
`trigger.on_triggered { |a| … }`.
|
|
173
|
+
- **Phase:** `update(dt)` ticks the per-action cooldowns. `control(actions)` emits
|
|
174
|
+
when an action is held and its cooldown has elapsed. Held plus cooldown gives
|
|
175
|
+
auto-repeat.
|
|
58
176
|
|
|
59
|
-
|
|
60
|
-
at a constant speed and emits `on_finished` when it reaches the last waypoint — the seam a
|
|
61
|
-
tower-defense game uses to leak a life when an enemy reaches the base.
|
|
177
|
+
### `AnimatedSprite`
|
|
62
178
|
|
|
63
|
-
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
179
|
+
**Draws a sprite-sheet animation, chosen from its [`Mover`](#mover) sibling's
|
|
180
|
+
[heading](#mover).** It plays `walk_left`, `walk_right`, `walk_up` or `walk_down`
|
|
181
|
+
while moving, and `stand` when still. The heading's larger axis picks the
|
|
182
|
+
direction, and a tie goes horizontal. A keyboard diagonal therefore walks sideways,
|
|
183
|
+
while a stick held mostly down walks down, as does a route segment running mostly
|
|
184
|
+
down. Any mover works. A [`CharacterBody`](#characterbody) faces its intent. A
|
|
185
|
+
[`PathFollow`](#pathfollow) or [`Navigator`](#navigator) faces the segment it
|
|
186
|
+
walks. A [`Velocity`](#velocity) faces where it flies. The component owns an
|
|
187
|
+
`RGame::Engine::Animator` over the pure `AnimationSet` built from the sheet's
|
|
188
|
+
animation table.
|
|
189
|
+
|
|
190
|
+
- **Construct:** `AnimatedSprite.new(sheet:, z: 0)`. `sheet` is the asset's
|
|
191
|
+
relative path. `z` orders this component against the node's other drawing,
|
|
192
|
+
inside the node's own slot, as for [`Sprite`](#sprite).
|
|
193
|
+
- **Lifecycle:** `on_attach` resolves the sheet from the game's asset manager
|
|
194
|
+
(`node.root.context.assets.sheet(sheet)`) and builds its animation set. It
|
|
195
|
+
**sizes the node** to the sheet's frame (`node.width` and `height`), so a
|
|
196
|
+
[`FeetCollider`](#feetcollider) can read them. It then looks up the mover sibling
|
|
197
|
+
it faces by. A node with **two** movers raises here, naming both: both write the
|
|
198
|
+
position, so no facing is defined. The renderer resolves the same path when
|
|
199
|
+
drawing, so nothing is registered or passed in by hand.
|
|
200
|
+
- **Phase:** `update(dt)` selects and advances the animation.
|
|
201
|
+
`draw(renderer, view)` renders the current frame via `renderer.sprite` at
|
|
202
|
+
**`0, 0`** with no angle. The traversal already placed the renderer on the node,
|
|
203
|
+
and a [`WorldView`](scene_graph.md#view-transforms-and-the-camera) ancestor
|
|
204
|
+
already applied the camera. The frame is lifted by
|
|
205
|
+
[`node.elevation`](scene_graph.md#elevation), so it draws at `0, -elevation`.
|
|
206
|
+
The component skips the draw when the view cannot show it. It measures the
|
|
207
|
+
node's box, raised by the same elevation, against `node.world_x` and `world_y`.
|
|
208
|
+
Culling uses world coordinates because it compares against the camera.
|
|
209
|
+
[`Sprite`](#sprite) is the single-image counterpart.
|
|
210
|
+
|
|
211
|
+
### `BoxCollider`
|
|
212
|
+
|
|
213
|
+
**A rectangular collision shape in a scene's
|
|
214
|
+
[`CollisionWorld`](#collisionworld).** It is the sibling of
|
|
215
|
+
[`CircleCollider`](#circlecollider), for box-shaped entities: a crate, a platform,
|
|
216
|
+
a wall segment. It registers when the node enters the tree and unregisters when it
|
|
217
|
+
leaves, so spawning and despawning never leak a registration.
|
|
218
|
+
|
|
219
|
+
- **Construct:** `BoxCollider.new(width:, height:, offset_x: 0, offset_y: 0, layer: :default)`.
|
|
220
|
+
The offsets are relative to the node's origin, so a 32×32 sprite can carry a
|
|
221
|
+
small box at its feet. `layer` is an opaque tag. The *owner* reads it to decide
|
|
222
|
+
what a contact means; the collision system ignores it.
|
|
223
|
+
- **Lifecycle:** `on_attach` registers with `node.system(CollisionWorld)`, and
|
|
224
|
+
`on_detach` unregisters. In a scene with **no** world, it stays a bare shape and
|
|
225
|
+
does not raise. A collider is a shape; a world turns shapes into contacts. A
|
|
226
|
+
tile-only game can therefore carry a feet box for
|
|
227
|
+
[`CharacterBody(blocked_by:)`](#characterbody), or any other [`Mover`](#mover),
|
|
228
|
+
alone. The cost: an `on_hit` handler in such a scene never fires, and nothing
|
|
229
|
+
reports it.
|
|
230
|
+
- **Geometry:** the rectangle is an
|
|
231
|
+
[`RGame::Engine::CollisionBox`](toolbox.md#collisionbox--an-actors-feet-box),
|
|
232
|
+
exposed as the read/write `box` accessor. Assign a new one, such as
|
|
233
|
+
`CollisionBox.bottom_anchored(...)`, to retune a pooled entity's shape on reset.
|
|
234
|
+
No re-registration is needed. `aabb_x`, `aabb_y`, `aabb_w` and `aabb_h` give the
|
|
235
|
+
world-space box. `cx` and `cy` give the **box's** centre, which the world's range
|
|
236
|
+
queries measure from. A circle's centre, by contrast, is the node origin.
|
|
237
|
+
- **Rotation:** the box stays axis-aligned in world space and does not turn with
|
|
238
|
+
the node. A spinning entity wants a `CircleCollider`, which rotation does not
|
|
239
|
+
affect, instead of a box recomputed every frame.
|
|
240
|
+
- **Contacts:** `overlap?(other)` works against a box *or* a circle. The two
|
|
241
|
+
colliders settle the test between themselves, so both shapes mix freely in one
|
|
242
|
+
world.
|
|
243
|
+
- **Blocking:** a box on a layer that some [`Mover`](#mover) named in
|
|
244
|
+
`blocked_by:` also *stops* that mover's steps, flush against this box's edge. The
|
|
245
|
+
box needs no opt-in; the other body's layer declaration is the whole setup.
|
|
246
|
+
- **Signals:** `on_hit` fires with the other collider on the step a contact
|
|
247
|
+
**starts**, and `on_separated` on the step it **ends**:
|
|
248
|
+
`collider.on_hit { |other| ... }`. The system triggers them through
|
|
249
|
+
`emit_hit(other)` and `emit_separated(other)`. Each fires once per pair, so a
|
|
250
|
+
handler may count, play a sound or spend a life; see
|
|
251
|
+
[`CollisionWorld`](#collisionworld).
|
|
70
252
|
|
|
71
|
-
|
|
253
|
+
```ruby
|
|
254
|
+
collider = add_component(RGame::Engine::Components::BoxCollider.new(
|
|
255
|
+
width: 32, height: 32, layer: :pickup
|
|
256
|
+
))
|
|
257
|
+
collider.on_hit { |other| collect if other.layer == :player }
|
|
258
|
+
```
|
|
72
259
|
|
|
73
|
-
|
|
74
|
-
advance it) and emits `on_timeout` each time a whole interval elapses — a spawn cadence, a
|
|
75
|
-
tower's fire rate, a wave clock. Wraps the pure [`RGame::Engine::Timer`](toolbox.md#timer--paced-periodic-events),
|
|
76
|
-
reusing its drift-free carry-forward.
|
|
77
|
-
|
|
78
|
-
- **Construct:** `Timer.new(interval, repeating: true)` (seconds). Add it named when a node
|
|
79
|
-
needs several: `node.add_component(Timer.new(0.8), as: :spawn)`. `repeating: false` makes
|
|
80
|
-
it a **one-shot** — it fires `on_timeout` exactly once, then goes inert. The one-shot
|
|
81
|
-
replaces a dedicated "lifetime" component: a projectile that should vanish after N seconds
|
|
82
|
-
on a fixed board is `Timer.new(2.0, repeating: false)` + `on_timeout { node.queue_free }`
|
|
83
|
-
(use `DespawnOffscreen` instead when the board scrolls and the entity leaves the screen).
|
|
84
|
-
- **Signal:** `on_timeout` fires once per whole interval — `timer.on_timeout { spawn_enemy }`.
|
|
85
|
-
- **Lifecycle:** `on_attach` restarts the countdown (and re-arms a spent one-shot), so a
|
|
86
|
-
pooled node reacquired and re-added starts fresh rather than inheriting its previous
|
|
87
|
-
life's elapsed time.
|
|
88
|
-
- **Phase:** `update(dt)` advances and emits; a repeating timer emits once per interval
|
|
89
|
-
crossed in a single long step (catch-up, not drift), a one-shot at most once. Allocation-free.
|
|
90
|
-
- **Reset:** `reset` drops accumulated time and re-arms a one-shot — a fresh timer.
|
|
260
|
+
### `CameraFollow`
|
|
91
261
|
|
|
92
|
-
|
|
262
|
+
**Points a camera at the node it is attached to.**
|
|
93
263
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
- **Spawn:** `pool.spawn` takes a node (recycled or freshly built) and adds it as a child;
|
|
103
|
-
`pool.spawn { |n| n.reset(...) }` runs the block to re-initialise it *before* it enters the
|
|
104
|
-
tree (so `on_attach` sees the reset state — the order projectiles need).
|
|
105
|
-
- **Reclaim:** `update(dt)` returns every freed pooled node to the free list, detaching any
|
|
106
|
-
still attached. So despawning is just `node.queue_free` anywhere; the pool recycles it with
|
|
107
|
-
no game-side wiring. Allocation-free in steady state.
|
|
264
|
+
- **Construct:** `CameraFollow.new(camera:, offset_x: 0.0, offset_y: 0.0)`. The
|
|
265
|
+
offsets shift the point the camera centres on, for a node whose origin should not
|
|
266
|
+
sit mid-screen. A bottom-anchored sprite usually wants its feet centred.
|
|
267
|
+
- **Phase:** `update(dt)` calls `camera.center_on` with the node's world origin.
|
|
268
|
+
The camera trails the node's movement by one step, uniformly.
|
|
269
|
+
- **Example:** `examples/scroll_map`. The followed node is an invisible rig with a
|
|
270
|
+
`CharacterBody` and a `PlayerController`. That is all "scroll the map with the
|
|
271
|
+
arrow keys" takes.
|
|
108
272
|
|
|
109
|
-
|
|
273
|
+
**The camera belongs to a [player](input.md#players-seats-and-joining)**, not to
|
|
274
|
+
this component or the scene, because a scene may have any number of viewers. The
|
|
275
|
+
player owns the camera; this component moves it. "Player two's camera follows
|
|
276
|
+
player two" is this component holding player two's camera.
|
|
110
277
|
|
|
111
|
-
|
|
112
|
-
reappears on the opposite one.
|
|
278
|
+
### `CharacterBody`
|
|
113
279
|
|
|
114
|
-
-
|
|
115
|
-
|
|
116
|
-
|
|
280
|
+
**Direct, per-step walking for an actor.** A controller writes a movement intent,
|
|
281
|
+
each axis −1..1. The body turns it into a move each `update`, at a fixed speed with
|
|
282
|
+
no inertia. `Velocity`, by contrast, integrates a velocity the controller sets, and
|
|
283
|
+
`ThrustController` accelerates one.
|
|
117
284
|
|
|
118
|
-
|
|
285
|
+
A character is a `CharacterBody` plus a controller. A character the map stops adds
|
|
286
|
+
a feet box and a declaration. **[`Mover`](#mover) decides what may stop a step**,
|
|
287
|
+
for `Velocity` and `PathFollow` too. `blocked_by:`, `on_blocked`, `on_unblocked`
|
|
288
|
+
and the `apply_move` seam are documented there, and work the same way here.
|
|
119
289
|
|
|
120
|
-
|
|
121
|
-
|
|
290
|
+
```ruby
|
|
291
|
+
add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: 'hero.json'))
|
|
292
|
+
add_component(RGame::Engine::Components::FeetCollider.new(width: 12, height: 6))
|
|
293
|
+
add_component(RGame::Engine::Components::CharacterBody.new(speed: 80, blocked_by: [:tiles]))
|
|
294
|
+
add_component(RGame::Engine::Components::PlayerController.new)
|
|
295
|
+
```
|
|
122
296
|
|
|
123
|
-
- **Construct:** `
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
297
|
+
- **Construct:** `CharacterBody.new(speed:, blocked_by: [])`: walk speed in px/s,
|
|
298
|
+
and what may stop a step (see [`Mover`](#mover)).
|
|
299
|
+
- **State:** `set_intent(x, y)` writes the step's intent. `move_x` and `move_y`
|
|
300
|
+
read it back, and so do `heading_x` and `heading_y`. A body pressed into a wall
|
|
301
|
+
still heads into it.
|
|
302
|
+
- **Phase:** `update(dt)` applies `intent * speed * dt` through `apply_move`, and
|
|
303
|
+
moves nothing when the intent is zero. That still counts as a step, so a body
|
|
304
|
+
that stops pushing into a wall reports `on_unblocked`.
|
|
305
|
+
- **Seam:** a body that resolves a step differently, such as a platformer's with
|
|
306
|
+
gravity and a jump, overrides [`apply_move`](#mover). It inherits the intent, the
|
|
307
|
+
speed and the standing-still check.
|
|
308
|
+
- **Examples:** `examples/walk` uses this, a `PlayerController` and an
|
|
309
|
+
`AnimatedSprite`, and nothing else. `examples/collision_tiles` adds a feet box and
|
|
310
|
+
`blocked_by: [:tiles]`, and draws the box over the sprite so you can see what
|
|
311
|
+
collides. A crowd adds more names: walkers that each declare
|
|
312
|
+
`%i[tiles hero npc]` are stopped by the map and by one another.
|
|
130
313
|
|
|
131
314
|
### `CircleCollider`
|
|
132
315
|
|
|
133
|
-
A circular collision shape
|
|
134
|
-
|
|
135
|
-
and
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
- **Geometry:** `cx
|
|
144
|
-
read/write
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
- **
|
|
148
|
-
`
|
|
316
|
+
**A circular collision shape in a scene's [`CollisionWorld`](#collisionworld).** It
|
|
317
|
+
registers when the node enters the tree and unregisters when it leaves, so spawning
|
|
318
|
+
and despawning never leak a registration.
|
|
319
|
+
|
|
320
|
+
- **Construct:** `CircleCollider.new(radius:, layer: :default)`. `layer` is an
|
|
321
|
+
opaque tag. The *owner* reads it to decide what a contact means; the collision
|
|
322
|
+
system ignores it.
|
|
323
|
+
- **Lifecycle:** `on_attach` registers with `node.system(CollisionWorld)`, and
|
|
324
|
+
`on_detach` unregisters. As with [`BoxCollider`](#boxcollider), a scene with no
|
|
325
|
+
world leaves it a bare shape, without raising.
|
|
326
|
+
- **Geometry:** `cx` and `cy` are the node's world origin (`node.world_x`,
|
|
327
|
+
`world_y`). `radius` is read/write, so a pooled entity can retune its shape on
|
|
328
|
+
reset. `layer` is a reader. `aabb_x`, `aabb_y`, `aabb_w` and `aabb_h` give the
|
|
329
|
+
bounding box the world buckets on.
|
|
330
|
+
- **Contacts:** `overlap?(other)` works against a circle *or* a
|
|
331
|
+
[`BoxCollider`](#boxcollider). The two colliders settle the test between
|
|
332
|
+
themselves, so both shapes mix freely in one world.
|
|
333
|
+
- **It never blocks.** Blocking is box against box. A circle on a layer that a
|
|
334
|
+
[`Mover`](#mover) named in `blocked_by:` reports contacts as usual and stops
|
|
335
|
+
nobody. Nor can a circle *be* stopped. A mover that declares anything needs a
|
|
336
|
+
`BoxCollider` of its own, and raises at attach without one. The first rule is a
|
|
337
|
+
limit, not a check: layer membership is a runtime fact, so a raise at attach
|
|
338
|
+
would catch only circles that already existed.
|
|
339
|
+
- **Signals:** `on_hit` fires with the other collider on the step a contact
|
|
340
|
+
**starts**, and `on_separated` on the step it **ends**:
|
|
341
|
+
`collider.on_hit { |other| ... }`. The system triggers them through
|
|
342
|
+
`emit_hit(other)` and `emit_separated(other)`. Each fires once per pair; see
|
|
343
|
+
[`CollisionWorld`](#collisionworld).
|
|
149
344
|
|
|
150
345
|
### `CollisionWorld`
|
|
151
346
|
|
|
152
|
-
A scene-scoped broadphase collision
|
|
153
|
-
node, holds the registered colliders in a `SpatialHash`, and each step
|
|
154
|
-
overlapping
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
347
|
+
**A scene-scoped broadphase collision system.** The component lives on the scene
|
|
348
|
+
node, holds the registered colliders in a `SpatialHash`, and reports each step
|
|
349
|
+
where overlapping pairs begin and end. As a normal component, it runs in the
|
|
350
|
+
`update` traversal and goes away with the scene. See
|
|
351
|
+
[Systems & shared resources](systems.md).
|
|
352
|
+
|
|
353
|
+
**It ignores shape.** It buckets each collider by its reported bounding box
|
|
354
|
+
(`aabb_x`, `aabb_y`, `aabb_w`, `aabb_h`) and leaves the exact test to the pair's own
|
|
355
|
+
`overlap?`. [`CircleCollider`](#circlecollider) and [`BoxCollider`](#boxcollider)
|
|
356
|
+
therefore share one world and collide with each other. A game can add its own
|
|
357
|
+
shape by answering the same few methods.
|
|
358
|
+
|
|
359
|
+
- **Construct:** `CollisionWorld.new(cell_size:)`, the spatial hash's cell size.
|
|
360
|
+
**Size it to the colliders**, and nothing else. A tile map in the same scene is
|
|
361
|
+
no guide. A 64px collider in 16px cells lands in twenty-five cells and is queried
|
|
362
|
+
from all of them. That measured 2.8× the cost of a cell sized to the collider. A
|
|
363
|
+
12×6 feet box in the same 16px cells costs only 10–20% over its own optimum. A
|
|
364
|
+
wrong value costs frame time, never correctness, so pick it deliberately.
|
|
365
|
+
- **Registration:** `register(collider)` and `unregister(collider)`. Colliders call
|
|
366
|
+
these through their own lifecycle, so nodes never wire them.
|
|
367
|
+
- **Queries:** `query_box(x, y, w, h)` yields every registered collider bucketed in
|
|
368
|
+
a cell the region covers. It skips nodes queued for removal. It is the
|
|
369
|
+
rectangular form of `query_circle`, and a blocked [`Mover`](#mover) asks it each
|
|
370
|
+
step. `nearest(x, y, r, layer:)` and `cell_empty?(x, y)` complete the set. All of
|
|
371
|
+
them read the index the most recent `update` built, and allocate nothing.
|
|
372
|
+
- **Staying current mid-step:** `reindex(collider, from_x, from_y, from_w, from_h)`
|
|
373
|
+
re-buckets a collider that moved after the index was built, given the box it
|
|
374
|
+
*was* bucketed at. Buckets fill once per step, so a query over cells a mover left
|
|
375
|
+
would miss it. With two hundred actors, that measured 116 misses in 60,000
|
|
376
|
+
queries, and none once each mover re-bucketed itself. A [`Mover`](#mover) that
|
|
377
|
+
declares a collider layer does this through its resolver. Anything else that
|
|
378
|
+
moves a collider mid-step may call it directly: a mover that declared nothing, or
|
|
379
|
+
an ancestor.
|
|
380
|
+
- **Phase:** `update(dt)` rebuilds the spatial index. It fires both colliders'
|
|
381
|
+
`on_hit` for each pair that *started* overlapping, then both colliders'
|
|
382
|
+
`on_separated` for each pair that *stopped*. It **ignores layers**: it reports
|
|
383
|
+
contacts, and each owner decides their meaning by reading the other's `layer`. It
|
|
384
|
+
skips colliders whose node is queued for removal.
|
|
385
|
+
- **Contacts arrive one step late.** This component sits on the scene node, and a
|
|
386
|
+
node runs its components before its children. The world builds the index and
|
|
387
|
+
reports every pair *before* any actor moves this step. Contacts therefore
|
|
388
|
+
describe where things stood at the end of the previous step. The delay is
|
|
389
|
+
consistent, so nothing jitters, but a pair that starts overlapping during step N
|
|
390
|
+
is reported at the start of step N+1. A blocked [`Mover`](#mover) does not read
|
|
391
|
+
the index this way and is unaffected. It queries mid-step and re-buckets itself,
|
|
392
|
+
through `reindex` above.
|
|
393
|
+
- **A contact is two edges, not a state.** Each signal fires **once per pair**.
|
|
394
|
+
Nothing fires on the steps between, however long the overlap lasts, and a pair
|
|
395
|
+
spanning several cells reports once. A handler may therefore do what must happen
|
|
396
|
+
exactly once, such as `score += 100`, playing a sound or spending a life, with
|
|
397
|
+
nothing to guard. `examples/collision`'s crate counts arrivals in one line thanks
|
|
398
|
+
to this.
|
|
399
|
+
|
|
400
|
+
The world keeps an
|
|
401
|
+
[`Engine::ContactSet`](internals.md#contactset--the-two-edges-of-a-contact) per
|
|
402
|
+
collider for this. The set also absorbs the
|
|
403
|
+
[`SpatialHash`](internals.md#spatialhash--uniform-grid-broadphase) may-yield-twice
|
|
404
|
+
contract: a pair spanning three cells is offered three times and recorded once.
|
|
405
|
+
- **`on_separated` also fires when the partner goes.** A contact ends when the
|
|
406
|
+
partner is destroyed (`queue_free`) or leaves the tree, not only when the two move
|
|
407
|
+
apart. The survivor hears about it on its next step. Otherwise a ship could stay
|
|
408
|
+
"in contact" with a rock that no longer exists. The departing collider hears
|
|
409
|
+
nothing, because it is leaving.
|
|
410
|
+
|
|
411
|
+
**Remember that last point when pooling.** The world clears a collider's own
|
|
412
|
+
contacts when it registers, so a recycled entity never reports a separation from
|
|
413
|
+
its previous life. But state a *node* derived from those contacts, such as a "how
|
|
414
|
+
many things am I touching" count, never unwinds. The edge that would unwind it
|
|
415
|
+
never arrived. Zero that state in the node's `reset`, with the rest.
|
|
416
|
+
- **Example:** `examples/collision` puts this system on the scene, circles and boxes
|
|
417
|
+
on the nodes, two layers, and one line that ignores same-layer pairs. A circle
|
|
418
|
+
stays lit while inside a crate (`on_hit` on, `on_separated` off), and the crate
|
|
419
|
+
blinks once and counts it. The backdrop draws the broadphase grid, so you can see
|
|
420
|
+
`cell_size`.
|
|
421
|
+
- **Range queries (targeting):** the same index answers point-radius lookups
|
|
422
|
+
against the most recent `update`. A turret can find enemies without a contact:
|
|
423
|
+
- `query_circle(x, y, r) { |collider| }` yields every registered collider whose
|
|
424
|
+
centre lies within `r` of `(x, y)`. It measures centre distance and does not add
|
|
425
|
+
the collider's size, so it reads like a range ring. It skips freed nodes'
|
|
426
|
+
colliders, and may yield a collider more than once, which is fine for selecting.
|
|
427
|
+
Filter by `collider.layer` in the block.
|
|
428
|
+
- `nearest(x, y, r, layer: nil)` returns the closest such collider, optionally
|
|
429
|
+
limited to one `layer`, or `nil`. Both allocate nothing, so a targeting
|
|
430
|
+
component can call them every frame.
|
|
431
|
+
- **Cell occupancy (grid games):** `cell_empty?(x, y)` answers whether the cell
|
|
432
|
+
containing the **world** point `(x, y)` is free, for questions like "may a pickup
|
|
433
|
+
spawn on this square?". It takes a point, not a region; pass any coordinate inside
|
|
434
|
+
the square you mean. Set `cell_size` to the game's own square so the two grids
|
|
435
|
+
line up. The cells form the hash's own lattice, anchored at the world origin. A
|
|
436
|
+
board should therefore put its origin on a multiple of `cell_size`. Off that
|
|
437
|
+
lattice, each square straddles two cells and both read occupied. Like the queries
|
|
438
|
+
above, it reads the index the last `update` built. It allocates nothing, even on a
|
|
439
|
+
miss, which is what a board scan asks most.
|
|
440
|
+
|
|
441
|
+
A collider whose node is queued for removal does not occupy a cell, so a corpse
|
|
442
|
+
cannot reserve a square. The rest is
|
|
443
|
+
[`SpatialHash#cell_empty?`](internals.md#spatialhash--uniform-grid-broadphase).
|
|
444
|
+
Its cell walk is half-open on the far edge, so a piece filling one square leaves
|
|
445
|
+
its neighbours free.
|
|
446
|
+
|
|
447
|
+
It takes *world* coordinates, because colliders report those. A node whose grid
|
|
448
|
+
starts elsewhere adds its origin first:
|
|
449
|
+
|
|
450
|
+
```ruby
|
|
451
|
+
# in a Grid node whose cells are cell_size across
|
|
452
|
+
def free?(col, row)
|
|
453
|
+
collisions = system(RGame::Engine::Components::CollisionWorld)
|
|
454
|
+
collisions.cell_empty?(world_x + (col * cell_size), world_y + (row * cell_size))
|
|
455
|
+
end
|
|
456
|
+
```
|
|
175
457
|
|
|
176
|
-
### `
|
|
458
|
+
### `DespawnOffscreen`
|
|
177
459
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
-
|
|
193
|
-
|
|
460
|
+
**Removes the node once its origin passes an edge of the world bounds by more than
|
|
461
|
+
`margin`.** Use it for short-lived entities like projectiles. The margin stands in
|
|
462
|
+
for the node's size: a node drawn centred on its origin has fully left once the
|
|
463
|
+
margin reaches half its extent.
|
|
464
|
+
|
|
465
|
+
- **Construct:** `DespawnOffscreen.new(margin: 0.0)`, with the same optional
|
|
466
|
+
`width:` and `height:` override as `ScreenWrap`.
|
|
467
|
+
- **Lifecycle:** `on_attach` resolves the bounds, exactly as `ScreenWrap` does.
|
|
468
|
+
- **Phase:** `update(dt)` calls `node.queue_free` once the node's **world**
|
|
469
|
+
position passes an edge. A projectile spawned as a child of an offset emitter
|
|
470
|
+
leaves at the world's edge, not at an edge shifted by the emitter's position.
|
|
471
|
+
Removal is *deferred* (see [deferred free](scene_graph.md#deferred-free)), so
|
|
472
|
+
triggering it inside the update traversal is safe. For an entity that never
|
|
473
|
+
leaves a *fixed* board, such as a projectile that should vanish after N seconds,
|
|
474
|
+
use a one-shot [`Timer`](#timer) (`repeating: false`) with
|
|
475
|
+
`on_timeout { node.queue_free }` instead.
|
|
476
|
+
- **One response to the edge per node.** It raises at attach beside a `ScreenWrap`
|
|
477
|
+
or a mover declaring `blocked_by: [:bounds]`; see
|
|
478
|
+
[`WorldBounds.one_response!`](#world).
|
|
479
|
+
|
|
480
|
+
### `FeetCollider`
|
|
481
|
+
|
|
482
|
+
**A [`BoxCollider`](#boxcollider) whose rectangle is the node's feet**: centred
|
|
483
|
+
horizontally in the node's dimensions and anchored to their bottom. A top-down
|
|
484
|
+
character should collide with this shape. A 16×22 hero occupies the 12×6 patch
|
|
485
|
+
under them, not the whole sprite, so their head does not bump a wall a tile away. In
|
|
486
|
+
every other respect it is a `BoxCollider`: same registration, same signals, same
|
|
487
|
+
mixing with circles. `get_component(BoxCollider)` finds it.
|
|
488
|
+
|
|
489
|
+
- **Construct:** `FeetCollider.new(width:, height:, layer: :default)`, the feet box
|
|
490
|
+
size in px. It takes no sprite size and no offsets. Those come from `node.width`
|
|
491
|
+
and `node.height`, which [`AnimatedSprite`](#animatedsprite) sets from the sprite
|
|
492
|
+
frame, so box and sprite never disagree. A node without a sprite sets its own
|
|
493
|
+
dimensions.
|
|
494
|
+
- **Geometry:** the collider builds `box` on **first read** and keeps it, not at
|
|
495
|
+
construction. A node has no size until its sprite attaches, so add order never
|
|
496
|
+
matters. Reading `box` from a 0×0 node **raises**, naming the size. The
|
|
497
|
+
alternative would be a box anchored to nothing, and an actor walking through walls
|
|
498
|
+
far from the cause. Assigning `box =` still takes priority, which lets a pooled
|
|
499
|
+
entity retune its shape on reset.
|
|
500
|
+
- **Everything else:** as [`BoxCollider`](#boxcollider): `aabb_*`, `cx` and `cy`,
|
|
501
|
+
`overlap?`, `on_hit` and `on_separated`, and registration with the scene's
|
|
502
|
+
[`CollisionWorld`](#collisionworld) when one exists.
|
|
503
|
+
|
|
504
|
+
A blocked [`CharacterBody`](#characterbody) resolves its steps against this same
|
|
505
|
+
shape, so one component carries the feet box for both jobs.
|
|
506
|
+
`examples/collision_tiles` mounts no `CollisionWorld` at all, and uses the collider
|
|
507
|
+
only as the rectangle a step may not push past.
|
|
194
508
|
|
|
195
|
-
|
|
509
|
+
```ruby
|
|
510
|
+
add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: 'hero.json'))
|
|
511
|
+
feet = add_component(RGame::Engine::Components::FeetCollider.new(
|
|
512
|
+
width: 12, height: 6, layer: :hero
|
|
513
|
+
))
|
|
514
|
+
feet.on_hit { |other| take_damage if other.layer == :spike }
|
|
515
|
+
```
|
|
196
516
|
|
|
197
|
-
|
|
517
|
+
### `Hop`
|
|
518
|
+
|
|
519
|
+
**A jump in a top-down view.** The node's picture rises along a parabola and comes
|
|
520
|
+
back down. The node itself stays on the ground, and so does every collider, camera
|
|
521
|
+
and child that reads its position.
|
|
522
|
+
|
|
523
|
+
- **Construct:** `Hop.new(peak:, duration:, action: :jump)`. `peak` is the highest
|
|
524
|
+
the picture rises, in px, reached halfway through `duration` seconds. Both must be
|
|
525
|
+
positive, or the constructor raises `ArgumentError`. `action` names the action
|
|
526
|
+
whose **press edge** starts a hop, so holding it hops once. `action: nil` reads no
|
|
527
|
+
input.
|
|
528
|
+
- **State:** `height` (px above the ground now), `airborne?`, `peak`, `duration`.
|
|
529
|
+
- **Starting one:** press `action` during `control`, or call `jump`. `jump` does
|
|
530
|
+
nothing while a hop is under way. NPCs and scripts call it.
|
|
531
|
+
- **Phase:** `update(dt)` advances the arc and writes the height to
|
|
532
|
+
[`node.elevation`](scene_graph.md#elevation). [`AnimatedSprite`](#animatedsprite)
|
|
533
|
+
and [`Sprite`](#sprite) draw lifted by it. The arc depends on the time accumulated
|
|
534
|
+
in `update`, never on a clock, so a paused node hangs in the air.
|
|
535
|
+
- **Lifecycle:** `on_attach` lands the node, so a pooled node reused mid-hop starts
|
|
536
|
+
on the ground.
|
|
537
|
+
|
|
538
|
+
**The game decides what a hop crosses.** `Hop` knows nothing about tiles or
|
|
539
|
+
colliders. A [`CharacterBody`](#characterbody) blocked by a wall stays blocked while
|
|
540
|
+
its node is in the air. A game whose chasm tiles should be passable mid-hop reads
|
|
541
|
+
`airborne?` where it decides what is solid.
|
|
198
542
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
- **State:** `scale` is a read/write accessor (a pooled entity can retune it).
|
|
204
|
-
- **Phase:** `draw(renderer, view)` draws the image with **no angle** — `Node2D#draw`
|
|
205
|
-
already wraps a node's own draws in `renderer.rotated(abs_angle, …)`, so the node's
|
|
206
|
-
rotation orients the sprite; passing an angle here would rotate it twice. It skips the
|
|
207
|
-
draw entirely when the view cannot show it, measuring the node's box scaled — a node
|
|
208
|
-
that never set a size is never culled.
|
|
543
|
+
```ruby
|
|
544
|
+
hop = add_component(RGame::Engine::Components::Hop.new(peak: 18, duration: 0.5))
|
|
545
|
+
hop.airborne? # => false — until the :jump action is pressed
|
|
546
|
+
```
|
|
209
547
|
|
|
210
|
-
|
|
548
|
+
`examples/jump_topdown` draws the shadow and the feet box that stay on the ground
|
|
549
|
+
under the picture.
|
|
211
550
|
|
|
212
|
-
|
|
551
|
+
### `Identity`
|
|
213
552
|
|
|
214
|
-
|
|
215
|
-
shift the point being centred on, for a node whose origin is not what should be in the
|
|
216
|
-
middle of the screen (a bottom-anchored sprite usually wants its feet).
|
|
217
|
-
- **Phase:** `update(dt)` calls `camera.center_on` with the node's resolved absolute
|
|
218
|
-
origin. The camera trails the node's own movement by one step, uniformly.
|
|
553
|
+
**A stable name for one node**, so something outside the tree can refer to it.
|
|
219
554
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
555
|
+
```ruby
|
|
556
|
+
sheep.add_component(RGame::Engine::Components::Identity.new(id: 7))
|
|
557
|
+
RGame::Engine::Components::Identity.of(sheep) # => 7
|
|
558
|
+
```
|
|
224
559
|
|
|
225
|
-
|
|
560
|
+
- **Construct:** `Identity.new(id:)`, with any object; `nil` raises.
|
|
561
|
+
- **Read:** `#id`, or `Identity.of(node)`. `of` returns `nil` for a node without an
|
|
562
|
+
identity, and for no node at all.
|
|
563
|
+
- **Phase:** none. It holds a value and does nothing per frame.
|
|
226
564
|
|
|
227
|
-
|
|
228
|
-
|
|
565
|
+
**Most saving needs no identity.** A scene is a recipe and a save file is state. A
|
|
566
|
+
*singular* thing is named by the variable that holds it. *Interchangeable* things
|
|
567
|
+
are named by their order in an array. `examples/save_load` restores a dog and a
|
|
568
|
+
flock with exactly those two and nothing else. `examples/save_load_ids` needs this
|
|
569
|
+
component.
|
|
229
570
|
|
|
230
|
-
|
|
231
|
-
turn_action: :turn, thrust_action: :thrust)`.
|
|
232
|
-
- **Lifecycle:** `on_attach` pulls the node's `Velocity` component.
|
|
233
|
-
- **Phase:** `control(actions)` reads intent (turn → `velocity.spin`, thrust stored);
|
|
234
|
-
`update(dt)` accelerates along the heading (angle 0 = up, so forward is
|
|
235
|
-
`(sin θ, −cos θ)`), applies drag, and clamps to `max_speed`. Firing is intentionally
|
|
236
|
-
not here.
|
|
571
|
+
`Identity` covers two cases the others miss:
|
|
237
572
|
|
|
238
|
-
|
|
573
|
+
- **A collection whose members can die.** An array index names nothing once a
|
|
574
|
+
middle member is gone.
|
|
575
|
+
- **A reference from one saved thing to another**, such as a dog chasing one
|
|
576
|
+
particular sheep. This case truly forces ids. A collection can be respawned from
|
|
577
|
+
its own records, but a reference into it needs a name for its target.
|
|
578
|
+
`Targeting#target` shows the problem: it holds a *node*, and `Identity.of` turns
|
|
579
|
+
that into something a file can hold.
|
|
239
580
|
|
|
240
|
-
|
|
241
|
-
cooldown. One instance covers several actions (the engine allows one component per class
|
|
242
|
-
per node), so it emits the action name and lets listeners filter — reusable for "fire"
|
|
243
|
-
here, or "jump"/"fire" in a platformer.
|
|
581
|
+
The game must get two things right; this component does not check them:
|
|
244
582
|
|
|
245
|
-
- **
|
|
246
|
-
|
|
247
|
-
- **
|
|
248
|
-
|
|
249
|
-
action is held and its cooldown has elapsed (held + cooldown = auto-repeat).
|
|
583
|
+
- **Ids must be unique** among things that can refer to each other. A duplicate
|
|
584
|
+
restores the wrong object, silently.
|
|
585
|
+
- **The id allocator belongs in the save.** A counter that restarts at 1 on load
|
|
586
|
+
reissues ids the restored objects already hold. Save the next id with them.
|
|
250
587
|
|
|
251
|
-
### `
|
|
588
|
+
### `Mover`
|
|
252
589
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
590
|
+
**The base class of every component that moves its node**:
|
|
591
|
+
[`CharacterBody`](#characterbody), [`Velocity`](#velocity) and
|
|
592
|
+
[`PathFollow`](#pathfollow). Walking an intent, integrating a velocity and following
|
|
593
|
+
a path are three different jobs, so they are three classes. They share what happens
|
|
594
|
+
*after* a step is computed, and `Mover` holds that part. You never add a `Mover` on
|
|
595
|
+
its own.
|
|
257
596
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
(`node.root.context.assets.sheet(sheet)`), builds its animation set, **sizes the node** to the
|
|
263
|
-
sheet's frame (`node.width`/`height`, so a `CharacterBody` sibling can read them), and pulls that
|
|
264
|
-
sibling (the facing source). The renderer resolves the same path when drawing, so nothing is
|
|
265
|
-
registered or passed in by hand.
|
|
266
|
-
- **Phase:** `update(dt)` selects + advances the animation; `draw(renderer, view)` renders the
|
|
267
|
-
current frame via `renderer.sprite` at the node's **world** origin (`abs_x`/`abs_y`) with no
|
|
268
|
-
angle — a [`WorldView`](scene_graph.md#view-transforms-and-the-camera) ancestor applies the
|
|
269
|
-
camera offset, so the component never touches the camera. It skips the draw when the view
|
|
270
|
-
cannot show it, measuring the node's box. (`Sprite` above is the single-image counterpart.)
|
|
597
|
+
**A mover declares what stops a step; it does not subclass for it.**
|
|
598
|
+
`blocked_by:` lists what a step may not pass through. The default is nothing. Then
|
|
599
|
+
the mover writes the node's position directly, and needs **no sprite, no
|
|
600
|
+
dimensions, no collider and no system on the scene**.
|
|
271
601
|
|
|
272
|
-
|
|
602
|
+
```ruby
|
|
603
|
+
add_component(RGame::Engine::Components::BoxCollider.new(width: 12, height: 12, layer: :crate))
|
|
604
|
+
add_component(RGame::Engine::Components::Velocity.new(vx: 90, vy: 40, blocked_by: [:wall]))
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
Two names are reserved, and every other name is a collider layer:
|
|
608
|
+
|
|
609
|
+
| Name | Resolved against | Stops the step at |
|
|
610
|
+
|---|---|---|
|
|
611
|
+
| `:tiles` | the scene's [`TileWorld`](#tileworld) | the edge of a solid tile |
|
|
612
|
+
| `:bounds` | the scene's [`WorldBounds`](#world) | the edge of the world |
|
|
613
|
+
| anything else | the scene's [`CollisionWorld`](#collisionworld) | the edge of any `BoxCollider` wearing that layer |
|
|
614
|
+
|
|
615
|
+
**Each step resolves one axis at a time and takes the most restrictive answer.** A
|
|
616
|
+
diagonal held against a wall keeps its free half, so the mover slides. It slides
|
|
617
|
+
off a villager exactly as off a fence. A character wants that; a bullet does not,
|
|
618
|
+
but needs no separate resolver. `on_blocked` fires on the step the bullet hits, and
|
|
619
|
+
a bullet that frees itself there is gone before it slides. A mover that keeps
|
|
620
|
+
pushing also keeps its intent: a blocked `Velocity` does not zero its `vx`. A bullet
|
|
621
|
+
that bounces reads which axis stopped, and reverses that half of its velocity:
|
|
622
|
+
|
|
623
|
+
```ruby
|
|
624
|
+
velocity = RGame::Engine::Components::Velocity.new(vx: 120, vy: 80, blocked_by: %i[wall bounds])
|
|
625
|
+
velocity.on_blocked do |_by, axis|
|
|
626
|
+
velocity.vx = -velocity.vx unless axis == :y
|
|
627
|
+
velocity.vy = -velocity.vy unless axis == :x
|
|
628
|
+
end
|
|
629
|
+
```
|
|
273
630
|
|
|
274
|
-
|
|
275
|
-
(
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
- **
|
|
284
|
-
|
|
285
|
-
- **
|
|
286
|
-
|
|
287
|
-
|
|
631
|
+
```ruby
|
|
632
|
+
add_component(RGame::Engine::Components::FeetCollider.new(width: 12, height: 6, layer: :hero))
|
|
633
|
+
add_component(RGame::Engine::Components::CharacterBody.new(speed: 80, blocked_by: %i[tiles npc]))
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
Three rules apply to layer names:
|
|
637
|
+
|
|
638
|
+
- **An empty layer is not an error.** The declaration says what *may* stop this
|
|
639
|
+
mover, not what does.
|
|
640
|
+
- **A mover is never stopped by its own collider.** A crowd of villagers can all
|
|
641
|
+
declare `blocked_by: [:npc]` while each wears `:npc`.
|
|
642
|
+
- **Blocking is box against box.** A [`CircleCollider`](#circlecollider) on a
|
|
643
|
+
declared layer reports contacts as usual and stops nothing.
|
|
644
|
+
|
|
645
|
+
`blocked_by` and [`on_hit`](#boxcollider) answer different questions: what may I
|
|
646
|
+
walk through, and what am I touching? They are not alternatives. A blocked pair
|
|
647
|
+
ends up *touching*, and `CollisionBox.overlap?` is half-open, so a blocked step
|
|
648
|
+
reports no contact. An entity that must both stop and react needs both.
|
|
649
|
+
|
|
650
|
+
**The mover reports what stopped a step.** `on_blocked` fires on the step something
|
|
651
|
+
starts stopping the mover, and `on_unblocked` on the step it stops doing so. Each
|
|
652
|
+
fires once per blocker. `BoxCollider` reports contacts with the same pair of edges,
|
|
653
|
+
here aimed at what a step could not pass. A spiky ball that both stops the player and
|
|
654
|
+
hurts them is thus two ordinary components, not a hand-written `on_hit`.
|
|
655
|
+
|
|
656
|
+
```ruby
|
|
657
|
+
mover.on_blocked { |by| take_damage if by.layer == :spike }
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
The listener receives the blocker and reads `by.layer` and `by.node`, whatever kind
|
|
661
|
+
stopped the step. A collider answers its own layer and owning node. The map's solid
|
|
662
|
+
tiles answer `:tiles` and `nil`. The world's edge answers `:bounds` and `nil`. The
|
|
663
|
+
second argument is the stopped axis: `:x`, `:y` or `:both`. A listener that names
|
|
664
|
+
only the blocker never sees it. Three details matter:
|
|
665
|
+
|
|
666
|
+
- **Standing still counts as unblocking.** The set of blockers advances once per
|
|
667
|
+
`update`. A mover that stops pushing records nothing that step, so `on_unblocked`
|
|
668
|
+
fires. The mover has not moved; it has stopped *being stopped*.
|
|
669
|
+
- **Once per blocker, not once per axis.** A step stopped on both axes by one thing
|
|
670
|
+
fires once, with `:both`. Only the map produces that, with a diagonal into an
|
|
671
|
+
inside corner of solid tiles. A step resolves X first, and once X is flush, a
|
|
672
|
+
single collider no longer overlaps on Y. A step stopped on X by the map and on Y
|
|
673
|
+
by a villager fires twice, each with its own axis. Starting edges come before
|
|
674
|
+
ending ones, the order `CollisionWorld` uses for contacts. A blocker that stops X
|
|
675
|
+
on one step and Y on the next stood in the way throughout. It fires once, with the
|
|
676
|
+
axis it first stopped. `on_unblocked` carries no axis for the same reason.
|
|
677
|
+
- **A blocked pair is not a contact**, as the paragraph above explains. The spiky
|
|
678
|
+
ball listens to `on_blocked`; a trigger area listens to `on_hit`.
|
|
679
|
+
|
|
680
|
+
**`:bounds` must be declared.** A mover that does not name it leaves the world.
|
|
681
|
+
Stopping at the edge is one of three responses to it, beside
|
|
682
|
+
[`ScreenWrap`](#screenwrap) and [`DespawnOffscreen`](#despawnoffscreen), and a node
|
|
683
|
+
may carry only one. Declaring `:bounds` beside either raises at attach. A game whose
|
|
684
|
+
entities wrap or despawn at the edge gives their movers no `:bounds`.
|
|
685
|
+
`blocked_by?(name)` answers whether a mover declared a name.
|
|
686
|
+
|
|
687
|
+
**The shape has one owner, and it is not the mover.** A blocked step resolves
|
|
688
|
+
against the sibling [`BoxCollider`](#boxcollider)'s rectangle;
|
|
689
|
+
[`FeetCollider`](#feetcollider) suits a walking character. You give the box once,
|
|
690
|
+
to the component that *is* a shape. The same rectangle stops the step and reports
|
|
691
|
+
contacts. Reassigning `collider.box` retunes both, and no component hands data to
|
|
692
|
+
another.
|
|
693
|
+
|
|
694
|
+
- **Construct:** every mover takes `blocked_by: []`. A bare Symbol also works
|
|
695
|
+
(`blocked_by: :tiles`).
|
|
696
|
+
- **Lifecycle:** `on_attach` resolves the declarations and builds the mover's own
|
|
697
|
+
[`CollisionSystem`](internals.md#collisionsystem--move-an-actor-against-its-blockers)
|
|
698
|
+
from the sources it finds. It **raises** for anything missing. It checks the
|
|
699
|
+
node's collider first, then the scene's `TileWorld` for `:tiles`, its `WorldBounds`
|
|
700
|
+
for `:bounds`, and its `CollisionWorld` for any layer name. Falling back to free
|
|
701
|
+
movement would look like a collision bug, caused by a scene three files away that
|
|
702
|
+
never mounted the system.
|
|
703
|
+
- **Signals:** `on_blocked` fires with what stopped the step and the stopped axis.
|
|
704
|
+
`on_unblocked` fires with what stopped stopping it:
|
|
705
|
+
`mover.on_blocked { |by, axis| ... }`, `mover.on_unblocked { |by| ... }`.
|
|
706
|
+
- **Phase:** `update(dt)` opens the step, calls the subclass's private
|
|
707
|
+
`take_step(dt)`, and reports the edges. Do not override it; it guarantees that no
|
|
708
|
+
mover forgets an edge.
|
|
709
|
+
- **Heading:** `heading_x` and `heading_y` give the step's direction, each axis in
|
|
710
|
+
-1..1, and `0, 0` when the mover is not trying to move.
|
|
711
|
+
[`AnimatedSprite`](#animatedsprite) faces by it. It is a facing, not a velocity: a
|
|
712
|
+
mover pressed into a wall still heads into it. A [`CharacterBody`](#characterbody)
|
|
713
|
+
answers its intent. A [`Velocity`](#velocity) answers its velocity, scaled so the
|
|
714
|
+
larger axis is ±1. A [`PathFollow`](#pathfollow) answers the unit direction of its
|
|
715
|
+
current segment. Reading it allocates nothing.
|
|
716
|
+
- **Seam:** `apply_move(dx, dy)` lands a step. With nothing declared, it writes
|
|
717
|
+
straight onto the node. With declarations, it goes through the resolver. A mover
|
|
718
|
+
may call it several times in one step, and still reports the edges once.
|
|
719
|
+
- **Actor adapter:** when blocked, the mover passes *itself* to
|
|
720
|
+
[`CollisionSystem#move`](internals.md#collisionsystem--move-an-actor-against-its-blockers).
|
|
721
|
+
It answers `collision_box` from the collider, and `x`, `y`, `x=` and `y=` from the
|
|
722
|
+
node **in world space**, the frame the tile grid and broadphase use. Writing back
|
|
723
|
+
translates the node's local position. That is exact under an unrotated ancestor
|
|
724
|
+
chain and approximate under a rotated one. A spinning thing wants a circle anyway.
|
|
725
|
+
|
|
726
|
+
### `Navigator`
|
|
727
|
+
|
|
728
|
+
**A [`PathFollow`](#pathfollow) that plans its own paths.** `go_to(world_x, world_y)`
|
|
729
|
+
finds a route over the scene's [`TileWorld`](#tileworld) to the tile containing that
|
|
730
|
+
point. It smooths the route into as few straight segments as the node's collider can
|
|
731
|
+
travel, and walks it.
|
|
732
|
+
|
|
733
|
+
```ruby
|
|
734
|
+
# `actors` is the node TileMapLayer.mount returned, in a scene with a TileWorld mounted.
|
|
735
|
+
hero = RGame::Engine::Node2D.new(x: 40, y: 40)
|
|
736
|
+
hero.add_component(RGame::Engine::Components::AnimatedSprite.new(sheet: 'hero.json'))
|
|
737
|
+
hero.add_component(RGame::Engine::Components::FeetCollider.new(width: 12, height: 6))
|
|
738
|
+
navigator = hero.add_component(RGame::Engine::Components::Navigator.new(speed: 80, blocked_by: [:tiles]))
|
|
739
|
+
actors.add_node(hero)
|
|
740
|
+
|
|
741
|
+
navigator.go_to(200.0, 360.0) # => true — the hero sets off; false when there is no route
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
- **Construct:** `Navigator.new(speed:, blocked_by: [])`. It takes no `path:` and
|
|
745
|
+
stays idle until the first `go_to`. [`Mover`](#mover) decides what may stop it. A
|
|
746
|
+
navigator that should stay off solid tiles while walking declares `:tiles`, like
|
|
747
|
+
any mover.
|
|
748
|
+
- **Lifecycle:** `on_attach` raises when the scene has no `TileWorld` to plan over.
|
|
749
|
+
It also looks up the node's `BoxCollider`, if any. Calling `go_to` before the
|
|
750
|
+
node is in the tree raises too.
|
|
751
|
+
- **`go_to(world_x, world_y)`** plans from where the node stands and starts walking
|
|
752
|
+
at once, from exactly there. A navigator halfway along one route turns onto the
|
|
753
|
+
next without a jump. It returns `true`, or `false` when no route exists: the
|
|
754
|
+
target is solid, outside the map, or in a part the node cannot reach. On `false`
|
|
755
|
+
nothing changes, and a walk under way continues. `on_finished` never fires inside
|
|
756
|
+
`go_to`, even for a target in the node's own cell; it fires on the next step.
|
|
757
|
+
- **The anchor.** The centre of the collider's box reaches the target: the feet,
|
|
758
|
+
for a [`FeetCollider`](#feetcollider). On a node without a collider, the origin
|
|
759
|
+
does. The walk ends with the anchor on the centre of the target tile.
|
|
760
|
+
- **Readers:** `cells` returns the route as the search found it,
|
|
761
|
+
`[[col, row], ...]` from start tile to target, or `nil` before the first `go_to`.
|
|
762
|
+
Use it to draw the route. `path` returns the smoothed
|
|
763
|
+
[`Path`](toolbox.md#path--a-walkable-polyline) the node walks, in the node's
|
|
764
|
+
coordinates, starting where the node stood.
|
|
765
|
+
- **It can walk every route it plans.** Smoothing keeps a straight segment only if
|
|
766
|
+
the map's own blocker source says the collider's box can travel it. That source is
|
|
767
|
+
`TileWorld#blockers`, the one that stops a mover declaring `:tiles`, queried
|
|
768
|
+
through [`TileBlockers#travel?`](internals.md#tileblockers--the-tile-grid-as-a-blocker-source).
|
|
769
|
+
A test on tiles alone would keep a diagonal past a tree's corner that a point
|
|
770
|
+
clears but a feet box clips. A `PathFollow` held on a corner does not slide off;
|
|
771
|
+
it would stand there. `travel?` holds for a walker taking steps under a quarter
|
|
772
|
+
tile: 240 px/s at 60 ticks a second on 16 px tiles.
|
|
773
|
+
- **One component, not a search beside a walker.** A navigator *is* a `PathFollow`,
|
|
774
|
+
so no component hands a route to a sibling. There is no wiring to forget, and the
|
|
775
|
+
order you add components in does not matter.
|
|
776
|
+
- **Colliders up to one tile only.** Pathfinding for a collider wider or taller than
|
|
777
|
+
a tile is unsupported. Smoothing assumes the box fits the cells the search found.
|
|
778
|
+
`go_to` raises `ArgumentError`, naming the box and the tile size, instead of
|
|
779
|
+
planning a route such a walker could stall on. A collider of exactly one tile
|
|
780
|
+
works.
|
|
781
|
+
- **It waits; it does not replan.** It plans against the map and knows only the
|
|
782
|
+
map. A navigator declaring other collider layers waits behind anything standing on
|
|
783
|
+
its route, as a `PathFollow` does, and resumes when the way clears. To go around
|
|
784
|
+
instead, call `go_to` again.
|
|
785
|
+
- **Cost.** Planning runs when `go_to` is called, never per frame, and allocates. It
|
|
786
|
+
takes about 0.2 ms for a 65-tile route across a 60x40 map. A 117-tile route across
|
|
787
|
+
120x90 takes 1.3 ms, 1 ms of it in the search. The walk itself is `PathFollow`'s,
|
|
788
|
+
and allocates nothing.
|
|
789
|
+
- **Spaces:** it plans in world space and walks in the parent's. The two agree under
|
|
790
|
+
an unrotated ancestor chain, the same limit a blocked [`Mover`](#mover) has.
|
|
791
|
+
- **Example:** in `examples/pathfinding`, a tile cursor picks the target. The scene
|
|
792
|
+
draws `cells` as a dot per tile and `path` as lines. It adds the feet box's centre
|
|
793
|
+
back to each waypoint so the lines sit on the dots.
|
|
794
|
+
|
|
795
|
+
### `PathFollow`
|
|
796
|
+
|
|
797
|
+
**Walks the owning node along an
|
|
798
|
+
[`RGame::Engine::Path`](toolbox.md#path--a-walkable-polyline) at constant speed**,
|
|
799
|
+
and emits `on_finished` at the last waypoint. Hook whatever should happen on arrival
|
|
800
|
+
to that signal.
|
|
801
|
+
|
|
802
|
+
- **Construct:** `PathFollow.new(speed:, path: nil, blocked_by: [])`.
|
|
803
|
+
[`Mover`](#mover) decides what may stop it. Without a path, the follower is idle:
|
|
804
|
+
it moves nothing, never finishes, and heads nowhere until it receives one.
|
|
805
|
+
- **Lifecycle:** `on_attach` restarts the walk. It returns to the first waypoint,
|
|
806
|
+
clears progress, and *places* the node there regardless of declarations. A pooled
|
|
807
|
+
follower acquired and added again starts a fresh walk.
|
|
808
|
+
- **A new route:** `follow(path)` restarts with a different path, at any time. A
|
|
809
|
+
finished follower walks it and emits `on_finished` again. A follower halfway along
|
|
810
|
+
another route drops that route at once and is placed on the new first waypoint. You
|
|
811
|
+
may call it from an `on_finished` handler. `follow(nil)` stops the walk where it
|
|
812
|
+
stands. `path` returns the route being walked.
|
|
813
|
+
|
|
814
|
+
```ruby
|
|
815
|
+
out = RGame::Engine::Path.new([[40.0, 100.0], [200.0, 100.0]])
|
|
816
|
+
back = RGame::Engine::Path.new([[200.0, 100.0], [40.0, 100.0]])
|
|
817
|
+
patrol = RGame::Engine::Components::PathFollow.new(path: out, speed: 40)
|
|
818
|
+
patrol.on_finished { patrol.follow(patrol.path.equal?(out) ? back : out) }
|
|
819
|
+
```
|
|
820
|
+
- **Heading:** the unit direction of the current segment, computed as the walk
|
|
821
|
+
enters it. It is `0, 0` while idle, after finishing, and along a zero-length
|
|
822
|
+
segment.
|
|
823
|
+
- **Signal:** `on_finished` fires once, without payload, at the path's end:
|
|
824
|
+
`follow.on_finished { node.queue_free }`. `finished?` reports the same state.
|
|
825
|
+
- **Phase:** `update(dt)` advances `speed * dt`, crosses as many segments as one step
|
|
826
|
+
spans, and interpolates the node's position. It allocates nothing. With nothing
|
|
827
|
+
declared, it places the node on that point.
|
|
828
|
+
- **When blocked, the walk waits.** With declarations, the follower moves the node
|
|
829
|
+
to that point through `apply_move`. A step stopped short **does not advance the
|
|
830
|
+
walk**: progress returns to where the step began. A follower held for a second
|
|
831
|
+
arrives a second late instead of racing ahead once free. `on_finished` never fires
|
|
832
|
+
for a walker still standing in front of its blocker.
|
|
833
|
+
- **A held follower does not slide.** The step's free axis still moves, but each
|
|
834
|
+
step aims at the same path point again. A follower pressed diagonally against a
|
|
835
|
+
wall comes to rest, unlike a [`Velocity`](#velocity), which slides. To get around
|
|
836
|
+
an obstacle, replan the path.
|
|
288
837
|
|
|
289
838
|
### `PlayerController`
|
|
290
839
|
|
|
291
|
-
Drives a `CharacterBody` sibling from two input axes
|
|
292
|
-
`ThrustController`).
|
|
840
|
+
**Drives a `CharacterBody` sibling from two input axes**: direct 8-way walking, no
|
|
841
|
+
inertia (unlike `ThrustController`). It neither knows nor cares whether the body is
|
|
842
|
+
blocked.
|
|
293
843
|
|
|
294
844
|
- **Construct:** `PlayerController.new(x_axis: :move_x, y_axis: :move_y)`.
|
|
845
|
+
- **Lifecycle:** `on_attach` looks up the node's `CharacterBody` with
|
|
846
|
+
`require_sibling`.
|
|
295
847
|
- **Phase:** `control(actions)` copies the two axes into the body's intent.
|
|
848
|
+
- **Example:** `examples/walk`.
|
|
296
849
|
|
|
297
|
-
### `
|
|
850
|
+
### `Pool`
|
|
298
851
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
852
|
+
**Wraps an [`RGame::Engine::Pool`](toolbox.md#pool--reuse-dont-allocate) of nodes
|
|
853
|
+
and runs its tree bookkeeping in the frame tick.** A scene that recycles entities,
|
|
854
|
+
such as enemies or projectiles, writes no acquire/add/reclaim code. It calls `spawn`
|
|
855
|
+
and the ordinary `queue_free`. Pooled nodes are **normal children** of the owner, so
|
|
856
|
+
the scene's traversal updates and draws them. This component manages only pool
|
|
857
|
+
membership.
|
|
858
|
+
|
|
859
|
+
- **Construct:** `Pool.new { Enemy.new(...) }`. The factory builds a blank node. Add
|
|
860
|
+
the component with a name (`as:`) when a node needs several pools.
|
|
861
|
+
- **Spawn:** `pool.spawn` takes a node, recycled or newly built, and adds it as a
|
|
862
|
+
child. `pool.spawn { |n| n.reset(...) }` runs the block to re-initialise the node
|
|
863
|
+
*before* it enters the tree, so `on_attach` sees the reset state. Projectiles need
|
|
864
|
+
that order.
|
|
865
|
+
- **Reclaim:** `update(dt)` returns every freed pooled node to the free list, and
|
|
866
|
+
detaches any still attached. Despawning is thus `node.queue_free` from anywhere;
|
|
867
|
+
the pool recycles the node with no game-side wiring. It allocates nothing in steady
|
|
868
|
+
state.
|
|
869
|
+
- **State:** `size` counts the live pooled nodes, and `empty?` is true once all are
|
|
870
|
+
reclaimed. A scene reads it to tell when a wave is cleared.
|
|
302
871
|
|
|
303
|
-
|
|
304
|
-
- **Phase:** `update(dt)` counts down the timer and re-rolls on timeout or when blocked.
|
|
872
|
+
### `ScreenWrap`
|
|
305
873
|
|
|
306
|
-
|
|
874
|
+
**Wraps the node's position within the world bounds**, so an entity leaving one edge
|
|
875
|
+
reappears at the opposite one.
|
|
876
|
+
|
|
877
|
+
- **Construct:** `ScreenWrap.new(margin: 0.0)`. `margin` lets a sprite pass fully off
|
|
878
|
+
one edge before reappearing on the other. Bounds come from the scene's world
|
|
879
|
+
system. `ScreenWrap.new(width:, height:, margin:)` overrides them for a node whose
|
|
880
|
+
wrap region is not the whole world.
|
|
881
|
+
- **Lifecycle:** `on_attach` resolves the bounds, which is why you can omit them. A
|
|
882
|
+
pooled entity is built long before it enters a tree, and has nothing to ask yet. It
|
|
883
|
+
resolves again on every entry, so a recycled node follows the scene it lands in.
|
|
884
|
+
Attaching without bounds and without a world system in scope **raises**.
|
|
885
|
+
- **Phase:** `update(dt)` wraps the node's **world** position against the bounds, and
|
|
886
|
+
writes it back through [`Node2D#world_x=`](scene_graph.md#the-two-spaces). A node
|
|
887
|
+
under an offset container wraps at the world's edge, not at an edge shifted by the
|
|
888
|
+
container.
|
|
889
|
+
- **A wrap is a placement, not a step.** It does not consult a sibling mover's
|
|
890
|
+
`blocked_by` about the far side. A node wrapped onto something that blocks it stays
|
|
891
|
+
pressed against it.
|
|
892
|
+
- **One response to the edge per node.** It raises at attach beside a
|
|
893
|
+
`DespawnOffscreen`, or beside a mover declaring `blocked_by: [:bounds]`. A wrapping
|
|
894
|
+
entity's mover declares no `:bounds`; see [`WorldBounds.one_response!`](#world).
|
|
307
895
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
896
|
+
### `Sprite`
|
|
897
|
+
|
|
898
|
+
**Draws one registered image centred on the node's origin**, at `0, 0`, where the
|
|
899
|
+
traversal already placed and rotated the renderer.
|
|
900
|
+
|
|
901
|
+
- **Construct:** `Sprite.new(id:, scale: 1.0, z: 0)`. `id` is a renderer image id.
|
|
902
|
+
`z` orders this component against the node's *other* drawing, such as a shadow
|
|
903
|
+
under a sprite, inside the node's own slot. It is not the node's `z`, which orders
|
|
904
|
+
the node among its siblings. See [Drawing](drawing.md#draw-order).
|
|
905
|
+
- **State:** `scale` is read/write, so a pooled entity can retune it.
|
|
906
|
+
- **Phase:** `draw(renderer, view)` draws the image at **`0, 0`** with **no angle**.
|
|
907
|
+
`Node2D#draw` already pushed the node's transform, so the origin and rotation
|
|
908
|
+
already apply. Passing either would apply it twice. The image is lifted by
|
|
909
|
+
[`node.elevation`](scene_graph.md#elevation), in the node's local space. The
|
|
910
|
+
component skips the draw entirely when the view cannot show it. It measures the
|
|
911
|
+
node's box, scaled and lifted, against `node.world_x` and `world_y`. A node that
|
|
912
|
+
never set a size is never culled.
|
|
311
913
|
|
|
312
|
-
|
|
313
|
-
inside a `WorldView`, so the map is drawn once per viewport like the rest of world space.
|
|
314
|
-
This stays the thing actors ask questions of.
|
|
914
|
+
### `Targeting`
|
|
315
915
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
916
|
+
**Picks a node for the owner to aim at.** Each `update`, it queries the scene's
|
|
917
|
+
[`CollisionWorld`](#collisionworld) around the node's world origin and exposes the
|
|
918
|
+
chosen target. It only *selects*; it never moves or fires. The owner reads `target`
|
|
919
|
+
and acts. Every candidate already registers with the broadphase through its
|
|
920
|
+
collider, so targeting keeps no entity list.
|
|
921
|
+
|
|
922
|
+
- **Construct:** `Targeting.new(range:, policy: :nearest, layer: nil)`. `range` is
|
|
923
|
+
the reach in pixels. `layer` restricts candidates, so `:enemy` ignores allies and
|
|
924
|
+
projectiles. An unknown `policy` raises at construction.
|
|
925
|
+
- **Policies:** `:nearest`, the default and only policy, picks the closest candidate
|
|
926
|
+
in range with one broadphase lookup.
|
|
927
|
+
- **State:** `target` is the chosen **node**, or `nil` when nothing is in range. It
|
|
928
|
+
refreshes every `update`, so a freed or out-of-range target clears itself. It is a
|
|
929
|
+
node, not a collider, so the owner can read its position and components.
|
|
930
|
+
- **Lifecycle:** `on_attach` looks up the scene's `CollisionWorld`.
|
|
931
|
+
- **Phase:** `update(dt)` selects the target again. It allocates nothing, so it runs
|
|
932
|
+
every frame.
|
|
933
|
+
|
|
934
|
+
### `ThrustController`
|
|
935
|
+
|
|
936
|
+
**Inertial ship flight on top of a `Velocity` sibling.** A turn axis rotates the
|
|
937
|
+
node, and a thrust axis accelerates it along its heading.
|
|
938
|
+
|
|
939
|
+
- **Construct:** `ThrustController.new(turn_speed:, accel:, max_speed:, drag: 0.0,
|
|
940
|
+
turn_action: :turn, thrust_action: :thrust)`.
|
|
941
|
+
- **Lifecycle:** `on_attach` looks up the node's `Velocity` with `require_sibling`,
|
|
942
|
+
so a missing one raises at once instead of surfacing later as a `nil`.
|
|
943
|
+
- **Phase:** `control(actions)` reads intent: turn sets `velocity.spin`, and thrust
|
|
944
|
+
is stored. `update(dt)` accelerates along the heading, applies drag, and clamps to
|
|
945
|
+
`max_speed`. Angle 0 points up, so forward is `(sin θ, −cos θ)`. Firing is not part
|
|
946
|
+
of this component.
|
|
947
|
+
|
|
948
|
+
### `TileWorld`
|
|
949
|
+
|
|
950
|
+
**The scene-scoped tile system** (see [Systems](systems.md)). It holds the parsed
|
|
951
|
+
[`RGame::Engine::TileMap`](tile_maps.md) and answers what an actor needs from it: where the solid
|
|
952
|
+
tiles are, and how big the world is. Find it with `node.system(TileWorld)`. It
|
|
953
|
+
includes `WorldBounds` (see [`World`](#world)), so `ScreenWrap` and
|
|
954
|
+
`DespawnOffscreen` work in a tile scene with no arguments.
|
|
955
|
+
|
|
956
|
+
**It does not draw.** `RGame::Engine::TileMapLayer` draws, one node per Tiled layer,
|
|
957
|
+
mounted inside a `WorldView`. The map is therefore drawn once per viewport, like the
|
|
958
|
+
rest of world space. `TileWorld` stays the thing actors ask.
|
|
959
|
+
|
|
960
|
+
**Each piece of the tile stack is the kind of object its job requires:**
|
|
961
|
+
|
|
962
|
+
| Piece | Kind | Because |
|
|
963
|
+
|---|---|---|
|
|
964
|
+
| `TileMap`, `Tileset` | values | parsed data with no handle; the gid rows are a `Util::Tensor` |
|
|
965
|
+
| `TileWorld` | component, mounted as a system | it is a scene-scoped answer — solid tiles, world size — that actors look up |
|
|
966
|
+
| `TileMapLayer` | node | it draws in world space, and draw order is tree order |
|
|
967
|
+
|
|
968
|
+
None of them duplicates state the node owns. None needs a hand-written hook to pass
|
|
969
|
+
data to another, depends on a sibling's add order, or names a layer it may not name.
|
|
970
|
+
|
|
971
|
+
- **Construct:** `TileWorld.new(map:, tilemap_id:, cameras: [])`. It clamps each
|
|
972
|
+
camera it receives to the map's edges. `bound(camera)` does the same for a camera
|
|
973
|
+
that arrives later, when a player joins.
|
|
974
|
+
- **Queries:**
|
|
975
|
+
- `blockers` returns the map's solid tiles as an
|
|
976
|
+
[`Engine::TileBlockers`](internals.md#tileblockers--the-tile-grid-as-a-blocker-source),
|
|
977
|
+
the same object every time. A [`Mover`](#mover) declaring `:tiles` borrows it
|
|
978
|
+
and resolves its own steps against it.
|
|
979
|
+
- `nav_grid` returns the same solidity as an
|
|
980
|
+
[`Engine::NavGrid`](toolbox.md#navgrid--routes-over-a-tile-grid), for planning a
|
|
981
|
+
route instead of resolving a step. It is built on first request and reused.
|
|
982
|
+
- `tile_width` and `tile_height` turn a world position into a cell.
|
|
983
|
+
- `solid?(col, row)`, `world_width` and `world_height`.
|
|
984
|
+
- `tilemap_id` and `elapsed`, which the layers read.
|
|
985
|
+
- `layer_count` and `first_above_layer`, which `TileMapLayer.mount` reads to
|
|
986
|
+
decide where actors go.
|
|
987
|
+
- **Solidity is read from the map once.** On the first request, `TileWorld` reads
|
|
988
|
+
the map's `solid_tile?` once per cell into one
|
|
989
|
+
[`Util::SolidGrid`](values.md#rgameutilsolidgrid). From then on, `blockers`,
|
|
990
|
+
`nav_grid` and `solid?` all read that store, never the map. They cannot disagree
|
|
991
|
+
about a cell. A resolve on the per-frame path becomes a byte lookup instead of a
|
|
992
|
+
walk through the map's layers and tileset. Everything past the map's edges is open.
|
|
993
|
+
`TileWorld` does not hand the store out, so a game cannot change a tile's solidity
|
|
994
|
+
at runtime.
|
|
995
|
+
- **It does not resolve a step.** Tiles, other actors, the world's edge, or any
|
|
996
|
+
combination may stop a mover, and only the mover knows which. The resolver
|
|
997
|
+
therefore belongs to the mover, and the grid to this system.
|
|
322
998
|
- **Phase:** `update(dt)` advances the map's animation clock.
|
|
999
|
+
- **Examples:** `examples/scroll_map` loads a `.tmx` through the asset manager and
|
|
1000
|
+
uses this system, `TileMapLayer.mount`, and a camera clamped to the map's edges.
|
|
1001
|
+
`examples/collision_tiles` adds an actor that collides, and shows the solid half of
|
|
1002
|
+
this system.
|
|
323
1003
|
|
|
324
1004
|
```ruby
|
|
325
1005
|
world = scene.add_node(RGame::Engine::WorldView.new)
|
|
@@ -327,19 +1007,106 @@ actors = RGame::Engine::TileMapLayer.mount(world) # a node per Tiled layer
|
|
|
327
1007
|
actors.add_node(player) # in the gap between them
|
|
328
1008
|
```
|
|
329
1009
|
|
|
330
|
-
|
|
331
|
-
`above
|
|
332
|
-
overrides
|
|
1010
|
+
**`mount` returns the node the actors go in.** That node sits below the first layer
|
|
1011
|
+
Tiled flags `above`, so trunks draw under the walker and canopies over it.
|
|
1012
|
+
`mount(world, under: index)` overrides the position for a map arranged differently.
|
|
1013
|
+
Nothing here picks a `z`.
|
|
1014
|
+
|
|
1015
|
+
### `Timer`
|
|
1016
|
+
|
|
1017
|
+
**A node-driven interval timer.** It runs in the node's update tick, so nothing can
|
|
1018
|
+
forget to advance it. It emits `on_timeout` each time a whole interval elapses: a
|
|
1019
|
+
spawn cadence, a turret's fire rate, a wave clock. It wraps the pure
|
|
1020
|
+
[`RGame::Engine::Timer`](toolbox.md#timer--paced-periodic-events) and reuses its
|
|
1021
|
+
drift-free carry-forward.
|
|
1022
|
+
|
|
1023
|
+
- **Construct:** `Timer.new(interval, repeating: true)`, in seconds. Add it with a
|
|
1024
|
+
name when a node needs several: `node.add_component(Timer.new(0.8), as: :spawn)`.
|
|
1025
|
+
`repeating: false` makes a **one-shot** that fires `on_timeout` exactly once, then
|
|
1026
|
+
goes inert. A projectile that should vanish after N seconds on a fixed board is
|
|
1027
|
+
`Timer.new(2.0, repeating: false)` plus `on_timeout { node.queue_free }`. When the
|
|
1028
|
+
board scrolls and the entity leaves the screen, use `DespawnOffscreen` instead.
|
|
1029
|
+
- **Signal:** `on_timeout` fires once per whole interval:
|
|
1030
|
+
`timer.on_timeout { spawn_enemy }`.
|
|
1031
|
+
- **Lifecycle:** `on_attach` restarts the countdown and re-arms a spent one-shot. A
|
|
1032
|
+
pooled node acquired and added again starts fresh, without its previous life's
|
|
1033
|
+
elapsed time.
|
|
1034
|
+
- **Phase:** `update(dt)` advances and emits. In one long step, a repeating timer
|
|
1035
|
+
emits once per interval crossed, catching up without drift. A one-shot emits at
|
|
1036
|
+
most once. It allocates nothing.
|
|
1037
|
+
- **Reset:** `reset` drops accumulated time and re-arms a one-shot, giving a fresh
|
|
1038
|
+
timer.
|
|
1039
|
+
|
|
1040
|
+
### `Velocity`
|
|
1041
|
+
|
|
1042
|
+
**Integrates linear and angular velocity into the node's transform each step.**
|
|
1043
|
+
|
|
1044
|
+
- **Construct:** `Velocity.new(vx: 0.0, vy: 0.0, spin: 0.0, blocked_by: [])`.
|
|
1045
|
+
[`Mover`](#mover) decides what may stop it, as for a `CharacterBody`.
|
|
1046
|
+
- **State:** `vx`, `vy` and `spin` are read/write. A controller, or the node's own
|
|
1047
|
+
`control` hook, writes them as movement intent.
|
|
1048
|
+
- **Phase:** `update(dt)` moves the node by `vx*dt` and `vy*dt` through `apply_move`.
|
|
1049
|
+
It adds `spin*dt` to `node.angle` directly: a collision box does not turn with its
|
|
1050
|
+
node, so nothing can block a rotation.
|
|
1051
|
+
- **Blocked:** a stopped step leaves `vx` and `vy` unchanged. They are the intent.
|
|
1052
|
+
The game decides what a stop does to them, whether nothing, zero or a bounce, in an
|
|
1053
|
+
`on_blocked` handler. To block a `ThrustController` ship, declare `blocked_by:` on
|
|
1054
|
+
the `Velocity` it drives.
|
|
1055
|
+
|
|
1056
|
+
A free-moving entity can use `Velocity` alone. Pair it with a controller for input.
|
|
333
1057
|
|
|
334
1058
|
```ruby
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
super(x: x, y: y)
|
|
339
|
-
add_component(RGame::Engine::Components::Velocity.new(vx: vx, vy: vy))
|
|
340
|
-
add_component(RGame::Engine::Components::DespawnOffscreen.new(**bounds))
|
|
341
|
-
collider = add_component(RGame::Engine::Components::CircleCollider.new(radius: 3, layer: :bullet))
|
|
342
|
-
collider.on_hit { |other| queue_free if other.layer == :rock }
|
|
343
|
-
end
|
|
344
|
-
end
|
|
1059
|
+
add_component(RGame::Engine::Components::BoxCollider.new(width: 8, height: 8, layer: :bullet))
|
|
1060
|
+
velocity = add_component(RGame::Engine::Components::Velocity.new(vx: 400, blocked_by: %i[tiles]))
|
|
1061
|
+
velocity.on_blocked { queue_free }
|
|
345
1062
|
```
|
|
1063
|
+
|
|
1064
|
+
### `WanderController`
|
|
1065
|
+
|
|
1066
|
+
**A simple AI driver for a `CharacterBody`.** At intervals it rolls a new direction,
|
|
1067
|
+
one of eight or idle, and holds it. A wall that blocks it triggers an early re-roll.
|
|
1068
|
+
The RNG is injected, so tests get deterministic behaviour.
|
|
1069
|
+
|
|
1070
|
+
- **Construct:** `WanderController.new(rng: Random.new, change_interval: 1.0..3.0, idle_chance: 0.25)`.
|
|
1071
|
+
- **Lifecycle:** `on_attach` looks up the node's `CharacterBody` with
|
|
1072
|
+
`require_sibling`.
|
|
1073
|
+
- **Phase:** `update(dt)` counts down and re-rolls on timeout or when blocked.
|
|
1074
|
+
"Blocked" means *the node did not move while intending to*. The controller measures
|
|
1075
|
+
that instead of asking a collision world. It therefore works over a plain
|
|
1076
|
+
`CharacterBody` too, and never re-rolls early for a body whose steps always land.
|
|
1077
|
+
- **Example:** `examples/save_load`.
|
|
1078
|
+
|
|
1079
|
+
### `World`
|
|
1080
|
+
|
|
1081
|
+
**The scene-scoped world system for a rectangular world**: it knows how big the
|
|
1082
|
+
world is, and nothing more. Mount one on a scene with a plain rectangular world. The
|
|
1083
|
+
components that need bounds find it, so no constructor between scene and entity has
|
|
1084
|
+
to pass the numbers along.
|
|
1085
|
+
|
|
1086
|
+
- **Construct:** `World.new(width:, height:)`.
|
|
1087
|
+
- **Queries:** `world_width` and `world_height`. They never change; a world of a
|
|
1088
|
+
different size is a new scene.
|
|
1089
|
+
- **Contract:** it includes `WorldBounds`, and so does [`TileWorld`](#tileworld),
|
|
1090
|
+
which answers the same two questions from its map's pixel size. Ask for the
|
|
1091
|
+
contract, `node.system(RGame::Engine::Components::WorldBounds)`, and either kind of
|
|
1092
|
+
world answers. `get_component` matches an included module the same way it matches a
|
|
1093
|
+
class.
|
|
1094
|
+
- **Frame:** the bounds run from (0, 0) to (`world_width`, `world_height`) in
|
|
1095
|
+
**world** coordinates. Everything that compares a node against them reads
|
|
1096
|
+
`world_x` and `world_y`. An entity under an offset container is inside the world
|
|
1097
|
+
exactly when its world position is.
|
|
1098
|
+
- **One response to the edge per node:** `WorldBounds.one_response!(node)` raises,
|
|
1099
|
+
naming both, if the node carries more than one of [`ScreenWrap`](#screenwrap),
|
|
1100
|
+
[`DespawnOffscreen`](#despawnoffscreen) and a [`Mover`](#mover) declaring
|
|
1101
|
+
`blocked_by: [:bounds]`. Each of the three calls it from `on_attach`, so whichever
|
|
1102
|
+
attaches second raises, in any add order. No pair makes sense. Stopping and
|
|
1103
|
+
wrapping disagree about where the node ends up. Wrapping and despawning race on
|
|
1104
|
+
which margin is reached first. Stopping tests the collision box, while the other
|
|
1105
|
+
two test the node's origin. The engine therefore refuses every combination.
|
|
1106
|
+
|
|
1107
|
+
**The world is not the window.** The two coincide in a single-screen game, which
|
|
1108
|
+
makes the mistake easy to make and hard to see. Bind wrapping to the viewport, and
|
|
1109
|
+
the world changes shape without warning when the window resizes or the screen
|
|
1110
|
+
splits into viewports. Ask [`Viewports`](scene_graph.md#viewports-and-views), or the
|
|
1111
|
+
`View` a `draw` receives, how big the *window* is. Ask `World` how big the *world*
|
|
1112
|
+
is.
|