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/input.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# Input
|
|
2
|
+
|
|
3
|
+
Three pieces work together:
|
|
4
|
+
|
|
5
|
+
- **`RGame::Util::Controls`** — the vocabulary: which number means "the left
|
|
6
|
+
arrow key", "the A button", "player 2's controller". Plain values, usable
|
|
7
|
+
without loading any graphics library.
|
|
8
|
+
- **`RGame::Core::Input`** — translates your game's *actions* (`:fire`,
|
|
9
|
+
`:confirm`) into those ids and asks the app whether they are held.
|
|
10
|
+
- **`RGame::Core::Gamepad`** — which controllers are plugged in, for menus.
|
|
11
|
+
|
|
12
|
+
There is no mouse support, by design.
|
|
13
|
+
|
|
14
|
+
## `RGame::Core::Input`
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
input = RGame::Core::Input.new(app)
|
|
18
|
+
|
|
19
|
+
input.down?(:fire) # keyboard — the single-player default
|
|
20
|
+
input.down?(:fire, device: pad) # a specific controller
|
|
21
|
+
input.axis(:move_x, device: pad) # => Float
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`down?` and `axis` read a snapshot the engine takes **once per frame**, when it
|
|
25
|
+
pumps events. That is what makes them safe to call from `update`: a frame can
|
|
26
|
+
run several simulation ticks, and every tick sees the same answer. Reading
|
|
27
|
+
hardware directly would make a held key behave differently depending on how
|
|
28
|
+
slow the previous frame was.
|
|
29
|
+
|
|
30
|
+
### Actions, not keys
|
|
31
|
+
|
|
32
|
+
You ask for `:fire`, not for the space bar. The mapping lives in a binding
|
|
33
|
+
table, so the same game code works for a keyboard player and a controller
|
|
34
|
+
player:
|
|
35
|
+
|
|
36
|
+
| Action | Keyboard | Gamepad |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `:left` `:right` `:up` `:down` | arrow keys | dpad |
|
|
39
|
+
| `:confirm` | Return | A |
|
|
40
|
+
| `:fire` | Space | A |
|
|
41
|
+
|
|
42
|
+
Asking for an action nothing is bound to raises `KeyError`.
|
|
43
|
+
|
|
44
|
+
### Devices
|
|
45
|
+
|
|
46
|
+
Device 0 is the keyboard, and it is the default — so single-player code never
|
|
47
|
+
mentions devices at all. Controllers follow, one per player slot:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
Controls = RGame::Util::Controls
|
|
51
|
+
|
|
52
|
+
Controls::KEYBOARD # => 0
|
|
53
|
+
Controls.gamepad(0) # the first controller
|
|
54
|
+
Controls.gamepad(1) # the second
|
|
55
|
+
Controls::MAX_GAMEPADS # how many slots exist
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A device only answers for its own kind of input. Asking a gamepad about a
|
|
59
|
+
keyboard key is `false`, never the keyboard's answer — otherwise player two's
|
|
60
|
+
pad would echo player one. The keyboard has no axes, so `axis` on it is `0.0`.
|
|
61
|
+
|
|
62
|
+
### Rebinding
|
|
63
|
+
|
|
64
|
+
A binding table is just a Hash of action to id, and the ids are ordinary values
|
|
65
|
+
from `Util`, so a game can build its own and hand it over:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
controls = RGame::Util::Controls
|
|
69
|
+
bindings = controls::DEFAULT_KEYBOARD.merge(fire: controls::KEY_RETURN)
|
|
70
|
+
|
|
71
|
+
input = RGame::Core::Input.new(app, bindings: bindings)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`Input.new` accepts three optional tables:
|
|
75
|
+
|
|
76
|
+
| Keyword | Default | Used for |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `bindings:` | `Controls::DEFAULT_KEYBOARD` | keyboard buttons |
|
|
79
|
+
| `pad_bindings:` | `Controls::DEFAULT_PAD` | controller buttons |
|
|
80
|
+
| `axis_bindings:` | `Controls::DEFAULT_AXES` | analog axes |
|
|
81
|
+
|
|
82
|
+
The defaults are frozen, so `merge` a copy rather than mutating them.
|
|
83
|
+
|
|
84
|
+
## `RGame::Util::Controls`
|
|
85
|
+
|
|
86
|
+
The id vocabulary. Available from `require 'rgame'` **and** from
|
|
87
|
+
`require 'rgame/core'`, because these are plain integers with nothing behind
|
|
88
|
+
them — a game's configuration screen can name a key without pulling in a window.
|
|
89
|
+
|
|
90
|
+
**Keys** — `KEY_LEFT`, `KEY_RIGHT`, `KEY_UP`, `KEY_DOWN`, `KEY_RETURN`,
|
|
91
|
+
`KEY_SPACE`, `KEY_ESCAPE`, `KEY_F1`.
|
|
92
|
+
|
|
93
|
+
**Gamepad buttons** — `PAD_A`, `PAD_B`, `PAD_X`, `PAD_Y`, `PAD_BACK`,
|
|
94
|
+
`PAD_GUIDE`, `PAD_START`, `PAD_LEFT_STICK`, `PAD_RIGHT_STICK`,
|
|
95
|
+
`PAD_LEFT_SHOULDER`, `PAD_RIGHT_SHOULDER`, `PAD_DPAD_UP`, `PAD_DPAD_DOWN`,
|
|
96
|
+
`PAD_DPAD_LEFT`, `PAD_DPAD_RIGHT`.
|
|
97
|
+
|
|
98
|
+
**Axes** — `AXIS_LEFT_X`, `AXIS_LEFT_Y`, `AXIS_RIGHT_X`, `AXIS_RIGHT_Y`,
|
|
99
|
+
`AXIS_TRIGGER_LEFT`, `AXIS_TRIGGER_RIGHT`. Sticks read −1.0 to 1.0 with **y
|
|
100
|
+
positive downwards**; triggers read 0.0 to 1.0. No dead zone is applied — where
|
|
101
|
+
to put one is a game decision, and a resting stick genuinely does report small
|
|
102
|
+
non-zero values.
|
|
103
|
+
|
|
104
|
+
**Devices** — `KEYBOARD`, `GAMEPAD_FIRST`, `MAX_GAMEPADS`, and
|
|
105
|
+
`Controls.gamepad(slot)`.
|
|
106
|
+
|
|
107
|
+
**Default tables** — `DEFAULT_KEYBOARD`, `DEFAULT_PAD`, `DEFAULT_AXES`.
|
|
108
|
+
|
|
109
|
+
Buttons and keys share one numbering, partitioned into ranges, so a single
|
|
110
|
+
"is it held" query serves every device. You never need the numbers themselves —
|
|
111
|
+
use the constants.
|
|
112
|
+
|
|
113
|
+
## `RGame::Core::Gamepad`
|
|
114
|
+
|
|
115
|
+
A readout for menus — "Player 2: connect a controller". Reading a *button* goes
|
|
116
|
+
through `Input`; this answers what is plugged in.
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
pads = RGame::Core::Gamepad.new(app)
|
|
120
|
+
|
|
121
|
+
pads.count # how many are connected
|
|
122
|
+
pads.max_slots # how many slots exist
|
|
123
|
+
pads.connected?(0) # is slot 0 filled?
|
|
124
|
+
pads.name(0) # => "Xbox Controller", or nil
|
|
125
|
+
pads.device(0) # the id Input wants for that slot
|
|
126
|
+
pads.each_connected { |slot, name| ... } # lowest slot first
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`device(slot)` is the bridge to `Input`: a menu that has just found a pad can
|
|
130
|
+
drive it without knowing how devices are numbered.
|
|
131
|
+
|
|
132
|
+
Out-of-range slots answer rather than raising, so a UI loop needs no bounds
|
|
133
|
+
checks.
|
|
134
|
+
|
|
135
|
+
### Slots are stable across a replug
|
|
136
|
+
|
|
137
|
+
A controller that falls out and comes back returns to the **same** slot, so
|
|
138
|
+
player 2 stays player 2. The engine remembers which device last occupied each
|
|
139
|
+
slot; a genuinely new controller takes the lowest free one.
|
|
140
|
+
|
|
141
|
+
Two identical controllers report the same hardware id, so "the slot that
|
|
142
|
+
remembers this controller" is ambiguous for them. The rule resolves it the way
|
|
143
|
+
a player expects: two matching pads take slots 0 and 1, and whichever is
|
|
144
|
+
unplugged gets its own slot back when it returns.
|
|
145
|
+
|
|
146
|
+
## Reacting to hot-plug
|
|
147
|
+
|
|
148
|
+
Polling with `Gamepad` answers "what is connected now". The `App` hooks tell you
|
|
149
|
+
when that changes:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
class MyGame < RGame::Core::App
|
|
153
|
+
def initialize
|
|
154
|
+
super(width: 800, height: 600, caption: 'demo')
|
|
155
|
+
@pads = RGame::Core::Gamepad.new(self)
|
|
156
|
+
@input = RGame::Core::Input.new(self)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def gamepad_connected(slot)
|
|
160
|
+
puts "controller in slot #{slot}: #{@pads.name(slot)}"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def gamepad_disconnected(slot)
|
|
164
|
+
puts "controller left slot #{slot}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Read whichever device player one currently has.
|
|
168
|
+
def frame_begin
|
|
169
|
+
@device = @pads.connected?(0) ? @pads.device(0) : RGame::Util::Controls::KEYBOARD
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def update(_dt)
|
|
173
|
+
@moving_left = @input.down?(:left, device: @device)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
A controller unplugged mid-press has its buttons and axes cleared, so a button
|
|
179
|
+
held at that moment does not stay stuck down.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Internal building blocks
|
|
2
|
+
|
|
3
|
+
Low-level, pure-Ruby classes the engine's [components](components.md) and
|
|
4
|
+
[systems](systems.md) are built on. A game author rarely constructs these directly —
|
|
5
|
+
they sit *behind* a component (a `CharacterBody` resolves through `CollisionSystem`, an
|
|
6
|
+
`AnimatedSprite` plays through an `Animator`, a `CollisionWorld` indexes through a
|
|
7
|
+
`SpatialHash`) — but they are documented here because they carry the load-bearing
|
|
8
|
+
algorithms and are the seams the component tests drive. None `require "gosu"`.
|
|
9
|
+
|
|
10
|
+
For the helpers a game *does* reach for directly (pools, localization, the camera,
|
|
11
|
+
collision boxes, …), see [Utilities](toolbox.md).
|
|
12
|
+
|
|
13
|
+
## `SpatialHash` — uniform-grid broadphase
|
|
14
|
+
|
|
15
|
+
`RGame::Engine::SpatialHash` (`rgame/engine/spatial_hash`) is a broadphase index for collision:
|
|
16
|
+
bucket colliders into fixed-size grid cells, then test only candidates that share a
|
|
17
|
+
cell instead of every pair. It is the index inside
|
|
18
|
+
[`CollisionWorld`](components.md#collisionworld).
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
hash = RGame::Engine::SpatialHash.new(cell_size: 64)
|
|
22
|
+
hash.clear # reuse bucket arrays, keep capacity
|
|
23
|
+
rocks.each { |r| hash.insert(r, *r.aabb) } # insert the static set
|
|
24
|
+
hash.query(*bullet.aabb) { |rock| ...narrowphase... }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Typical per-frame use is `clear`, `insert` every collider of one set, then `query`
|
|
28
|
+
around each moving collider. Both `insert` and `query` take an AABB (`x, y, w, h`);
|
|
29
|
+
an item spanning several cells is inserted into each and so **may be yielded more than
|
|
30
|
+
once** by `query`. That dedup is deliberately the narrowphase caller's job — guard with
|
|
31
|
+
`next if a.dead? || b.dead?` to keep hits idempotent — which lets the hash skip a
|
|
32
|
+
per-query visited set and stay allocation-free. Cell keys are packed into a single
|
|
33
|
+
tagged Fixnum (with an offset so off-screen / mid-wrap negative cells stay
|
|
34
|
+
non-negative), so keying allocates nothing either.
|
|
35
|
+
|
|
36
|
+
`query_circle(cx, cy, r, &)` is the radial counterpart to `query`: it yields the items
|
|
37
|
+
in the cells the circle's bounding box covers (delegating to the same cell walk), for
|
|
38
|
+
range and nearest lookups. It is still broadphase — it carries the same may-yield-twice
|
|
39
|
+
contract, and the caller refines candidates by true distance (see
|
|
40
|
+
[`CollisionWorld`](components.md#collisionworld)'s `query_circle`/`nearest`).
|
|
41
|
+
|
|
42
|
+
## `TileCollision` — axis-separated AABB-vs-tile resolution
|
|
43
|
+
|
|
44
|
+
`RGame::Engine::TileCollision` (`rgame/engine/tile_collision`) resolves an axis-aligned box against a
|
|
45
|
+
grid of solid tiles. `solid` is a callable `solid.call(col, row) -> bool`, so the tile
|
|
46
|
+
source is decoupled (a `TileMap`, a fake in tests). It moves the box **one axis at a
|
|
47
|
+
time** — `resolve_x` then `resolve_y` (fed the resolved x) — which gives wall-sliding: a
|
|
48
|
+
diagonal push into a wall keeps the component that's still free.
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
tiles = RGame::Engine::TileCollision.new(tile_width: 16, tile_height: 16,
|
|
52
|
+
solid: ->(col, row) { map.solid_tile?(col, row) })
|
|
53
|
+
nx = tiles.resolve_x(x, y, w, h, dx) # snaps flush against a solid in the dx direction
|
|
54
|
+
ny = tiles.resolve_y(nx, y, w, h, dy)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
It assumes per-step movement smaller than a tile (no tunneling), which holds for the
|
|
58
|
+
engine's speeds. It is the maths inside [`CollisionSystem`](#collisionsystem--move-an-actor-against-the-tiles-and-the-world).
|
|
59
|
+
|
|
60
|
+
## `CollisionSystem` — move an actor against the tiles and the world
|
|
61
|
+
|
|
62
|
+
`RGame::Engine::CollisionSystem` (`rgame/engine/collision_system`) wraps `TileCollision` with a
|
|
63
|
+
world-bounds clamp and an actor-facing `move`. It is what
|
|
64
|
+
[`TileWorld`](components.md#tileworld) delegates to (and what a `CharacterBody` moves
|
|
65
|
+
through).
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
collision = RGame::Engine::CollisionSystem.new(
|
|
69
|
+
tile_collision: tiles, world_width: map.pixel_width, world_height: map.pixel_height
|
|
70
|
+
)
|
|
71
|
+
collision.move(actor, dx, dy) # actor responds to x / y / x= / y= / collision_box
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`move` reads the actor's [`CollisionBox`](toolbox.md#collisionbox--an-actors-feet-box)
|
|
75
|
+
AABB, resolves it through `TileCollision` on both axes, clamps the box inside the world
|
|
76
|
+
as a backstop, and writes the resolved position back to the actor (accounting for the
|
|
77
|
+
box's offset from the sprite origin).
|
|
78
|
+
|
|
79
|
+
## `AnimationSet` — pure frame maths
|
|
80
|
+
|
|
81
|
+
`RGame::Engine::AnimationSet` (`rgame/engine/animation_set`) turns an atlas's animation table plus an
|
|
82
|
+
elapsed time into the sprite-sheet cell to show — no renderer, no images, fully testable.
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
set = RGame::Engine::AnimationSet.new(
|
|
86
|
+
stand: { row: 0, col: 1, frames: 1, fps: 1 },
|
|
87
|
+
walk_right: { row: 1, frames: 3, fps: 6 }
|
|
88
|
+
)
|
|
89
|
+
set.frame(:walk_right, elapsed) # => [row, col, flip_x]
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Each animation is `{ row:, col: (start column, default 0), frames:, fps:, flip_x: }`.
|
|
93
|
+
`frame(name, elapsed)` advances `frames` columns from `col` at `fps`, wrapping — so a
|
|
94
|
+
held animation cycles.
|
|
95
|
+
|
|
96
|
+
## `Animator` — animation playback state
|
|
97
|
+
|
|
98
|
+
`RGame::Engine::Animator` (`rgame/engine/animator`) owns the playback state on top of an
|
|
99
|
+
`AnimationSet`: the current animation name and elapsed time. It is what
|
|
100
|
+
[`AnimatedSprite`](components.md#animatedsprite) drives.
|
|
101
|
+
|
|
102
|
+
```ruby
|
|
103
|
+
animator = RGame::Engine::Animator.new(set, initial: :stand)
|
|
104
|
+
animator.play(:walk_right) # switch (a no-op if already playing, so a walk keeps cycling)
|
|
105
|
+
animator.update(dt) # advance elapsed time
|
|
106
|
+
animator.frame # => [row, col, flip_x] for the current animation now
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`play` only restarts elapsed time on an actual change, so calling it every frame with the
|
|
110
|
+
current intent keeps a continuing walk smooth rather than stuttering on frame 0.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Scene graph
|
|
2
|
+
|
|
3
|
+
The engine builds a game out of a tree of nodes — a classic scene graph. A node
|
|
4
|
+
holds state, logic and drawing for one game object; nesting nodes builds up whole
|
|
5
|
+
scenes. Everything is pure Ruby: nodes draw through the renderer interface and
|
|
6
|
+
read input from a per-frame snapshot, never naming a graphics library at all.
|
|
7
|
+
|
|
8
|
+
## Node2D
|
|
9
|
+
|
|
10
|
+
`RGame::Engine::Node2D` (`engine/node2d`) is the basic building block. (The `2D` in the
|
|
11
|
+
name leaves room for a future 3D node; today everything is 2D.) A node carries:
|
|
12
|
+
|
|
13
|
+
- a **transform** — relative `x`, `y`, `z` plus `width`/`height`;
|
|
14
|
+
- **children** — other nodes nested under it (`add_node`);
|
|
15
|
+
- **components** — reusable pieces of behaviour attached to it (`add_component`);
|
|
16
|
+
- a **parent** — the node it hangs off (set automatically when it is added).
|
|
17
|
+
|
|
18
|
+
Nodes extend the signal DSL (`RGame::Engine::Signal::DSL`), so any subclass can declare
|
|
19
|
+
and emit signals without opting in. See [Signals](signals.md).
|
|
20
|
+
|
|
21
|
+
### The tick: control → update → draw
|
|
22
|
+
|
|
23
|
+
A node is driven in three phases, run in this order every frame:
|
|
24
|
+
|
|
25
|
+
1. `control(actions)` — read intent, both from the player (the `actions`
|
|
26
|
+
snapshot) and from AI/scripted controllers.
|
|
27
|
+
2. `update(dt)` — advance game logic and physics over the timestep `dt`.
|
|
28
|
+
3. `draw(renderer)` — render the current visual state.
|
|
29
|
+
|
|
30
|
+
Each phase **settles the node itself first — its components, then its own hook —
|
|
31
|
+
and only then descends into the children**. So you override the hook, not the
|
|
32
|
+
phase itself:
|
|
33
|
+
|
|
34
|
+
- `on_control(actions)`
|
|
35
|
+
- `on_update(dt)`
|
|
36
|
+
- `on_draw(renderer)`
|
|
37
|
+
|
|
38
|
+
Self-before-subtree keeps the transform flowing downward: a component or hook
|
|
39
|
+
that moves the node does so before its children resolve their origin from it (see
|
|
40
|
+
[Absolute position](#absolute-position)).
|
|
41
|
+
|
|
42
|
+
Because the traversal recurses into children for you, **never re-implement child
|
|
43
|
+
iteration** — add children with `add_node` and let the tree drive them.
|
|
44
|
+
|
|
45
|
+
### Absolute position
|
|
46
|
+
|
|
47
|
+
`x`/`y`/`z` are **relative to the parent**. At the start of each phase a node
|
|
48
|
+
resolves its absolute position by accumulating onto the parent's origin
|
|
49
|
+
(`abs_x = parent.abs_x + x`, and likewise for `y`/`z`); a node with no parent
|
|
50
|
+
sits at the origin. Moving or re-layering a node therefore moves and re-layers
|
|
51
|
+
its whole subtree. (Rotation, dirty-flag caching and smarter `z`/depth handling
|
|
52
|
+
are noted as future work in the source.)
|
|
53
|
+
|
|
54
|
+
### View transforms and the camera
|
|
55
|
+
|
|
56
|
+
A node's transform is its place in the **world**. A *view* transform is different: it
|
|
57
|
+
maps that world onto the screen (a camera), and it must wrap a whole subtree's draw
|
|
58
|
+
without being baked into any node's position. So `draw` calls a `draw_children` step a
|
|
59
|
+
subclass can override to wrap the subtree in a renderer transform.
|
|
60
|
+
|
|
61
|
+
`RGame::Engine::CameraView` is that subclass: built with an `RGame::Engine::Camera`, it wraps its
|
|
62
|
+
children's draw in `renderer.translated(-camera.x, -camera.y)`. Its children draw at
|
|
63
|
+
their own world origin (they never know about the camera); the translate maps them to
|
|
64
|
+
the screen. Because the offset is a draw-time transform rather than a node position, the
|
|
65
|
+
same world can later be drawn through several cameras — split-screen is repeating the
|
|
66
|
+
pass under different offsets/clips. The owning scene drives the camera (e.g. centring it
|
|
67
|
+
on the player); `CameraView` only applies it. See `examples/15_tiled_world`.
|
|
68
|
+
|
|
69
|
+
## Components
|
|
70
|
+
|
|
71
|
+
`RGame::Engine::Component` (`rgame/engine/component`) is a piece of behaviour you attach to a
|
|
72
|
+
node instead of baking it into a subclass. A component knows its owning `node`,
|
|
73
|
+
and like nodes it extends the signal DSL.
|
|
74
|
+
|
|
75
|
+
- `add_component(component, as: nil)` attaches one in a **named slot** and back-links
|
|
76
|
+
it to the node. The slot defaults to the component's class, so by default a node
|
|
77
|
+
still holds **at most one component per class** — a taken slot raises. Pass a name
|
|
78
|
+
(`add_component(Timer.new, as: :spawn)`) when a node needs several of one type.
|
|
79
|
+
- `get_component(key)` looks a component up by its slot: a class (matched by ancestry,
|
|
80
|
+
so a base class finds a subclass instance) or a Symbol name. A class lookup **raises
|
|
81
|
+
if it is ambiguous** — several components share that type — so name them and look up
|
|
82
|
+
by name.
|
|
83
|
+
- `remove_component(key)` detaches and unlinks the component in that slot (class or
|
|
84
|
+
name), returning it (or `nil` if the slot is empty).
|
|
85
|
+
|
|
86
|
+
A component mirrors the node's three phases — `control(actions)`, `update(dt)`,
|
|
87
|
+
`draw(renderer)` — and the node drives its components in each phase, before its
|
|
88
|
+
own hook and before its children. It also has the two tree-lifecycle hooks below
|
|
89
|
+
(`on_attach`/`on_detach`).
|
|
90
|
+
|
|
91
|
+
## Lifecycle: constructing vs. entering the tree
|
|
92
|
+
|
|
93
|
+
A node has two distinct moments, and conflating them is a classic source of bugs
|
|
94
|
+
(it is why mature engines split Godot's `_init`/`_ready`, Unity's `Awake`/`OnEnable`,
|
|
95
|
+
Unreal's constructor/`BeginPlay`):
|
|
96
|
+
|
|
97
|
+
1. **Construction** (`initialize`) — the node and its components exist, but the node
|
|
98
|
+
is **not yet in the live tree**. It has no resolved anchors: `root`/`scene` (below)
|
|
99
|
+
don't point anywhere useful, and shared systems aren't reachable. Build children
|
|
100
|
+
and attach components here; do **not** look anything up across the tree.
|
|
101
|
+
2. **Entering the tree** — when the node becomes live, the engine runs a depth-first
|
|
102
|
+
cascade that fires, in order: each component's `on_attach`, then the node's
|
|
103
|
+
`on_add`, then the same for every child. **This is where anchors and systems are
|
|
104
|
+
available**, so it's where a component registers with a shared system. Leaving the
|
|
105
|
+
tree runs the mirror cascade — children first, then `on_remove`, then component
|
|
106
|
+
`on_detach` to release those registrations.
|
|
107
|
+
|
|
108
|
+
The engine drives this; you never call it. The relevant calls are `enter_tree` /
|
|
109
|
+
`exit_tree` (and `in_tree?`), fired automatically:
|
|
110
|
+
|
|
111
|
+
- `add_node` enters the child immediately **iff** the parent is already live;
|
|
112
|
+
otherwise the child waits and is entered when its ancestor enters. So a node tree
|
|
113
|
+
assembled in `initialize` (before it's mounted) comes alive all at once when it
|
|
114
|
+
is. `remove_node` exits the subtree the same way.
|
|
115
|
+
- `add_component` / `remove_component` fire `on_attach` / `on_detach` immediately when
|
|
116
|
+
the host node is already live (otherwise attach happens during the node's entry).
|
|
117
|
+
- `SceneStack#push` / `pop` enter/exit a scene; the platform enters the root once at
|
|
118
|
+
boot (`RGame::Game#start`).
|
|
119
|
+
|
|
120
|
+
**The split is load-bearing for the `on_add`/`initialize` divide:** put cross-tree
|
|
121
|
+
lookups (anchors, systems, sibling components) in `on_add` / `on_attach`, never in
|
|
122
|
+
`initialize`. The engine guarantees the anchors are wired before those hooks run, so
|
|
123
|
+
you can't accidentally read them too early.
|
|
124
|
+
|
|
125
|
+
## Anchors and shared systems
|
|
126
|
+
|
|
127
|
+
Two back-links let any node reach shared state without it being threaded through
|
|
128
|
+
constructors, both **resolved by walking parents** (never cached, so they can't go
|
|
129
|
+
stale):
|
|
130
|
+
|
|
131
|
+
- `root` — the top-most node (a node with no parent is its own root). Home for
|
|
132
|
+
global, program-lifetime systems.
|
|
133
|
+
- `scene` — the nearest enclosing scene node (marked as a boundary by `SceneStack`).
|
|
134
|
+
Home for scene-lifetime systems.
|
|
135
|
+
|
|
136
|
+
A *system* is just a `Component` living on one of those anchor nodes; nodes find one
|
|
137
|
+
with `node.system(SomeSystem)` (scene scope first, then the global root). See
|
|
138
|
+
[Systems & shared resources](systems.md) for the scoping model and worked examples.
|
|
139
|
+
|
|
140
|
+
## Deferred free
|
|
141
|
+
|
|
142
|
+
A node that detaches itself or a sibling mid-tick would mutate a parent's `children`
|
|
143
|
+
while that list is being iterated — the classic scene-graph footgun. So removal is
|
|
144
|
+
**deferred** (as in Godot's `queue_free`):
|
|
145
|
+
|
|
146
|
+
- `queue_free` marks a node for removal; `freed?` reports the mark. The node stays in
|
|
147
|
+
the tree and keeps ticking until the sweep.
|
|
148
|
+
- `sweep_freed` detaches every marked node, depth-first, running the normal leave-tree
|
|
149
|
+
cascade (`on_remove` / `on_detach`) on each. It runs from a safe point **outside** the
|
|
150
|
+
tick — the platform loop flushes it once per step, after `update`.
|
|
151
|
+
|
|
152
|
+
Because it's deferred, any component or hook can call `node.queue_free` from inside
|
|
153
|
+
`update` without corrupting the traversal. Container components that hold nodes off the
|
|
154
|
+
normal child list (e.g. `SceneStack`) override `Component#sweep_freed` to forward the
|
|
155
|
+
sweep into the subtree they own.
|
|
156
|
+
|
|
157
|
+
`enter_tree` clears the freed flag, so a node detached and later re-added comes back
|
|
158
|
+
alive. This is what lets a pool recycle nodes: a despawned (freed) node is returned to
|
|
159
|
+
its pool, and re-acquiring it and `add_node`-ing it revives it cleanly.
|
data/docs/api/signals.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Signals
|
|
2
|
+
|
|
3
|
+
Signals are the engine's typed take on the observer pattern: a tiny object that
|
|
4
|
+
holds a list of listener blocks and `emit`s to them. They are how decoupled parts
|
|
5
|
+
of the engine talk to each other — a `Button` tells a `Menu` it was clicked, a
|
|
6
|
+
`Selector` announces its value changed, gameplay asks the audio layer to play a
|
|
7
|
+
sound — without the emitter knowing who (if anyone) is listening.
|
|
8
|
+
|
|
9
|
+
Signals replace the earlier global event bus / `Node#on` observer API. There is no
|
|
10
|
+
central dispatcher and no string/symbol event types to match on: a signal *is* the
|
|
11
|
+
channel, named by the attribute that exposes it, and its arity is fixed when it is
|
|
12
|
+
defined.
|
|
13
|
+
|
|
14
|
+
`RGame::Engine::Signal` is pure Ruby — no graphics — and lives in
|
|
15
|
+
`lib/rgame/engine/signal.rb`.
|
|
16
|
+
|
|
17
|
+
## The Signal class: `Signal.define`
|
|
18
|
+
|
|
19
|
+
`Signal.define(*fields)` builds a signal **class**. Each instance is one channel:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
ClickSignal = Signal.define # carries no payload
|
|
23
|
+
ChangeSignal = Signal.define(:index, :value) # carries two values
|
|
24
|
+
|
|
25
|
+
sig = ChangeSignal.new
|
|
26
|
+
handle = sig.connect { |index, value| puts "#{index} -> #{value}" }
|
|
27
|
+
sig.emit(index: 2, value: :hard) # prints "2 -> hard"
|
|
28
|
+
sig.disconnect(handle) # stops that listener
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Three instance methods:
|
|
32
|
+
|
|
33
|
+
- **`connect(&block)`** — registers a listener and returns it as the *handle*. Listeners
|
|
34
|
+
fire in the order they connected.
|
|
35
|
+
- **`emit(...)`** — notifies every listener.
|
|
36
|
+
- **`disconnect(handle)`** — removes the listener returned by `connect`.
|
|
37
|
+
|
|
38
|
+
### Keyword in, positional out
|
|
39
|
+
|
|
40
|
+
The field names exist to give `emit` a **self-documenting, mistake-catching
|
|
41
|
+
signature** — you call `emit(index:, value:)`, not `emit(2, :hard)`, so a wrong or
|
|
42
|
+
missing field raises at the call site. But the *listener* block receives the values
|
|
43
|
+
**positionally**:
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
ChangeSignal = Signal.define(:index, :value)
|
|
47
|
+
sig.connect { |index, value| ... } # positional params
|
|
48
|
+
sig.emit(index: 2, value: :hard) # keyword args -> it.call(2, :hard)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This is deliberate. Ruby blocks bind positional parameters cleanly but handle
|
|
52
|
+
keyword arguments awkwardly, so the generated `emit` translates `emit(x:, y:)` into
|
|
53
|
+
`it.call(x, y)`.
|
|
54
|
+
|
|
55
|
+
A single-field signal does not follow this keyword convention, in this case the single parameter is non-keyworded.
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
PlaySound = Signal.define(:id)
|
|
59
|
+
sig.connect { @audio.play_sound(it) }
|
|
60
|
+
sig.emit(:boom)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### No per-emit allocation
|
|
64
|
+
|
|
65
|
+
`emit` forwards its arguments straight to each listener — it never collects them
|
|
66
|
+
into an array or hash. Defining the signature with explicit fields (rather than a
|
|
67
|
+
`*splat`) is what makes this allocation-free, which matters because some signals
|
|
68
|
+
fire every frame (the engine's rule: never allocate on the hot path).
|
|
69
|
+
|
|
70
|
+
## The DSL: declaring a signal slot
|
|
71
|
+
|
|
72
|
+
Hand-wiring a signal onto a class is repetitive — an ivar to hold the instance, a
|
|
73
|
+
public method to subscribe, and a way to emit:
|
|
74
|
+
|
|
75
|
+
```ruby
|
|
76
|
+
# Without the DSL:
|
|
77
|
+
ClickSignal = Signal.define
|
|
78
|
+
def initialize(...) = @on_clicked = ClickSignal.new
|
|
79
|
+
def on_clicked(&block) = @on_clicked.connect(&block)
|
|
80
|
+
def activate = @on_clicked.emit
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`RGame::Engine::Signal::DSL` collapses that to one declaration. `extend` it, then declare
|
|
84
|
+
slots with `signal`:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class Button < Control
|
|
88
|
+
extend RGame::Engine::Signal::DSL
|
|
89
|
+
|
|
90
|
+
signal :on_clicked # a no-arg signal
|
|
91
|
+
# signal :on_changed, Signal.define(:index, :value) # a typed one
|
|
92
|
+
|
|
93
|
+
def activate = on_clicked_signal.emit
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`signal :on_clicked` generates two methods:
|
|
98
|
+
|
|
99
|
+
- **`on_clicked(&block)`** — *public*. Subscribe a listener; returns the handle. This
|
|
100
|
+
is the API observers use: `button.on_clicked { ... }`.
|
|
101
|
+
- **`on_clicked_signal`** — *private*. The lazily-built `Signal` instance. Emit
|
|
102
|
+
through it from inside the class: `on_clicked_signal.emit`.
|
|
103
|
+
|
|
104
|
+
The signal is created on first use (`@on_clicked ||= type.new`), so the host wires
|
|
105
|
+
**nothing** in `initialize`. Pass a signal class as the second argument for a typed
|
|
106
|
+
slot; omit it for a no-arg signal.
|
|
107
|
+
|
|
108
|
+
A note on cost: the generated methods use `define_method`, and emitting goes through
|
|
109
|
+
the private reader rather than a bare ivar — one extra method dispatch per emit
|
|
110
|
+
(single-digit nanoseconds under YJIT, and `emit` itself stays a full-speed `def`).
|
|
111
|
+
Negligible for UI and per-frame signals. For a signal emitted thousands of times per
|
|
112
|
+
frame, hand-write it against a direct ivar instead.
|
|
113
|
+
|
|
114
|
+
## Two shapes of signal
|
|
115
|
+
|
|
116
|
+
**Per-instance signals (the DSL).** Each object owns its channels. This is the UI
|
|
117
|
+
pattern: every `Button` has its own `on_clicked`, every `Selector` its own
|
|
118
|
+
`on_changed`. The `signal` macro is built for exactly this (it stores the instance
|
|
119
|
+
in an ivar).
|
|
120
|
+
|
|
121
|
+
**A shared signal hub (module-level).** When one global channel serves the whole app,
|
|
122
|
+
expose signals as module state instead. `RGame::Engine::AudioBus` is the example: it holds
|
|
123
|
+
`Signal.define(:id).new` instances at module scope and exposes them through reader
|
|
124
|
+
methods, so gameplay anywhere does `RGame::Engine::AudioBus.on_play_sound.emit(:boom)`
|
|
125
|
+
and the `AudioDirector` connects once. The DSL doesn't apply here (there is no
|
|
126
|
+
per-instance ivar); the hub hand-rolls the readers.
|
|
127
|
+
|
|
128
|
+
## When to reach for a signal
|
|
129
|
+
|
|
130
|
+
Follow the engine's communication rules (see [Scene graph](scene_graph.md)):
|
|
131
|
+
|
|
132
|
+
- **Parent → child:** call methods directly. No signal needed; the parent holds the
|
|
133
|
+
reference.
|
|
134
|
+
- **Child → parent, or sibling → sibling:** the child *exposes* a signal and the
|
|
135
|
+
parent (or a parent-arranged observer) subscribes. A `Button` exposes `on_clicked`;
|
|
136
|
+
its `Menu` parent connects in `on_add` and re-exposes a higher-level
|
|
137
|
+
`on_selected(index, id)` to the scene. Edges stay direct node-to-node.
|
|
138
|
+
- **Cross-cutting app concerns** with no natural owner (audio, later maybe analytics):
|
|
139
|
+
a module-level hub like `AudioBus`.
|
|
140
|
+
|
|
141
|
+
Keep the emitter ignorant of its listeners: a signal with no observers connected
|
|
142
|
+
emits harmlessly to nobody.
|