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,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Core
5
+ # A bordered texture drawn at any size, by cutting it into nine pieces and
6
+ # treating each differently.
7
+ #
8
+ # panel = RGame::Core::NineSlice.new(image, x: 0, y: 0, w: 26, h: 28,
9
+ # border: 7, scale: 3)
10
+ # panel.draw(renderer, x, y, width, height, z: 0)
11
+ #
12
+ # The four corners keep their size, the four edges stretch along one axis
13
+ # and the centre along both — so one small piece of art fills a button, a
14
+ # dialog or a health bar without the corners smearing.
15
+ #
16
+ # ```
17
+ # ┌──┬────────┬──┐ corners: fixed
18
+ # │tl│ top │tr│ top / bottom: tiled across
19
+ # ├──┼────────┼──┤ left / right: tiled down
20
+ # │l │ centre │ r│ centre: tiled both ways
21
+ # ├──┼────────┼──┤
22
+ # │bl│ bottom │br│
23
+ # └──┴────────┴──┘
24
+ # ```
25
+ #
26
+ # **Edges and the centre are *tiled*, not stretched.** Repeating a 7-pixel
27
+ # motif keeps pixel art crisp at any widget size; stretching it would blur
28
+ # exactly the detail the art was drawn for. Each band is clipped to itself,
29
+ # so the last tile in a row is cropped cleanly rather than spilling over the
30
+ # corner next to it.
31
+ #
32
+ # `scale` is an integer pixel scale for the chrome itself. Source art is
33
+ # small — corners are often 7 pixels — so a scale of 2 or 3 gives legible
34
+ # borders on a 640x480 screen without any blurring, because every source
35
+ # pixel becomes a whole square of screen pixels.
36
+ #
37
+ # The nine pieces are cut once at construction, so `#draw` allocates
38
+ # nothing. It is called once per widget per frame.
39
+ class NineSlice
40
+ # `border` is a uniform integer or a hash of `left`/`right`/`top`/`bottom`.
41
+ # (x, y, w, h) is the source rectangle inside `image`, so one sheet can
42
+ # hold many of these — which is what UiAtlas does with it.
43
+ def initialize(image, x:, y:, w:, h:, border:, scale: 1)
44
+ # A zero or negative scale would give every tile a step of zero pixels,
45
+ # and `#draw`'s tiling loops would never advance. Refusing it here costs
46
+ # nothing; a guard inside the loop would cost a branch per tile, per
47
+ # widget, per frame.
48
+ raise ArgumentError, "nine-slice scale must be positive, got #{scale}" unless scale.positive?
49
+
50
+ @scale = scale
51
+ border = normalize_border(border)
52
+ @l = border.fetch(:left)
53
+ @r = border.fetch(:right)
54
+ @t = border.fetch(:top)
55
+ @b = border.fetch(:bottom)
56
+
57
+ cut_pieces(image, x, y, w, h)
58
+ end
59
+
60
+ # Fills the rectangle (dx, dy, dw, dh).
61
+ #
62
+ # `color` tints every piece — a focus highlight, a disabled grey. A
63
+ # rectangle smaller than its own borders draws its corners and nothing
64
+ # else, which is the least misleading thing a widget too small for its own
65
+ # chrome can look like.
66
+ def draw(renderer, dx, dy, dw, dh, z: 0, color: nil)
67
+ s = @scale
68
+ l = @l * s
69
+ r = @r * s
70
+ t = @t * s
71
+ b = @b * s
72
+ # These go negative for a rectangle narrower than its own borders, and
73
+ # are not clamped: #band refuses a non-positive size anyway, so clamping
74
+ # here would be a second guard that cannot change any outcome — and
75
+ # would read as if the case were handled in two places.
76
+ inner_w = dw - l - r
77
+ inner_h = dh - t - b
78
+
79
+ band(renderer, @centre, dx + l, dy + t, inner_w, inner_h, z, color)
80
+ band(renderer, @top, dx + l, dy, inner_w, t, z, color)
81
+ band(renderer, @bottom, dx + l, dy + dh - b, inner_w, b, z, color)
82
+ band(renderer, @left, dx, dy + t, l, inner_h, z, color)
83
+ band(renderer, @right, dx + dw - r, dy + t, r, inner_h, z, color)
84
+
85
+ # Last, so a tile that reached the edge of its band is covered rather
86
+ # than showing through a corner's transparent pixels.
87
+ piece(renderer, @tl, dx, dy, z, color)
88
+ piece(renderer, @tr, dx + dw - r, dy, z, color)
89
+ piece(renderer, @bl, dx, dy + dh - b, z, color)
90
+ piece(renderer, @br, dx + dw - r, dy + dh - b, z, color)
91
+ end
92
+
93
+ private
94
+
95
+ # A uniform integer becomes four equal sides. Accepting both shapes here
96
+ # rather than in the callers is what makes `NineSlice.new(image, border: 7,
97
+ # ...)` work on its own — a bare integer would otherwise fail as
98
+ # `7[:left]`, which names nothing that appears in the caller's code.
99
+ def normalize_border(border)
100
+ return border.transform_keys(&:to_sym) unless border.is_a?(Integer)
101
+
102
+ { left: border, right: border, top: border, bottom: border }
103
+ end
104
+
105
+ def cut_pieces(image, x, y, w, h)
106
+ centre_w = w - @l - @r
107
+ centre_h = h - @t - @b
108
+ if centre_w.negative? || centre_h.negative?
109
+ raise ArgumentError,
110
+ "nine-slice borders (#{@l}, #{@r}, #{@t}, #{@b}) do not fit in a #{w}x#{h} rect"
111
+ end
112
+
113
+ @tl = cut(image, x, y, @l, @t)
114
+ @tr = cut(image, x + w - @r, y, @r, @t)
115
+ @bl = cut(image, x, y + h - @b, @l, @b)
116
+ @br = cut(image, x + w - @r, y + h - @b, @r, @b)
117
+
118
+ @top = cut(image, x + @l, y, centre_w, @t)
119
+ @bottom = cut(image, x + @l, y + h - @b, centre_w, @b)
120
+ @left = cut(image, x, y + @t, @l, centre_h)
121
+ @right = cut(image, x + w - @r, y + @t, @r, centre_h)
122
+ @centre = cut(image, x + @l, y + @t, centre_w, centre_h)
123
+ end
124
+
125
+ # A piece with no pixels is a legitimate part of a legitimate slice — a
126
+ # border with no centre column is a bar that only stretches vertically,
127
+ # and a zero border is one that does not stretch at all on that side. Such
128
+ # a piece is `nil` and simply never drawn; asking `Image#subimage` for it
129
+ # would raise.
130
+ def cut(image, x, y, width, height)
131
+ return nil unless width.positive? && height.positive?
132
+
133
+ image.subimage(x, y, width, height)
134
+ end
135
+
136
+ def piece(renderer, image, x, y, z, color)
137
+ return unless image
138
+
139
+ renderer.image_at(image, x, y, scale_x: @scale, scale_y: @scale, z: z, color: color)
140
+ end
141
+
142
+ # Repeats `image` across (bx, by, bw, bh), clipped to it so the trailing
143
+ # tile is cropped instead of overrunning into the next band.
144
+ def band(renderer, image, bx, by, bw, bh, z, color)
145
+ return if image.nil? || bw <= 0 || bh <= 0
146
+
147
+ step_x = image.width * @scale
148
+ step_y = image.height * @scale
149
+ renderer.clipped(bx, by, bw, bh) do
150
+ y = by
151
+ while y < by + bh
152
+ x = bx
153
+ while x < bx + bw
154
+ piece(renderer, image, x, y, z, color)
155
+ x += step_x
156
+ end
157
+ y += step_y
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgame/core_ext'
4
+ require_relative '../util/color'
5
+
6
+ module RGame
7
+ module Core
8
+ # A block of drawing baked once and replayed cheaply.
9
+ #
10
+ # ground = renderer.record do
11
+ # tiles.each { |tile| renderer.image(tile.image, tile.x, tile.y) }
12
+ # end
13
+ #
14
+ # def draw
15
+ # ground.draw(-camera.x, -camera.y)
16
+ # end
17
+ #
18
+ # A screen of tiles is a couple of thousand quads that have not changed
19
+ # since the level loaded. Baking them turns the per-frame cost from "walk
20
+ # every tile and transform four corners" into one call per texture — the
21
+ # work happens at bake time and never again.
22
+ #
23
+ # Recordings come from `Renderer#record`; there is no `new`.
24
+ #
25
+ # ## What is baked in and what is not
26
+ #
27
+ # Positions, texture coordinates and colours are baked, and so are any
28
+ # transforms applied *inside* the block. The transform in effect when the
29
+ # recording is *drawn* is applied on top, which is what lets a baked layer
30
+ # scroll under a camera without being rebuilt.
31
+ #
32
+ # Clipping is not baked — it happens when pixels are rasterised, so a clip
33
+ # rectangle captured in one place would be wrong everywhere else the
34
+ # recording is drawn. Pushing a clip inside a `record` block raises. Clip
35
+ # the replay instead:
36
+ #
37
+ # renderer.clipped(0, 0, 400, 600) { ground.draw(0, 0) }
38
+ class Recording
39
+ Color = RGame::Util::Color
40
+
41
+ # Replays everything that was recorded, with the recording's origin at
42
+ # (x, y).
43
+ #
44
+ # `color` tints what was baked: each recorded colour is multiplied by it,
45
+ # so white (the default) draws the recording unchanged and a colour with
46
+ # alpha fades the whole layer out at once.
47
+ def draw(x = 0, y = 0, z: 0, color: nil)
48
+ draw_at(x, y, z, Color.coerce(color).packed)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rgame/core_ext'
4
+ require_relative 'font'
5
+ require_relative '../util/color'
6
+
7
+ module RGame
8
+ module Core
9
+ # What a game draws with.
10
+ #
11
+ # class MyGame < RGame::Core::App
12
+ # def initialize
13
+ # super(width: 800, height: 600, caption: 'demo')
14
+ # @renderer = RGame::Core::Renderer.new(self)
15
+ # @hero = RGame::Core::Image.new(self, 'hero.png')
16
+ # end
17
+ #
18
+ # def draw
19
+ # @renderer.rect(10, 10, 100, 40, color: RGame::Util::Color::WHITE)
20
+ # @renderer.image(@hero, 400, 300, angle: 45)
21
+ # end
22
+ # end
23
+ #
24
+ # Drawing is only legal inside `draw`, and calling one of these outside it
25
+ # raises. That is on purpose: the frame is not open, so the vertices would
26
+ # be silently discarded, and an invisible failure is the worst kind.
27
+ #
28
+ # Nothing is drawn immediately. Calls accumulate and are z-sorted when the
29
+ # frame closes, so `z:` decides what ends up on top — not call order. Equal
30
+ # z keeps call order, which is what stops same-layer sprites flickering
31
+ # between frames.
32
+ #
33
+ # The C half of this class (ext/rgame_core/ruby/renderer_ext.c) has the `draw_*`
34
+ # and `push_*` primitives; everything here is the comfortable surface over
35
+ # them.
36
+ #
37
+ # ## Colours
38
+ #
39
+ # Every drawing method takes `color:`, accepting whatever `Color.coerce`
40
+ # does: `nil` (white — an untinted draw), `[r, g, b]`, `[r, g, b, a]`, or a
41
+ # `RGame::Util::Color`. Passing a `Color` is the allocation-free path and is
42
+ # what per-frame code should do; an array allocates one colour per call.
43
+ class Renderer
44
+ Color = RGame::Util::Color
45
+
46
+ # Shapes default above sprites, so a debug box or a health bar drawn
47
+ # without a `z:` lands on top of the scene rather than under it. The
48
+ # values match the layer this replaces.
49
+ SHAPE_Z = 50
50
+ IMAGE_Z = 0
51
+
52
+ # Enough segments that a circle reads as round at the sizes a 2D game
53
+ # draws one, and few enough that a screenful of them is still one batch.
54
+ CIRCLE_SEGMENTS = 64
55
+
56
+ # Text defaults above sprites but below shapes, and the size matches what
57
+ # the layer this replaces used, so ported UI lays out unchanged.
58
+ TEXT_Z = 10
59
+ FONT_SIZE = 18
60
+
61
+ # Translucent red, for #debug_box.
62
+ DEBUG_BOX_COLOR = Color.new(255, 40, 40, 120)
63
+
64
+ # `assets:` is where a draw id that is not registered gets resolved from,
65
+ # and defaults to the app's own manager — so the common case wires itself
66
+ # and `renderer.sprite('hero.json', …)` works with nothing set up.
67
+ #
68
+ # A Ruby `self.new` because the C `initialize` has fixed arity and no
69
+ # business knowing what an asset manager is; the same shape `Font`'s
70
+ # `path:` uses.
71
+ def self.new(app, assets: nil)
72
+ renderer = super(app)
73
+ renderer.assets = assets.nil? ? app.assets : assets
74
+ renderer
75
+ end
76
+
77
+ attr_accessor :assets
78
+
79
+ # --- draw-by-id ---------------------------------------------------------
80
+ #
81
+ # Game logic names assets, it does not hold them: the engine layer may
82
+ # hold `RGame::Util` values but no `RGame::Core` handle at all, so a
83
+ # Symbol or a path is the only thing a node *can* carry. Resolving it is
84
+ # this side of the boundary's job.
85
+ #
86
+ # An id is normally a **root-relative path**, resolved through the asset
87
+ # manager and then remembered, so a per-frame draw neither re-resolves nor
88
+ # allocates a lookup key:
89
+ #
90
+ # renderer.sprite('example 09/player.json', row, col, x, y)
91
+ #
92
+ # `register_*` pre-binds an id to a chosen object, for the two things a
93
+ # path cannot name: an id that is not a file (nine-slice ids are *atlas
94
+ # element* names) and an object the game assembled itself.
95
+
96
+ def register_image(id, image) = registry(:image)[id] = image
97
+ def register_sheet(id, sheet) = registry(:sheet)[id] = sheet
98
+ def register_tilemap(id, tilemap) = registry(:tilemap)[id] = tilemap
99
+ def register_nine_slice(id, nine_slice) = registry(:nine_slice)[id] = nine_slice
100
+
101
+ # Registers every element of a UiAtlas under its own name, since those
102
+ # names are what a widget asks for.
103
+ def register_ui_atlas(atlas)
104
+ atlas.nine_slices.each { |id, nine_slice| register_nine_slice(id, nine_slice) }
105
+ self
106
+ end
107
+
108
+ # One frame of a registered or resolvable sprite sheet, top-left at (x, y).
109
+ def sprite(id, row, col, x, y, flip_x: false, z: IMAGE_Z)
110
+ lookup(:sheet, id).draw(self, row, col, x, y, flip_x: flip_x, z: z)
111
+ end
112
+
113
+ # A nine-slice filling (x, y, width, height), tinted by `tint` if given.
114
+ # Registration only: a nine-slice id names an element of an atlas, not a
115
+ # file, so there is nothing for the asset manager to resolve it to.
116
+ def nine_slice(id, x, y, width, height, z: IMAGE_Z, tint: nil)
117
+ lookup(:nine_slice, id).draw(self, x, y, width, height, z: z, color: tint)
118
+ end
119
+
120
+ # A tile map's below-the-actor band (ground and same-level detail).
121
+ #
122
+ # `elapsed` is the seconds its animated tiles have been running for, and
123
+ # is an argument rather than a clock read on purpose — see CLAUDE.md,
124
+ # "`draw` renders state; time enters through `update`". A scene
125
+ # accumulates it in `update`, which is what makes pausing work.
126
+ def tilemap(id, camera_x, camera_y, viewport_width, viewport_height, elapsed: 0.0)
127
+ lookup(:tilemap, id)
128
+ .draw(self, camera_x, camera_y, viewport_width, viewport_height, elapsed: elapsed)
129
+ end
130
+
131
+ # Its above-the-actor band (canopies, roofs), at a `z` the scene picks so
132
+ # it lands over the actors.
133
+ def tilemap_overlay(id, camera_x, camera_y, viewport_width, viewport_height,
134
+ z:, elapsed: 0.0)
135
+ lookup(:tilemap, id).draw_overlay(self, camera_x, camera_y, viewport_width,
136
+ viewport_height, z: z, elapsed: elapsed)
137
+ end
138
+
139
+ # A filled axis-aligned rectangle.
140
+ def rect(x, y, width, height, z: SHAPE_Z, color: nil)
141
+ draw_rect(x, y, width, height, z, packed(color))
142
+ end
143
+
144
+ # Four arbitrary points, in loop order: listing them in Z order gives an
145
+ # hourglass rather than a shape.
146
+ def quad(x1, y1, x2, y2, x3, y3, x4, y4, z: SHAPE_Z, color: nil)
147
+ draw_quad(x1, y1, x2, y2, x3, y3, x4, y4, z, packed(color))
148
+ end
149
+
150
+ def triangle(x1, y1, x2, y2, x3, y3, z: SHAPE_Z, color: nil)
151
+ draw_triangle(x1, y1, x2, y2, x3, y3, z, packed(color))
152
+ end
153
+
154
+ # A line of real thickness — drawn as a quad, because GL's own line width
155
+ # is a suggestion drivers may ignore above one pixel.
156
+ def line(x1, y1, x2, y2, thickness: 1.0, z: SHAPE_Z, color: nil)
157
+ draw_line(x1, y1, x2, y2, thickness, z, packed(color))
158
+ end
159
+
160
+ # A filled circle, as a fan of triangles around its centre.
161
+ def circle(cx, cy, radius, z: SHAPE_Z, color: nil, segments: CIRCLE_SEGMENTS)
162
+ draw_circle(cx, cy, radius, segments, z, packed(color))
163
+ end
164
+
165
+ # An image centred on (cx, cy), rotated `angle` degrees clockwise about
166
+ # that centre and uniformly scaled. Unrotated and unscaled is a fast path
167
+ # that skips the transform stack entirely.
168
+ #
169
+ # Takes an `Image` or an id for one — see #resolve_image.
170
+ def image(image, cx, cy, angle: 0, scale: 1, z: IMAGE_Z, color: nil)
171
+ draw_image_rot(resolve_image(image), cx, cy, angle, scale, z, packed(color))
172
+ end
173
+
174
+ # An image with its top-left at (x, y), scaled independently per axis —
175
+ # a tile, a nine-slice corner, a sprite-sheet frame.
176
+ #
177
+ # A **negative scale mirrors the image inside the same rectangle**; it
178
+ # does not move it. So a frame drawn at (x, y) covers the same pixels
179
+ # whichever way it faces, and `scale_x: -1` means "facing the other way"
180
+ # rather than "one width to the left":
181
+ #
182
+ # renderer.image_at(frame, x, y, scale_x: facing_left ? -1 : 1)
183
+ #
184
+ # A zero scale draws nothing.
185
+ def image_at(image, x, y, scale_x: 1, scale_y: 1, z: IMAGE_Z, color: nil)
186
+ draw_image_scaled(resolve_image(image), x, y, scale_x, scale_y, z, packed(color))
187
+ end
188
+
189
+ # An image with its top-left at (x, y), at its natural size — a
190
+ # full-screen backdrop by default. `image_at` with both scales at 1, kept
191
+ # because "put this at the origin" is worth a name of its own.
192
+ def background(image, x = 0, y = 0, z: IMAGE_Z, color: nil)
193
+ draw_image(resolve_image(image), x, y, z, packed(color))
194
+ end
195
+
196
+ # Everything drawn in the block is rotated `angle` degrees about
197
+ # (pivot_x, pivot_y), so a node can spin all of its parts coherently
198
+ # around one point.
199
+ #
200
+ # A zero angle skips the push entirely — unrotated drawing pays nothing,
201
+ # which matters because most drawing is unrotated.
202
+ def rotated(angle, pivot_x, pivot_y)
203
+ return yield if angle.zero?
204
+
205
+ push_rotate(angle, pivot_x, pivot_y)
206
+ begin
207
+ yield
208
+ ensure
209
+ # An ensure, not a plain pop: a scene that raises mid-draw would
210
+ # otherwise leave the stack deeper than it found it, and every
211
+ # later frame would draw askew for a reason nothing points at.
212
+ pop
213
+ end
214
+ end
215
+
216
+ # Everything drawn in the block is shifted by (dx, dy) screen pixels —
217
+ # the camera's view transform. Because it is a draw-time transform rather
218
+ # than something baked into positions, the same world can be drawn again
219
+ # under a different offset and clip, which is what split-screen is.
220
+ def translated(dx, dy)
221
+ return yield if dx.zero? && dy.zero?
222
+
223
+ push_translate(dx, dy)
224
+ begin
225
+ yield
226
+ ensure
227
+ pop
228
+ end
229
+ end
230
+
231
+ def scaled(sx, sy = sx)
232
+ return yield if sx == 1 && sy == 1
233
+
234
+ push_scale(sx, sy)
235
+ begin
236
+ yield
237
+ ensure
238
+ pop
239
+ end
240
+ end
241
+
242
+ # Everything drawn in the block is confined to the given rectangle.
243
+ #
244
+ # A clip only ever narrows: nesting one inside another intersects them, so
245
+ # a child cannot draw outside the region its parent allowed. Give each
246
+ # player a clipped block and you have split-screen.
247
+ def clipped(x, y, width, height)
248
+ push_clip(x, y, width, height)
249
+ begin
250
+ yield
251
+ ensure
252
+ pop
253
+ end
254
+ end
255
+
256
+ # The font this renderer draws with when a call does not name one.
257
+ #
258
+ # Built on first use rather than in the constructor: creating a font needs
259
+ # a GL context, and a renderer is often built before there is one. Set
260
+ # your own with #font= to change what every unqualified #text call uses.
261
+ def font
262
+ @font ||= Font.new(app, FONT_SIZE)
263
+ end
264
+
265
+ attr_writer :font
266
+
267
+ # One line of text, with its top-left corner at (x, y) — the same corner
268
+ # every other drawing method takes, rather than the baseline typography
269
+ # would use.
270
+ #
271
+ # Newlines are not special. A caller wanting two lines draws two, stepping
272
+ # by #text_height.
273
+ def text(string, x, y, z: TEXT_Z, color: nil, font: nil)
274
+ draw_text(font || self.font, string, x, y, z, packed(color))
275
+ end
276
+
277
+ # What #text would occupy, for centring and layout. Unlike the drawing
278
+ # methods this works outside `draw`, because measuring touches no GL.
279
+ def text_width(string, font: nil) = (font || self.font).text_width(string)
280
+
281
+ # The line height: what to step y by for a second line.
282
+ def text_height(font: nil) = (font || self.font).height
283
+
284
+ # Bakes everything the block draws into a RGame::Core::Recording, which
285
+ # can then be replayed for the cost of one call per texture however many
286
+ # draws went into it. Nothing is drawn *now* — the block's output goes
287
+ # into the recording instead of into this frame.
288
+ #
289
+ # ground = renderer.record { tiles.each { |t| renderer.image(t.img, t.x, t.y) } }
290
+ # ground.draw(-camera.x, -camera.y)
291
+ #
292
+ # Recording happens inside `draw` like everything else, and does not
293
+ # nest. A clip pushed inside the block raises: clipping cannot be baked,
294
+ # so clip the replay instead. See RGame::Core::Recording.
295
+ def record
296
+ begin_record
297
+ completed = false
298
+ begin
299
+ yield
300
+ completed = true
301
+ ensure
302
+ # A block that raised leaves a half-built recording open, and the
303
+ # next frame would keep drawing into it. Unwinding here means the
304
+ # exception is the only thing the caller has to deal with.
305
+ cancel_record unless completed
306
+ end
307
+ end_record
308
+ end
309
+
310
+ # A translucent overlay for visualising a collision box, so a scene can
311
+ # ask for one without knowing what colour "debug" is.
312
+ def debug_box(x, y, width, height, z: SHAPE_Z)
313
+ rect(x, y, width, height, z: z, color: DEBUG_BOX_COLOR)
314
+ end
315
+
316
+ private
317
+
318
+ # Colours cross into C as a packed integer. `Color.coerce` is the single
319
+ # place nil/array/Color are turned into one, so no drawing method has its
320
+ # own idea of what a colour is.
321
+ def packed(color) = Color.coerce(color).packed
322
+
323
+ # An `Image` is already what it is; anything else is an id for one.
324
+ #
325
+ # The two callers this serves cannot be reconciled any other way. Core's
326
+ # own drawing classes — SpriteSheet, NineSlice — hold real images and pass
327
+ # them; the engine layer is forbidden from holding one and can only pass
328
+ # an id. Dispatching on the type keeps both spellings of `#image` and
329
+ # `#background` working without two parallel method names for the same
330
+ # picture.
331
+ def resolve_image(image) = image.is_a?(Image) ? image : lookup(:image, image)
332
+
333
+ def registry(type) = (@registries ||= {})[type] ||= {}
334
+
335
+ # Prefer an explicit registration, otherwise ask the asset manager, then
336
+ # remember the answer — so a per-frame draw neither re-resolves nor
337
+ # allocates a lookup key.
338
+ def lookup(type, id)
339
+ # `nil` is never an id, and reporting it as one ("no image registered
340
+ # for nil") describes a typo when the actual bug is an asset that
341
+ # resolved to nothing. The two want different fixes.
342
+ raise TypeError, "no implicit conversion of nil into #{type}" if id.nil?
343
+
344
+ table = registry(type)
345
+ table.fetch(id) { table[id] = resolve_asset(type, id) }
346
+ end
347
+
348
+ # Only a String is offered to the asset manager, because only a String can
349
+ # be a path. A Symbol id is a name a game chose, so a missing one is the
350
+ # KeyError below — naming the id — rather than whatever the manager makes
351
+ # of being handed a Symbol where it wanted a filename.
352
+ #
353
+ # `respond_to?` for the same reason one step along: the manager grows an
354
+ # accessor per asset type, and asking for one it does not have yet should
355
+ # be this error rather than a NoMethodError from inside it.
356
+ def resolve_asset(type, id)
357
+ resolved = @assets.public_send(type, id) if id.is_a?(String) && @assets.respond_to?(type)
358
+ resolved || raise(KeyError, "no #{type} registered for #{id.inspect} " \
359
+ 'and no AssetManager to resolve it')
360
+ end
361
+ end
362
+ end
363
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ require_relative 'image'
6
+
7
+ module RGame
8
+ module Core
9
+ # A texture atlas — one image plus a JSON descriptor — sliced into frames and
10
+ # drawn one at a time.
11
+ #
12
+ # sheet = RGame::Core::SpriteSheet.load(app, 'media/hero.json')
13
+ # sheet.draw(renderer, row, col, x, y, flip_x: facing_left, z: 10)
14
+ #
15
+ # ## The descriptor
16
+ #
17
+ # {
18
+ # "image": "hero.png",
19
+ # "frame_width": 16, "frame_height": 24,
20
+ # "cell_width": 32, "cell_height": 32,
21
+ # "origin_x": 8, "origin_y": 4,
22
+ # "animations": { "walk_left": { "row": 1, "frames": 4, "fps": 8 } }
23
+ # }
24
+ #
25
+ # `image` is resolved next to the descriptor. The rest describes the grid.
26
+ #
27
+ # **A frame can be smaller than its cell.** Cells are laid out on a fixed
28
+ # `cell_width` x `cell_height` grid, but what is *drawn* is a
29
+ # `frame_width` x `frame_height` rectangle offset by `origin_x`/`origin_y`
30
+ # inside the cell. That is what lets a sheet whose cells are sized for the
31
+ # widest pose — an attack, a swing — still expose a tight, centred box for
32
+ # walking, so a character does not appear to change size between animations.
33
+ # Leave the cell and origin keys out and frame == cell, which is what a
34
+ # simple sheet wants.
35
+ #
36
+ # `animations` is handed back untouched by `#animations`. This class knows
37
+ # nothing about time; the scene layer builds its own animation table from
38
+ # that hash, which is why the raw form is what gets exposed.
39
+ #
40
+ # ## Slicing costs nothing
41
+ #
42
+ # Every frame is cut once, at construction, as a view onto the one upload —
43
+ # so a sheet of two hundred frames is two hundred small objects and a single
44
+ # texture, and `#draw` is an array index plus one call.
45
+ class SpriteSheet
46
+ attr_reader :frame_width, :frame_height, :animations
47
+
48
+ # Loads a descriptor and the image beside it.
49
+ #
50
+ # The `AssetManager` does not use this — it calls `.new` with an image it
51
+ # has already cached, so a sheet's PNG is shared with a standalone load of
52
+ # the same file. This is the standalone path, for a game with one sheet
53
+ # and no asset manager.
54
+ def self.load(app, atlas_path)
55
+ atlas = JSON.parse(File.read(atlas_path), symbolize_names: true)
56
+ directory = File.dirname(File.expand_path(atlas_path))
57
+ new(Image.new(app, File.join(directory, atlas[:image])), atlas)
58
+ end
59
+
60
+ # `atlas` is the parsed descriptor; `image` an already-loaded sheet image.
61
+ def initialize(image, atlas)
62
+ @frame_width = atlas.fetch(:frame_width) { missing(:frame_width) }
63
+ @frame_height = atlas.fetch(:frame_height) { missing(:frame_height) }
64
+ @animations = atlas[:animations] || {}
65
+
66
+ @frames = slice(image, atlas)
67
+ end
68
+
69
+ # Draws one frame with its top-left at (x, y).
70
+ #
71
+ # `flip_x` mirrors the frame within that same rectangle, so a character
72
+ # occupies the same pixels whichever way it faces — see
73
+ # RGame::Core::Renderer#image_at.
74
+ def draw(renderer, row, col, x, y, flip_x: false, z: 0)
75
+ renderer.image_at(@frames[row][col], x, y, scale_x: flip_x ? -1 : 1, z: z)
76
+ end
77
+
78
+ # How many frames the sheet was cut into, as [rows, columns].
79
+ def grid = [@frames.length, @frames.empty? ? 0 : @frames[0].length]
80
+
81
+ private
82
+
83
+ def slice(image, atlas)
84
+ cell_width = atlas[:cell_width] || @frame_width
85
+ cell_height = atlas[:cell_height] || @frame_height
86
+ origin_x = atlas[:origin_x] || 0
87
+ origin_y = atlas[:origin_y] || 0
88
+
89
+ columns = image.width / cell_width
90
+ rows = image.height / cell_height
91
+
92
+ Array.new(rows) do |row|
93
+ Array.new(columns) do |col|
94
+ image.subimage((col * cell_width) + origin_x, (row * cell_height) + origin_y,
95
+ @frame_width, @frame_height)
96
+ end
97
+ end
98
+ end
99
+
100
+ # A typo'd or missing key would otherwise surface as a NoMethodError on nil
101
+ # from inside the slicing arithmetic, which says nothing about the file
102
+ # that is actually wrong.
103
+ def missing(key)
104
+ raise ArgumentError, "sprite sheet descriptor has no #{key}"
105
+ end
106
+ end
107
+ end
108
+ end