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
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.
@@ -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.