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