rgame 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +26 -0
- data/README.md +406 -0
- data/docs/api/README.md +167 -0
- data/docs/api/app.md +192 -0
- data/docs/api/assets.md +426 -0
- data/docs/api/audio.md +208 -0
- data/docs/api/components.md +321 -0
- data/docs/api/drawing.md +330 -0
- data/docs/api/game.md +99 -0
- data/docs/api/images.md +118 -0
- data/docs/api/input.md +179 -0
- data/docs/api/internals.md +110 -0
- data/docs/api/scene_graph.md +159 -0
- data/docs/api/signals.md +142 -0
- data/docs/api/systems.md +98 -0
- data/docs/api/text.md +116 -0
- data/docs/api/toolbox.md +240 -0
- data/docs/api/values.md +101 -0
- data/ext/README.md +225 -0
- data/ext/rgame_core/app/app.c +721 -0
- data/ext/rgame_core/app/app_gl.h +64 -0
- data/ext/rgame_core/app/frame_loop.c +42 -0
- data/ext/rgame_core/app/frame_loop.h +54 -0
- data/ext/rgame_core/audio/audio.c +466 -0
- data/ext/rgame_core/audio/audio_internal.h +45 -0
- data/ext/rgame_core/audio/vorbis_decoder.c +282 -0
- data/ext/rgame_core/audio/vorbis_decoder.h +45 -0
- data/ext/rgame_core/example.rb +188 -0
- data/ext/rgame_core/extconf.rb +167 -0
- data/ext/rgame_core/graphics/backend.c +52 -0
- data/ext/rgame_core/graphics/backend.h +64 -0
- data/ext/rgame_core/graphics/canvas.c +247 -0
- data/ext/rgame_core/graphics/canvas.h +143 -0
- data/ext/rgame_core/graphics/clip.c +87 -0
- data/ext/rgame_core/graphics/clip.h +89 -0
- data/ext/rgame_core/graphics/draw_queue.c +216 -0
- data/ext/rgame_core/graphics/draw_queue.h +174 -0
- data/ext/rgame_core/graphics/gl_backend.c +122 -0
- data/ext/rgame_core/graphics/gl_backend.h +43 -0
- data/ext/rgame_core/graphics/image.c +304 -0
- data/ext/rgame_core/graphics/image_internal.h +30 -0
- data/ext/rgame_core/graphics/primitives.c +189 -0
- data/ext/rgame_core/graphics/primitives.h +111 -0
- data/ext/rgame_core/graphics/recording.c +119 -0
- data/ext/rgame_core/graphics/recording.h +88 -0
- data/ext/rgame_core/graphics/texture.c +181 -0
- data/ext/rgame_core/graphics/texture.h +165 -0
- data/ext/rgame_core/graphics/transform.c +128 -0
- data/ext/rgame_core/graphics/transform.h +106 -0
- data/ext/rgame_core/include/rgame/core.h +577 -0
- data/ext/rgame_core/input/device_slots.c +103 -0
- data/ext/rgame_core/input/device_slots.h +93 -0
- data/ext/rgame_core/input/gamepad.c +145 -0
- data/ext/rgame_core/input/gamepad.h +63 -0
- data/ext/rgame_core/input/input.c +109 -0
- data/ext/rgame_core/input/input.h +99 -0
- data/ext/rgame_core/ruby/audio_ext.c +321 -0
- data/ext/rgame_core/ruby/core_ext.c +513 -0
- data/ext/rgame_core/ruby/core_ext.h +51 -0
- data/ext/rgame_core/ruby/font_ext.c +168 -0
- data/ext/rgame_core/ruby/image_ext.c +230 -0
- data/ext/rgame_core/ruby/recording_ext.c +186 -0
- data/ext/rgame_core/ruby/renderer_ext.c +376 -0
- data/ext/rgame_core/text/atlas.c +59 -0
- data/ext/rgame_core/text/atlas.h +85 -0
- data/ext/rgame_core/text/font.c +281 -0
- data/ext/rgame_core/text/font.h +139 -0
- data/ext/rgame_core/text/font_atlas.c +385 -0
- data/ext/rgame_core/text/font_internal.h +47 -0
- data/ext/rgame_core/text/glyph_cache.c +142 -0
- data/ext/rgame_core/text/glyph_cache.h +89 -0
- data/ext/rgame_core/vendor/README.md +159 -0
- data/ext/rgame_core/vendor/miniaudio.h +95864 -0
- data/ext/rgame_core/vendor/miniaudio_impl.c +62 -0
- data/ext/rgame_core/vendor/stb_image.h +7988 -0
- data/ext/rgame_core/vendor/stb_image_impl.c +31 -0
- data/ext/rgame_core/vendor/stb_truetype.h +5079 -0
- data/ext/rgame_core/vendor/stb_truetype_impl.c +23 -0
- data/ext/rgame_core/vendor/stb_vorbis.c +5584 -0
- data/ext/rgame_core/vendor/stb_vorbis_impl.c +29 -0
- data/ext/rgame_util/color.c +19 -0
- data/ext/rgame_util/color.h +60 -0
- data/ext/rgame_util/color_ext.c +156 -0
- data/ext/rgame_util/extconf.rb +27 -0
- data/ext/rgame_util/tensor.c +186 -0
- data/ext/rgame_util/util_ext.c +27 -0
- data/ext/rgame_util/util_ext.h +16 -0
- data/lib/rgame/boot.rb +13 -0
- data/lib/rgame/core/app.rb +82 -0
- data/lib/rgame/core/asset_manager.rb +224 -0
- data/lib/rgame/core/audio.rb +124 -0
- data/lib/rgame/core/font.rb +49 -0
- data/lib/rgame/core/gamepad.rb +55 -0
- data/lib/rgame/core/image.rb +55 -0
- data/lib/rgame/core/input.rb +77 -0
- data/lib/rgame/core/nine_slice.rb +163 -0
- data/lib/rgame/core/recording.rb +52 -0
- data/lib/rgame/core/renderer.rb +363 -0
- data/lib/rgame/core/sprite_sheet.rb +108 -0
- data/lib/rgame/core/tile_map_renderer.rb +160 -0
- data/lib/rgame/core/ui_atlas.rb +86 -0
- data/lib/rgame/core.rb +24 -0
- data/lib/rgame/engine/actor.rb +53 -0
- data/lib/rgame/engine/animation_set.rb +49 -0
- data/lib/rgame/engine/animator.rb +44 -0
- data/lib/rgame/engine/audio_bus.rb +24 -0
- data/lib/rgame/engine/audio_director.rb +29 -0
- data/lib/rgame/engine/body.rb +49 -0
- data/lib/rgame/engine/cached_label.rb +33 -0
- data/lib/rgame/engine/camera.rb +33 -0
- data/lib/rgame/engine/camera_view.rb +28 -0
- data/lib/rgame/engine/circle_collider.rb +32 -0
- data/lib/rgame/engine/collision_box.rb +34 -0
- data/lib/rgame/engine/collision_system.rb +44 -0
- data/lib/rgame/engine/component.rb +30 -0
- data/lib/rgame/engine/components/action_trigger.rb +41 -0
- data/lib/rgame/engine/components/animated_sprite.rb +63 -0
- data/lib/rgame/engine/components/character_body.rb +70 -0
- data/lib/rgame/engine/components/circle_collider.rb +44 -0
- data/lib/rgame/engine/components/collision_world.rb +103 -0
- data/lib/rgame/engine/components/despawn_offscreen.rb +26 -0
- data/lib/rgame/engine/components/path_follow.rb +84 -0
- data/lib/rgame/engine/components/player_controller.rb +24 -0
- data/lib/rgame/engine/components/pool.rb +53 -0
- data/lib/rgame/engine/components/screen_wrap.rb +27 -0
- data/lib/rgame/engine/components/sprite.rb +31 -0
- data/lib/rgame/engine/components/targeting.rb +54 -0
- data/lib/rgame/engine/components/thrust_controller.rb +65 -0
- data/lib/rgame/engine/components/tile_world.rb +68 -0
- data/lib/rgame/engine/components/timer.rb +75 -0
- data/lib/rgame/engine/components/velocity.rb +27 -0
- data/lib/rgame/engine/components/wander_controller.rb +60 -0
- data/lib/rgame/engine/debug_overlay.rb +106 -0
- data/lib/rgame/engine/i18n.rb +97 -0
- data/lib/rgame/engine/input/action_mapper.rb +46 -0
- data/lib/rgame/engine/input/actions.rb +41 -0
- data/lib/rgame/engine/input/player_controller.rb +14 -0
- data/lib/rgame/engine/matrix.rb +32 -0
- data/lib/rgame/engine/node2d.rb +271 -0
- data/lib/rgame/engine/path.rb +78 -0
- data/lib/rgame/engine/pool.rb +51 -0
- data/lib/rgame/engine/resettable.rb +67 -0
- data/lib/rgame/engine/scene/scene_stack.rb +65 -0
- data/lib/rgame/engine/signal.rb +75 -0
- data/lib/rgame/engine/spatial_hash.rb +71 -0
- data/lib/rgame/engine/tile_collision.rb +78 -0
- data/lib/rgame/engine/tile_map.rb +149 -0
- data/lib/rgame/engine/tileset.rb +101 -0
- data/lib/rgame/engine/timer.rb +51 -0
- data/lib/rgame/engine.rb +68 -0
- data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
- data/lib/rgame/fonts/OFL.txt +102 -0
- data/lib/rgame/game.rb +129 -0
- data/lib/rgame/util/color.rb +27 -0
- data/lib/rgame/util/controls.rb +107 -0
- data/lib/rgame/util/tensor.rb +12 -0
- data/lib/rgame/util.rb +8 -0
- data/lib/rgame/version.rb +12 -0
- data/lib/rgame.rb +20 -0
- metadata +215 -0
|
@@ -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
|