rgame 0.4.0-x86_64-linux-gnu
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/CHANGELOG.md +251 -0
- data/LICENSE +26 -0
- data/README.md +325 -0
- data/docs/api/README.md +224 -0
- data/docs/api/app.md +249 -0
- data/docs/api/assets.md +463 -0
- data/docs/api/audio.md +249 -0
- data/docs/api/cli.md +259 -0
- data/docs/api/components.md +1112 -0
- data/docs/api/drawing.md +398 -0
- data/docs/api/examples.md +263 -0
- data/docs/api/game.md +219 -0
- data/docs/api/images.md +118 -0
- data/docs/api/input.md +476 -0
- data/docs/api/internals.md +289 -0
- data/docs/api/localization.md +285 -0
- data/docs/api/scene_graph.md +539 -0
- data/docs/api/signals.md +147 -0
- data/docs/api/systems.md +231 -0
- data/docs/api/text.md +133 -0
- data/docs/api/tile_maps.md +237 -0
- data/docs/api/toolbox.md +493 -0
- data/docs/api/ui.md +927 -0
- data/docs/api/values.md +316 -0
- data/examples/assets/README.md +322 -0
- data/examples/assets/blip.ogg +0 -0
- data/examples/assets/glyphs.json +5 -0
- data/examples/assets/glyphs.png +0 -0
- data/examples/assets/hero.json +12 -0
- data/examples/assets/hero.png +0 -0
- data/examples/assets/icons.json +13 -0
- data/examples/assets/icons.png +0 -0
- data/examples/assets/music.ogg +0 -0
- data/examples/assets/skills.json +10 -0
- data/examples/assets/skills.png +0 -0
- data/examples/assets/tileset.png +0 -0
- data/examples/assets/tileset.tsx +65 -0
- data/examples/assets/town.tmx +26 -0
- data/examples/assets/ui.json +11 -0
- data/examples/assets/ui.png +0 -0
- data/examples/collision/locales/en.yml +8 -0
- data/examples/collision/main.rb +316 -0
- data/examples/collision_tiles/locales/en.yml +9 -0
- data/examples/collision_tiles/main.rb +274 -0
- data/examples/fullscreen/locales/en.yml +10 -0
- data/examples/fullscreen/main.rb +216 -0
- data/examples/game_menu/locales/en.yml +8 -0
- data/examples/game_menu/main.rb +170 -0
- data/examples/input_glyphs/locales/en.yml +14 -0
- data/examples/input_glyphs/main.rb +213 -0
- data/examples/jump_topdown/locales/en.yml +9 -0
- data/examples/jump_topdown/main.rb +178 -0
- data/examples/localization/locales/de.yml +12 -0
- data/examples/localization/locales/en.yml +13 -0
- data/examples/localization/main.rb +158 -0
- data/examples/menu_navigation/locales/en.yml +23 -0
- data/examples/menu_navigation/main.rb +365 -0
- data/examples/music/locales/en.yml +7 -0
- data/examples/music/main.rb +134 -0
- data/examples/pathfinding/locales/en.yml +17 -0
- data/examples/pathfinding/main.rb +298 -0
- data/examples/pooling/locales/en.yml +7 -0
- data/examples/pooling/main.rb +259 -0
- data/examples/quick_wheel/locales/en.yml +16 -0
- data/examples/quick_wheel/main.rb +184 -0
- data/examples/radial_menu/locales/en.yml +16 -0
- data/examples/radial_menu/main.rb +184 -0
- data/examples/save_load/locales/en.yml +11 -0
- data/examples/save_load/main.rb +207 -0
- data/examples/save_load_ids/locales/en.yml +11 -0
- data/examples/save_load_ids/main.rb +322 -0
- data/examples/scroll_map/locales/en.yml +4 -0
- data/examples/scroll_map/main.rb +140 -0
- data/examples/signals/locales/en.yml +6 -0
- data/examples/signals/main.rb +278 -0
- data/examples/skill_bar/locales/en.yml +14 -0
- data/examples/skill_bar/main.rb +159 -0
- data/examples/sound/locales/en.yml +6 -0
- data/examples/sound/main.rb +122 -0
- data/examples/split_screen/locales/en.yml +9 -0
- data/examples/split_screen/main.rb +304 -0
- data/examples/sprite/locales/en.yml +8 -0
- data/examples/sprite/main.rb +180 -0
- data/examples/timer/locales/en.yml +12 -0
- data/examples/timer/main.rb +273 -0
- data/examples/velocity/locales/en.yml +6 -0
- data/examples/velocity/main.rb +196 -0
- data/examples/walk/locales/en.yml +4 -0
- data/examples/walk/main.rb +99 -0
- data/exe/rgame +9 -0
- data/lib/rgame/boot.rb +3 -0
- data/lib/rgame/cli/new_project.rb +139 -0
- data/lib/rgame/cli/templates/Gemfile.tt +23 -0
- data/lib/rgame/cli/templates/README.md.tt +93 -0
- data/lib/rgame/cli/templates/Rakefile.tt +9 -0
- data/lib/rgame/cli/templates/assets/locales/en.yml.tt +10 -0
- data/lib/rgame/cli/templates/game.rb.tt +23 -0
- data/lib/rgame/cli/templates/gitignore.tt +12 -0
- data/lib/rgame/cli/templates/main.rb.tt +11 -0
- data/lib/rgame/cli/templates/nodes/root.rb.tt +24 -0
- data/lib/rgame/cli/templates/rspec.tt +2 -0
- data/lib/rgame/cli/templates/rubocop.yml.tt +75 -0
- data/lib/rgame/cli/templates/ruby-version.tt +1 -0
- data/lib/rgame/cli/templates/spec/locales_spec.rb.tt +18 -0
- data/lib/rgame/cli/templates/spec/nodes/root_spec.rb.tt +18 -0
- data/lib/rgame/cli/templates/spec/spec_helper.rb.tt +37 -0
- data/lib/rgame/cli.rb +66 -0
- data/lib/rgame/core/app.rb +46 -0
- data/lib/rgame/core/asset_manager.rb +206 -0
- data/lib/rgame/core/audio.rb +145 -0
- data/lib/rgame/core/font.rb +46 -0
- data/lib/rgame/core/gamepad.rb +55 -0
- data/lib/rgame/core/image.rb +55 -0
- data/lib/rgame/core/input.rb +71 -0
- data/lib/rgame/core/locale.rb +22 -0
- data/lib/rgame/core/nine_slice.rb +142 -0
- data/lib/rgame/core/recording.rb +54 -0
- data/lib/rgame/core/renderer.rb +354 -0
- data/lib/rgame/core/sprite_sheet.rb +105 -0
- data/lib/rgame/core/tile_map_renderer.rb +172 -0
- data/lib/rgame/core/ui_atlas.rb +101 -0
- data/lib/rgame/core/virtual_gamepad.rb +26 -0
- data/lib/rgame/core.rb +18 -0
- data/lib/rgame/core_ext.so +0 -0
- data/lib/rgame/engine/actor_blockers.rb +131 -0
- data/lib/rgame/engine/animation_set.rb +50 -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 +59 -0
- data/lib/rgame/engine/bounds_blockers.rb +74 -0
- data/lib/rgame/engine/camera.rb +78 -0
- data/lib/rgame/engine/circle_collider.rb +34 -0
- data/lib/rgame/engine/collision_box.rb +59 -0
- data/lib/rgame/engine/collision_system.rb +132 -0
- data/lib/rgame/engine/component.rb +64 -0
- data/lib/rgame/engine/components/action_trigger.rb +40 -0
- data/lib/rgame/engine/components/animated_sprite.rb +71 -0
- data/lib/rgame/engine/components/box_collider.rb +99 -0
- data/lib/rgame/engine/components/camera_follow.rb +45 -0
- data/lib/rgame/engine/components/character_body.rb +50 -0
- data/lib/rgame/engine/components/circle_collider.rb +80 -0
- data/lib/rgame/engine/components/collision_world.rb +231 -0
- data/lib/rgame/engine/components/despawn_offscreen.rb +42 -0
- data/lib/rgame/engine/components/feet_collider.rb +61 -0
- data/lib/rgame/engine/components/hop.rb +76 -0
- data/lib/rgame/engine/components/identity.rb +73 -0
- data/lib/rgame/engine/components/mover.rb +285 -0
- data/lib/rgame/engine/components/navigator.rb +145 -0
- data/lib/rgame/engine/components/path_follow.rb +176 -0
- data/lib/rgame/engine/components/player_controller.rb +27 -0
- data/lib/rgame/engine/components/pool.rb +53 -0
- data/lib/rgame/engine/components/screen_wrap.rb +49 -0
- data/lib/rgame/engine/components/sprite.rb +47 -0
- data/lib/rgame/engine/components/targeting.rb +52 -0
- data/lib/rgame/engine/components/thrust_controller.rb +65 -0
- data/lib/rgame/engine/components/tile_world.rb +109 -0
- data/lib/rgame/engine/components/timer.rb +75 -0
- data/lib/rgame/engine/components/velocity.rb +43 -0
- data/lib/rgame/engine/components/wander_controller.rb +64 -0
- data/lib/rgame/engine/components/world.rb +133 -0
- data/lib/rgame/engine/contact_set.rb +74 -0
- data/lib/rgame/engine/culling.rb +45 -0
- data/lib/rgame/engine/debug_overlay.rb +111 -0
- data/lib/rgame/engine/i18n/plural.rb +45 -0
- data/lib/rgame/engine/i18n/plural_rules.rb +82 -0
- data/lib/rgame/engine/i18n/template.rb +59 -0
- data/lib/rgame/engine/i18n.rb +322 -0
- data/lib/rgame/engine/input/action_mapper.rb +100 -0
- data/lib/rgame/engine/input/actions.rb +92 -0
- data/lib/rgame/engine/input/input_map.rb +196 -0
- data/lib/rgame/engine/layout.rb +82 -0
- data/lib/rgame/engine/nav_grid.rb +87 -0
- data/lib/rgame/engine/node2d.rb +555 -0
- data/lib/rgame/engine/path.rb +76 -0
- data/lib/rgame/engine/player.rb +69 -0
- data/lib/rgame/engine/player_layer.rb +70 -0
- data/lib/rgame/engine/players.rb +205 -0
- data/lib/rgame/engine/pool.rb +51 -0
- data/lib/rgame/engine/presentation.rb +171 -0
- data/lib/rgame/engine/scene/scene_stack.rb +87 -0
- data/lib/rgame/engine/sealed_privates.rb +54 -0
- data/lib/rgame/engine/signal.rb +75 -0
- data/lib/rgame/engine/spatial_hash.rb +116 -0
- data/lib/rgame/engine/text.rb +194 -0
- data/lib/rgame/engine/tile_blockers.rb +63 -0
- data/lib/rgame/engine/tile_map.rb +147 -0
- data/lib/rgame/engine/tile_map_layer.rb +82 -0
- data/lib/rgame/engine/tileset.rb +99 -0
- data/lib/rgame/engine/timer.rb +51 -0
- data/lib/rgame/engine/ui/button.rb +248 -0
- data/lib/rgame/engine/ui/column.rb +20 -0
- data/lib/rgame/engine/ui/icon_button.rb +93 -0
- data/lib/rgame/engine/ui/menu.rb +290 -0
- data/lib/rgame/engine/ui/navigation.rb +57 -0
- data/lib/rgame/engine/ui/nine_slice_style.rb +50 -0
- data/lib/rgame/engine/ui/option_button.rb +163 -0
- data/lib/rgame/engine/ui/panel_button.rb +32 -0
- data/lib/rgame/engine/ui/panel_menu.rb +36 -0
- data/lib/rgame/engine/ui/pointing.rb +146 -0
- data/lib/rgame/engine/ui/radial_menu.rb +85 -0
- data/lib/rgame/engine/ui/ring.rb +55 -0
- data/lib/rgame/engine/ui/row.rb +21 -0
- data/lib/rgame/engine/ui/shape_style.rb +102 -0
- data/lib/rgame/engine/ui/stack.rb +58 -0
- data/lib/rgame/engine/ui/stepping.rb +93 -0
- data/lib/rgame/engine/ui/text_button.rb +59 -0
- data/lib/rgame/engine/view.rb +76 -0
- data/lib/rgame/engine/viewports.rb +171 -0
- data/lib/rgame/engine/world_view.rb +71 -0
- data/lib/rgame/engine.rb +86 -0
- data/lib/rgame/fonts/LiberationSans-Regular.ttf +0 -0
- data/lib/rgame/fonts/OFL.txt +102 -0
- data/lib/rgame/game.rb +271 -0
- data/lib/rgame/rubocop/cop/game/draw_in_local_space.rb +103 -0
- data/lib/rgame/rubocop/cop/game/hot_path.rb +36 -0
- data/lib/rgame/rubocop/cop/game/layer_boundary.rb +43 -0
- data/lib/rgame/rubocop/cop/game/no_core_in_engine_layer.rb +100 -0
- data/lib/rgame/rubocop/cop/game/no_engine_in_core_layer.rb +84 -0
- data/lib/rgame/rubocop/cop/game/no_interpolation_in_hot_path.rb +50 -0
- data/lib/rgame/rubocop/cop/game/no_literal_text.rb +41 -0
- data/lib/rgame/rubocop/cop/game/no_needless_allocation.rb +112 -0
- data/lib/rgame/rubocop/default.yml +39 -0
- data/lib/rgame/rubocop/plugin.rb +45 -0
- data/lib/rgame/rubocop.rb +11 -0
- data/lib/rgame/util/color.rb +23 -0
- data/lib/rgame/util/controls.rb +169 -0
- data/lib/rgame/util/route_search.rb +27 -0
- data/lib/rgame/util/save_file.rb +107 -0
- data/lib/rgame/util/solid_grid.rb +37 -0
- data/lib/rgame/util/tensor.rb +3 -0
- data/lib/rgame/util/tile_sweep.rb +36 -0
- data/lib/rgame/util/z.rb +123 -0
- data/lib/rgame/util.rb +10 -0
- data/lib/rgame/util_ext.so +0 -0
- data/lib/rgame/version.rb +12 -0
- data/lib/rgame.rb +5 -0
- data/licenses/SDL2/LICENSE.txt +18 -0
- metadata +309 -0
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
raise ArgumentError, "nine-slice scale must be positive, got #{scale}" unless scale.positive?
|
|
45
|
+
|
|
46
|
+
@scale = scale
|
|
47
|
+
border = normalize_border(border)
|
|
48
|
+
@l = border.fetch(:left)
|
|
49
|
+
@r = border.fetch(:right)
|
|
50
|
+
@t = border.fetch(:top)
|
|
51
|
+
@b = border.fetch(:bottom)
|
|
52
|
+
|
|
53
|
+
cut_pieces(image, x, y, w, h)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fills the rectangle (dx, dy, dw, dh).
|
|
57
|
+
#
|
|
58
|
+
# `color` tints every piece — a focus highlight, a disabled grey. A
|
|
59
|
+
# rectangle smaller than its own borders draws its corners and nothing
|
|
60
|
+
# else, which is the least misleading thing a widget too small for its own
|
|
61
|
+
# chrome can look like.
|
|
62
|
+
def draw(renderer, dx, dy, dw, dh, z: 0, color: nil)
|
|
63
|
+
s = @scale
|
|
64
|
+
l = @l * s
|
|
65
|
+
r = @r * s
|
|
66
|
+
t = @t * s
|
|
67
|
+
b = @b * s
|
|
68
|
+
inner_w = dw - l - r
|
|
69
|
+
inner_h = dh - t - b
|
|
70
|
+
|
|
71
|
+
band(renderer, @centre, dx + l, dy + t, inner_w, inner_h, z, color)
|
|
72
|
+
band(renderer, @top, dx + l, dy, inner_w, t, z, color)
|
|
73
|
+
band(renderer, @bottom, dx + l, dy + dh - b, inner_w, b, z, color)
|
|
74
|
+
band(renderer, @left, dx, dy + t, l, inner_h, z, color)
|
|
75
|
+
band(renderer, @right, dx + dw - r, dy + t, r, inner_h, z, color)
|
|
76
|
+
|
|
77
|
+
piece(renderer, @tl, dx, dy, z, color)
|
|
78
|
+
piece(renderer, @tr, dx + dw - r, dy, z, color)
|
|
79
|
+
piece(renderer, @bl, dx, dy + dh - b, z, color)
|
|
80
|
+
piece(renderer, @br, dx + dw - r, dy + dh - b, z, color)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def normalize_border(border)
|
|
86
|
+
return border.transform_keys(&:to_sym) unless border.is_a?(Integer)
|
|
87
|
+
|
|
88
|
+
{ left: border, right: border, top: border, bottom: border }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def cut_pieces(image, x, y, w, h)
|
|
92
|
+
centre_w = w - @l - @r
|
|
93
|
+
centre_h = h - @t - @b
|
|
94
|
+
if centre_w.negative? || centre_h.negative?
|
|
95
|
+
raise ArgumentError,
|
|
96
|
+
"nine-slice borders (#{@l}, #{@r}, #{@t}, #{@b}) do not fit in a #{w}x#{h} rect"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
@tl = cut(image, x, y, @l, @t)
|
|
100
|
+
@tr = cut(image, x + w - @r, y, @r, @t)
|
|
101
|
+
@bl = cut(image, x, y + h - @b, @l, @b)
|
|
102
|
+
@br = cut(image, x + w - @r, y + h - @b, @r, @b)
|
|
103
|
+
|
|
104
|
+
@top = cut(image, x + @l, y, centre_w, @t)
|
|
105
|
+
@bottom = cut(image, x + @l, y + h - @b, centre_w, @b)
|
|
106
|
+
@left = cut(image, x, y + @t, @l, centre_h)
|
|
107
|
+
@right = cut(image, x + w - @r, y + @t, @r, centre_h)
|
|
108
|
+
@centre = cut(image, x + @l, y + @t, centre_w, centre_h)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cut(image, x, y, width, height)
|
|
112
|
+
return nil unless width.positive? && height.positive?
|
|
113
|
+
|
|
114
|
+
image.subimage(x, y, width, height)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def piece(renderer, image, x, y, z, color)
|
|
118
|
+
return unless image
|
|
119
|
+
|
|
120
|
+
renderer.image_at(image, x, y, scale_x: @scale, scale_y: @scale, z: z, color: color)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def band(renderer, image, bx, by, bw, bh, z, color)
|
|
124
|
+
return if image.nil? || bw <= 0 || bh <= 0
|
|
125
|
+
|
|
126
|
+
step_x = image.width * @scale
|
|
127
|
+
step_y = image.height * @scale
|
|
128
|
+
renderer.clipped(bx, by, bw, bh) do
|
|
129
|
+
y = by
|
|
130
|
+
while y < by + bh
|
|
131
|
+
x = bx
|
|
132
|
+
while x < bx + bw
|
|
133
|
+
piece(renderer, image, x, y, z, color)
|
|
134
|
+
x += step_x
|
|
135
|
+
end
|
|
136
|
+
y += step_y
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rgame/core_ext'
|
|
4
|
+
require_relative '../util/color'
|
|
5
|
+
require_relative '../util/z'
|
|
6
|
+
|
|
7
|
+
module RGame
|
|
8
|
+
module Core
|
|
9
|
+
# A block of drawing baked once and replayed cheaply.
|
|
10
|
+
#
|
|
11
|
+
# ground = renderer.record do
|
|
12
|
+
# tiles.each { |tile| renderer.image(tile.image, tile.x, tile.y) }
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# def draw
|
|
16
|
+
# ground.draw(-camera.x, -camera.y)
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# A screen of tiles is a couple of thousand quads that have not changed
|
|
20
|
+
# since the level loaded. Baking them turns the per-frame cost from "walk
|
|
21
|
+
# every tile and transform four corners" into one call per texture — the
|
|
22
|
+
# work happens at bake time and never again.
|
|
23
|
+
#
|
|
24
|
+
# Recordings come from `Renderer#record`; there is no `new`.
|
|
25
|
+
#
|
|
26
|
+
# ## What is baked in and what is not
|
|
27
|
+
#
|
|
28
|
+
# Positions, texture coordinates and colours are baked, and so are any
|
|
29
|
+
# transforms applied *inside* the block. The transform in effect when the
|
|
30
|
+
# recording is *drawn* is applied on top, which is what lets a baked layer
|
|
31
|
+
# scroll under a camera without being rebuilt.
|
|
32
|
+
#
|
|
33
|
+
# Clipping is not baked — it happens when pixels are rasterised, so a clip
|
|
34
|
+
# rectangle captured in one place would be wrong everywhere else the
|
|
35
|
+
# recording is drawn. Pushing a clip inside a `record` block raises. Clip
|
|
36
|
+
# the replay instead:
|
|
37
|
+
#
|
|
38
|
+
# renderer.clipped(0, 0, 400, 600) { ground.draw(0, 0) }
|
|
39
|
+
class Recording
|
|
40
|
+
Color = RGame::Util::Color
|
|
41
|
+
Z = RGame::Util::Z
|
|
42
|
+
|
|
43
|
+
# Replays everything that was recorded, with the recording's origin at
|
|
44
|
+
# (x, y).
|
|
45
|
+
#
|
|
46
|
+
# `color` tints what was baked: each recorded colour is multiplied by it,
|
|
47
|
+
# so white (the default) draws the recording unchanged and a colour with
|
|
48
|
+
# alpha fades the whole layer out at once.
|
|
49
|
+
def draw(x = 0, y = 0, z: 0, color: nil)
|
|
50
|
+
draw_at(x, y, Z.offset(z), Color.coerce(color).packed)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rgame/core_ext'
|
|
4
|
+
require_relative 'font'
|
|
5
|
+
require_relative '../util/color'
|
|
6
|
+
require_relative '../util/z'
|
|
7
|
+
|
|
8
|
+
module RGame
|
|
9
|
+
module Core
|
|
10
|
+
# What a game draws with.
|
|
11
|
+
#
|
|
12
|
+
# class MyGame < RGame::Core::App
|
|
13
|
+
# def initialize
|
|
14
|
+
# super(width: 800, height: 600, caption: 'demo')
|
|
15
|
+
# @renderer = RGame::Core::Renderer.new(self)
|
|
16
|
+
# @hero = RGame::Core::Image.new(self, 'hero.png')
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# def draw
|
|
20
|
+
# @renderer.rect(10, 10, 100, 40, color: RGame::Util::Color::WHITE)
|
|
21
|
+
# @renderer.image(@hero, 400, 300, angle: 45)
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# Drawing is only legal inside `draw`, and calling one of these outside it
|
|
26
|
+
# raises. That is on purpose: the frame is not open, so the vertices would
|
|
27
|
+
# be silently discarded, and an invisible failure is the worst kind.
|
|
28
|
+
#
|
|
29
|
+
# Nothing is drawn immediately. Calls accumulate and are sorted when the
|
|
30
|
+
# frame closes, so what ends up on top is decided by z rather than by call
|
|
31
|
+
# order. Equal z keeps call order, which is what stops same-layer sprites
|
|
32
|
+
# flickering between frames.
|
|
33
|
+
#
|
|
34
|
+
# ## `z:` is an offset inside the current layer, not a global number
|
|
35
|
+
#
|
|
36
|
+
# A `z:` is added to whatever `#layered` most recently pushed, and must be
|
|
37
|
+
# within RGame::Util::Z::Z_MIN..Z_MAX — one *node's* worth of room. It
|
|
38
|
+
# orders that node's own drawing (its panel under its label, a shadow under
|
|
39
|
+
# its sprite) and can reach nothing else. Which node comes before which is
|
|
40
|
+
# the scene graph's business, resolved by the traversal and handed here as a
|
|
41
|
+
# layer; see RGame::Util::Z.
|
|
42
|
+
#
|
|
43
|
+
# A caller outside any `#layered` block draws at the base layer 0, which is
|
|
44
|
+
# what a spec or a bare script gets.
|
|
45
|
+
#
|
|
46
|
+
# The C half of this class (ext/rgame_core/ruby/renderer_ext.c) has the `draw_*`
|
|
47
|
+
# and `push_*` primitives, registered private; everything here is the
|
|
48
|
+
# surface over them, so a draw cannot skip the z offset or the colour
|
|
49
|
+
# coercion, and a push cannot go without its pop.
|
|
50
|
+
#
|
|
51
|
+
# ## Colours
|
|
52
|
+
#
|
|
53
|
+
# Every drawing method takes `color:`, accepting whatever `Color.coerce`
|
|
54
|
+
# does: `nil` (white — an untinted draw), `[r, g, b]`, `[r, g, b, a]`, or a
|
|
55
|
+
# `RGame::Util::Color`. Passing a `Color` is the allocation-free path and is
|
|
56
|
+
# what per-frame code should do; an array allocates one colour per call.
|
|
57
|
+
class Renderer
|
|
58
|
+
Color = RGame::Util::Color
|
|
59
|
+
|
|
60
|
+
Z = RGame::Util::Z
|
|
61
|
+
|
|
62
|
+
SHAPE_Z = 50
|
|
63
|
+
IMAGE_Z = 0
|
|
64
|
+
|
|
65
|
+
CIRCLE_SEGMENTS = 64
|
|
66
|
+
|
|
67
|
+
TEXT_Z = 10
|
|
68
|
+
FONT_SIZE = 18
|
|
69
|
+
|
|
70
|
+
DEBUG_BOX_COLOR = Color.new(255, 40, 40, 120)
|
|
71
|
+
|
|
72
|
+
# `assets:` is where a draw id that is not registered gets resolved from,
|
|
73
|
+
# and defaults to the app's own manager — so the common case wires itself
|
|
74
|
+
# and `renderer.sprite('hero.json', …)` works with nothing set up.
|
|
75
|
+
#
|
|
76
|
+
# A Ruby `self.new` because the C `initialize` has fixed arity and no
|
|
77
|
+
# business knowing what an asset manager is; the same shape `Font`'s
|
|
78
|
+
# `path:` uses.
|
|
79
|
+
def self.new(app, assets: nil)
|
|
80
|
+
renderer = super(app)
|
|
81
|
+
renderer.assets = assets.nil? ? app.assets : assets
|
|
82
|
+
renderer
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
attr_accessor :assets
|
|
86
|
+
|
|
87
|
+
def register_image(id, image) = registry(:image)[id] = image
|
|
88
|
+
def register_sheet(id, sheet) = registry(:sheet)[id] = sheet
|
|
89
|
+
def register_tilemap(id, tilemap) = registry(:tilemap)[id] = tilemap
|
|
90
|
+
def register_nine_slice(id, nine_slice) = registry(:nine_slice)[id] = nine_slice
|
|
91
|
+
|
|
92
|
+
# Registers every element of a UiAtlas — its nine-slices and its images —
|
|
93
|
+
# under its own name, since those names are what a widget asks for.
|
|
94
|
+
def register_ui_atlas(atlas)
|
|
95
|
+
atlas.nine_slices.each { |id, nine_slice| register_nine_slice(id, nine_slice) }
|
|
96
|
+
atlas.images.each { |id, image| register_image(id, image) }
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One frame of a registered or resolvable sprite sheet, top-left at (x, y).
|
|
101
|
+
def sprite(id, row, col, x, y, flip_x: false, z: IMAGE_Z)
|
|
102
|
+
lookup(:sheet, id).draw(self, row, col, x, y, flip_x: flip_x, z: z)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# A nine-slice filling (x, y, width, height), tinted by `tint` if given.
|
|
106
|
+
# Registration only: a nine-slice id names an element of an atlas, not a
|
|
107
|
+
# file, so there is nothing for the asset manager to resolve it to.
|
|
108
|
+
def nine_slice(id, x, y, width, height, z: IMAGE_Z, tint: nil)
|
|
109
|
+
lookup(:nine_slice, id).draw(self, x, y, width, height, z: z, color: tint)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# One layer of a tile map — the layer a Tiled `.tmx` lists at `layer`,
|
|
113
|
+
# counting from the bottom.
|
|
114
|
+
#
|
|
115
|
+
# One layer rather than the whole map, because a scene draws its actors
|
|
116
|
+
# between two of them: trunks under, canopies over. Which is which is the
|
|
117
|
+
# scene tree's business — `RGame::Engine::TileMapLayer` mounts a node per
|
|
118
|
+
# layer — so this takes no `z:`.
|
|
119
|
+
#
|
|
120
|
+
# **Drawn in world coordinates**: a tile at column 3 lands at
|
|
121
|
+
# `3 * tile_width`, and getting it onto the screen is the caller's
|
|
122
|
+
# transform, like every other drawing method here. The rectangle is a
|
|
123
|
+
# **cull rect** — which part of the world is worth drawing — so a camera
|
|
124
|
+
# supplies it but does not move the result. That is what lets one map be
|
|
125
|
+
# drawn through several cameras in a frame.
|
|
126
|
+
#
|
|
127
|
+
# `elapsed` is the seconds its animated tiles have been running for, and
|
|
128
|
+
# is an argument rather than a clock read on purpose — see CLAUDE.md,
|
|
129
|
+
# "`draw` renders state; time enters through `update`". A scene
|
|
130
|
+
# accumulates it in `update`, which is what makes pausing work.
|
|
131
|
+
def tilemap(id, layer, cull_x, cull_y, cull_width, cull_height, elapsed: 0.0)
|
|
132
|
+
lookup(:tilemap, id)
|
|
133
|
+
.draw_layer(self, layer, cull_x, cull_y, cull_width, cull_height, elapsed: elapsed)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# A filled axis-aligned rectangle.
|
|
137
|
+
def rect(x, y, width, height, z: SHAPE_Z, color: nil)
|
|
138
|
+
draw_rect(x, y, width, height, Z.offset(z), packed(color))
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Four arbitrary points, in loop order: listing them in Z order gives an
|
|
142
|
+
# hourglass rather than a shape.
|
|
143
|
+
def quad(x1, y1, x2, y2, x3, y3, x4, y4, z: SHAPE_Z, color: nil)
|
|
144
|
+
draw_quad(x1, y1, x2, y2, x3, y3, x4, y4, Z.offset(z), packed(color))
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def triangle(x1, y1, x2, y2, x3, y3, z: SHAPE_Z, color: nil)
|
|
148
|
+
draw_triangle(x1, y1, x2, y2, x3, y3, Z.offset(z), packed(color))
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A line of real thickness — drawn as a quad, because GL's own line width
|
|
152
|
+
# is a suggestion drivers may ignore above one pixel.
|
|
153
|
+
def line(x1, y1, x2, y2, thickness: 1.0, z: SHAPE_Z, color: nil)
|
|
154
|
+
draw_line(x1, y1, x2, y2, thickness, Z.offset(z), packed(color))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# A filled circle, as a fan of triangles around its centre.
|
|
158
|
+
def circle(cx, cy, radius, z: SHAPE_Z, color: nil, segments: CIRCLE_SEGMENTS)
|
|
159
|
+
draw_circle(cx, cy, radius, segments, Z.offset(z), packed(color))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# An image centred on (cx, cy), rotated `angle` degrees clockwise about
|
|
163
|
+
# that centre and uniformly scaled. Unrotated and unscaled is a fast path
|
|
164
|
+
# that skips the transform stack entirely.
|
|
165
|
+
#
|
|
166
|
+
# Takes an `Image` or an id for one — see #resolve_image.
|
|
167
|
+
def image(image, cx, cy, angle: 0, scale: 1, z: IMAGE_Z, color: nil)
|
|
168
|
+
draw_image_rot(resolve_image(image), cx, cy, angle, scale, Z.offset(z), packed(color))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# An image with its top-left at (x, y), scaled independently per axis —
|
|
172
|
+
# a tile, a nine-slice corner, a sprite-sheet frame.
|
|
173
|
+
#
|
|
174
|
+
# A **negative scale mirrors the image inside the same rectangle**; it
|
|
175
|
+
# does not move it. So a frame drawn at (x, y) covers the same pixels
|
|
176
|
+
# whichever way it faces, and `scale_x: -1` means "facing the other way"
|
|
177
|
+
# rather than "one width to the left":
|
|
178
|
+
#
|
|
179
|
+
# renderer.image_at(frame, x, y, scale_x: facing_left ? -1 : 1)
|
|
180
|
+
#
|
|
181
|
+
# A zero scale draws nothing.
|
|
182
|
+
def image_at(image, x, y, scale_x: 1, scale_y: 1, z: IMAGE_Z, color: nil)
|
|
183
|
+
draw_image_scaled(resolve_image(image), x, y, scale_x, scale_y, Z.offset(z), packed(color))
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# An image with its top-left at (x, y), at its natural size — a
|
|
187
|
+
# full-screen backdrop by default. `image_at` with both scales at 1, kept
|
|
188
|
+
# because "put this at the origin" is worth a name of its own.
|
|
189
|
+
def background(image, x = 0, y = 0, z: IMAGE_Z, color: nil)
|
|
190
|
+
draw_image(resolve_image(image), x, y, Z.offset(z), packed(color))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Everything drawn in the block is rotated `angle` degrees about
|
|
194
|
+
# (pivot_x, pivot_y), so a node can spin all of its parts coherently
|
|
195
|
+
# around one point.
|
|
196
|
+
#
|
|
197
|
+
# A zero angle skips the push entirely — unrotated drawing pays nothing,
|
|
198
|
+
# which matters because most drawing is unrotated.
|
|
199
|
+
def rotated(angle, pivot_x, pivot_y)
|
|
200
|
+
return yield if angle.zero?
|
|
201
|
+
|
|
202
|
+
push_rotate(angle, pivot_x, pivot_y)
|
|
203
|
+
begin
|
|
204
|
+
yield
|
|
205
|
+
ensure
|
|
206
|
+
pop
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Everything drawn in the block is shifted by (dx, dy) screen pixels —
|
|
211
|
+
# the camera's view transform. Because it is a draw-time transform rather
|
|
212
|
+
# than something baked into positions, the same world can be drawn again
|
|
213
|
+
# under a different offset and clip, which is what split-screen is.
|
|
214
|
+
def translated(dx, dy)
|
|
215
|
+
return yield if dx.zero? && dy.zero?
|
|
216
|
+
|
|
217
|
+
push_translate(dx, dy)
|
|
218
|
+
begin
|
|
219
|
+
yield
|
|
220
|
+
ensure
|
|
221
|
+
pop
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def scaled(sx, sy = sx)
|
|
226
|
+
return yield if sx == 1 && sy == 1
|
|
227
|
+
|
|
228
|
+
push_scale(sx, sy)
|
|
229
|
+
begin
|
|
230
|
+
yield
|
|
231
|
+
ensure
|
|
232
|
+
pop
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Everything drawn in the block draws in its own layer: a fresh slot in
|
|
237
|
+
# `band`, which every `z:` inside is then an offset from.
|
|
238
|
+
#
|
|
239
|
+
# renderer.layered(:hud) { renderer.text(score, 12, 10) }
|
|
240
|
+
#
|
|
241
|
+
# Two things fall out of it, and they are the whole of draw order.
|
|
242
|
+
# **Slots are handed out in the order they are asked for**, so nesting
|
|
243
|
+
# this the way a scene graph is nested makes draw order tree order — a
|
|
244
|
+
# node drawn later is in front, and its whole subtree with it. And **a
|
|
245
|
+
# band is a hard partition**: every slot in `:hud` is above every slot in
|
|
246
|
+
# `:world`, whatever either drew, because they are 2**40 apart and a `z:`
|
|
247
|
+
# cannot reach out of one slot. See RGame::Util::Z.
|
|
248
|
+
#
|
|
249
|
+
# A caller that never uses this draws at layer 0 and gets exactly the z it
|
|
250
|
+
# passes, which is what a spec or a one-off script wants.
|
|
251
|
+
def layered(band = Z::DEFAULT)
|
|
252
|
+
index = Z.index(band)
|
|
253
|
+
push_layer(Z.slot_base(index, next_layer_slot(index)))
|
|
254
|
+
begin
|
|
255
|
+
yield
|
|
256
|
+
ensure
|
|
257
|
+
pop
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Everything drawn in the block is confined to the given rectangle.
|
|
262
|
+
#
|
|
263
|
+
# A clip only ever narrows: nesting one inside another intersects them, so
|
|
264
|
+
# a child cannot draw outside the region its parent allowed. Give each
|
|
265
|
+
# player a clipped block and you have split-screen.
|
|
266
|
+
def clipped(x, y, width, height)
|
|
267
|
+
push_clip(x, y, width, height)
|
|
268
|
+
begin
|
|
269
|
+
yield
|
|
270
|
+
ensure
|
|
271
|
+
pop
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# The font this renderer draws with when a call does not name one.
|
|
276
|
+
#
|
|
277
|
+
# Built on first use rather than in the constructor: creating a font needs
|
|
278
|
+
# a GL context, and a renderer is often built before there is one. Set
|
|
279
|
+
# your own with #font= to change what every unqualified #text call uses.
|
|
280
|
+
def font
|
|
281
|
+
@font ||= Font.new(app, FONT_SIZE)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
attr_writer :font
|
|
285
|
+
|
|
286
|
+
# One line of text, with its top-left corner at (x, y) — the same corner
|
|
287
|
+
# every other drawing method takes, rather than the baseline typography
|
|
288
|
+
# would use.
|
|
289
|
+
#
|
|
290
|
+
# Newlines are not special. A caller wanting two lines draws two, stepping
|
|
291
|
+
# by #text_height.
|
|
292
|
+
def text(string, x, y, z: TEXT_Z, color: nil, font: nil)
|
|
293
|
+
draw_text(font || self.font, string, x, y, Z.offset(z), packed(color))
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# What #text would occupy, for centring and layout. Unlike the drawing
|
|
297
|
+
# methods this works outside `draw`, because measuring touches no GL.
|
|
298
|
+
def text_width(string, font: nil) = (font || self.font).text_width(string)
|
|
299
|
+
|
|
300
|
+
# The line height: what to step y by for a second line.
|
|
301
|
+
def text_height(font: nil) = (font || self.font).height
|
|
302
|
+
|
|
303
|
+
# Bakes everything the block draws into a RGame::Core::Recording, which
|
|
304
|
+
# can then be replayed for the cost of one call per texture however many
|
|
305
|
+
# draws went into it. Nothing is drawn *now* — the block's output goes
|
|
306
|
+
# into the recording instead of into this frame.
|
|
307
|
+
#
|
|
308
|
+
# ground = renderer.record { tiles.each { |t| renderer.image(t.img, t.x, t.y) } }
|
|
309
|
+
# ground.draw(-camera.x, -camera.y)
|
|
310
|
+
#
|
|
311
|
+
# Recording happens inside `draw` like everything else, and does not
|
|
312
|
+
# nest. A clip pushed inside the block raises: clipping cannot be baked,
|
|
313
|
+
# so clip the replay instead. See RGame::Core::Recording.
|
|
314
|
+
def record
|
|
315
|
+
begin_record
|
|
316
|
+
completed = false
|
|
317
|
+
begin
|
|
318
|
+
yield
|
|
319
|
+
completed = true
|
|
320
|
+
ensure
|
|
321
|
+
cancel_record unless completed
|
|
322
|
+
end
|
|
323
|
+
end_record
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# A translucent overlay for visualising a collision box, so a scene can
|
|
327
|
+
# ask for one without knowing what colour "debug" is.
|
|
328
|
+
def debug_box(x, y, width, height, z: SHAPE_Z)
|
|
329
|
+
rect(x, y, width, height, z: z, color: DEBUG_BOX_COLOR)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
private
|
|
333
|
+
|
|
334
|
+
def packed(color) = Color.coerce(color).packed
|
|
335
|
+
|
|
336
|
+
def resolve_image(image) = image.is_a?(Image) ? image : lookup(:image, image)
|
|
337
|
+
|
|
338
|
+
def registry(type) = (@registries ||= {})[type] ||= {}
|
|
339
|
+
|
|
340
|
+
def lookup(type, id)
|
|
341
|
+
raise TypeError, "no implicit conversion of nil into #{type}" if id.nil?
|
|
342
|
+
|
|
343
|
+
table = registry(type)
|
|
344
|
+
table.fetch(id) { table[id] = resolve_asset(type, id) }
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def resolve_asset(type, id)
|
|
348
|
+
resolved = @assets.public_send(type, id) if id.is_a?(String) && @assets.respond_to?(type)
|
|
349
|
+
resolved || raise(KeyError, "no #{type} registered for #{id.inspect} " \
|
|
350
|
+
'and no AssetManager to resolve it')
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
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
|
+
def missing(key)
|
|
101
|
+
raise ArgumentError, "sprite sheet descriptor has no #{key}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|