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/app.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# `RGame::Core::App`
|
|
2
|
+
|
|
3
|
+
The window and the frame loop. Subclass it, override the hooks you need, call
|
|
4
|
+
`run`.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
require 'rgame/core'
|
|
8
|
+
|
|
9
|
+
class MyGame < RGame::Core::App
|
|
10
|
+
def initialize = super(width: 800, height: 600, caption: 'My Game')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
MyGame.new.run # returns when the loop stops
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`App.new` takes keyword arguments only. `width:`, `height:` and `caption:` are
|
|
17
|
+
required; `media_root:` is optional and defaults to `'media'`. Creating one
|
|
18
|
+
opens a real window immediately.
|
|
19
|
+
|
|
20
|
+
## What the app owns
|
|
21
|
+
|
|
22
|
+
Two things a game needs exactly one of, built on first use:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
class MyGame < RGame::Core::App
|
|
26
|
+
def initialize = super(width: 640, height: 480, caption: 'demo', media_root: MEDIA)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
app.assets # => RGame::Core::AssetManager, rooted at media_root
|
|
30
|
+
app.audio # => RGame::Core::Audio, the sound device
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**A game never constructs either of them.** An image belongs to one OpenGL
|
|
34
|
+
context and has to be told which, so something must hold the app — and since the
|
|
35
|
+
asset manager is the only thing in the engine that loads from a path, that
|
|
36
|
+
something is the app itself, once, rather than a parameter threaded through
|
|
37
|
+
every class that ends up owning an image.
|
|
38
|
+
|
|
39
|
+
Both are lazy, and that matters in each case. An app that draws only shapes
|
|
40
|
+
builds no asset manager; an app that never plays anything never opens a sound
|
|
41
|
+
device — and asking for a sound is the first thing that needs one, so that is
|
|
42
|
+
also the right moment to open it.
|
|
43
|
+
|
|
44
|
+
`media_root` is read-only and set at construction. There is deliberately no
|
|
45
|
+
writer: changing it after an asset had loaded would leave one cache keyed
|
|
46
|
+
against two roots.
|
|
47
|
+
|
|
48
|
+
See [Sheets, atlases and maps](assets.md) for what the asset manager does.
|
|
49
|
+
|
|
50
|
+
## The frame loop
|
|
51
|
+
|
|
52
|
+
`run` drives the loop until something stops it, calling back into your object.
|
|
53
|
+
One rendered frame looks like this:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
poll input and window events → button_down / button_up / resize
|
|
57
|
+
gamepad_connected / gamepad_disconnected
|
|
58
|
+
frame_begin → once, before any ticks
|
|
59
|
+
update(dt) → zero or more times (see below)
|
|
60
|
+
needs_redraw? → once; false skips the draw
|
|
61
|
+
draw → once, unless skipped
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### `update(dt)` runs a *fixed* number of times, not once per frame
|
|
65
|
+
|
|
66
|
+
This is the most important thing to understand about the loop.
|
|
67
|
+
|
|
68
|
+
The simulation advances in fixed steps. Real elapsed time accumulates, and each
|
|
69
|
+
frame runs however many whole steps have come due — which may be **zero**
|
|
70
|
+
(the machine is rendering faster than the simulation needs) or **several** (a
|
|
71
|
+
frame took a long time and the simulation is catching up). Catch-up is capped,
|
|
72
|
+
so a very slow frame makes time slow down rather than spiral.
|
|
73
|
+
|
|
74
|
+
`dt` is always the same fixed step, currently **1/60 second**. It is never
|
|
75
|
+
wall-clock frame time. That is deliberate: it makes movement reproducible, and
|
|
76
|
+
it is why a test can drive `update` directly and simulate any amount of time.
|
|
77
|
+
|
|
78
|
+
The practical consequence: **do not sample input inside `update`.** A key held
|
|
79
|
+
for one frame would be read once or five times depending on how slow the last
|
|
80
|
+
frame was. Sample it in `frame_begin` instead, or rely on `Input`, which reads
|
|
81
|
+
a snapshot taken once per frame and therefore answers identically for every
|
|
82
|
+
tick of that frame.
|
|
83
|
+
|
|
84
|
+
### `needs_redraw?`
|
|
85
|
+
|
|
86
|
+
Return `false` and the draw is skipped for that frame; the simulation still
|
|
87
|
+
advances. Useful when nothing has changed and drawing is expensive. The default
|
|
88
|
+
is `true`.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
def update(_dt)
|
|
92
|
+
@dirty = true if something_moved
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def needs_redraw? = @dirty
|
|
96
|
+
|
|
97
|
+
def draw
|
|
98
|
+
# ...
|
|
99
|
+
@dirty = false
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Because `update` running at all means a step happened, `@dirty = true` inside
|
|
104
|
+
`update` is usually the whole rule you need.
|
|
105
|
+
|
|
106
|
+
## Hooks you can override
|
|
107
|
+
|
|
108
|
+
Every one has an inherited no-op default, so override only what you use.
|
|
109
|
+
|
|
110
|
+
| Hook | When |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `frame_begin` | Once per frame, before that frame's ticks. Sample input here. |
|
|
113
|
+
| `update(dt)` | One fixed simulation tick. |
|
|
114
|
+
| `needs_redraw?` | Before drawing; `false` skips `draw`. Default `true`. |
|
|
115
|
+
| `draw` | Render one frame. |
|
|
116
|
+
| `button_down(id)` | A key was pressed. Auto-repeats are filtered, so a held key fires once. |
|
|
117
|
+
| `button_up(id)` | A key was released. |
|
|
118
|
+
| `resize(width, height)` | The window changed size. |
|
|
119
|
+
| `gamepad_connected(slot)` | A controller arrived in a player slot. |
|
|
120
|
+
| `gamepad_disconnected(slot)` | A controller left a slot. |
|
|
121
|
+
|
|
122
|
+
`id` is a value from [`RGame::Util::Controls`](input.md) — for example
|
|
123
|
+
`Controls::KEY_ESCAPE`.
|
|
124
|
+
|
|
125
|
+
### There is no built-in quit key
|
|
126
|
+
|
|
127
|
+
Closing the window stops the loop, because that really is the platform's
|
|
128
|
+
decision. Quitting on Escape is *your* decision, so the engine does not make it
|
|
129
|
+
for you:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
def button_down(id)
|
|
133
|
+
close if id == RGame::Util::Controls::KEY_ESCAPE
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Window methods
|
|
138
|
+
|
|
139
|
+
| Method | |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `run` | Runs the loop until it stops. Returns `self`. |
|
|
142
|
+
| `close` | Asks the loop to stop. Safe to call from inside any hook. |
|
|
143
|
+
| `width`, `height` | Current window size. |
|
|
144
|
+
| `caption`, `caption=` | The window title. |
|
|
145
|
+
| `ticks_ms` | Monotonic milliseconds since startup. For animation phase. |
|
|
146
|
+
| `fps` | Most recent frames-per-second reading, updated about once a second. |
|
|
147
|
+
|
|
148
|
+
`close` takes effect promptly — the loop checks between steps, so it will not
|
|
149
|
+
start further work in the current frame.
|
|
150
|
+
|
|
151
|
+
## Raw input queries
|
|
152
|
+
|
|
153
|
+
`App` exposes the input snapshot directly. Most code should use
|
|
154
|
+
[`RGame::Core::Input`](input.md), which takes symbolic action names instead of
|
|
155
|
+
numeric ids, but these are the primitives underneath:
|
|
156
|
+
|
|
157
|
+
| Method | |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `input_down?(device, button_id)` | Is that button held on that device? |
|
|
160
|
+
| `input_axis(device, axis_id)` | Analog axis value; sticks −1.0…1.0, triggers 0.0…1.0. |
|
|
161
|
+
| `gamepad_present?(slot)` | Is a controller plugged into that player slot? |
|
|
162
|
+
| `gamepad_name(slot)` | Its human-readable name, or `nil`. |
|
|
163
|
+
| `gamepad_count` | How many controllers are connected. |
|
|
164
|
+
|
|
165
|
+
Note the query is `gamepad_present?`, not `gamepad_connected?` — the latter
|
|
166
|
+
name belongs to the hot-plug *hook* above, and two methods differing only by a
|
|
167
|
+
`?` would be a trap.
|
|
168
|
+
|
|
169
|
+
## When a hook raises
|
|
170
|
+
|
|
171
|
+
An exception thrown from any hook comes back out of `run` with its class,
|
|
172
|
+
message and backtrace intact. The loop shuts down cleanly first, so the window
|
|
173
|
+
is not left stranded:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
begin
|
|
177
|
+
MyGame.new.run
|
|
178
|
+
rescue MyGameError => e
|
|
179
|
+
# the loop has already stopped by the time this runs
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
A **non-local exit** — `throw`, `break` or `return` crossing out of a hook —
|
|
184
|
+
cannot be carried across the loop the same way, and is reported as a
|
|
185
|
+
`RuntimeError` telling you to use `close` instead. Use `close` to stop the
|
|
186
|
+
loop; it is the only supported way out other than closing the window.
|
|
187
|
+
|
|
188
|
+
## Several windows in one process
|
|
189
|
+
|
|
190
|
+
Creating more than one `App` works, and they may overlap in lifetime; the
|
|
191
|
+
engine keeps SDL alive until the last one is gone. This mostly matters for test
|
|
192
|
+
suites, which create and discard a window per example.
|
data/docs/api/assets.md
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# Sheets, atlases and maps
|
|
2
|
+
|
|
3
|
+
The classes between a file on disk and a draw call: a sprite sheet sliced into
|
|
4
|
+
frames, a nine-slice panel stretched to any size, a UI atlas, a tile map, and
|
|
5
|
+
the asset manager that loads and caches all of them.
|
|
6
|
+
|
|
7
|
+
They are pure Ruby, but they live in `RGame::Core` because they hold images, and
|
|
8
|
+
an image is a GPU handle. Game logic names them by id and never holds one — see
|
|
9
|
+
[Testing what a scene draws](drawing.md#testing-what-a-scene-draws).
|
|
10
|
+
|
|
11
|
+
| Page section | Class |
|
|
12
|
+
|---|---|
|
|
13
|
+
| [The asset manager](#the-asset-manager) | `RGame::Core::AssetManager` |
|
|
14
|
+
| [Sprite sheets](#sprite-sheets) | `RGame::Core::SpriteSheet` |
|
|
15
|
+
| [Nine-slices](#nine-slices) | `RGame::Core::NineSlice` |
|
|
16
|
+
| [UI atlases](#ui-atlases) | `RGame::Core::UiAtlas` |
|
|
17
|
+
| [Tile maps](#tile-maps) | `RGame::Core::TileMapRenderer` |
|
|
18
|
+
|
|
19
|
+
*This page grows as the rest lands.*
|
|
20
|
+
|
|
21
|
+
## The asset manager
|
|
22
|
+
|
|
23
|
+
The one place file-backed assets are loaded and cached. Every game has one, and
|
|
24
|
+
does not build it — `app.assets` does, rooted at the app's `media_root:`:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
app.assets.image('space.png') # => RGame::Core::Image
|
|
28
|
+
app.assets.sound('example 09/boom.ogg') # => RGame::Core::Sample
|
|
29
|
+
app.assets.song('example 09/theme.ogg') # => RGame::Core::Song
|
|
30
|
+
app.assets.sheet('example 09/player.json') # => RGame::Core::SpriteSheet
|
|
31
|
+
app.assets.ui_atlas('ui/ui_atlas.json') # => RGame::Core::UiAtlas
|
|
32
|
+
app.assets.read('data/levels.txt') # => String
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Paths are relative to the media root; an absolute one is used as it stands. Two
|
|
36
|
+
spellings of the same file — `'a/b.png'`, `'a/./b.png'`, the absolute form —
|
|
37
|
+
are one cache entry, not three.
|
|
38
|
+
|
|
39
|
+
### Adding an asset type
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
app.assets.add_loader(:level) { |path| MyLevel.parse(File.read(path)) }
|
|
43
|
+
app.assets.level('levels/one.json') # cached and grouped like any other
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The built-in types go through the same mechanism at construction, so an added
|
|
47
|
+
one is not a second-class citizen. It exists because some types cannot be built
|
|
48
|
+
from inside `RGame::Core` at all — see [Tile maps](#tile-maps).
|
|
49
|
+
|
|
50
|
+
Every path is **relative to the media root**, and every accessor returns the
|
|
51
|
+
same object each time it is asked — so a file wanted twice is read, decoded and
|
|
52
|
+
uploaded once. That is the point: loading stops being scattered across a game's
|
|
53
|
+
setup, building paths ad hoc and constructing images inline, and becomes one
|
|
54
|
+
object that knows what is loaded.
|
|
55
|
+
|
|
56
|
+
### Groups, and what `release` frees
|
|
57
|
+
|
|
58
|
+
Each cached asset remembers the **set of groups** that asked for it. An
|
|
59
|
+
ungrouped load belongs to a permanent sentinel and survives every `release`; a
|
|
60
|
+
grouped one is reference counted.
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
app.assets.image('ui/buttons.png') # ungrouped: permanent
|
|
64
|
+
app.assets.preload(:level1, image: ['lvl1/bg.png'],
|
|
65
|
+
sound: ['lvl1/hit.ogg'],
|
|
66
|
+
sheet: ['lvl1/foes.json'])
|
|
67
|
+
app.assets.image('shared.png', :level2) # one group, by hand
|
|
68
|
+
|
|
69
|
+
app.assets.release(:level1) # drops lvl1/* unless another group still holds it
|
|
70
|
+
app.assets.clear # drops everything, permanent included
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
An asset two levels both loaded survives until **both** release it, so two
|
|
74
|
+
scenes can share a texture without either one pulling it out from under the
|
|
75
|
+
other. A cache *hit* under a new group is tagged with it too — the alternative
|
|
76
|
+
silently loses the second group's claim.
|
|
77
|
+
|
|
78
|
+
Releasing drops this cache's reference. When the GPU texture actually goes is
|
|
79
|
+
the collector's business; `Image.debug_live_textures` is there if you want to
|
|
80
|
+
watch it happen.
|
|
81
|
+
|
|
82
|
+
`release` refuses the permanent sentinel by name, because releasing it would
|
|
83
|
+
drop every ungrouped asset — the opposite of what "permanent" means. Use
|
|
84
|
+
`clear`.
|
|
85
|
+
|
|
86
|
+
### Composites share their parts
|
|
87
|
+
|
|
88
|
+
A sprite sheet is a descriptor plus an image, and **both are pulled through this
|
|
89
|
+
same cache**. So these hand back one upload between them:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
sheet = app.assets.sheet('sheets/hero.json') # names hero.png inside
|
|
93
|
+
image = app.assets.image('sheets/hero.png') # the same texture, not a second one
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The descriptor's image is resolved *next to the descriptor*, which is what lands
|
|
97
|
+
it on the same cache key a standalone load would use. Release the sheet's group
|
|
98
|
+
and its PNG goes with it.
|
|
99
|
+
|
|
100
|
+
**One known gap.** A composite tags its parts with the group that first built
|
|
101
|
+
it. If a *second* group later asks for the same already-cached composite, only
|
|
102
|
+
the composite's own key is re-tagged, not its parts — so releasing the first
|
|
103
|
+
group can drop a PNG the second still expects. Fine for the usual "each level
|
|
104
|
+
owns its assets" pattern, and it would take per-part tracking to close.
|
|
105
|
+
|
|
106
|
+
### Failure
|
|
107
|
+
|
|
108
|
+
A loader's own error comes through unchanged — `Image::LoadError`,
|
|
109
|
+
`Sample::LoadError`, `Errno::ENOENT` — naming the file. A load that failed
|
|
110
|
+
leaves **nothing** behind: no cache entry and no group tag, so a retry is a
|
|
111
|
+
clean retry rather than a half-registered asset that can never be released.
|
|
112
|
+
|
|
113
|
+
### Testing without files
|
|
114
|
+
|
|
115
|
+
Every asset type maps to a loader proc, and they are injectable:
|
|
116
|
+
|
|
117
|
+
```ruby
|
|
118
|
+
assets = RGame::Core::AssetManager.new(
|
|
119
|
+
root: '/media', app: nil,
|
|
120
|
+
loaders: { image: ->(path) { FakeImage.new(path) } }
|
|
121
|
+
)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The defaults name `Image` and `Audio` only *inside* their bodies, never at load
|
|
125
|
+
time. That is deliberate: it means the caching, path resolution and grouping —
|
|
126
|
+
which is all of the logic here — can be specced with no window, no GL context
|
|
127
|
+
and no files at all.
|
|
128
|
+
|
|
129
|
+
## Sprite sheets
|
|
130
|
+
|
|
131
|
+
A sheet is one image plus a JSON descriptor, sliced into frames at load time and
|
|
132
|
+
drawn one frame at a time.
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
sheet = RGame::Core::SpriteSheet.load(app, 'media/hero.json')
|
|
136
|
+
|
|
137
|
+
sheet.frame_width # => 16
|
|
138
|
+
sheet.grid # => [rows, columns]
|
|
139
|
+
sheet.animations # => the raw table from the descriptor
|
|
140
|
+
|
|
141
|
+
sheet.draw(renderer, row, col, x, y, flip_x: false, z: 0)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
### The descriptor
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"image": "hero.png",
|
|
149
|
+
"frame_width": 16,
|
|
150
|
+
"frame_height": 24,
|
|
151
|
+
"cell_width": 32,
|
|
152
|
+
"cell_height": 32,
|
|
153
|
+
"origin_x": 8,
|
|
154
|
+
"origin_y": 4,
|
|
155
|
+
"animations": {
|
|
156
|
+
"walk_left": { "row": 1, "frames": 4, "fps": 8 },
|
|
157
|
+
"stand": { "row": 0, "col": 1, "frames": 1, "fps": 1 }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`image` is resolved **next to the descriptor**, so a sheet can be moved as a
|
|
163
|
+
pair of files without editing either. `frame_width` and `frame_height` are the
|
|
164
|
+
only required keys; a descriptor missing one raises `ArgumentError` naming it.
|
|
165
|
+
|
|
166
|
+
### A frame can be smaller than its cell
|
|
167
|
+
|
|
168
|
+
Cells sit on a fixed `cell_width` x `cell_height` grid. What gets *drawn* is a
|
|
169
|
+
`frame_width` x `frame_height` rectangle offset by `origin_x` / `origin_y`
|
|
170
|
+
inside its cell:
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
cell (32x32) frame (16x24) at origin (8, 4)
|
|
174
|
+
┌──────────────┐ ┌──────────────┐
|
|
175
|
+
│ │ │ ┌────┐ │
|
|
176
|
+
│ │ │ │ │ │
|
|
177
|
+
│ │ │ │ │ │
|
|
178
|
+
└──────────────┘ └────┴────┴────┘
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
That is what lets a sheet whose cells are sized for the widest pose — an attack,
|
|
182
|
+
a swing — still expose a tight, centred box for walking, so a character does not
|
|
183
|
+
appear to change size when its animation changes. Leave the four keys out and
|
|
184
|
+
frame == cell, which is what a simple sheet wants.
|
|
185
|
+
|
|
186
|
+
Only whole cells count: a sheet 70 pixels wide with 16-pixel cells has four
|
|
187
|
+
columns, and the six leftover pixels are ignored rather than becoming a narrow
|
|
188
|
+
fifth.
|
|
189
|
+
|
|
190
|
+
### Facing
|
|
191
|
+
|
|
192
|
+
`flip_x` mirrors the frame **inside the same rectangle**, so a character
|
|
193
|
+
occupies the same pixels whichever way it faces:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
sheet.draw(renderer, row, col, x, y, flip_x: moving_left)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
There is no width to add back — see
|
|
200
|
+
[Mirroring](drawing.md#mirroring) for why, if you are coming from Gosu.
|
|
201
|
+
|
|
202
|
+
### Animations are handed back raw
|
|
203
|
+
|
|
204
|
+
`#animations` returns the descriptor's table untouched. This class knows nothing
|
|
205
|
+
about time: which frame to show at a given moment is the scene layer's job, and
|
|
206
|
+
it builds its own animation state from that hash. Keeping the raw form here is
|
|
207
|
+
what lets the two sides evolve separately.
|
|
208
|
+
|
|
209
|
+
A sheet with no `animations` key gets `{}`, not `nil` — a sheet of static tiles
|
|
210
|
+
is a legitimate sheet, and a caller should not have to branch.
|
|
211
|
+
|
|
212
|
+
### Slicing costs nothing
|
|
213
|
+
|
|
214
|
+
Every frame is cut once, at construction, as a view onto the single upload. A
|
|
215
|
+
sheet of two hundred frames is two hundred small objects and **one** texture, and
|
|
216
|
+
`#draw` is an array index plus one draw call. Nothing is re-cut per frame.
|
|
217
|
+
|
|
218
|
+
### Loading
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
RGame::Core::SpriteSheet.load(app, path) # standalone
|
|
222
|
+
RGame::Core::SpriteSheet.new(image, atlas) # from an already-loaded image
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Use `.load` for a game with a sheet or two and no asset manager. The asset
|
|
226
|
+
manager uses the second form, with an image it has already cached, so a sheet's
|
|
227
|
+
PNG is shared with a standalone load of the same file rather than decoded twice.
|
|
228
|
+
|
|
229
|
+
## Nine-slices
|
|
230
|
+
|
|
231
|
+
A bordered texture drawn at any size, by cutting it into nine pieces and
|
|
232
|
+
treating each differently.
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
panel = RGame::Core::NineSlice.new(image, x: 0, y: 0, w: 26, h: 28,
|
|
236
|
+
border: 7, scale: 3)
|
|
237
|
+
|
|
238
|
+
panel.draw(renderer, x, y, width, height, z: 0, color: nil)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
┌──┬────────┬──┐ corners: fixed size
|
|
243
|
+
│tl│ top │tr│ top / bottom: tiled across
|
|
244
|
+
├──┼────────┼──┤ left / right: tiled down
|
|
245
|
+
│l │ centre │ r│ centre: tiled both ways
|
|
246
|
+
├──┼────────┼──┤
|
|
247
|
+
│bl│ bottom │br│
|
|
248
|
+
└──┴────────┴──┘
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
One small piece of art fills a button, a dialog or a health bar of any size,
|
|
252
|
+
without the corners smearing.
|
|
253
|
+
|
|
254
|
+
`(x, y, w, h)` is the source rectangle **inside** the image, so one sheet can
|
|
255
|
+
hold many of them — which is what a [UI atlas](#ui-atlases) does with it.
|
|
256
|
+
|
|
257
|
+
### Tiled, not stretched
|
|
258
|
+
|
|
259
|
+
Edges and the centre **repeat**. Stretching a 7-pixel motif would blur exactly
|
|
260
|
+
the detail the art was drawn for; repeating it keeps pixel art crisp at every
|
|
261
|
+
widget size. Each band is clipped to itself, so the last tile in a row is
|
|
262
|
+
cropped cleanly rather than spilling into the corner beside it — and the loops
|
|
263
|
+
always start one more tile rather than stopping short, because a gap at the seam
|
|
264
|
+
is more visible than an overhang that gets cropped.
|
|
265
|
+
|
|
266
|
+
### `border` and `scale`
|
|
267
|
+
|
|
268
|
+
`border` is either a uniform integer or a hash:
|
|
269
|
+
|
|
270
|
+
```ruby
|
|
271
|
+
border: 7
|
|
272
|
+
border: { left: 2, right: 6, top: 4, bottom: 4 }
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
`scale` is an **integer pixel scale for the chrome itself**. Source art is
|
|
276
|
+
small — corners are often 7 pixels — so a scale of 2 or 3 gives legible borders
|
|
277
|
+
on a 640x480 screen with no blurring at all, because every source pixel becomes
|
|
278
|
+
a whole square of screen pixels. It scales the pieces *and* the step between
|
|
279
|
+
tiles, so the tiling stays seamless.
|
|
280
|
+
|
|
281
|
+
### Edge cases, and what they do
|
|
282
|
+
|
|
283
|
+
| | |
|
|
284
|
+
|---|---|
|
|
285
|
+
| A rectangle smaller than its own borders | draws its corners and no bands |
|
|
286
|
+
| A border with no room for a centre (`left + right == w`) | fine — a bar that stretches only vertically |
|
|
287
|
+
| Borders wider than the source rect | `ArgumentError`, naming the borders and the rect |
|
|
288
|
+
| `scale` of zero or less | `ArgumentError` — the tiling loop would never advance |
|
|
289
|
+
|
|
290
|
+
### What it costs
|
|
291
|
+
|
|
292
|
+
The nine pieces are cut once at construction, as views onto the one upload, so
|
|
293
|
+
`#draw` allocates nothing. It issues one call per tile, which is what makes
|
|
294
|
+
`scale` worth having: a panel drawn at 3x is a ninth of the tiles of the same
|
|
295
|
+
panel drawn at 1x.
|
|
296
|
+
|
|
297
|
+
## UI atlases
|
|
298
|
+
|
|
299
|
+
One sheet of UI chrome, cut into named [nine-slices](#nine-slices).
|
|
300
|
+
|
|
301
|
+
```ruby
|
|
302
|
+
atlas = app.assets.ui_atlas('ui/ui_atlas.json')
|
|
303
|
+
renderer.register_ui_atlas(atlas)
|
|
304
|
+
|
|
305
|
+
renderer.nine_slice(:button_idle, x, y, width, height)
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
A button has four states, a panel has one, a scrollbar has three pieces — all
|
|
309
|
+
small, and all cheaper as sub-rectangles of one texture than as a dozen files.
|
|
310
|
+
|
|
311
|
+
### The descriptor
|
|
312
|
+
|
|
313
|
+
```json
|
|
314
|
+
{
|
|
315
|
+
"image": "buttons.png",
|
|
316
|
+
"scale": 3,
|
|
317
|
+
"nine_slices": {
|
|
318
|
+
"button_idle": { "x": 11, "y": 59, "w": 26, "h": 28, "border": 7 },
|
|
319
|
+
"button_focus": { "x": 43, "y": 59, "w": 26, "h": 28, "border": 7 },
|
|
320
|
+
"panel": { "x": 0, "y": 0, "w": 32, "h": 32, "scale": 2,
|
|
321
|
+
"border": { "left": 4, "right": 4, "top": 8, "bottom": 4 } }
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`image` is resolved next to the descriptor. Each entry is a source rectangle
|
|
327
|
+
plus a `border` — a uniform integer or one value per side — and an optional
|
|
328
|
+
`scale` that overrides the sheet-wide one. A sheet with no `scale` draws at 1.
|
|
329
|
+
|
|
330
|
+
### Element names, not filenames
|
|
331
|
+
|
|
332
|
+
`nine_slices` is keyed by whatever the descriptor calls each element, and those
|
|
333
|
+
names are what a widget asks for. That is why nine-slices are the one asset the
|
|
334
|
+
renderer resolves **by registration only** — `:button_focus` is not a file and
|
|
335
|
+
never can be. `register_ui_atlas` binds every element in one call:
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
renderer.register_ui_atlas(atlas) # all of them
|
|
339
|
+
renderer.register_nine_slice(:panel, atlas.nine_slices[:panel]) # or one
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### When an entry is wrong
|
|
343
|
+
|
|
344
|
+
A descriptor holds a dozen of these, so a broken one **names itself**:
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
ArgumentError: ui atlas element :button_idle: nine-slice borders (40, 40, 40, 40)
|
|
348
|
+
do not fit in a 26x28 rect
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Without the element name the failure is arithmetic from inside `NineSlice`, and
|
|
352
|
+
finding the culprit means bisecting the JSON by hand.
|
|
353
|
+
|
|
354
|
+
Parsing happens once, at load. Nothing here is touched again per frame.
|
|
355
|
+
|
|
356
|
+
## Tile maps
|
|
357
|
+
|
|
358
|
+
Draws a Tiled map: the static layers baked once, the animated tiles drawn each
|
|
359
|
+
frame and culled to the viewport.
|
|
360
|
+
|
|
361
|
+
```ruby
|
|
362
|
+
tiles = app.assets.tilemap('map/island.tmx')
|
|
363
|
+
|
|
364
|
+
renderer.tilemap('map/island.tmx', camera_x, camera_y, view_w, view_h, elapsed: seconds)
|
|
365
|
+
# ... the scene draws its actors here ...
|
|
366
|
+
renderer.tilemap_overlay('map/island.tmx', camera_x, camera_y, view_w, view_h,
|
|
367
|
+
z: 20, elapsed: seconds)
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Two bands, with the actors between them
|
|
371
|
+
|
|
372
|
+
Layers split by Tiled's `above` custom property. The **below** band — ground and
|
|
373
|
+
same-level detail — is drawn under the actors; the **above** band — tree
|
|
374
|
+
canopies, roofs — over them, at a `z` the scene picks. Two calls rather than
|
|
375
|
+
one, because the scene draws its actors in between; collapsing them would put
|
|
376
|
+
every canopy behind every character.
|
|
377
|
+
|
|
378
|
+
### What it costs
|
|
379
|
+
|
|
380
|
+
Within each band, every tile that is **not** animated is baked into a
|
|
381
|
+
[recording](drawing.md#recordings-bake-once-replay-cheaply) the first time that
|
|
382
|
+
band is drawn. Scrolling it afterwards is one call per texture, however many
|
|
383
|
+
thousand tiles went into it. The handful that *are* animated are drawn
|
|
384
|
+
individually, **culled to the viewport** — so a map far larger than the screen
|
|
385
|
+
costs only what is on screen.
|
|
386
|
+
|
|
387
|
+
Two maps sharing a tileset share one GPU upload, because the tiles come through
|
|
388
|
+
the asset manager rather than being loaded by the map.
|
|
389
|
+
|
|
390
|
+
### Animation is advanced by you
|
|
391
|
+
|
|
392
|
+
`elapsed` is seconds, and it is an argument rather than a clock this reads:
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
def update(dt) = @elapsed += dt
|
|
396
|
+
def draw(renderer)
|
|
397
|
+
renderer.tilemap(@id, camera.x, camera.y, w, h, elapsed: @elapsed)
|
|
398
|
+
end
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Stop accumulating and the water freezes; accumulate slower and it runs slow; a
|
|
402
|
+
spec passes `0.15` and gets the second frame. See
|
|
403
|
+
[the frame loop](app.md#the-frame-loop) for why nothing on a draw path reads a
|
|
404
|
+
clock.
|
|
405
|
+
|
|
406
|
+
### It is wired up, not built in
|
|
407
|
+
|
|
408
|
+
`RGame::Core` cannot parse a `.tmx` — that is the engine layer's job, and Core
|
|
409
|
+
is not allowed to know the engine layer exists. So the type is *installed*, by
|
|
410
|
+
the one class that may name both:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
app.assets.add_loader(:tilemap) do |path|
|
|
414
|
+
map, image_path = RGame::Engine::TileMap.load(path)
|
|
415
|
+
tiles = app.assets.image(image_path).tiles(map.tileset.tile_width,
|
|
416
|
+
map.tileset.tile_height)
|
|
417
|
+
RGame::Core::TileMapRenderer.new(map, tiles)
|
|
418
|
+
end
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Until that runs, `app.assets` has no `tilemap` accessor and a tilemap draw id
|
|
422
|
+
raises `KeyError` — which is the honest answer, rather than a half-working
|
|
423
|
+
subsystem.
|
|
424
|
+
|
|
425
|
+
`TileMapRenderer#map` hands the parsed map back, for the scene's own collision
|
|
426
|
+
and world-bounds queries.
|