rgame 0.1.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 +7 -0
- data/LICENSE +26 -0
- data/README.md +406 -0
- data/docs/api/README.md +167 -0
- data/docs/api/app.md +192 -0
- data/docs/api/assets.md +426 -0
- data/docs/api/audio.md +208 -0
- data/docs/api/components.md +321 -0
- data/docs/api/drawing.md +330 -0
- data/docs/api/game.md +99 -0
- data/docs/api/images.md +118 -0
- data/docs/api/input.md +179 -0
- data/docs/api/internals.md +110 -0
- data/docs/api/scene_graph.md +159 -0
- data/docs/api/signals.md +142 -0
- data/docs/api/systems.md +98 -0
- data/docs/api/text.md +116 -0
- data/docs/api/toolbox.md +240 -0
- data/docs/api/values.md +101 -0
- data/ext/README.md +225 -0
- data/ext/rgame_core/app/app.c +721 -0
- data/ext/rgame_core/app/app_gl.h +64 -0
- data/ext/rgame_core/app/frame_loop.c +42 -0
- data/ext/rgame_core/app/frame_loop.h +54 -0
- data/ext/rgame_core/audio/audio.c +466 -0
- data/ext/rgame_core/audio/audio_internal.h +45 -0
- data/ext/rgame_core/audio/vorbis_decoder.c +282 -0
- data/ext/rgame_core/audio/vorbis_decoder.h +45 -0
- data/ext/rgame_core/example.rb +188 -0
- data/ext/rgame_core/extconf.rb +167 -0
- data/ext/rgame_core/graphics/backend.c +52 -0
- data/ext/rgame_core/graphics/backend.h +64 -0
- data/ext/rgame_core/graphics/canvas.c +247 -0
- data/ext/rgame_core/graphics/canvas.h +143 -0
- data/ext/rgame_core/graphics/clip.c +87 -0
- data/ext/rgame_core/graphics/clip.h +89 -0
- data/ext/rgame_core/graphics/draw_queue.c +216 -0
- data/ext/rgame_core/graphics/draw_queue.h +174 -0
- data/ext/rgame_core/graphics/gl_backend.c +122 -0
- data/ext/rgame_core/graphics/gl_backend.h +43 -0
- data/ext/rgame_core/graphics/image.c +304 -0
- data/ext/rgame_core/graphics/image_internal.h +30 -0
- data/ext/rgame_core/graphics/primitives.c +189 -0
- data/ext/rgame_core/graphics/primitives.h +111 -0
- data/ext/rgame_core/graphics/recording.c +119 -0
- data/ext/rgame_core/graphics/recording.h +88 -0
- data/ext/rgame_core/graphics/texture.c +181 -0
- data/ext/rgame_core/graphics/texture.h +165 -0
- data/ext/rgame_core/graphics/transform.c +128 -0
- data/ext/rgame_core/graphics/transform.h +106 -0
- data/ext/rgame_core/include/rgame/core.h +577 -0
- data/ext/rgame_core/input/device_slots.c +103 -0
- data/ext/rgame_core/input/device_slots.h +93 -0
- data/ext/rgame_core/input/gamepad.c +145 -0
- data/ext/rgame_core/input/gamepad.h +63 -0
- data/ext/rgame_core/input/input.c +109 -0
- data/ext/rgame_core/input/input.h +99 -0
- data/ext/rgame_core/ruby/audio_ext.c +321 -0
- data/ext/rgame_core/ruby/core_ext.c +513 -0
- data/ext/rgame_core/ruby/core_ext.h +51 -0
- data/ext/rgame_core/ruby/font_ext.c +168 -0
- data/ext/rgame_core/ruby/image_ext.c +230 -0
- data/ext/rgame_core/ruby/recording_ext.c +186 -0
- data/ext/rgame_core/ruby/renderer_ext.c +376 -0
- data/ext/rgame_core/text/atlas.c +59 -0
- data/ext/rgame_core/text/atlas.h +85 -0
- data/ext/rgame_core/text/font.c +281 -0
- data/ext/rgame_core/text/font.h +139 -0
- data/ext/rgame_core/text/font_atlas.c +385 -0
- data/ext/rgame_core/text/font_internal.h +47 -0
- data/ext/rgame_core/text/glyph_cache.c +142 -0
- data/ext/rgame_core/text/glyph_cache.h +89 -0
- data/ext/rgame_core/vendor/README.md +159 -0
- data/ext/rgame_core/vendor/miniaudio.h +95864 -0
- data/ext/rgame_core/vendor/miniaudio_impl.c +62 -0
- data/ext/rgame_core/vendor/stb_image.h +7988 -0
- data/ext/rgame_core/vendor/stb_image_impl.c +31 -0
- data/ext/rgame_core/vendor/stb_truetype.h +5079 -0
- data/ext/rgame_core/vendor/stb_truetype_impl.c +23 -0
- data/ext/rgame_core/vendor/stb_vorbis.c +5584 -0
- data/ext/rgame_core/vendor/stb_vorbis_impl.c +29 -0
- data/ext/rgame_util/color.c +19 -0
- data/ext/rgame_util/color.h +60 -0
- data/ext/rgame_util/color_ext.c +156 -0
- data/ext/rgame_util/extconf.rb +27 -0
- data/ext/rgame_util/tensor.c +186 -0
- data/ext/rgame_util/util_ext.c +27 -0
- data/ext/rgame_util/util_ext.h +16 -0
- data/lib/rgame/boot.rb +13 -0
- data/lib/rgame/core/app.rb +82 -0
- data/lib/rgame/core/asset_manager.rb +224 -0
- data/lib/rgame/core/audio.rb +124 -0
- data/lib/rgame/core/font.rb +49 -0
- data/lib/rgame/core/gamepad.rb +55 -0
- data/lib/rgame/core/image.rb +55 -0
- data/lib/rgame/core/input.rb +77 -0
- data/lib/rgame/core/nine_slice.rb +163 -0
- data/lib/rgame/core/recording.rb +52 -0
- data/lib/rgame/core/renderer.rb +363 -0
- data/lib/rgame/core/sprite_sheet.rb +108 -0
- data/lib/rgame/core/tile_map_renderer.rb +160 -0
- data/lib/rgame/core/ui_atlas.rb +86 -0
- data/lib/rgame/core.rb +24 -0
- data/lib/rgame/engine/actor.rb +53 -0
- data/lib/rgame/engine/animation_set.rb +49 -0
- data/lib/rgame/engine/animator.rb +44 -0
- data/lib/rgame/engine/audio_bus.rb +24 -0
- data/lib/rgame/engine/audio_director.rb +29 -0
- data/lib/rgame/engine/body.rb +49 -0
- data/lib/rgame/engine/cached_label.rb +33 -0
- data/lib/rgame/engine/camera.rb +33 -0
- data/lib/rgame/engine/camera_view.rb +28 -0
- data/lib/rgame/engine/circle_collider.rb +32 -0
- data/lib/rgame/engine/collision_box.rb +34 -0
- data/lib/rgame/engine/collision_system.rb +44 -0
- data/lib/rgame/engine/component.rb +30 -0
- data/lib/rgame/engine/components/action_trigger.rb +41 -0
- data/lib/rgame/engine/components/animated_sprite.rb +63 -0
- data/lib/rgame/engine/components/character_body.rb +70 -0
- data/lib/rgame/engine/components/circle_collider.rb +44 -0
- data/lib/rgame/engine/components/collision_world.rb +103 -0
- data/lib/rgame/engine/components/despawn_offscreen.rb +26 -0
- data/lib/rgame/engine/components/path_follow.rb +84 -0
- data/lib/rgame/engine/components/player_controller.rb +24 -0
- data/lib/rgame/engine/components/pool.rb +53 -0
- data/lib/rgame/engine/components/screen_wrap.rb +27 -0
- data/lib/rgame/engine/components/sprite.rb +31 -0
- data/lib/rgame/engine/components/targeting.rb +54 -0
- data/lib/rgame/engine/components/thrust_controller.rb +65 -0
- data/lib/rgame/engine/components/tile_world.rb +68 -0
- data/lib/rgame/engine/components/timer.rb +75 -0
- data/lib/rgame/engine/components/velocity.rb +27 -0
- data/lib/rgame/engine/components/wander_controller.rb +60 -0
- data/lib/rgame/engine/debug_overlay.rb +106 -0
- data/lib/rgame/engine/i18n.rb +97 -0
- data/lib/rgame/engine/input/action_mapper.rb +46 -0
- data/lib/rgame/engine/input/actions.rb +41 -0
- data/lib/rgame/engine/input/player_controller.rb +14 -0
- data/lib/rgame/engine/matrix.rb +32 -0
- data/lib/rgame/engine/node2d.rb +271 -0
- data/lib/rgame/engine/path.rb +78 -0
- data/lib/rgame/engine/pool.rb +51 -0
- data/lib/rgame/engine/resettable.rb +67 -0
- data/lib/rgame/engine/scene/scene_stack.rb +65 -0
- data/lib/rgame/engine/signal.rb +75 -0
- data/lib/rgame/engine/spatial_hash.rb +71 -0
- data/lib/rgame/engine/tile_collision.rb +78 -0
- data/lib/rgame/engine/tile_map.rb +149 -0
- data/lib/rgame/engine/tileset.rb +101 -0
- data/lib/rgame/engine/timer.rb +51 -0
- data/lib/rgame/engine.rb +68 -0
- data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
- data/lib/rgame/fonts/OFL.txt +102 -0
- data/lib/rgame/game.rb +129 -0
- data/lib/rgame/util/color.rb +27 -0
- data/lib/rgame/util/controls.rb +107 -0
- data/lib/rgame/util/tensor.rb +12 -0
- data/lib/rgame/util.rb +8 -0
- data/lib/rgame/version.rb +12 -0
- data/lib/rgame.rb +20 -0
- metadata +215 -0
data/docs/api/audio.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Audio
|
|
2
|
+
|
|
3
|
+
```ruby
|
|
4
|
+
require 'rgame/core'
|
|
5
|
+
|
|
6
|
+
class MyGame < RGame::Core::App
|
|
7
|
+
def initialize
|
|
8
|
+
super(width: 800, height: 600, caption: 'demo')
|
|
9
|
+
@audio = RGame::Core::Audio.new
|
|
10
|
+
@hit = @audio.sample('assets/hit.ogg')
|
|
11
|
+
@music = @audio.song('assets/theme.ogg')
|
|
12
|
+
@music.play(looping: true)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def button_down(id)
|
|
16
|
+
@hit.play if id == RGame::Util::Controls::KEY_SPACE
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Three classes. `Audio` is the sound device; `Sample` is a short sound played
|
|
22
|
+
over itself; `Song` is a long one streamed from disk.
|
|
23
|
+
|
|
24
|
+
Ogg Vorbis and WAV are the formats. Nothing else — see [What is not
|
|
25
|
+
here](#what-is-not-here).
|
|
26
|
+
|
|
27
|
+
## The device
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
audio = RGame::Core::Audio.new
|
|
31
|
+
audio.backend # => "PulseAudio"
|
|
32
|
+
audio.volume # => 1.0
|
|
33
|
+
audio.volume = 0.8
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**It takes no app.** Sound is not tied to a window: it survives one being
|
|
37
|
+
resized or recreated, and there is no GL context involved. One device for the
|
|
38
|
+
program is the normal arrangement.
|
|
39
|
+
|
|
40
|
+
**A machine with no sound hardware still gets a working device.** It opens a
|
|
41
|
+
null backend and plays silently rather than raising, and `#backend` returns
|
|
42
|
+
`"Null"`. That is deliberate — a game should run on a CI runner, in a container,
|
|
43
|
+
or on a laptop with the sound card switched off, and crashing at startup over
|
|
44
|
+
something nobody asked for is the worse failure. If your game wants to know, ask
|
|
45
|
+
`#backend`; nothing else changes.
|
|
46
|
+
|
|
47
|
+
`#volume` is the master volume, multiplied into everything the device plays.
|
|
48
|
+
|
|
49
|
+
## Samples
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
hit = audio.sample('assets/hit.ogg')
|
|
53
|
+
hit.volume = 0.5
|
|
54
|
+
hit.play
|
|
55
|
+
hit.play # a second voice, over the first
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A sample is decoded once, into memory, and played as often as you like.
|
|
59
|
+
**Playing one that is already sounding layers another voice over it** rather
|
|
60
|
+
than restarting it — which is what makes a fast run of footsteps sound like
|
|
61
|
+
footsteps instead of one stuttering step.
|
|
62
|
+
|
|
63
|
+
There is no handle for an individual play, and no way to stop one: a sample is
|
|
64
|
+
fire-and-forget. Volume belongs to the sample and reaches every voice it has
|
|
65
|
+
out, including the ones already sounding.
|
|
66
|
+
|
|
67
|
+
Keep samples for short sounds. A sample holds its whole decoded length in
|
|
68
|
+
memory — measured at roughly 10 MB per minute of CD-quality stereo — so a music
|
|
69
|
+
track belongs in a `Song`.
|
|
70
|
+
|
|
71
|
+
Two samples loaded from the **same path** share one decoded copy, so loading a
|
|
72
|
+
file twice costs nothing the second time.
|
|
73
|
+
|
|
74
|
+
## Songs
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
music = audio.song('assets/theme.ogg')
|
|
78
|
+
music.play(looping: true)
|
|
79
|
+
music.playing? # => true
|
|
80
|
+
music.volume = 0.6
|
|
81
|
+
music.stop
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A song is streamed from the file as it plays, so a three-minute track costs a
|
|
85
|
+
buffer rather than forty megabytes.
|
|
86
|
+
|
|
87
|
+
**A song is one voice.** Playing one that is already playing restarts it from
|
|
88
|
+
the beginning; so does playing it after `stop`. There is no pause — `stop` then
|
|
89
|
+
`play` is "from the top", not "resume".
|
|
90
|
+
|
|
91
|
+
`#playing?` and `#looping?` report what the song was last told to do.
|
|
92
|
+
`#looping?` is the flag, not a count.
|
|
93
|
+
|
|
94
|
+
**"One song at a time" is your rule, not the engine's.** Two songs can play at
|
|
95
|
+
once, which is what a crossfade is; if a game wants only one, it stops the old
|
|
96
|
+
one before starting the new one.
|
|
97
|
+
|
|
98
|
+
## Playing by id
|
|
99
|
+
|
|
100
|
+
The same boundary drawing has: gameplay emits a fact and names the sound,
|
|
101
|
+
because a scene may not hold a `Sample`.
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
audio.register_sound(:hit, app.assets.sound('example 09/hurt.ogg'))
|
|
105
|
+
audio.register_music(:theme, app.assets.song('example 09/theme.ogg'))
|
|
106
|
+
|
|
107
|
+
audio.play_sound(:hit)
|
|
108
|
+
audio.play_music(:theme) # loops
|
|
109
|
+
audio.stop_music
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Registration only, unlike the renderer's draw-by-id: a sound id is whatever a
|
|
113
|
+
game wants to call it, and there is no per-frame path here to make resolving a
|
|
114
|
+
path worth caching. An unknown id is a `KeyError`.
|
|
115
|
+
|
|
116
|
+
**`play_music` is idempotent.** Asking for the track that is already playing
|
|
117
|
+
does nothing, so a scene that re-emits the request every time it is entered
|
|
118
|
+
never restarts the music mid-loop.
|
|
119
|
+
|
|
120
|
+
**`stop_music` stops the song *this registry* started.** Gosu had a
|
|
121
|
+
process-wide "current song"; there is no such global here, because one-song-at-
|
|
122
|
+
a-time is a game's policy rather than the engine's. A `Song` you started by hand
|
|
123
|
+
is yours to stop, and `stop_music` with nothing playing is a no-op.
|
|
124
|
+
|
|
125
|
+
## Loading and failure
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
audio.sample(path) # => RGame::Core::Sample
|
|
129
|
+
audio.song(path) # => RGame::Core::Song
|
|
130
|
+
|
|
131
|
+
RGame::Core::Sample.new(audio, path) # the same thing
|
|
132
|
+
RGame::Core::Song.new(audio, path)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Both forms exist. Prefer `audio.sample` — it reads in the direction the objects
|
|
136
|
+
depend, and a stand-in device can offer it while `Sample.new` cannot (see
|
|
137
|
+
[Testing](#testing)).
|
|
138
|
+
|
|
139
|
+
A file that cannot be read, or that is not a format the engine decodes, raises
|
|
140
|
+
`RGame::Core::Sample::LoadError` or `RGame::Core::Song::LoadError`, naming the
|
|
141
|
+
file. Both are `StandardError`, so an ordinary `rescue` catches them:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
@music = begin
|
|
145
|
+
audio.song('assets/theme.ogg')
|
|
146
|
+
rescue RGame::Core::Song::LoadError => e
|
|
147
|
+
warn "no music: #{e.message}"
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The decision is made by **content, not by extension** — a text file named
|
|
153
|
+
`.ogg` is refused.
|
|
154
|
+
|
|
155
|
+
## Volume
|
|
156
|
+
|
|
157
|
+
Every volume — the device's, a sample's, a song's — behaves the same way:
|
|
158
|
+
|
|
159
|
+
| Value | Effect |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `1.0` | unchanged, the default |
|
|
162
|
+
| `0.0` … `1.0` | quieter |
|
|
163
|
+
| above `1.0` | amplified; clipping is yours to avoid |
|
|
164
|
+
| below `0.0` | clamped to `0.0` |
|
|
165
|
+
|
|
166
|
+
Negative is clamped rather than refused because a fader driven by a slider or an
|
|
167
|
+
easing curve undershoots constantly, and silence is the meaningful answer — a
|
|
168
|
+
negative volume would phase-invert the samples, which is *louder*.
|
|
169
|
+
|
|
170
|
+
Volumes are 32-bit floats inside the mixer, so `0.8` reads back as
|
|
171
|
+
`0.800000011920929`. Compare with a tolerance, not with `==`.
|
|
172
|
+
|
|
173
|
+
## What it costs
|
|
174
|
+
|
|
175
|
+
Nothing needs freeing. A sample or a song releases what it holds when it is
|
|
176
|
+
collected, and each one keeps its device alive for as long as it exists — so
|
|
177
|
+
dropping your reference to the `Audio` while a sound is still around is safe,
|
|
178
|
+
in either order.
|
|
179
|
+
|
|
180
|
+
`RGame::Core::Audio.debug_live_sounds` reports how many samples and songs exist.
|
|
181
|
+
It is there for tests, not for gameplay.
|
|
182
|
+
|
|
183
|
+
## Testing
|
|
184
|
+
|
|
185
|
+
Audio follows the same pattern as drawing: the engine layer is handed a device
|
|
186
|
+
and calls it by method name, never by class, so a headless spec can substitute
|
|
187
|
+
one that makes no sound and records everything.
|
|
188
|
+
|
|
189
|
+
`spec/support/fake_audio.rb` is that stand-in, and
|
|
190
|
+
`spec/support/shared_examples/an_audio_server.rb` is the interface both it and
|
|
191
|
+
the real device are run against — so the fake cannot drift into describing an
|
|
192
|
+
engine that no longer exists.
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
audio = FakeAudio.new
|
|
196
|
+
audio.sample('hit.ogg').play
|
|
197
|
+
|
|
198
|
+
expect(audio.played?('hit.ogg')).to be(true)
|
|
199
|
+
expect(audio.calls.map(&:name)).to eq(%i[sample sample_play])
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Nothing there loads a file, opens a device, or needs a sound card.
|
|
203
|
+
|
|
204
|
+
## What is not here
|
|
205
|
+
|
|
206
|
+
MP3 and FLAC (Vorbis and WAV only, to keep the gem small), positional and 3D
|
|
207
|
+
audio, effects and filters, fades, pausing, seeking, per-play handles, playback
|
|
208
|
+
position, and recording.
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# Components
|
|
2
|
+
|
|
3
|
+
A **component** is a reusable piece of behaviour attached to a `Node2D`, instead of
|
|
4
|
+
baked into a node subclass. A node composes several of them; each knows its owning
|
|
5
|
+
`node` and is driven by the node's tick. Components live in `engine/components/` under
|
|
6
|
+
`RGame::Engine::Components` and subclass `RGame::Engine::Component`. See [Scene graph](scene_graph.md)
|
|
7
|
+
for how nodes drive components, and [Systems & shared resources](systems.md) for
|
|
8
|
+
components that act as shared, scene- or program-scoped services.
|
|
9
|
+
|
|
10
|
+
## The `Component` base
|
|
11
|
+
|
|
12
|
+
`RGame::Engine::Component` (`rgame/engine/component`) gives every component a `node` back-link and
|
|
13
|
+
a set of hooks the node calls — override the ones you need; the rest are no-ops. Like
|
|
14
|
+
nodes, it extends the signal DSL, so a component can declare and emit signals.
|
|
15
|
+
|
|
16
|
+
Per-tick hooks (a node runs its components in each phase, before its own hook and
|
|
17
|
+
before its children):
|
|
18
|
+
|
|
19
|
+
- `control(actions)` — read intent from the per-frame action snapshot.
|
|
20
|
+
- `update(dt)` — advance state over the timestep.
|
|
21
|
+
- `draw(renderer)` — render against the renderer interface.
|
|
22
|
+
|
|
23
|
+
Tree-lifecycle hooks (fired by the engine when the node enters/leaves the live tree —
|
|
24
|
+
this is where anchors and sibling systems are reachable, so do cross-node wiring here,
|
|
25
|
+
not in `initialize`):
|
|
26
|
+
|
|
27
|
+
- `on_attach` — the node entered the tree; pull and register with shared systems.
|
|
28
|
+
- `on_detach` — the node is leaving; release those registrations.
|
|
29
|
+
|
|
30
|
+
`sweep_freed` exists for container components that hold nodes off the normal child
|
|
31
|
+
list; the default is a no-op (see [deferred free](scene_graph.md#deferred-free)).
|
|
32
|
+
|
|
33
|
+
A node holds **at most one component per slot**. The slot defaults to the component's
|
|
34
|
+
class, so by default that's one per class (`add_component` raises on a taken slot) — but
|
|
35
|
+
pass `as: :name` to keep several of one type (a spawn timer and a wave timer). Look a
|
|
36
|
+
component up with `get_component(key)`, where `key` is a class (matched by ancestry; it
|
|
37
|
+
raises if several share the type) or a Symbol name.
|
|
38
|
+
|
|
39
|
+
## Available components
|
|
40
|
+
|
|
41
|
+
### `Velocity`
|
|
42
|
+
|
|
43
|
+
Integrates linear and angular velocity into the node's transform each step.
|
|
44
|
+
|
|
45
|
+
- **Construct:** `Velocity.new(vx: 0.0, vy: 0.0, spin: 0.0)`.
|
|
46
|
+
- **State:** `vx`, `vy`, `spin` are read/write accessors — a controller (or the node's
|
|
47
|
+
own `control` hook) writes them as movement intent.
|
|
48
|
+
- **Phase:** `update(dt)` adds `vx*dt`/`vy*dt` to `node.x`/`node.y` and `spin*dt` to
|
|
49
|
+
`node.angle`.
|
|
50
|
+
|
|
51
|
+
A free-moving entity can use `Velocity` alone; pair it with a controller for input.
|
|
52
|
+
|
|
53
|
+
### `PathFollow`
|
|
54
|
+
|
|
55
|
+
Walks the owning node along an [`RGame::Engine::Path`](toolbox.md#path--a-walkable-polyline)
|
|
56
|
+
at a constant speed and emits `on_finished` when it reaches the last waypoint — the seam a
|
|
57
|
+
tower-defense game uses to leak a life when an enemy reaches the base.
|
|
58
|
+
|
|
59
|
+
- **Construct:** `PathFollow.new(path:, speed:)`.
|
|
60
|
+
- **Lifecycle:** `on_attach` (re)starts the walk — back to the first waypoint with progress
|
|
61
|
+
cleared — so a pooled follower reacquired and re-added begins a fresh walk.
|
|
62
|
+
- **Signal:** `on_finished` fires once (no payload) at the end of the path —
|
|
63
|
+
`follow.on_finished { node.queue_free }`.
|
|
64
|
+
- **Phase:** `update(dt)` advances `speed * dt`, crossing as many segments as one step
|
|
65
|
+
spans and interpolating the node's position; allocation-free.
|
|
66
|
+
|
|
67
|
+
### `Timer`
|
|
68
|
+
|
|
69
|
+
A node-driven interval timer: it rides the node's update tick (so nothing can forget to
|
|
70
|
+
advance it) and emits `on_timeout` each time a whole interval elapses — a spawn cadence, a
|
|
71
|
+
tower's fire rate, a wave clock. Wraps the pure [`RGame::Engine::Timer`](toolbox.md#timer--paced-periodic-events),
|
|
72
|
+
reusing its drift-free carry-forward.
|
|
73
|
+
|
|
74
|
+
- **Construct:** `Timer.new(interval, repeating: true)` (seconds). Add it named when a node
|
|
75
|
+
needs several: `node.add_component(Timer.new(0.8), as: :spawn)`. `repeating: false` makes
|
|
76
|
+
it a **one-shot** — it fires `on_timeout` exactly once, then goes inert. The one-shot
|
|
77
|
+
replaces a dedicated "lifetime" component: a projectile that should vanish after N seconds
|
|
78
|
+
on a fixed board is `Timer.new(2.0, repeating: false)` + `on_timeout { node.queue_free }`
|
|
79
|
+
(use `DespawnOffscreen` instead when the board scrolls and the entity leaves the screen).
|
|
80
|
+
- **Signal:** `on_timeout` fires once per whole interval — `timer.on_timeout { spawn_enemy }`.
|
|
81
|
+
- **Lifecycle:** `on_attach` restarts the countdown (and re-arms a spent one-shot), so a
|
|
82
|
+
pooled node reacquired and re-added starts fresh rather than inheriting its previous
|
|
83
|
+
life's elapsed time.
|
|
84
|
+
- **Phase:** `update(dt)` advances and emits; a repeating timer emits once per interval
|
|
85
|
+
crossed in a single long step (catch-up, not drift), a one-shot at most once. Allocation-free.
|
|
86
|
+
- **Reset:** `reset` drops accumulated time and re-arms a one-shot — a fresh timer.
|
|
87
|
+
|
|
88
|
+
### `Pool`
|
|
89
|
+
|
|
90
|
+
Wraps an [`RGame::Engine::Pool`](toolbox.md#pool--reuse-dont-allocate) of nodes and folds the
|
|
91
|
+
tree bookkeeping into the frame tick, so a scene that recycles entities (enemies, projectiles)
|
|
92
|
+
writes no acquire/add/reclaim bridge of its own — just `spawn` and the ordinary `queue_free`.
|
|
93
|
+
Pooled nodes are **normal children** of the owner, so the scene's usual traversal updates and
|
|
94
|
+
draws them; this component only manages their pool membership.
|
|
95
|
+
|
|
96
|
+
- **Construct:** `Pool.new { Enemy.new(...) }` — the factory builds a blank node. Add it named
|
|
97
|
+
(`as:`) when a node needs more than one pool.
|
|
98
|
+
- **Spawn:** `pool.spawn` takes a node (recycled or freshly built) and adds it as a child;
|
|
99
|
+
`pool.spawn { |n| n.reset(...) }` runs the block to re-initialise it *before* it enters the
|
|
100
|
+
tree (so `on_attach` sees the reset state — the order projectiles need).
|
|
101
|
+
- **Reclaim:** `update(dt)` returns every freed pooled node to the free list, detaching any
|
|
102
|
+
still attached. So despawning is just `node.queue_free` anywhere; the pool recycles it with
|
|
103
|
+
no game-side wiring. Allocation-free in steady state.
|
|
104
|
+
|
|
105
|
+
### `Clickable`
|
|
106
|
+
|
|
107
|
+
Makes the owning node a world-space click target: on the click-down edge it hit-tests the
|
|
108
|
+
pointer against a circle of `radius` about the node's absolute origin and emits `on_clicked`
|
|
109
|
+
(no payload — the node identifies the click, like a button). It reads the pointer from the
|
|
110
|
+
Actions snapshot, so it assumes screen == world (a fixed, unscrolled board; a scrolling
|
|
111
|
+
camera would need the pointer unprojected first).
|
|
112
|
+
|
|
113
|
+
- **Construct:** `Clickable.new(radius:, action: :ui_click)`. The action must be bound to the
|
|
114
|
+
pointer button — `action_map: { ui_click: { button: %i[pointer] } }`.
|
|
115
|
+
- **Signal:** `on_clicked` fires once per press inside the radius —
|
|
116
|
+
`spot.on_clicked { build_tower }`.
|
|
117
|
+
- **Phase:** `control(actions)` hit-tests and emits; allocation-free. Add it named (`as:`)
|
|
118
|
+
when a node needs more than one click region.
|
|
119
|
+
|
|
120
|
+
### `ScreenWrap`
|
|
121
|
+
|
|
122
|
+
Wraps the node's position toroidally within a rectangle, so an entity leaving one edge
|
|
123
|
+
reappears on the opposite one.
|
|
124
|
+
|
|
125
|
+
- **Construct:** `ScreenWrap.new(width:, height:, margin: 0.0)` — `margin` lets a
|
|
126
|
+
sprite pass fully off one edge before reappearing on the other.
|
|
127
|
+
- **Phase:** `update(dt)` clamps-and-wraps `node.x`/`node.y` against the bounds.
|
|
128
|
+
|
|
129
|
+
### `DespawnOffscreen`
|
|
130
|
+
|
|
131
|
+
Removes the node once it has fully left the bounds (plus margin) — for short-lived
|
|
132
|
+
entities like projectiles.
|
|
133
|
+
|
|
134
|
+
- **Construct:** `DespawnOffscreen.new(width:, height:, margin: 0.0)`.
|
|
135
|
+
- **Phase:** `update(dt)` calls `node.queue_free` when the node is past every edge.
|
|
136
|
+
Removal is *deferred* (see [deferred free](scene_graph.md#deferred-free)), so it is
|
|
137
|
+
safe to trigger from inside the update traversal. For a *fixed* board (an entity that
|
|
138
|
+
never leaves the screen, e.g. a projectile that should vanish after N seconds), use a
|
|
139
|
+
one-shot [`Timer`](#timer) (`repeating: false`) with `on_timeout { node.queue_free }`
|
|
140
|
+
instead.
|
|
141
|
+
|
|
142
|
+
### `CircleCollider`
|
|
143
|
+
|
|
144
|
+
A circular collision shape that participates in a scene's
|
|
145
|
+
[`CollisionWorld`](#collisionworld). It registers itself when the node enters the tree
|
|
146
|
+
and unregisters when it leaves, so a spawned or despawned entity never leaks a
|
|
147
|
+
registration.
|
|
148
|
+
|
|
149
|
+
- **Construct:** `CircleCollider.new(radius:, layer: :default)`. `layer` is an opaque
|
|
150
|
+
tag the *owner* reads to decide what a contact means; the collision system itself is
|
|
151
|
+
layer-agnostic.
|
|
152
|
+
- **Lifecycle:** `on_attach` registers with `node.system(CollisionWorld)`; `on_detach`
|
|
153
|
+
unregisters.
|
|
154
|
+
- **Geometry:** `cx`/`cy` are the node's resolved absolute origin; `radius` is a
|
|
155
|
+
read/write accessor (so a pooled entity can retune its shape on reset — see
|
|
156
|
+
`ScreenWrap`/pooling), `layer` is a reader; `overlap?(other)` is the circle-vs-circle
|
|
157
|
+
test.
|
|
158
|
+
- **Signal:** `on_hit` fires with the other collider on each contact —
|
|
159
|
+
`collider.on_hit { |other| ... }`. The system triggers it via `emit_hit(other)`.
|
|
160
|
+
|
|
161
|
+
### `CollisionWorld`
|
|
162
|
+
|
|
163
|
+
A scene-scoped broadphase collision **system**: a component that lives on the scene
|
|
164
|
+
node, holds the registered colliders in a `SpatialHash`, and each step reports every
|
|
165
|
+
overlapping pair. Because it is a normal component it rides the `update` traversal and
|
|
166
|
+
is torn down with the scene. See [Systems & shared resources](systems.md).
|
|
167
|
+
|
|
168
|
+
- **Construct:** `CollisionWorld.new(cell_size:)` — the spatial-hash cell size (tune to
|
|
169
|
+
the typical collider size).
|
|
170
|
+
- **Registration:** `register(collider)` / `unregister(collider)`; colliders call these
|
|
171
|
+
through their own lifecycle, so nodes never wire this by hand.
|
|
172
|
+
- **Phase:** `update(dt)` rebuilds the spatial index and, for each overlapping pair,
|
|
173
|
+
fires both colliders' `on_hit`. It is **layer-agnostic** — it reports contacts and
|
|
174
|
+
lets each collider's owner decide meaning by reading the other's `layer`. Colliders
|
|
175
|
+
whose node is queued for removal are skipped.
|
|
176
|
+
- **Range queries (targeting):** the same index answers point-radius lookups against the
|
|
177
|
+
most recent `update`, so a tower can find enemies without a contact:
|
|
178
|
+
- `query_circle(x, y, r) { |collider| }` yields every registered collider whose centre
|
|
179
|
+
is within `r` of `(x, y)` (centre distance — the collider's own radius isn't added,
|
|
180
|
+
so it reads like a range ring); freed-node colliders are skipped, and a collider may
|
|
181
|
+
be yielded more than once (broadphase dedup contract — fine for selecting). Filter by
|
|
182
|
+
`collider.layer` in the block.
|
|
183
|
+
- `nearest(x, y, r, layer: nil)` returns the closest such collider (optionally limited
|
|
184
|
+
to one `layer`), or `nil`. Both are allocation-free, so a targeting component can call
|
|
185
|
+
them every frame.
|
|
186
|
+
|
|
187
|
+
### `Targeting`
|
|
188
|
+
|
|
189
|
+
Picks an enemy for the owning node (a tower) to aim at: each `update` it queries the
|
|
190
|
+
scene's [`CollisionWorld`](#collisionworld) around the node's world origin and exposes the
|
|
191
|
+
chosen target. It only *selects* — it never moves or fires; the owner reads `target` and
|
|
192
|
+
acts. Because enemies already register with the broadphase through their
|
|
193
|
+
[`CircleCollider`](#circlecollider), targeting keeps no entity list of its own.
|
|
194
|
+
|
|
195
|
+
- **Construct:** `Targeting.new(range:, policy: :nearest, layer: nil)`. `range` is the
|
|
196
|
+
reach in pixels; `layer` restricts candidates (a tower passes `:enemy`, so it ignores
|
|
197
|
+
other towers/projectiles); an unknown `policy` raises at construction.
|
|
198
|
+
- **Policies** (how to choose among the in-range candidates):
|
|
199
|
+
- `:nearest` — the closest enemy (the default; one broadphase nearest-lookup).
|
|
200
|
+
- **State:** `target` is the chosen enemy **node** (or `nil` when nothing is in range),
|
|
201
|
+
refreshed every `update` — so a freed/out-of-range target clears on its own. It's a node
|
|
202
|
+
(not a collider) so the owner can read its position and components.
|
|
203
|
+
- **Lifecycle:** `on_attach` pulls the scene's `CollisionWorld`.
|
|
204
|
+
- **Phase:** `update(dt)` re-selects the target; allocation-free, so it runs every frame.
|
|
205
|
+
|
|
206
|
+
### `Sprite`
|
|
207
|
+
|
|
208
|
+
Draws a single registered image centered on the node's absolute origin.
|
|
209
|
+
|
|
210
|
+
- **Construct:** `Sprite.new(id:, scale: 1.0, z: 0)` — `id` is a renderer image id; `z`
|
|
211
|
+
is the render layer (distinct from the node's transform `z`/`abs_z`).
|
|
212
|
+
- **State:** `scale` is a read/write accessor (a pooled entity can retune it).
|
|
213
|
+
- **Phase:** `draw(renderer)` draws the image with **no angle** — `Node2D#draw` already
|
|
214
|
+
wraps a node's own draws in `renderer.rotated(abs_angle, …)`, so the node's rotation
|
|
215
|
+
orients the sprite; passing an angle here would rotate it twice.
|
|
216
|
+
|
|
217
|
+
### `ThrustController`
|
|
218
|
+
|
|
219
|
+
Inertial "ship" flight on top of a `Velocity` sibling: a turn axis rotates the node and
|
|
220
|
+
a thrust axis accelerates it along its heading.
|
|
221
|
+
|
|
222
|
+
- **Construct:** `ThrustController.new(turn_speed:, accel:, max_speed:, drag: 0.0,
|
|
223
|
+
turn_action: :turn, thrust_action: :thrust)`.
|
|
224
|
+
- **Lifecycle:** `on_attach` pulls the node's `Velocity` component.
|
|
225
|
+
- **Phase:** `control(actions)` reads intent (turn → `velocity.spin`, thrust stored);
|
|
226
|
+
`update(dt)` accelerates along the heading (angle 0 = up, so forward is
|
|
227
|
+
`(sin θ, −cos θ)`), applies drag, and clamps to `max_speed`. Firing is intentionally
|
|
228
|
+
not here.
|
|
229
|
+
|
|
230
|
+
### `ActionTrigger`
|
|
231
|
+
|
|
232
|
+
Maps held input actions to an `on_triggered(action)` signal, rate-limited by a per-action
|
|
233
|
+
cooldown. One instance covers several actions (the engine allows one component per class
|
|
234
|
+
per node), so it emits the action name and lets listeners filter — reusable for "fire"
|
|
235
|
+
here, or "jump"/"fire" in a platformer.
|
|
236
|
+
|
|
237
|
+
- **Construct:** `ActionTrigger.new(cooldowns)` where `cooldowns` is `{ action => seconds }`,
|
|
238
|
+
e.g. `ActionTrigger.new(fire: 0.22)`.
|
|
239
|
+
- **Signal:** `on_triggered` fires with the action name — `trigger.on_triggered { |a| … }`.
|
|
240
|
+
- **Phase:** `update(dt)` ticks the per-action cooldowns; `control(actions)` emits when an
|
|
241
|
+
action is held and its cooldown has elapsed (held + cooldown = auto-repeat).
|
|
242
|
+
|
|
243
|
+
### `AnimatedSprite`
|
|
244
|
+
|
|
245
|
+
Draws a sprite-sheet animation and picks the animation from a [`CharacterBody`](#characterbody)
|
|
246
|
+
sibling's movement: `walk_left`/`walk_right`/`walk_up`/`walk_down` while moving (horizontal wins
|
|
247
|
+
on a diagonal), `stand` when still. Owns an `RGame::Engine::Animator` over the pure `AnimationSet` built
|
|
248
|
+
from the sheet's animation table.
|
|
249
|
+
|
|
250
|
+
- **Construct:** `AnimatedSprite.new(sheet:, z: 0)` — `sheet` is the asset's relative path; `z` the
|
|
251
|
+
render layer.
|
|
252
|
+
- **Lifecycle:** `on_attach` resolves the sheet from the game's asset manager
|
|
253
|
+
(`node.root.context.assets.sheet(sheet)`), builds its animation set, **sizes the node** to the
|
|
254
|
+
sheet's frame (`node.width`/`height`, so a `CharacterBody` sibling can read them), and pulls that
|
|
255
|
+
sibling (the facing source). The renderer resolves the same path when drawing, so nothing is
|
|
256
|
+
registered or passed in by hand.
|
|
257
|
+
- **Phase:** `update(dt)` selects + advances the animation; `draw` renders the current frame via
|
|
258
|
+
`renderer.sprite` at the node's **world** origin (`abs_x`/`abs_y`) with no angle — a
|
|
259
|
+
[`CameraView`](scene_graph.md) ancestor applies the camera offset, so the component never touches
|
|
260
|
+
the camera. (`Sprite` above is the single-image counterpart.)
|
|
261
|
+
|
|
262
|
+
### `CharacterBody`
|
|
263
|
+
|
|
264
|
+
Collision-checked walking for a tile-bound actor. A controller writes a per-step movement intent
|
|
265
|
+
(each axis −1..1); the body turns it into a real move each `update`, resolved against the scene's
|
|
266
|
+
[`TileWorld`](#tileworld) so the actor slides along walls and stays in the map. Unlike `Velocity`
|
|
267
|
+
(which integrates blindly), every step here is collision-checked.
|
|
268
|
+
|
|
269
|
+
- **Construct:** `CharacterBody.new(feet_width:, feet_height:, speed:)` — the feet box size (in px)
|
|
270
|
+
and walk speed (px/s). No sprite size is passed: `collision_box` is built lazily from the node's
|
|
271
|
+
`width`/`height` (which `AnimatedSprite` sets), centred horizontally and bottom-anchored. A body
|
|
272
|
+
with no sprite must set the node's dimensions itself.
|
|
273
|
+
- **State:** `set_intent(x, y)` writes the step's intent; `move_x`/`move_y` read it back (the facing
|
|
274
|
+
for `AnimatedSprite`).
|
|
275
|
+
- **Lifecycle:** `on_attach` caches the scene's `TileWorld`.
|
|
276
|
+
- **Phase:** `update(dt)` moves `intent * speed * dt` through the tile world (nothing when the
|
|
277
|
+
intent is zero).
|
|
278
|
+
|
|
279
|
+
### `PlayerController`
|
|
280
|
+
|
|
281
|
+
Drives a `CharacterBody` sibling from two input axes — direct 8-way walking, no inertia (unlike
|
|
282
|
+
`ThrustController`).
|
|
283
|
+
|
|
284
|
+
- **Construct:** `PlayerController.new(x_axis: :move_x, y_axis: :move_y)`.
|
|
285
|
+
- **Phase:** `control(actions)` copies the two axes into the body's intent.
|
|
286
|
+
|
|
287
|
+
### `WanderController`
|
|
288
|
+
|
|
289
|
+
A simple AI driver for a `CharacterBody`: every so often it rolls a new direction (one of eight, or
|
|
290
|
+
idle) and holds it, re-rolling early when a wall blocks it. The RNG is injected, so behaviour is
|
|
291
|
+
deterministic in tests.
|
|
292
|
+
|
|
293
|
+
- **Construct:** `WanderController.new(rng: Random.new, change_interval: 1.0..3.0, idle_chance: 0.25)`.
|
|
294
|
+
- **Phase:** `update(dt)` counts down the timer and re-rolls on timeout or when blocked.
|
|
295
|
+
|
|
296
|
+
### `TileWorld`
|
|
297
|
+
|
|
298
|
+
The scene-scoped tile **system** (see [Systems](systems.md)): it holds the parsed `RGame::Engine::TileMap`
|
|
299
|
+
and answers everything an actor needs from it — collision against the solid tiles (reusing
|
|
300
|
+
`RGame::Engine::CollisionSystem`), the world bounds, and drawing the map through the scene's camera. Found
|
|
301
|
+
with `node.system(TileWorld)`.
|
|
302
|
+
|
|
303
|
+
- **Construct:** `TileWorld.new(map:, tilemap_id:, camera:)`.
|
|
304
|
+
- **Queries:** `move(actor, dx, dy)` slides an actor (anything responding to `x`/`y`/`collision_box`)
|
|
305
|
+
along solids and clamps it to the world; `solid?(col, row)`; `world_width`/`world_height`.
|
|
306
|
+
- **Phase:** `draw(renderer)` draws the below band at `GROUND_Z` and the above band at `OVERLAY_Z`
|
|
307
|
+
(canopies/roofs). Actors draw at a z in between, so the renderer's z-sort composites ground < actors <
|
|
308
|
+
canopy regardless of draw-call order.
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
# A node composing components, with collision meaning decided by the owner:
|
|
312
|
+
class Bullet < RGame::Engine::Node2D
|
|
313
|
+
def initialize(x:, y:, vx:, vy:, bounds:)
|
|
314
|
+
super(x: x, y: y)
|
|
315
|
+
add_component(RGame::Engine::Components::Velocity.new(vx: vx, vy: vy))
|
|
316
|
+
add_component(RGame::Engine::Components::DespawnOffscreen.new(**bounds))
|
|
317
|
+
collider = add_component(RGame::Engine::Components::CircleCollider.new(radius: 3, layer: :bullet))
|
|
318
|
+
collider.on_hit { |other| queue_free if other.layer == :rock }
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
```
|