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.
Files changed (161) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +26 -0
  3. data/README.md +406 -0
  4. data/docs/api/README.md +167 -0
  5. data/docs/api/app.md +192 -0
  6. data/docs/api/assets.md +426 -0
  7. data/docs/api/audio.md +208 -0
  8. data/docs/api/components.md +321 -0
  9. data/docs/api/drawing.md +330 -0
  10. data/docs/api/game.md +99 -0
  11. data/docs/api/images.md +118 -0
  12. data/docs/api/input.md +179 -0
  13. data/docs/api/internals.md +110 -0
  14. data/docs/api/scene_graph.md +159 -0
  15. data/docs/api/signals.md +142 -0
  16. data/docs/api/systems.md +98 -0
  17. data/docs/api/text.md +116 -0
  18. data/docs/api/toolbox.md +240 -0
  19. data/docs/api/values.md +101 -0
  20. data/ext/README.md +225 -0
  21. data/ext/rgame_core/app/app.c +721 -0
  22. data/ext/rgame_core/app/app_gl.h +64 -0
  23. data/ext/rgame_core/app/frame_loop.c +42 -0
  24. data/ext/rgame_core/app/frame_loop.h +54 -0
  25. data/ext/rgame_core/audio/audio.c +466 -0
  26. data/ext/rgame_core/audio/audio_internal.h +45 -0
  27. data/ext/rgame_core/audio/vorbis_decoder.c +282 -0
  28. data/ext/rgame_core/audio/vorbis_decoder.h +45 -0
  29. data/ext/rgame_core/example.rb +188 -0
  30. data/ext/rgame_core/extconf.rb +167 -0
  31. data/ext/rgame_core/graphics/backend.c +52 -0
  32. data/ext/rgame_core/graphics/backend.h +64 -0
  33. data/ext/rgame_core/graphics/canvas.c +247 -0
  34. data/ext/rgame_core/graphics/canvas.h +143 -0
  35. data/ext/rgame_core/graphics/clip.c +87 -0
  36. data/ext/rgame_core/graphics/clip.h +89 -0
  37. data/ext/rgame_core/graphics/draw_queue.c +216 -0
  38. data/ext/rgame_core/graphics/draw_queue.h +174 -0
  39. data/ext/rgame_core/graphics/gl_backend.c +122 -0
  40. data/ext/rgame_core/graphics/gl_backend.h +43 -0
  41. data/ext/rgame_core/graphics/image.c +304 -0
  42. data/ext/rgame_core/graphics/image_internal.h +30 -0
  43. data/ext/rgame_core/graphics/primitives.c +189 -0
  44. data/ext/rgame_core/graphics/primitives.h +111 -0
  45. data/ext/rgame_core/graphics/recording.c +119 -0
  46. data/ext/rgame_core/graphics/recording.h +88 -0
  47. data/ext/rgame_core/graphics/texture.c +181 -0
  48. data/ext/rgame_core/graphics/texture.h +165 -0
  49. data/ext/rgame_core/graphics/transform.c +128 -0
  50. data/ext/rgame_core/graphics/transform.h +106 -0
  51. data/ext/rgame_core/include/rgame/core.h +577 -0
  52. data/ext/rgame_core/input/device_slots.c +103 -0
  53. data/ext/rgame_core/input/device_slots.h +93 -0
  54. data/ext/rgame_core/input/gamepad.c +145 -0
  55. data/ext/rgame_core/input/gamepad.h +63 -0
  56. data/ext/rgame_core/input/input.c +109 -0
  57. data/ext/rgame_core/input/input.h +99 -0
  58. data/ext/rgame_core/ruby/audio_ext.c +321 -0
  59. data/ext/rgame_core/ruby/core_ext.c +513 -0
  60. data/ext/rgame_core/ruby/core_ext.h +51 -0
  61. data/ext/rgame_core/ruby/font_ext.c +168 -0
  62. data/ext/rgame_core/ruby/image_ext.c +230 -0
  63. data/ext/rgame_core/ruby/recording_ext.c +186 -0
  64. data/ext/rgame_core/ruby/renderer_ext.c +376 -0
  65. data/ext/rgame_core/text/atlas.c +59 -0
  66. data/ext/rgame_core/text/atlas.h +85 -0
  67. data/ext/rgame_core/text/font.c +281 -0
  68. data/ext/rgame_core/text/font.h +139 -0
  69. data/ext/rgame_core/text/font_atlas.c +385 -0
  70. data/ext/rgame_core/text/font_internal.h +47 -0
  71. data/ext/rgame_core/text/glyph_cache.c +142 -0
  72. data/ext/rgame_core/text/glyph_cache.h +89 -0
  73. data/ext/rgame_core/vendor/README.md +159 -0
  74. data/ext/rgame_core/vendor/miniaudio.h +95864 -0
  75. data/ext/rgame_core/vendor/miniaudio_impl.c +62 -0
  76. data/ext/rgame_core/vendor/stb_image.h +7988 -0
  77. data/ext/rgame_core/vendor/stb_image_impl.c +31 -0
  78. data/ext/rgame_core/vendor/stb_truetype.h +5079 -0
  79. data/ext/rgame_core/vendor/stb_truetype_impl.c +23 -0
  80. data/ext/rgame_core/vendor/stb_vorbis.c +5584 -0
  81. data/ext/rgame_core/vendor/stb_vorbis_impl.c +29 -0
  82. data/ext/rgame_util/color.c +19 -0
  83. data/ext/rgame_util/color.h +60 -0
  84. data/ext/rgame_util/color_ext.c +156 -0
  85. data/ext/rgame_util/extconf.rb +27 -0
  86. data/ext/rgame_util/tensor.c +186 -0
  87. data/ext/rgame_util/util_ext.c +27 -0
  88. data/ext/rgame_util/util_ext.h +16 -0
  89. data/lib/rgame/boot.rb +13 -0
  90. data/lib/rgame/core/app.rb +82 -0
  91. data/lib/rgame/core/asset_manager.rb +224 -0
  92. data/lib/rgame/core/audio.rb +124 -0
  93. data/lib/rgame/core/font.rb +49 -0
  94. data/lib/rgame/core/gamepad.rb +55 -0
  95. data/lib/rgame/core/image.rb +55 -0
  96. data/lib/rgame/core/input.rb +77 -0
  97. data/lib/rgame/core/nine_slice.rb +163 -0
  98. data/lib/rgame/core/recording.rb +52 -0
  99. data/lib/rgame/core/renderer.rb +363 -0
  100. data/lib/rgame/core/sprite_sheet.rb +108 -0
  101. data/lib/rgame/core/tile_map_renderer.rb +160 -0
  102. data/lib/rgame/core/ui_atlas.rb +86 -0
  103. data/lib/rgame/core.rb +24 -0
  104. data/lib/rgame/engine/actor.rb +53 -0
  105. data/lib/rgame/engine/animation_set.rb +49 -0
  106. data/lib/rgame/engine/animator.rb +44 -0
  107. data/lib/rgame/engine/audio_bus.rb +24 -0
  108. data/lib/rgame/engine/audio_director.rb +29 -0
  109. data/lib/rgame/engine/body.rb +49 -0
  110. data/lib/rgame/engine/cached_label.rb +33 -0
  111. data/lib/rgame/engine/camera.rb +33 -0
  112. data/lib/rgame/engine/camera_view.rb +28 -0
  113. data/lib/rgame/engine/circle_collider.rb +32 -0
  114. data/lib/rgame/engine/collision_box.rb +34 -0
  115. data/lib/rgame/engine/collision_system.rb +44 -0
  116. data/lib/rgame/engine/component.rb +30 -0
  117. data/lib/rgame/engine/components/action_trigger.rb +41 -0
  118. data/lib/rgame/engine/components/animated_sprite.rb +63 -0
  119. data/lib/rgame/engine/components/character_body.rb +70 -0
  120. data/lib/rgame/engine/components/circle_collider.rb +44 -0
  121. data/lib/rgame/engine/components/collision_world.rb +103 -0
  122. data/lib/rgame/engine/components/despawn_offscreen.rb +26 -0
  123. data/lib/rgame/engine/components/path_follow.rb +84 -0
  124. data/lib/rgame/engine/components/player_controller.rb +24 -0
  125. data/lib/rgame/engine/components/pool.rb +53 -0
  126. data/lib/rgame/engine/components/screen_wrap.rb +27 -0
  127. data/lib/rgame/engine/components/sprite.rb +31 -0
  128. data/lib/rgame/engine/components/targeting.rb +54 -0
  129. data/lib/rgame/engine/components/thrust_controller.rb +65 -0
  130. data/lib/rgame/engine/components/tile_world.rb +68 -0
  131. data/lib/rgame/engine/components/timer.rb +75 -0
  132. data/lib/rgame/engine/components/velocity.rb +27 -0
  133. data/lib/rgame/engine/components/wander_controller.rb +60 -0
  134. data/lib/rgame/engine/debug_overlay.rb +106 -0
  135. data/lib/rgame/engine/i18n.rb +97 -0
  136. data/lib/rgame/engine/input/action_mapper.rb +46 -0
  137. data/lib/rgame/engine/input/actions.rb +41 -0
  138. data/lib/rgame/engine/input/player_controller.rb +14 -0
  139. data/lib/rgame/engine/matrix.rb +32 -0
  140. data/lib/rgame/engine/node2d.rb +271 -0
  141. data/lib/rgame/engine/path.rb +78 -0
  142. data/lib/rgame/engine/pool.rb +51 -0
  143. data/lib/rgame/engine/resettable.rb +67 -0
  144. data/lib/rgame/engine/scene/scene_stack.rb +65 -0
  145. data/lib/rgame/engine/signal.rb +75 -0
  146. data/lib/rgame/engine/spatial_hash.rb +71 -0
  147. data/lib/rgame/engine/tile_collision.rb +78 -0
  148. data/lib/rgame/engine/tile_map.rb +149 -0
  149. data/lib/rgame/engine/tileset.rb +101 -0
  150. data/lib/rgame/engine/timer.rb +51 -0
  151. data/lib/rgame/engine.rb +68 -0
  152. data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
  153. data/lib/rgame/fonts/OFL.txt +102 -0
  154. data/lib/rgame/game.rb +129 -0
  155. data/lib/rgame/util/color.rb +27 -0
  156. data/lib/rgame/util/controls.rb +107 -0
  157. data/lib/rgame/util/tensor.rb +12 -0
  158. data/lib/rgame/util.rb +8 -0
  159. data/lib/rgame/version.rb +12 -0
  160. data/lib/rgame.rb +20 -0
  161. metadata +215 -0
@@ -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
+ ```
@@ -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.