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/drawing.md
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# Drawing
|
|
2
|
+
|
|
3
|
+
`RGame::Core::Renderer` is what a game draws with. It is created from an app and
|
|
4
|
+
used inside `draw`:
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
require 'rgame'
|
|
8
|
+
require 'rgame/core'
|
|
9
|
+
|
|
10
|
+
class MyGame < RGame::Core::App
|
|
11
|
+
Color = RGame::Util::Color
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
super(width: 800, height: 600, caption: 'demo')
|
|
15
|
+
@renderer = RGame::Core::Renderer.new(self)
|
|
16
|
+
@hero = RGame::Core::Image.new(self, 'hero.png')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def draw
|
|
20
|
+
@renderer.rect(40, 40, 160, 100, color: Color.new(224, 64, 64))
|
|
21
|
+
@renderer.circle(620, 110, 70, color: Color.new(64, 96, 224))
|
|
22
|
+
@renderer.image(@hero, 400, 300, angle: 45)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
MyGame.new.run
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Two things about that are worth knowing before anything else.
|
|
30
|
+
|
|
31
|
+
**Drawing is only legal inside `draw`.** Calling one of these from `update` or
|
|
32
|
+
from a constructor raises. The frame is not open at those times, so the call
|
|
33
|
+
would be silently discarded — and an invisible failure is worse than a loud one.
|
|
34
|
+
|
|
35
|
+
**Nothing is drawn immediately.** Calls accumulate, and the frame is sorted and
|
|
36
|
+
sent to the GPU once, after `draw` returns. So the order you make calls in does
|
|
37
|
+
not decide what ends up on top — `z:` does.
|
|
38
|
+
|
|
39
|
+
## Coordinates, colours and z
|
|
40
|
+
|
|
41
|
+
| | |
|
|
42
|
+
|---|---|
|
|
43
|
+
| Origin | Top-left. x grows right, y grows **down**. |
|
|
44
|
+
| Angles | Degrees. A **positive angle turns clockwise** on screen. |
|
|
45
|
+
| `z:` | Higher is nearer the viewer. Equal z keeps call order. |
|
|
46
|
+
| `color:` | `nil` (white), `[r, g, b]`, `[r, g, b, a]`, or a `RGame::Util::Color`. |
|
|
47
|
+
|
|
48
|
+
`z` defaults to `50` for shapes and `0` for images, so a debug box or a health
|
|
49
|
+
bar drawn without a `z:` lands on top of the scene rather than under it.
|
|
50
|
+
|
|
51
|
+
Equal-z stability matters more than it sounds: without it, two sprites on the
|
|
52
|
+
same layer would swap places whenever the sort felt like it, which reads as
|
|
53
|
+
flicker.
|
|
54
|
+
|
|
55
|
+
### Colours and allocation
|
|
56
|
+
|
|
57
|
+
Passing a `Color` allocates nothing — it is a frozen value, and the same one can
|
|
58
|
+
be shared by every sprite that uses it. Passing an array allocates a colour per
|
|
59
|
+
call, which is fine at setup and wasteful sixty times a second:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
RED = RGame::Util::Color.new(224, 64, 64) # once
|
|
63
|
+
|
|
64
|
+
def draw
|
|
65
|
+
@renderer.rect(10, 10, 50, 50, color: RED) # every frame, allocation-free
|
|
66
|
+
end
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Shapes
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
renderer.rect(x, y, width, height, z: 50, color: nil)
|
|
73
|
+
renderer.quad(x1, y1, x2, y2, x3, y3, x4, y4, z: 50, color: nil)
|
|
74
|
+
renderer.triangle(x1, y1, x2, y2, x3, y3, z: 50, color: nil)
|
|
75
|
+
renderer.line(x1, y1, x2, y2, thickness: 1.0, z: 50, color: nil)
|
|
76
|
+
renderer.circle(cx, cy, radius, z: 50, color: nil, segments: 64)
|
|
77
|
+
renderer.debug_box(x, y, width, height, z: 50)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A **quad's** four points are taken in loop order — top-left, top-right,
|
|
81
|
+
bottom-right, bottom-left for a rectangle. Listing them in Z order gives an
|
|
82
|
+
hourglass.
|
|
83
|
+
|
|
84
|
+
A **line** has real thickness because it is drawn as a quad. OpenGL's own line
|
|
85
|
+
width is a suggestion drivers are free to ignore above one pixel, so a line
|
|
86
|
+
worth seeing has to be a shape.
|
|
87
|
+
|
|
88
|
+
A **circle** is a fan of triangles, and the whole fan is one batch — there is no
|
|
89
|
+
cached circle texture to warm up and nothing to configure. `segments:` is there
|
|
90
|
+
for the rare case where 64 is too many or too few.
|
|
91
|
+
|
|
92
|
+
`debug_box` is a translucent red rectangle for visualising a collision box, so a
|
|
93
|
+
scene can ask for one without deciding what colour "debug" is.
|
|
94
|
+
|
|
95
|
+
## Images
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
renderer.image(image, cx, cy, angle: 0, scale: 1, z: 0, color: nil)
|
|
99
|
+
renderer.image_at(image, x, y, scale_x: 1, scale_y: 1, z: 0, color: nil)
|
|
100
|
+
renderer.background(image, x = 0, y = 0, z: 0, color: nil)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Three anchors for three jobs. `image` **centres** on the position given and
|
|
104
|
+
rotates about that centre — the sprite case. `image_at` places the **top-left**
|
|
105
|
+
corner and scales each axis on its own — tiles, nine-slice corners, sheet
|
|
106
|
+
frames. `background` is `image_at` at natural size, named for its usual job.
|
|
107
|
+
|
|
108
|
+
### Mirroring
|
|
109
|
+
|
|
110
|
+
A negative scale on `image_at` mirrors the image **inside the same rectangle**.
|
|
111
|
+
It does not move it:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
renderer.image_at(frame, x, y, scale_x: facing_left ? -1 : 1)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Both calls cover the same pixels; only the picture is reversed. That is worth
|
|
118
|
+
knowing if you are coming from Gosu, where a negative scale mirrors *about* the
|
|
119
|
+
anchor and the caller adds a width back to compensate. Here `(x, y)` is the
|
|
120
|
+
top-left corner whatever the sign, so there is nothing to compensate for — and
|
|
121
|
+
nothing to forget.
|
|
122
|
+
|
|
123
|
+
A scale of `0` draws nothing.
|
|
124
|
+
|
|
125
|
+
`color:` tints: the image's pixels are multiplied by it, so white leaves the
|
|
126
|
+
image alone and a colour with alpha fades it.
|
|
127
|
+
|
|
128
|
+
See [Images](images.md) for loading files and slicing sprite sheets.
|
|
129
|
+
|
|
130
|
+
**An image can only be drawn by the app that loaded it.** GPU textures belong to
|
|
131
|
+
one window's OpenGL context and are not shared with another, so drawing another
|
|
132
|
+
app's image would sample nothing and paint a plain white rectangle. Rather than
|
|
133
|
+
let that happen quietly, it raises `ArgumentError`. In a one-window game — which
|
|
134
|
+
is nearly all of them — this never comes up.
|
|
135
|
+
|
|
136
|
+
## Drawing by id
|
|
137
|
+
|
|
138
|
+
Game logic names an asset; it does not hold one. That is not a convenience —
|
|
139
|
+
the scene layer may hold `RGame::Util` values but no `RGame::Core` handle at
|
|
140
|
+
all, so a Symbol or a path is the only thing a node *can* carry.
|
|
141
|
+
|
|
142
|
+
An id is normally a **root-relative path**, resolved through the app's
|
|
143
|
+
[asset manager](assets.md) and then remembered:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
renderer.sprite('example 09/player.json', row, col, x, y, flip_x: false, z: 0)
|
|
147
|
+
renderer.image('space.png', cx, cy, angle: 0, scale: 1)
|
|
148
|
+
renderer.background('space.png')
|
|
149
|
+
renderer.tilemap('map/island.tmx', camera_x, camera_y, viewport_w, viewport_h)
|
|
150
|
+
renderer.tilemap_overlay('map/island.tmx', camera_x, camera_y, viewport_w, viewport_h, z: 20)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Nothing has to be set up for that: `Renderer.new(app)` takes the app's own
|
|
154
|
+
manager, so a path just works. `Renderer.new(app, assets: other)` overrides it.
|
|
155
|
+
|
|
156
|
+
### Registering
|
|
157
|
+
|
|
158
|
+
`register_*` pre-binds an id to an object you chose, and wins over the asset
|
|
159
|
+
manager. It is for the two things a path cannot name: an id that is not a file,
|
|
160
|
+
and an object the game assembled itself.
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
renderer.register_image(:space, app.assets.image('space.png'))
|
|
164
|
+
renderer.register_sheet(:hero, app.assets.sheet('hero.json'))
|
|
165
|
+
renderer.register_tilemap(:level1, app.assets.tilemap('island.tmx'))
|
|
166
|
+
renderer.register_nine_slice(:panel, atlas.nine_slices[:panel])
|
|
167
|
+
renderer.register_ui_atlas(atlas) # every element under its own name
|
|
168
|
+
|
|
169
|
+
renderer.image(:space, 100, 100)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Nine-slices are registration-only.** Their ids name an *element of an atlas*,
|
|
173
|
+
not a file, so there is nothing for a manager to resolve them to.
|
|
174
|
+
|
|
175
|
+
### What resolution does
|
|
176
|
+
|
|
177
|
+
| Given | |
|
|
178
|
+
|---|---|
|
|
179
|
+
| An `Image` | drawn directly — `#image`, `#image_at` and `#background` all take one |
|
|
180
|
+
| A registered id | the registered object |
|
|
181
|
+
| A `String` | resolved through the asset manager, then remembered |
|
|
182
|
+
| A `Symbol` that is not registered | `KeyError`, naming the id and the type |
|
|
183
|
+
| `nil` | `TypeError` |
|
|
184
|
+
|
|
185
|
+
A Symbol is never offered to the asset manager, because only a String can be a
|
|
186
|
+
path. So a typo'd Symbol says "no sheet registered for `:heor`" rather than
|
|
187
|
+
whatever a loader makes of being handed a Symbol for a filename — and a broken
|
|
188
|
+
*file* still raises its own `LoadError` naming it, which is a different bug
|
|
189
|
+
wanting a different fix.
|
|
190
|
+
|
|
191
|
+
Resolution happens once per id and the answer is kept, so per-frame drawing
|
|
192
|
+
neither re-resolves nor allocates a lookup key.
|
|
193
|
+
|
|
194
|
+
## Transform blocks
|
|
195
|
+
|
|
196
|
+
Each of these applies to everything drawn inside it, and undoes itself
|
|
197
|
+
afterwards — including when the block raises.
|
|
198
|
+
|
|
199
|
+
```ruby
|
|
200
|
+
renderer.translated(dx, dy) { ... }
|
|
201
|
+
renderer.rotated(angle, pivot_x, pivot_y) { ... }
|
|
202
|
+
renderer.scaled(sx, sy = sx) { ... }
|
|
203
|
+
renderer.clipped(x, y, width, height) { ... }
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
They nest, and they compose in the order they are opened:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
renderer.translated(-camera.x, -camera.y) do # world space -> screen space
|
|
210
|
+
renderer.rotated(ship.angle, ship.x, ship.y) do
|
|
211
|
+
renderer.image(hull, ship.x, ship.y)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`translated` is how a camera works, and the reason it is a *draw-time* transform
|
|
217
|
+
rather than something baked into positions is that the same world can then be
|
|
218
|
+
drawn twice, under two different offsets — which is what split-screen is.
|
|
219
|
+
|
|
220
|
+
`rotated(0, …)`, `translated(0, 0)` and `scaled(1)` are free: they skip the
|
|
221
|
+
transform entirely and just run the block, so unrotated drawing pays nothing.
|
|
222
|
+
|
|
223
|
+
### Clipping and split-screen
|
|
224
|
+
|
|
225
|
+
A clip **narrows**. Nesting one inside another intersects them, so a child can
|
|
226
|
+
never draw outside the region its parent allowed. Two clipped blocks are a
|
|
227
|
+
split screen:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
def draw
|
|
231
|
+
@renderer.clipped(0, 0, 400, 600) do
|
|
232
|
+
@renderer.translated(-@player_one.x, -@player_one.y) { draw_world }
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
@renderer.clipped(400, 0, 400, 600) do
|
|
236
|
+
@renderer.translated(400 - @player_two.x, -@player_two.y) { draw_world }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Recordings: bake once, replay cheaply
|
|
242
|
+
|
|
243
|
+
A tile layer is a couple of thousand quads that have not changed since the level
|
|
244
|
+
loaded. `record` bakes a block of drawing so that replaying it costs one call
|
|
245
|
+
per texture, however many draws went into it:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
def draw
|
|
249
|
+
@ground ||= @renderer.record do
|
|
250
|
+
@tiles.each { |tile| @renderer.image(tile.image, tile.x, tile.y) }
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
@ground.draw(-@camera.x, -@camera.y)
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Nothing is drawn at bake time — the block's output goes into the recording
|
|
258
|
+
instead of into the frame. `record` must be called inside `draw` like everything
|
|
259
|
+
else, which is why the example bakes on the first frame rather than in
|
|
260
|
+
`initialize`.
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
baked.draw(x = 0, y = 0, z: 0, color: nil)
|
|
264
|
+
baked.batch_count # GL calls one replay costs
|
|
265
|
+
baked.width # the size of what was baked
|
|
266
|
+
baked.empty?
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Positions, texture coordinates, colours and any transforms inside the block
|
|
270
|
+
are baked in.** The transform in effect when the recording is *drawn* applies on
|
|
271
|
+
top, so a baked layer scrolls under a camera without being rebuilt, and the same
|
|
272
|
+
recording can be stamped in several places:
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
5.times { |i| @bush.draw(i * 120, 300) }
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**`color:` tints the replay** — each recorded colour is multiplied by it, so a
|
|
279
|
+
whole baked layer can be faded out at once.
|
|
280
|
+
|
|
281
|
+
**Clipping cannot be baked.** Clipping happens when pixels are rasterised, so a
|
|
282
|
+
clip rectangle captured in one place would be wrong everywhere else the
|
|
283
|
+
recording is drawn. Pushing a clip inside a `record` block raises; clip the
|
|
284
|
+
replay instead, which is what was meant anyway:
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
@renderer.clipped(0, 0, 400, 600) { @ground.draw(-@camera.x, -@camera.y) }
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Recordings do not nest, and a block that raises leaves nothing half-recorded
|
|
291
|
+
behind. A recording keeps the images baked into it alive, so a sprite sheet
|
|
292
|
+
dropped after baking does not take its texture with it.
|
|
293
|
+
|
|
294
|
+
## Testing what a scene draws
|
|
295
|
+
|
|
296
|
+
The renderer is an interface, not a class your game should name. Game logic
|
|
297
|
+
receives one and calls methods on it; a headless spec passes a recording fake
|
|
298
|
+
instead and asserts on the calls:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
renderer = FakeRenderer.new
|
|
302
|
+
health_bar.draw(renderer)
|
|
303
|
+
|
|
304
|
+
expect(renderer.calls_to(:rect).map(&:args)).to eq([[10, 10, 64, 8]])
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Recordings are faked too, and the fake keeps the two questions apart — what was
|
|
308
|
+
baked, and where it was replayed:
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
ground = renderer.record { ... } # => a FakeRecording
|
|
312
|
+
|
|
313
|
+
expect(ground.calls.size).to eq(tiles.size) # baked once, not per frame
|
|
314
|
+
expect(ground.draws.map(&:args)).to eq([[-camera.x, -camera.y]])
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
That runs with no window, no GPU and no clock. The fake and the real renderer
|
|
318
|
+
are both checked against one shared contract (`spec/support/shared_examples/
|
|
319
|
+
a_renderer.rb`), so the fake cannot drift into describing a renderer that does
|
|
320
|
+
not exist — which would leave a green test suite and a game that no longer runs.
|
|
321
|
+
|
|
322
|
+
## Text
|
|
323
|
+
|
|
324
|
+
`renderer.text(string, x, y)` draws a line of text, and `text_width` measures
|
|
325
|
+
one. See [Text](text.md) for fonts, the shipped default and what it covers.
|
|
326
|
+
|
|
327
|
+
## What is not here yet
|
|
328
|
+
|
|
329
|
+
Audio and drawing by asset id (`sprite(:hero, row, col, …)`) are still to come.
|
|
330
|
+
Today an image is passed as an object rather than looked up in a registry.
|
data/docs/api/game.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# `RGame::Game`
|
|
2
|
+
|
|
3
|
+
The entry point of a game, and the one class that knows both halves of the
|
|
4
|
+
engine.
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
8
|
+
require 'rgame/game'
|
|
9
|
+
|
|
10
|
+
class HelloScene < Engine::Node2D
|
|
11
|
+
def on_draw(renderer) = renderer.text('Hello world!', 250, 200)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
RGame::Game.new(root: HelloScene.new, caption: 'Hello').start
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
A complete game is a root node plus that. `Game` assembles what a running game
|
|
18
|
+
needs around it — the window and its loop, the renderer, the asset manager, the
|
|
19
|
+
sound device, the input mapper, the debug overlay — and drives the root node.
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
RGame::Game.new(root:, width: 640, height: 480, caption: 'RGame',
|
|
23
|
+
media_root: 'media', action_map: {})
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
| Reader | |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `root` | the node tree |
|
|
29
|
+
| `renderer` | what scenes draw through |
|
|
30
|
+
| `action_mapper` | physical input → named actions |
|
|
31
|
+
| `assets`, `audio`, `media_root`, `width`, `height`, `fps` | inherited from [App](app.md) |
|
|
32
|
+
|
|
33
|
+
`start` brings the tree live — it hands the game to the root as its `context`,
|
|
34
|
+
calls `enter_tree`, and runs the loop until the window closes. `Esc` quits and
|
|
35
|
+
`F1` toggles the debug overlay.
|
|
36
|
+
|
|
37
|
+
## Why this class exists at all
|
|
38
|
+
|
|
39
|
+
`RGame::Engine` holds game concepts and may not name
|
|
40
|
+
`RGame::Core`; `RGame::Core` owns windows, textures and sound devices and may
|
|
41
|
+
not know Engine exists. Two RuboCop cops enforce that. Something still has to
|
|
42
|
+
introduce them, and **this is that something** — keeping the introduction in one
|
|
43
|
+
file is what makes the rule checkable everywhere else.
|
|
44
|
+
|
|
45
|
+
The tile map is the clearest case: parsing a `.tmx` is Engine's job, drawing one
|
|
46
|
+
is Core's, and neither may call the other. So `Game` installs the loader that
|
|
47
|
+
joins them, and `app.assets.tilemap('map/island.tmx')` works from then on.
|
|
48
|
+
|
|
49
|
+
## Reaching the game from a node
|
|
50
|
+
|
|
51
|
+
A node deep in the tree gets at the asset manager through the root's context,
|
|
52
|
+
so nothing has to be threaded through constructors:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
sheet = node.root.context.assets.sheet('player.json')
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Input
|
|
59
|
+
|
|
60
|
+
`action_map` names the actions a game has, in terms of the physical ids
|
|
61
|
+
[Input](input.md) knows:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
RGame::Game.new(
|
|
65
|
+
root: Root.new,
|
|
66
|
+
action_map: {
|
|
67
|
+
move_x: { axis: %i[left right] }, # -1.0 .. 1.0
|
|
68
|
+
fire: { button: %i[fire] } # held / pressed / released
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
A scene reads the resulting snapshot in `on_control(actions)` — `actions.axis(:move_x)`,
|
|
74
|
+
`actions.pressed?(:fire)` — and never sees a key.
|
|
75
|
+
|
|
76
|
+
**Input is polled once per simulation tick**, not once per rendered frame. That
|
|
77
|
+
matters for edge queries: `pressed?` means "held now, not held at the previous
|
|
78
|
+
poll", so whatever polls decides what a press *is*. A loop that renders faster
|
|
79
|
+
than it simulates would otherwise consume the press between two ticks, and
|
|
80
|
+
menus would stop responding on fast machines only.
|
|
81
|
+
|
|
82
|
+
Polling per tick costs nothing and loses nothing, because the C layer snapshots
|
|
83
|
+
the keyboard once per frame: several ticks inside one frame read identical
|
|
84
|
+
state, and the edge lands on the first of them. One press, one `pressed?`.
|
|
85
|
+
|
|
86
|
+
## Subclassing it
|
|
87
|
+
|
|
88
|
+
`Game` is an [`App`](app.md), so anything an App can override it can too. The
|
|
89
|
+
loop, the fixed timestep and the catch-up cap are the engine's; a subclass adds
|
|
90
|
+
behaviour around the tree rather than replacing the shell.
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
class MyGame < RGame::Game
|
|
94
|
+
def button_down(id)
|
|
95
|
+
super # keeps Esc and F1 working
|
|
96
|
+
@paused = !@paused if id == RGame::Util::Controls::KEY_SPACE
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
data/docs/api/images.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Images
|
|
2
|
+
|
|
3
|
+
`RGame::Core::Image` is a picture on the GPU. Loading one decodes a PNG and
|
|
4
|
+
uploads it; everything after that — subimages, tiles, whole sprite sheets — is a
|
|
5
|
+
*view* of that single upload.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
require 'rgame/core'
|
|
9
|
+
|
|
10
|
+
img = RGame::Core::Image.new(app, 'hero.png')
|
|
11
|
+
frame = img.subimage(0, 0, 16, 16)
|
|
12
|
+
walk = RGame::Core::Image.load_tiles(app, 'hero.png', 16, 16)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
There is nothing to draw them with yet — the renderer is the next piece of the
|
|
16
|
+
engine to land. What works today is loading, slicing and measuring.
|
|
17
|
+
|
|
18
|
+
## Loading
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
image = RGame::Core::Image.new(app, 'assets/hero.png')
|
|
22
|
+
image.width # => 64
|
|
23
|
+
image.height # => 32
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The `app` argument is required and comes first. A texture lives inside one
|
|
27
|
+
OpenGL context, so an image genuinely is an image *of* a window rather than a
|
|
28
|
+
free-floating object — and saying so is what makes two windows work, and what
|
|
29
|
+
lets the image keep its app alive for as long as it needs it.
|
|
30
|
+
|
|
31
|
+
PNG is the only format. A file that cannot be read or decoded raises
|
|
32
|
+
`RGame::Core::Image::LoadError` with the path in the message:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
begin
|
|
36
|
+
RGame::Core::Image.new(app, 'assets/typo.png')
|
|
37
|
+
rescue RGame::Core::Image::LoadError => e
|
|
38
|
+
warn e.message # => "could not read assets/typo.png"
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Greyscale and palette PNGs load fine; they are converted to RGBA on the way in,
|
|
43
|
+
so there is only ever one pixel format in play.
|
|
44
|
+
|
|
45
|
+
**Images are always sampled nearest-neighbour.** There is no setting for it.
|
|
46
|
+
The engine exists to draw pixel art, and blurring it on scale-up is never the
|
|
47
|
+
intent.
|
|
48
|
+
|
|
49
|
+
## Slicing: subimages and tiles
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
sheet = RGame::Core::Image.new(app, 'tiles.png') # say 64x32
|
|
53
|
+
|
|
54
|
+
sheet.subimage(16, 0, 16, 16) # one 16x16 region
|
|
55
|
+
sheet.tile_count(16, 16) # => 8 (4 columns x 2 rows)
|
|
56
|
+
sheet.tile(16, 16, 5) # the sixth tile
|
|
57
|
+
sheet.tiles(16, 16) # => [Image, Image, ...] all eight
|
|
58
|
+
sheet.each_tile(16, 16) { |t| } # the same, without building the Array
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`Image.load_tiles(app, path, w, h)` is `new` plus `tiles` in one step, and is
|
|
62
|
+
the usual way to open a sprite sheet:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
frames = RGame::Core::Image.load_tiles(app, 'explosion.png', 32, 32)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Three things are worth knowing about all of these:
|
|
69
|
+
|
|
70
|
+
**Nothing is decoded or uploaded twice.** A hundred tiles are a hundred small
|
|
71
|
+
Ruby objects over one texture. Slicing a sheet is cheap enough to do at load
|
|
72
|
+
time without thinking about it.
|
|
73
|
+
|
|
74
|
+
**Tiles come back in reading order** — left to right, then top to bottom — which
|
|
75
|
+
is how sprite-sheet frames are numbered everywhere else.
|
|
76
|
+
|
|
77
|
+
**A partial tile at the right or bottom edge is not a tile.** A 70-pixel-wide
|
|
78
|
+
sheet sliced into 16s yields four columns and leaves six pixels of padding
|
|
79
|
+
alone, because half a sprite is never what was meant.
|
|
80
|
+
|
|
81
|
+
### Coordinates are relative to what you cut from
|
|
82
|
+
|
|
83
|
+
`subimage` on a subimage composes, and cannot escape its parent:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
row = sheet.subimage(0, 16, 64, 16) # the bottom row of the sheet
|
|
87
|
+
tile = row.subimage(32, 0, 16, 16) # 32 pixels into *the row*, not the sheet
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
A rectangle that does not fit raises `ArgumentError`, and an out-of-range tile
|
|
91
|
+
index raises `IndexError`, rather than either returning `nil`:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
sheet.subimage(0, 0, 999, 999) # ArgumentError: does not fit in a 64x32 image
|
|
95
|
+
sheet.tile(16, 16, 99) # IndexError: 8 tiles of 16x16
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A `nil` here would travel a long way — into an asset table, out of it three
|
|
99
|
+
scenes later — before failing as a `NoMethodError` with nothing left pointing at
|
|
100
|
+
the coordinates that were wrong.
|
|
101
|
+
|
|
102
|
+
## Lifetime
|
|
103
|
+
|
|
104
|
+
You never free an image. The texture is released when the last view of it is
|
|
105
|
+
garbage-collected, and the order does not matter: dropping the sheet while its
|
|
106
|
+
tiles are still in use keeps the upload alive, and dropping the window first is
|
|
107
|
+
also fine.
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
sheet = RGame::Core::Image.new(app, 'tiles.png')
|
|
111
|
+
ground = sheet.tile(16, 16, 0)
|
|
112
|
+
sheet = nil # the upload stays — `ground` is still a view of it
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
That is worth stating because a leaked GPU texture is invisible while it
|
|
116
|
+
happens: nothing is slower, nothing looks wrong, and video memory fills up over
|
|
117
|
+
an hour of play. `Image.debug_live_textures` reports how many uploads exist, and
|
|
118
|
+
is there for tests to assert against; it is not part of the drawing API.
|