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/systems.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Systems & shared resources
|
|
2
|
+
|
|
3
|
+
Some things a node needs don't live on the node: a tilemap, the world bounds, a
|
|
4
|
+
shared collision world. The engine resolves this the way scene-graph engines do —
|
|
5
|
+
shared resources are **systems that live on an anchor node and are reached by
|
|
6
|
+
walking the tree**, not threaded through constructors. There is **no `GameContext`
|
|
7
|
+
bag**: a system is just an `RGame::Engine::Component` on a boundary node, found with the
|
|
8
|
+
same `get_component` every node already has.
|
|
9
|
+
|
|
10
|
+
> Status: the anchor + lookup mechanism (`root`, `scene`, `system`), the
|
|
11
|
+
> tree-lifecycle hooks, and deferred removal (`queue_free`) are in place.
|
|
12
|
+
> `examples/14_asteroids` exercises the whole path end to end and shows **both
|
|
13
|
+
> scopes**: a scene-scoped `CollisionWorld` system and a root-scoped `HighScores`
|
|
14
|
+
> system. `examples/15_tiled_world` adds a second scene-scoped system, `TileWorld`
|
|
15
|
+
> (the tile map: collision, world bounds, drawing). More systems arrive with the
|
|
16
|
+
> rest of the component port (see `docs/wip/components.md`).
|
|
17
|
+
|
|
18
|
+
## Two scopes = two anchor nodes
|
|
19
|
+
|
|
20
|
+
Scope is a property of the **owner** you attach a system to, not of the system
|
|
21
|
+
itself — the same insight behind Unreal's `UGameInstanceSubsystem` (whole session)
|
|
22
|
+
vs `UWorldSubsystem` (one level), and Godot's autoload singletons vs per-scene
|
|
23
|
+
nodes.
|
|
24
|
+
|
|
25
|
+
- **Global scope → the root node.** `root` is set once and never changes, reachable
|
|
26
|
+
from every node. Program-lifetime systems (e.g. an audio bus, i18n) are components
|
|
27
|
+
on the root: `node.root.get_component(AudioBus)`.
|
|
28
|
+
- **Scene scope → the scene node** (what `SceneStack` pushes). Scene-lifetime systems
|
|
29
|
+
(the collision world, the tilemap/world-bounds holder) are components on *that*
|
|
30
|
+
node — born when the scene is pushed, gone when it's popped:
|
|
31
|
+
`node.scene.get_component(CollisionWorld)`.
|
|
32
|
+
|
|
33
|
+
## The anchors
|
|
34
|
+
|
|
35
|
+
Both anchors are **methods that walk the parent chain**, not cached fields. A cached
|
|
36
|
+
back-link set at add-time goes stale when a node is built before it's mounted (its
|
|
37
|
+
children would cache the wrong root); resolving on access can't.
|
|
38
|
+
|
|
39
|
+
- `root` — `@parent ? @parent.root : self`. The top-most node is its own root.
|
|
40
|
+
- `scene` — the nearest ancestor marked as a scene boundary. `SceneStack#push` marks
|
|
41
|
+
the pushed scene with `scene.scene = scene`; descendants resolve up to it. Outside
|
|
42
|
+
any scene, `scene` is `nil`.
|
|
43
|
+
|
|
44
|
+
### Looking a system up
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
node.system(CollisionWorld)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`Node2D#system(klass)` checks the **scene scope first, then the global root**, so a
|
|
51
|
+
scene can override a global default and free-standing nodes still find globals. Use
|
|
52
|
+
the explicit anchor (`node.root.get_component` / `node.scene.get_component`) when you
|
|
53
|
+
specifically mean one scope.
|
|
54
|
+
|
|
55
|
+
## Registering with a system — use the lifecycle, not `initialize`
|
|
56
|
+
|
|
57
|
+
A system and its clients only connect once everything is **in the live tree**, so
|
|
58
|
+
wiring happens in the tree-lifecycle hooks, never in `initialize` (where a node has
|
|
59
|
+
no anchors). See [Lifecycle](scene_graph.md#lifecycle-constructing-vs-entering-the-tree).
|
|
60
|
+
|
|
61
|
+
The entered-tree cascade guarantees ordering that makes this safe: a scene's own
|
|
62
|
+
components `on_attach` (so a `CollisionWorld` on the scene node exists), then the
|
|
63
|
+
scene's `on_add`, then its children enter — so by the time a child collider attaches,
|
|
64
|
+
the scene-scoped system it looks up is already there.
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# CircleCollider (engine/components/circle_collider.rb) registers itself when it
|
|
68
|
+
# enters the tree and releases the registration when it leaves — the engine fires
|
|
69
|
+
# both hooks, so a spawned/despawned entity can't leak a registration.
|
|
70
|
+
class CircleCollider < RGame::Engine::Component
|
|
71
|
+
def on_attach = node.system(CollisionWorld).register(self)
|
|
72
|
+
def on_detach = node.system(CollisionWorld)&.unregister(self)
|
|
73
|
+
end
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Systems that index their clients (the tag-registry pattern)
|
|
77
|
+
|
|
78
|
+
A many-to-many system (broadphase collision) lives on the scene node and keeps its
|
|
79
|
+
own index of registered clients, so it processes only nearby candidates instead of
|
|
80
|
+
walking the tree for every pair. `CollisionWorld`
|
|
81
|
+
(engine/components/collision_world.rb) holds a `SpatialHash` for exactly this — a
|
|
82
|
+
spatial index of registered colliders, rebuilt each `update`. This indexing is the
|
|
83
|
+
same idea as Godot's **groups**: a registry of node references. It is *not* an ECS —
|
|
84
|
+
it indexes references, carries no component data, and gains none of ECS's
|
|
85
|
+
data-locality; it's a lightweight index.
|
|
86
|
+
|
|
87
|
+
`CollisionWorld` is layer-agnostic: it reports every overlapping pair by firing each
|
|
88
|
+
collider's `on_hit` signal with the other collider, and the owning node decides what
|
|
89
|
+
a contact *means* by reading the other's `layer` tag:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
collider.on_hit { |other| queue_free if other.layer == :bullet } # in a Rock node
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Because it's a normal component on the scene node, it rides the `update` traversal
|
|
96
|
+
(its broadphase runs in `update`) and is torn down with the scene. See
|
|
97
|
+
`examples/14_asteroids` for the whole loop: ship, bullets, and rocks spawning,
|
|
98
|
+
colliding, and despawning through this system.
|
data/docs/api/text.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# Text
|
|
2
|
+
|
|
3
|
+
```ruby
|
|
4
|
+
require 'rgame'
|
|
5
|
+
require 'rgame/core'
|
|
6
|
+
|
|
7
|
+
class MyGame < RGame::Core::App
|
|
8
|
+
def initialize
|
|
9
|
+
super(width: 800, height: 600, caption: 'demo')
|
|
10
|
+
@renderer = RGame::Core::Renderer.new(self)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def draw
|
|
14
|
+
@renderer.text('Score: 1200', 10, 10)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
That is the whole of it for most cases — the renderer has a font already, and
|
|
20
|
+
`text` uses it.
|
|
21
|
+
|
|
22
|
+
## Where text goes
|
|
23
|
+
|
|
24
|
+
`text(string, x, y, …)` puts the **top-left corner** of the line at `(x, y)`,
|
|
25
|
+
the same corner every other drawing method takes. Typography works from the
|
|
26
|
+
baseline; a caller placing a label does not have to.
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
renderer.text(string, x, y, z: 10, color: nil, font: nil)
|
|
30
|
+
renderer.text_width(string, font: nil) # => Float, pixels
|
|
31
|
+
renderer.text_height(font: nil) # => Integer, the line height
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
**A string is one line.** Newlines are not special. Two lines are two calls,
|
|
35
|
+
stepped by `text_height`:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
lines.each_with_index do |line, i|
|
|
39
|
+
@renderer.text(line, 10, 10 + (i * @renderer.text_height))
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**`text_width` and `text` agree.** They walk the same code, so a label measured
|
|
44
|
+
and then centred lands where it was measured to:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
@renderer.text(label, (width - @renderer.text_width(label)) / 2, 20)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Unlike the drawing methods, `text_width` and `text_height` work **outside**
|
|
51
|
+
`draw` — measuring touches no GPU, and laying out a menu happens while updating.
|
|
52
|
+
|
|
53
|
+
## Fonts
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
font = RGame::Core::Font.new(app, 18) # the shipped font
|
|
57
|
+
font = RGame::Core::Font.new(app, 18, path: 'assets/pixel.ttf')
|
|
58
|
+
|
|
59
|
+
font.height # => 18
|
|
60
|
+
font.text_width('Hello') # => 38.7
|
|
61
|
+
|
|
62
|
+
renderer.text('Hello', 10, 10, font: font)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A `Font` is **one typeface at one pixel size**. Two sizes are two fonts. Like an
|
|
66
|
+
image, it belongs to the app whose GPU context holds its glyphs, and drawing it
|
|
67
|
+
through another app's renderer raises rather than painting blank boxes.
|
|
68
|
+
|
|
69
|
+
The renderer builds its own font at 18px on first use. Replace it and every
|
|
70
|
+
unqualified `text` call follows:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
@renderer.font = RGame::Core::Font.new(self, 24)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
A file that cannot be read or is not a TrueType font raises
|
|
77
|
+
`RGame::Core::Font::LoadError`, naming the path.
|
|
78
|
+
|
|
79
|
+
### The default font, and what it covers
|
|
80
|
+
|
|
81
|
+
The engine ships **Liberation Sans** and uses it when no path is given. There is
|
|
82
|
+
no font-*name* lookup and no system font database — a font is a file.
|
|
83
|
+
|
|
84
|
+
That is a deliberate trade. Asking the operating system for "Arial" (which is
|
|
85
|
+
what Gosu does) means a different font on every machine, so a UI laid out on the
|
|
86
|
+
developer's box can overflow on a player's. Shipping one means text renders
|
|
87
|
+
identically everywhere, at the cost of ~400 KB in the gem.
|
|
88
|
+
|
|
89
|
+
| | |
|
|
90
|
+
|---|---|
|
|
91
|
+
| Covers | English, German, French, Italian, Spanish, Portuguese, Nordic, Polish — in full, including `ß`, `ẞ`, `« »`, curly quotes and `€`. Greek and Cyrillic too. |
|
|
92
|
+
| Does not cover | CJK, Arabic, Hebrew, Devanagari. Pass your own font file for those; no font of this size includes them. |
|
|
93
|
+
|
|
94
|
+
Text is UTF-8. A malformed byte draws one replacement character and the rest of
|
|
95
|
+
the string survives — a bad byte in a data file costs a visible box, not the
|
|
96
|
+
label.
|
|
97
|
+
|
|
98
|
+
## What it costs
|
|
99
|
+
|
|
100
|
+
Glyphs are rasterised the first time they are drawn and kept in a texture atlas
|
|
101
|
+
afterwards, so the cost is bounded by the **characters** a game uses, not by the
|
|
102
|
+
strings it draws. A score that changes every frame is free after the first ten
|
|
103
|
+
digits; a whole Latin character set fits on one 512×512 page, so a line of text
|
|
104
|
+
is one draw call.
|
|
105
|
+
|
|
106
|
+
A font that is only measured and never drawn allocates no video memory at all.
|
|
107
|
+
|
|
108
|
+
Nothing needs freeing — a font's atlas is released when the font is collected,
|
|
109
|
+
in either order relative to its app. `Font.debug_live_pages` reports how many
|
|
110
|
+
atlas pages exist and is there for tests, not for gameplay.
|
|
111
|
+
|
|
112
|
+
## What is not here
|
|
113
|
+
|
|
114
|
+
Markup (`<b>`, colour tags), bold and italic variants, multi-line layout, word
|
|
115
|
+
wrapping, text input, and right-to-left or complex shaping. A string is one line
|
|
116
|
+
of left-to-right glyphs.
|
data/docs/api/toolbox.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Utilities
|
|
2
|
+
|
|
3
|
+
Engine classes a **game author reaches for directly** that don't belong to the scene
|
|
4
|
+
graph, components, signals, or systems chapters — pooling, localization, audio facts,
|
|
5
|
+
flat grids, the camera, collision boxes. All are pure Ruby (none `require "gosu"`), so
|
|
6
|
+
they stay headless-testable.
|
|
7
|
+
|
|
8
|
+
For the low-level classes that sit *behind* components and are rarely constructed by
|
|
9
|
+
hand (collision maths, the spatial index, animation playback), see
|
|
10
|
+
[Internal building blocks](internals.md).
|
|
11
|
+
|
|
12
|
+
## `Matrix` — a flat fixed-size grid
|
|
13
|
+
|
|
14
|
+
`RGame::Engine::Matrix` is a fixed-size grid addressed as `[x, y]` but backed by a
|
|
15
|
+
**single flat (row-major) array**, not an array-of-arrays. One contiguous
|
|
16
|
+
allocation is cheaper than nested arrays, and it is the shape a C-level buffer
|
|
17
|
+
takes — which is not hypothetical: its 3-D sibling made exactly that move.
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
grid = RGame::Engine::Matrix.new(width, height, initial: 0)
|
|
21
|
+
grid[col, row] = gid
|
|
22
|
+
grid[col, row] # row-major: index = y * width + x
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
It does no bounds checking on the hot path (callers stay in range).
|
|
26
|
+
|
|
27
|
+
For three dimensions, reach for [`RGame::Util::Tensor`](values.md#rgameutiltensor) — the C
|
|
28
|
+
one. `TileMap` stacks its tile layers in a single
|
|
29
|
+
`Tensor(width, height, layer_count)`, and that is the worked example of the rule
|
|
30
|
+
that the engine layer may hold `RGame::Util` values: a grid is a value, so the
|
|
31
|
+
layer above owns one outright rather than being handed it.
|
|
32
|
+
|
|
33
|
+
## `CachedLabel` — a display string rebuilt only on change
|
|
34
|
+
|
|
35
|
+
`RGame::Engine::CachedLabel` (`rgame/engine/cached_label`) holds a label string and rebuilds it only when its
|
|
36
|
+
source value changes, so a per-frame draw shows the cached copy without interpolating (and
|
|
37
|
+
allocating) a `String` every frame. Construct it — and its format block — outside the per-frame
|
|
38
|
+
path (e.g. in `on_add`), then read it by value in `on_draw`:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
@score_label = RGame::Engine::CachedLabel.new { |score| "Score: #{score}" } # built once
|
|
42
|
+
|
|
43
|
+
def on_draw(renderer)
|
|
44
|
+
renderer.text(@score_label[@score], 12, 10) # cached; rebuilds only when @score changes
|
|
45
|
+
end
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`@score_label[value]` returns the same `String` object while `value` is unchanged. This is the
|
|
49
|
+
sanctioned home for build-on-change interpolation, so the per-frame allocation cops
|
|
50
|
+
(`rubocop/cop/game/`) exempt it. For a value that changes *every* frame (an FPS or allocation counter) a cached string can't
|
|
51
|
+
help — draw the digits individually from cached glyph strings instead, as `RGame::Engine::DebugOverlay` does.
|
|
52
|
+
|
|
53
|
+
## `Pool` — reuse, don't allocate
|
|
54
|
+
|
|
55
|
+
`RGame::Engine::Pool` (`rgame/engine/pool`) recycles many short-lived, homogeneous objects —
|
|
56
|
+
bullets, particles, transient enemies — so steady-state spawning allocates nothing.
|
|
57
|
+
Acquired objects come from a free list, falling back to a factory block only when the
|
|
58
|
+
list is empty.
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
pool = RGame::Engine::Pool.new { Bullet.new } # factory builds a blank object
|
|
62
|
+
b = pool.acquire # recycled, or freshly built once
|
|
63
|
+
b.reset(x, y, angle) # caller re-initialises after acquire
|
|
64
|
+
pool.each { |bullet| bullet.update(dt) }
|
|
65
|
+
pool.reclaim_if(&:dead?) # sweep dead → free list, once per frame
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The factory builds a *blank* object; the caller re-initialises it after `acquire`
|
|
69
|
+
(typically via a `reset` from an [`RGame::Engine::Resettable`](#resettable--mutable-only-where-a-pool-needs-it)
|
|
70
|
+
value object). `reclaim_if` is the deferred-removal seam: it sweeps the active list
|
|
71
|
+
once, moving every object the block marks dead onto the free list. Call it *after*
|
|
72
|
+
iterating with `each` — never mutate the active list mid-iteration. `active`, `size`,
|
|
73
|
+
and `each` expose the live set for update/draw traversal.
|
|
74
|
+
|
|
75
|
+
## `Path` — a walkable polyline
|
|
76
|
+
|
|
77
|
+
`RGame::Engine::Path` (`rgame/engine/path`) is an ordered polyline of waypoints an entity walks along —
|
|
78
|
+
the "road" of a tower-defense level. Pure data: it holds the waypoints and the precomputed
|
|
79
|
+
per-segment lengths, so a follower walking it at runtime allocates nothing. Waypoints are
|
|
80
|
+
stored flat (`x0, y0, x1, y1, …`) in one contiguous array and read back through scalar
|
|
81
|
+
accessors, so neither construction nor traversal leaks a pair-object per waypoint.
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
path = RGame::Engine::Path.new([[0, 0], [100, 0], [100, 100]]) # ≥ 2 waypoints, in walk order
|
|
85
|
+
path.count # number of waypoints
|
|
86
|
+
path.x_at(i) # scalar coords of waypoint i (no allocation)
|
|
87
|
+
path.y_at(i)
|
|
88
|
+
path.segment_length(i) # length of the segment from waypoint i to i+1
|
|
89
|
+
path.length # total length
|
|
90
|
+
path.distance_to(x, y) # shortest distance from a point to the polyline
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A follower ([`Components::PathFollow`](components.md#pathfollow)) reads segments by index
|
|
94
|
+
and interpolates itself; Path never returns a coordinate pair. `distance_to` answers "how
|
|
95
|
+
far is this point from the road" (allocation-free scalar maths) — e.g. to mask the
|
|
96
|
+
tower-placement cells that sit on or hug the road.
|
|
97
|
+
|
|
98
|
+
## `Timer` — paced periodic events
|
|
99
|
+
|
|
100
|
+
`RGame::Engine::Timer` (`rgame/engine/timer`) is a repeating interval timer for periodic events that
|
|
101
|
+
aren't driven by input — a spawner emitting an enemy every N seconds, a tower's fire
|
|
102
|
+
rate, a wave clock. It only **accumulates** time; the owner decides what each elapsed
|
|
103
|
+
interval means. That split is deliberate: the same primitive serves both "act
|
|
104
|
+
automatically" (consume every ready interval) and "stay loaded until conditions allow"
|
|
105
|
+
(check `ready?`, but `consume` only when actually acting) — so a tower with no target
|
|
106
|
+
keeps its shot ready instead of wasting it. Pure and allocation-free, so it ticks on the
|
|
107
|
+
per-frame path.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
@spawn_timer = RGame::Engine::Timer.new(0.8) # built once, off the hot path
|
|
111
|
+
|
|
112
|
+
def on_update(dt)
|
|
113
|
+
@spawn_timer.update(dt)
|
|
114
|
+
return unless @spawn_timer.ready? # a whole interval has accumulated
|
|
115
|
+
|
|
116
|
+
@spawn_timer.consume # deduct it; the remainder carries forward
|
|
117
|
+
spawn_enemy
|
|
118
|
+
end
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`consume` carries the overshoot forward (rather than zeroing), so a long-running cadence
|
|
122
|
+
doesn't drift; `reset` drops accumulated time after retuning `interval`. When a step might
|
|
123
|
+
span several intervals, loop: `while timer.ready? do …; timer.consume end`.
|
|
124
|
+
|
|
125
|
+
For a node that should tick automatically, reach for
|
|
126
|
+
[`Components::Timer`](components.md#timer) instead — it owns one of these, rides the node's
|
|
127
|
+
update tick (so nothing can forget to drive it), and emits `on_timeout` rather than making
|
|
128
|
+
you poll `ready?`/`consume`.
|
|
129
|
+
|
|
130
|
+
## `Camera` — follow a point, clamp to the world
|
|
131
|
+
|
|
132
|
+
`RGame::Engine::Camera` (`rgame/engine/camera`) is the pure follow-and-clamp maths for a scrolling
|
|
133
|
+
view: `center_on(world_x, world_y)` parks its top-left so the point is centred, but
|
|
134
|
+
clamped so it never shows past the map edges (near a corner the target drifts off-centre
|
|
135
|
+
instead). A scene constructs one, sizes it with the viewport and the world, and centres
|
|
136
|
+
it on the player each frame.
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
camera = RGame::Engine::Camera.new(
|
|
140
|
+
viewport_width: 640, viewport_height: 480,
|
|
141
|
+
world_width: map.pixel_width, world_height: map.pixel_height
|
|
142
|
+
)
|
|
143
|
+
camera.center_on(player_x, player_y) # camera.x / camera.y now hold the clamped offset
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
It only computes the offset; applying it is a *view transform* —
|
|
147
|
+
[`CameraView`](scene_graph.md#view-transforms-and-the-camera) wraps the world subtree in
|
|
148
|
+
`renderer.translated(-camera.x, -camera.y)`, and a [`TileWorld`](components.md#tileworld)
|
|
149
|
+
draws the map at the same offset. See `examples/15_tiled_world`.
|
|
150
|
+
|
|
151
|
+
## `CollisionBox` — an actor's feet box
|
|
152
|
+
|
|
153
|
+
`RGame::Engine::CollisionBox` (`rgame/engine/collision_box`) is a character's collision rectangle,
|
|
154
|
+
expressed as an offset + size **relative to the sprite's top-left origin** — decoupled
|
|
155
|
+
from the sprite size, so a 32×32 sprite can carry a small box at its feet. A
|
|
156
|
+
[`CharacterBody`](components.md#characterbody) holds one and the collision code resolves
|
|
157
|
+
*it* (not the sprite) against the tiles.
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
box = RGame::Engine::CollisionBox.bottom_anchored(
|
|
161
|
+
sprite_width: 32, sprite_height: 32, width: 16, height: 16
|
|
162
|
+
) # centred horizontally, anchored to the sprite's feet
|
|
163
|
+
box.aabb(x, y) # => [x + offset_x, y + offset_y, width, height]
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`bottom_anchored` is the common case (feet box); the raw constructor takes explicit
|
|
167
|
+
`offset_x:`/`offset_y:`/`width:`/`height:` for anything else.
|
|
168
|
+
|
|
169
|
+
## `RGame::Engine::I18n` — localization
|
|
170
|
+
|
|
171
|
+
`RGame::Engine::I18n` (`engine/i18n`) is minimal localization: per-locale translation tables
|
|
172
|
+
(loaded from YAML or an inline Hash), `t(key)` lookup with `%{var}` interpolation and a
|
|
173
|
+
fallback locale, and pluralization. It is a **global module** (like the signal
|
|
174
|
+
dispatcher), so `t` is reachable anywhere without wiring. YAML is its only dependency.
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
RGame::Engine::I18n.load_file(:en, "locales/en.yml")
|
|
178
|
+
RGame::Engine::I18n.load(:de, menu: { title: "Hauptmenü" }) # nested Hashes allowed
|
|
179
|
+
RGame::Engine::I18n.default = :en # fallback when the current locale lacks a key
|
|
180
|
+
RGame::Engine::I18n.locale = :de
|
|
181
|
+
|
|
182
|
+
RGame::Engine::I18n.t("menu.title") # dotted key, resolved in :de then :en
|
|
183
|
+
RGame::Engine::I18n.t(:greeting, name: "Ada") # => "Hello, Ada" from %{name}
|
|
184
|
+
RGame::Engine::I18n.t(:apples, count: 3) # pluralized: { one:, other:, zero? }
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Keys are symbolized on load, so YAML's string keys and inline symbol keys look the same
|
|
188
|
+
to `t`. `t` resolves a dotted key in the current locale, then the fallback, then returns
|
|
189
|
+
the key itself as a last resort. Pass `count:` to pluralize — the key's value is then a
|
|
190
|
+
`{ one:, other:, optionally zero: }` table, and `count` is also exposed to interpolation
|
|
191
|
+
as `%{count}` (English/German use the one/other rule).
|
|
192
|
+
|
|
193
|
+
The **`generation` counter** is the headless-friendly change seam: it ticks whenever the
|
|
194
|
+
locale changes, so cached UI text can re-resolve only when `generation` moves rather than
|
|
195
|
+
re-running `t` every frame — keeping with the engine's no-per-frame-allocation rule.
|
|
196
|
+
|
|
197
|
+
## `AudioBus` — decoupled audio facts
|
|
198
|
+
|
|
199
|
+
`RGame::Engine::AudioBus` (`rgame/engine/audio_bus`) is a global, always-present audio bus: gameplay
|
|
200
|
+
emits audio *facts* (`play this sound`, `play this music`) here, decoupled from playback,
|
|
201
|
+
and an `AudioDirector` subscribes and turns them into actual
|
|
202
|
+
sound. A module rather than an instance so any node can reach it without wiring.
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
RGame::Engine::AudioBus.play_sound(:boom)
|
|
206
|
+
RGame::Engine::AudioBus.play_music(:theme)
|
|
207
|
+
RGame::Engine::AudioBus.stop_music
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The emit shims (`play_sound`, `play_music`, `stop_music`) are the gameplay-facing API;
|
|
211
|
+
underneath each is an [`RGame::Engine::Signal`](signals.md) (`on_play_sound`, `on_play_music`,
|
|
212
|
+
`on_stop_music`) that is the actual subscription seam the director listens on. Because
|
|
213
|
+
the engine only emits facts and never names an audio device, the bus stays in the engine
|
|
214
|
+
layer and playback stays in `RGame::Core`.
|
|
215
|
+
|
|
216
|
+
## `Resettable` — mutable only where a pool needs it
|
|
217
|
+
|
|
218
|
+
`RGame::Engine::Resettable` (`rgame/engine/resettable`) builds value-object classes for pooling. Like
|
|
219
|
+
`Data.define`, instances expose read-only accessors and carry their fields as a unit —
|
|
220
|
+
but where a `Data` value is fully immutable (every change is a fresh allocation), these
|
|
221
|
+
add exactly one mutation: `reset`, which overwrites all fields at once and returns self.
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
Point = RGame::Engine::Resettable.define(:x, :y)
|
|
225
|
+
p = Point.new(3, 4)
|
|
226
|
+
p.x # => 3 (read-only; no x= setter)
|
|
227
|
+
p.reset(5, 6) # overwrite in place, allocation-free → self
|
|
228
|
+
|
|
229
|
+
Vel = RGame::Engine::Resettable.define(:dx, :dy, keyword_init: true)
|
|
230
|
+
Vel.new(dx: 1, dy: 0).reset(dx: 2, dy: 0)
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
That single in-place `reset` is the only mutability a [`Pool`](#pool--reuse-dont-allocate)
|
|
234
|
+
needs: acquire a recycled instance and `reset` it, without exposing the per-field setters
|
|
235
|
+
a `Struct` would. Methods are generated fixed-arity with direct ivar assignment (as
|
|
236
|
+
Struct/Data do), so `reset` is allocation-free and recycling stays zero-allocation in
|
|
237
|
+
steady state — including the `keyword_init: true` form, which generates named parameters
|
|
238
|
+
(`reset(x:, y:)`) rather than a `**kwargs` splat (the one form that would build a Hash per
|
|
239
|
+
call). Reach for this over a mutable `Struct` whenever a value object is pool-recycled; see
|
|
240
|
+
the Style notes in `CLAUDE.md`.
|
data/docs/api/values.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Value types
|
|
2
|
+
|
|
3
|
+
Everything in `RGame::Util` is a *value*: cheap, comparable, owning no window,
|
|
4
|
+
GPU handle or file. That is what makes them safe for game logic to hold as
|
|
5
|
+
attributes — they load with `require 'rgame'` and pull in no graphics libraries
|
|
6
|
+
at all.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
require 'rgame'
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## `RGame::Util::Color`
|
|
13
|
+
|
|
14
|
+
An RGBA colour. Instances are **frozen** and compare **by value**, so one can be
|
|
15
|
+
shared freely and used as a Hash key.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
Color = RGame::Util::Color
|
|
19
|
+
|
|
20
|
+
Color.new(255, 128, 0) # r, g, b — alpha defaults to 255
|
|
21
|
+
Color.new(255, 128, 0, 200) # explicit alpha
|
|
22
|
+
Color.rgba(255, 128, 0, 200) # the same thing, named
|
|
23
|
+
Color.from_packed(0xFF8000C8) # 0xRRGGBBAA
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| | |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `r` `g` `b` `a` | Components, `0..255`. |
|
|
29
|
+
| `packed` | The `0xRRGGBBAA` form as an Integer. |
|
|
30
|
+
| `==`, `eql?`, `hash` | Value semantics. |
|
|
31
|
+
| `inspect` | `#<RGame::Util::Color r=1 g=2 b=3 a=4>` |
|
|
32
|
+
|
|
33
|
+
Named colours: `Color::WHITE`, `Color::BLACK`, `Color::TRANSPARENT`.
|
|
34
|
+
|
|
35
|
+
### Out-of-range components raise
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
Color.new(300, 0, 0) # ArgumentError: red must be in 0..255, got 300
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Silently clamping would hide the bug that produced the 300.
|
|
42
|
+
|
|
43
|
+
### `Color.coerce`
|
|
44
|
+
|
|
45
|
+
Drawing calls accept a colour in several forms, and `coerce` is the single
|
|
46
|
+
place that conversion happens:
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
Color.coerce(nil) # => Color::WHITE — an untinted draw
|
|
50
|
+
Color.coerce([255, 128, 0]) # => opaque
|
|
51
|
+
Color.coerce([255, 128, 0, 64]) # => with alpha
|
|
52
|
+
Color.coerce(Color::WHITE) # => returned unchanged, not copied
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Anything else raises `TypeError`; a wrongly-sized array raises `ArgumentError`.
|
|
56
|
+
|
|
57
|
+
### Value semantics in practice
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
a = Color.new(1, 2, 3)
|
|
61
|
+
b = Color.new(1, 2, 3)
|
|
62
|
+
|
|
63
|
+
a == b # => true — two objects, one value
|
|
64
|
+
{ a => :hit }[b] # => :hit
|
|
65
|
+
a.frozen? # => true
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Because a colour is frozen, handing the same one to two sprites is safe: nobody
|
|
69
|
+
can tint it out from under the other.
|
|
70
|
+
|
|
71
|
+
## `RGame::Util::Tensor`
|
|
72
|
+
|
|
73
|
+
A fixed-size three-dimensional grid, addressed as `[x, y, z]`. Backed by a
|
|
74
|
+
single flat array in C, so it stays compact for the sizes a tile map or a
|
|
75
|
+
lighting volume needs.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
grid = RGame::Util::Tensor.new(width, height, depth)
|
|
79
|
+
grid = RGame::Util::Tensor.new(16, 16, 4, initial: 0) # fill value
|
|
80
|
+
|
|
81
|
+
grid[3, 4, 1] = :wall
|
|
82
|
+
grid[3, 4, 1] # => :wall
|
|
83
|
+
|
|
84
|
+
grid.width # also #height and #depth
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Cells hold any Ruby object. `initial:` is optional and defaults to `nil`.
|
|
88
|
+
|
|
89
|
+
The layout is x-fastest, then y, then z, so one z-slice is a contiguous run —
|
|
90
|
+
worth knowing if you iterate a layer at a time and care about locality.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
grid.depth.times do |z|
|
|
94
|
+
grid.height.times do |y|
|
|
95
|
+
grid.width.times do |x|
|
|
96
|
+
cell = grid[x, y, z]
|
|
97
|
+
# ...
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|