rgame 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +94 -0
- data/README.md +130 -233
- data/docs/api/README.md +116 -69
- data/docs/api/assets.md +11 -12
- data/docs/api/components.md +58 -34
- data/docs/api/drawing.md +77 -9
- data/docs/api/game.md +34 -13
- data/docs/api/input.md +232 -51
- data/docs/api/scene_graph.md +242 -15
- data/docs/api/systems.md +20 -0
- data/docs/api/toolbox.md +19 -15
- data/docs/api/ui.md +98 -0
- data/docs/api/values.md +32 -0
- data/ext/README.md +6 -5
- data/ext/rgame_core/app/app.c +182 -8
- data/ext/rgame_core/audio/audio.c +74 -0
- data/ext/rgame_core/example.rb +17 -6
- data/ext/rgame_core/extconf.rb +52 -24
- data/ext/rgame_core/graphics/canvas.c +45 -4
- data/ext/rgame_core/graphics/canvas.h +65 -10
- data/ext/rgame_core/graphics/clip.c +22 -13
- data/ext/rgame_core/include/rgame/core.h +113 -3
- data/ext/rgame_core/input/gamepad.c +57 -3
- data/ext/rgame_core/ruby/core_ext.c +16 -0
- data/ext/rgame_core/ruby/renderer_ext.c +23 -0
- data/ext/rgame_util/color_ext.c +12 -3
- data/lib/rgame/core/app.rb +2 -0
- data/lib/rgame/core/input.rb +35 -41
- data/lib/rgame/core/recording.rb +3 -1
- data/lib/rgame/core/renderer.rb +76 -28
- data/lib/rgame/core/tile_map_renderer.rb +84 -55
- data/lib/rgame/engine/camera.rb +55 -10
- data/lib/rgame/engine/component.rb +11 -1
- data/lib/rgame/engine/components/animated_sprite.rb +9 -3
- data/lib/rgame/engine/components/camera_follow.rb +44 -0
- data/lib/rgame/engine/components/character_body.rb +25 -4
- data/lib/rgame/engine/components/sprite.rb +11 -1
- data/lib/rgame/engine/components/tile_world.rb +31 -18
- data/lib/rgame/engine/culling.rb +47 -0
- data/lib/rgame/engine/debug_overlay.rb +20 -9
- data/lib/rgame/engine/input/action_mapper.rb +101 -21
- data/lib/rgame/engine/input/actions.rb +69 -12
- data/lib/rgame/engine/input/input_map.rb +178 -0
- data/lib/rgame/engine/layout.rb +82 -0
- data/lib/rgame/engine/node2d.rb +205 -36
- data/lib/rgame/engine/player.rb +69 -0
- data/lib/rgame/engine/player_layer.rb +70 -0
- data/lib/rgame/engine/players.rb +212 -0
- data/lib/rgame/engine/scene/scene_stack.rb +25 -3
- data/lib/rgame/engine/spatial_hash.rb +17 -4
- data/lib/rgame/engine/tile_map_layer.rb +84 -0
- data/lib/rgame/engine/ui/menu.rb +115 -0
- data/lib/rgame/engine/ui/menu_item.rb +84 -0
- data/lib/rgame/engine/view.rb +76 -0
- data/lib/rgame/engine/viewports.rb +174 -0
- data/lib/rgame/engine/world_view.rb +70 -0
- data/lib/rgame/engine.rb +13 -1
- data/lib/rgame/game.rb +81 -11
- data/lib/rgame/util/controls.rb +117 -41
- data/lib/rgame/util/z.rb +133 -0
- data/lib/rgame/util.rb +1 -0
- data/lib/rgame/version.rb +1 -1
- metadata +26 -11
- data/lib/rgame/engine/camera_view.rb +0 -28
data/lib/rgame/core/renderer.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'rgame/core_ext'
|
|
4
4
|
require_relative 'font'
|
|
5
5
|
require_relative '../util/color'
|
|
6
|
+
require_relative '../util/z'
|
|
6
7
|
|
|
7
8
|
module RGame
|
|
8
9
|
module Core
|
|
@@ -25,10 +26,22 @@ module RGame
|
|
|
25
26
|
# raises. That is on purpose: the frame is not open, so the vertices would
|
|
26
27
|
# be silently discarded, and an invisible failure is the worst kind.
|
|
27
28
|
#
|
|
28
|
-
# Nothing is drawn immediately. Calls accumulate and are
|
|
29
|
-
# frame closes, so
|
|
30
|
-
# z keeps call order, which is what stops same-layer sprites
|
|
31
|
-
# between frames.
|
|
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.
|
|
32
45
|
#
|
|
33
46
|
# The C half of this class (ext/rgame_core/ruby/renderer_ext.c) has the `draw_*`
|
|
34
47
|
# and `push_*` primitives; everything here is the comfortable surface over
|
|
@@ -43,9 +56,12 @@ module RGame
|
|
|
43
56
|
class Renderer
|
|
44
57
|
Color = RGame::Util::Color
|
|
45
58
|
|
|
59
|
+
Z = RGame::Util::Z
|
|
60
|
+
|
|
46
61
|
# Shapes default above sprites, so a debug box or a health bar drawn
|
|
47
|
-
# without a `z:` lands on top of
|
|
48
|
-
#
|
|
62
|
+
# without a `z:` lands on top of *that node's* sprite rather than under
|
|
63
|
+
# it. Well inside one slot, so the defaults order a node's own drawing and
|
|
64
|
+
# nothing further.
|
|
49
65
|
SHAPE_Z = 50
|
|
50
66
|
IMAGE_Z = 0
|
|
51
67
|
|
|
@@ -53,8 +69,7 @@ module RGame
|
|
|
53
69
|
# draws one, and few enough that a screenful of them is still one batch.
|
|
54
70
|
CIRCLE_SEGMENTS = 64
|
|
55
71
|
|
|
56
|
-
# Text defaults above sprites but below shapes,
|
|
57
|
-
# the layer this replaces used, so ported UI lays out unchanged.
|
|
72
|
+
# Text defaults above sprites but below shapes, within the same slot.
|
|
58
73
|
TEXT_Z = 10
|
|
59
74
|
FONT_SIZE = 18
|
|
60
75
|
|
|
@@ -117,49 +132,54 @@ module RGame
|
|
|
117
132
|
lookup(:nine_slice, id).draw(self, x, y, width, height, z: z, color: tint)
|
|
118
133
|
end
|
|
119
134
|
|
|
120
|
-
#
|
|
135
|
+
# One layer of a tile map — the layer a Tiled `.tmx` lists at `layer`,
|
|
136
|
+
# counting from the bottom.
|
|
137
|
+
#
|
|
138
|
+
# One layer rather than the whole map, because a scene draws its actors
|
|
139
|
+
# between two of them: trunks under, canopies over. Which is which is the
|
|
140
|
+
# scene tree's business — `RGame::Engine::TileMapLayer` mounts a node per
|
|
141
|
+
# layer — so this takes no `z:`.
|
|
142
|
+
#
|
|
143
|
+
# **Drawn in world coordinates**: a tile at column 3 lands at
|
|
144
|
+
# `3 * tile_width`, and getting it onto the screen is the caller's
|
|
145
|
+
# transform, like every other drawing method here. The rectangle is a
|
|
146
|
+
# **cull rect** — which part of the world is worth drawing — so a camera
|
|
147
|
+
# supplies it but does not move the result. That is what lets one map be
|
|
148
|
+
# drawn through several cameras in a frame.
|
|
121
149
|
#
|
|
122
150
|
# `elapsed` is the seconds its animated tiles have been running for, and
|
|
123
151
|
# is an argument rather than a clock read on purpose — see CLAUDE.md,
|
|
124
152
|
# "`draw` renders state; time enters through `update`". A scene
|
|
125
153
|
# accumulates it in `update`, which is what makes pausing work.
|
|
126
|
-
def tilemap(id,
|
|
154
|
+
def tilemap(id, layer, cull_x, cull_y, cull_width, cull_height, elapsed: 0.0)
|
|
127
155
|
lookup(:tilemap, id)
|
|
128
|
-
.
|
|
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)
|
|
156
|
+
.draw_layer(self, layer, cull_x, cull_y, cull_width, cull_height, elapsed: elapsed)
|
|
137
157
|
end
|
|
138
158
|
|
|
139
159
|
# A filled axis-aligned rectangle.
|
|
140
160
|
def rect(x, y, width, height, z: SHAPE_Z, color: nil)
|
|
141
|
-
draw_rect(x, y, width, height, z, packed(color))
|
|
161
|
+
draw_rect(x, y, width, height, Z.offset(z), packed(color))
|
|
142
162
|
end
|
|
143
163
|
|
|
144
164
|
# Four arbitrary points, in loop order: listing them in Z order gives an
|
|
145
165
|
# hourglass rather than a shape.
|
|
146
166
|
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))
|
|
167
|
+
draw_quad(x1, y1, x2, y2, x3, y3, x4, y4, Z.offset(z), packed(color))
|
|
148
168
|
end
|
|
149
169
|
|
|
150
170
|
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))
|
|
171
|
+
draw_triangle(x1, y1, x2, y2, x3, y3, Z.offset(z), packed(color))
|
|
152
172
|
end
|
|
153
173
|
|
|
154
174
|
# A line of real thickness — drawn as a quad, because GL's own line width
|
|
155
175
|
# is a suggestion drivers may ignore above one pixel.
|
|
156
176
|
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))
|
|
177
|
+
draw_line(x1, y1, x2, y2, thickness, Z.offset(z), packed(color))
|
|
158
178
|
end
|
|
159
179
|
|
|
160
180
|
# A filled circle, as a fan of triangles around its centre.
|
|
161
181
|
def circle(cx, cy, radius, z: SHAPE_Z, color: nil, segments: CIRCLE_SEGMENTS)
|
|
162
|
-
draw_circle(cx, cy, radius, segments, z, packed(color))
|
|
182
|
+
draw_circle(cx, cy, radius, segments, Z.offset(z), packed(color))
|
|
163
183
|
end
|
|
164
184
|
|
|
165
185
|
# An image centred on (cx, cy), rotated `angle` degrees clockwise about
|
|
@@ -168,7 +188,7 @@ module RGame
|
|
|
168
188
|
#
|
|
169
189
|
# Takes an `Image` or an id for one — see #resolve_image.
|
|
170
190
|
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))
|
|
191
|
+
draw_image_rot(resolve_image(image), cx, cy, angle, scale, Z.offset(z), packed(color))
|
|
172
192
|
end
|
|
173
193
|
|
|
174
194
|
# An image with its top-left at (x, y), scaled independently per axis —
|
|
@@ -183,14 +203,14 @@ module RGame
|
|
|
183
203
|
#
|
|
184
204
|
# A zero scale draws nothing.
|
|
185
205
|
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))
|
|
206
|
+
draw_image_scaled(resolve_image(image), x, y, scale_x, scale_y, Z.offset(z), packed(color))
|
|
187
207
|
end
|
|
188
208
|
|
|
189
209
|
# An image with its top-left at (x, y), at its natural size — a
|
|
190
210
|
# full-screen backdrop by default. `image_at` with both scales at 1, kept
|
|
191
211
|
# because "put this at the origin" is worth a name of its own.
|
|
192
212
|
def background(image, x = 0, y = 0, z: IMAGE_Z, color: nil)
|
|
193
|
-
draw_image(resolve_image(image), x, y, z, packed(color))
|
|
213
|
+
draw_image(resolve_image(image), x, y, Z.offset(z), packed(color))
|
|
194
214
|
end
|
|
195
215
|
|
|
196
216
|
# Everything drawn in the block is rotated `angle` degrees about
|
|
@@ -239,6 +259,34 @@ module RGame
|
|
|
239
259
|
end
|
|
240
260
|
end
|
|
241
261
|
|
|
262
|
+
# Everything drawn in the block draws in its own layer: a fresh slot in
|
|
263
|
+
# `band`, which every `z:` inside is then an offset from.
|
|
264
|
+
#
|
|
265
|
+
# renderer.layered(:hud) { renderer.text(score, 12, 10) }
|
|
266
|
+
#
|
|
267
|
+
# Two things fall out of it, and they are the whole of draw order.
|
|
268
|
+
# **Slots are handed out in the order they are asked for**, so nesting
|
|
269
|
+
# this the way a scene graph is nested makes draw order tree order — a
|
|
270
|
+
# node drawn later is in front, and its whole subtree with it. And **a
|
|
271
|
+
# band is a hard partition**: every slot in `:hud` is above every slot in
|
|
272
|
+
# `:world`, whatever either drew, because they are 2**40 apart and a `z:`
|
|
273
|
+
# cannot reach out of one slot. See RGame::Util::Z.
|
|
274
|
+
#
|
|
275
|
+
# A caller that never uses this draws at layer 0 and gets exactly the z it
|
|
276
|
+
# passes, which is what a spec or a one-off script wants.
|
|
277
|
+
def layered(band = Z::DEFAULT)
|
|
278
|
+
index = Z.index(band)
|
|
279
|
+
push_layer(Z.slot_base(index, next_layer_slot(index)))
|
|
280
|
+
begin
|
|
281
|
+
yield
|
|
282
|
+
ensure
|
|
283
|
+
# An ensure for the same reason `rotated` has one: a scene that raises
|
|
284
|
+
# mid-draw would otherwise leave every later node drawing in its
|
|
285
|
+
# layer, and the frame would come out interleaved with no clue why.
|
|
286
|
+
pop
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
242
290
|
# Everything drawn in the block is confined to the given rectangle.
|
|
243
291
|
#
|
|
244
292
|
# A clip only ever narrows: nesting one inside another intersects them, so
|
|
@@ -271,7 +319,7 @@ module RGame
|
|
|
271
319
|
# Newlines are not special. A caller wanting two lines draws two, stepping
|
|
272
320
|
# by #text_height.
|
|
273
321
|
def text(string, x, y, z: TEXT_Z, color: nil, font: nil)
|
|
274
|
-
draw_text(font || self.font, string, x, y, z, packed(color))
|
|
322
|
+
draw_text(font || self.font, string, x, y, Z.offset(z), packed(color))
|
|
275
323
|
end
|
|
276
324
|
|
|
277
325
|
# What #text would occupy, for centring and layout. Unlike the drawing
|
|
@@ -3,31 +3,53 @@
|
|
|
3
3
|
module RGame
|
|
4
4
|
module Core
|
|
5
5
|
# Draws a tile map: the static layers baked once, the animated tiles drawn
|
|
6
|
-
# per frame
|
|
6
|
+
# per frame, both culled to a rectangle of the world.
|
|
7
7
|
#
|
|
8
8
|
# tiles = RGame::Core::TileMapRenderer.new(map, tileset_images)
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
10
|
+
# map.layer_count.times do |layer|
|
|
11
|
+
# tiles.draw_layer(renderer, layer, cull_x, cull_y, cull_w, cull_h,
|
|
12
12
|
# elapsed: seconds)
|
|
13
|
+
# end
|
|
13
14
|
#
|
|
14
|
-
# ##
|
|
15
|
+
# ## It draws in world coordinates
|
|
15
16
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
17
|
+
# A tile at column 3 is drawn at `3 * tile_width`, and getting it onto the
|
|
18
|
+
# screen is the caller's transform — the same deal every other drawable
|
|
19
|
+
# gets. The rectangle passed in is therefore a **cull rect** and nothing
|
|
20
|
+
# else: which part of the world is worth drawing.
|
|
21
|
+
#
|
|
22
|
+
# It used to be both, offsetting the output by `-camera` as well as culling
|
|
23
|
+
# to it, which worked exactly as long as there was one camera. Under
|
|
24
|
+
# split-screen the same map is drawn through several, so a call that bakes
|
|
25
|
+
# placement into its output can only be right for one of them. Culling is
|
|
26
|
+
# genuinely per-camera; placement is the transform stack's job.
|
|
27
|
+
#
|
|
28
|
+
# ## One call per layer, because the actors go between them
|
|
29
|
+
#
|
|
30
|
+
# A layer is drawn on its own, and the order they are drawn in is the
|
|
31
|
+
# caller's. That is what lets a scene put its actors between two of them —
|
|
32
|
+
# tree trunks below, canopies above — which is the whole reason this does
|
|
33
|
+
# not simply draw the map in one go.
|
|
34
|
+
#
|
|
35
|
+
# It is also why nothing here consults the map's `above_layer?` flag any
|
|
36
|
+
# more: which layers cover the actors is a question about where the actors
|
|
37
|
+
# are in the scene, and Tiled already answers "in what order do the layers
|
|
38
|
+
# go" by listing them. `RGame::Engine::TileMapLayer` mounts one node per
|
|
39
|
+
# layer and the tree does the rest.
|
|
22
40
|
#
|
|
23
41
|
# ## What is baked and what is not
|
|
24
42
|
#
|
|
25
|
-
# Within each
|
|
26
|
-
# recording, the first time that
|
|
43
|
+
# Within each layer, every tile that is *not* animated is baked into one
|
|
44
|
+
# recording, the first time that layer is drawn. Scrolling a baked layer is
|
|
27
45
|
# then one call per texture however many thousand tiles went into it. The
|
|
28
46
|
# handful that *are* animated are drawn individually each frame, culled to
|
|
29
47
|
# the viewport — a map far larger than the screen costs only what is on it.
|
|
30
48
|
#
|
|
49
|
+
# Splitting per layer rather than into two bands bakes the same tiles into
|
|
50
|
+
# more recordings, not more vertices: the partition changed, the contents
|
|
51
|
+
# did not.
|
|
52
|
+
#
|
|
31
53
|
# ## It loads nothing and holds no clock
|
|
32
54
|
#
|
|
33
55
|
# The tiles arrive already sliced, so two maps sharing a tileset share one
|
|
@@ -44,9 +66,9 @@ module RGame
|
|
|
44
66
|
# It never names the map's class — the tile map lives a layer *above* this
|
|
45
67
|
# one and Core may not reach up (CLAUDE.md, "The rule points both ways").
|
|
46
68
|
# What it calls is the 'a tile map' contract in
|
|
47
|
-
# `spec/support/shared_examples/`: `layer_count`, `
|
|
48
|
-
# `
|
|
49
|
-
# `
|
|
69
|
+
# `spec/support/shared_examples/`: `layer_count`, `width`, `height`,
|
|
70
|
+
# `tile_width`, `tile_height`, `gid`, and a `tileset` answering `local_id`,
|
|
71
|
+
# `animations` and `frame_local_id`.
|
|
50
72
|
class TileMapRenderer
|
|
51
73
|
# The map this was built from. A scene reads it for collision and world
|
|
52
74
|
# bounds, which are its business rather than this class's.
|
|
@@ -58,47 +80,53 @@ module RGame
|
|
|
58
80
|
@map = map
|
|
59
81
|
@tileset = map.tileset
|
|
60
82
|
@tiles = tiles
|
|
61
|
-
@
|
|
83
|
+
@animated = collect_animated_tiles
|
|
62
84
|
# Baked on first draw, not here: recording needs a live frame, and there
|
|
63
85
|
# is no renderer at construction.
|
|
64
|
-
@
|
|
65
|
-
@static_above = nil
|
|
86
|
+
@static = Array.new(map.layer_count)
|
|
66
87
|
end
|
|
67
88
|
|
|
68
|
-
def
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
def layer_count = @map.layer_count
|
|
90
|
+
|
|
91
|
+
# One layer, culled to `(cull_x, cull_y, cull_width, cull_height)` in world
|
|
92
|
+
# coordinates and drawn in them.
|
|
93
|
+
#
|
|
94
|
+
# The recording is replayed at its own origin, so it lands wherever the
|
|
95
|
+
# caller's transform puts it. That also makes it **view-independent**: one
|
|
96
|
+
# bake serves every viewport, which is what keeps split-screen affordable
|
|
97
|
+
# and is why the bake is not keyed on a camera. Baking happens on the
|
|
98
|
+
# first draw, and it is safe to do that inside a transform or a clip —
|
|
99
|
+
# recording runs on its own canvas, begun at identity, and captures
|
|
100
|
+
# neither.
|
|
101
|
+
#
|
|
102
|
+
# No `z:`. A layer is drawn by a node of its own, so where it sits is the
|
|
103
|
+
# scene tree's answer; everything this issues belongs to that one node and
|
|
104
|
+
# goes in its slot.
|
|
105
|
+
def draw_layer(renderer, index, cull_x, cull_y, cull_width, cull_height, elapsed: 0.0)
|
|
106
|
+
unless index.is_a?(Integer) && index >= 0 && index < @static.size
|
|
107
|
+
raise ArgumentError, "no layer #{index.inspect} in this map (it has #{@static.size})"
|
|
108
|
+
end
|
|
74
109
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
draw_animated(renderer, @animated_above, camera_x, camera_y,
|
|
80
|
-
viewport_width, viewport_height, z, elapsed)
|
|
110
|
+
@static[index] ||= bake(renderer, index)
|
|
111
|
+
@static[index].draw
|
|
112
|
+
draw_animated(renderer, @animated[index], cull_x, cull_y,
|
|
113
|
+
cull_width, cull_height, elapsed)
|
|
81
114
|
end
|
|
82
115
|
|
|
83
|
-
# The ground band sits at the bottom; the scene chooses where the overlay
|
|
84
|
-
# goes, because only it knows what its actors are drawn at.
|
|
85
|
-
BELOW_Z = 0
|
|
86
|
-
|
|
87
116
|
private
|
|
88
117
|
|
|
89
|
-
# [col, row, local_id] for every animated tile,
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
118
|
+
# [col, row, local_id] for every animated tile, per layer. Walked once at
|
|
119
|
+
# construction: a map is thousands of tiles and a handful of animated
|
|
120
|
+
# ones, and finding them again each frame would be the whole cost this
|
|
121
|
+
# class exists to avoid.
|
|
93
122
|
def collect_animated_tiles
|
|
94
|
-
|
|
95
|
-
above = []
|
|
123
|
+
found = Array.new(@map.layer_count) { [] }
|
|
96
124
|
each_tile do |layer, col, row, local|
|
|
97
125
|
next unless @tileset.animations.key?(local)
|
|
98
126
|
|
|
99
|
-
|
|
127
|
+
found[layer] << [col, row, local]
|
|
100
128
|
end
|
|
101
|
-
|
|
129
|
+
found
|
|
102
130
|
end
|
|
103
131
|
|
|
104
132
|
# Every non-empty tile of every layer, as [layer, col, row, local_id].
|
|
@@ -115,22 +143,21 @@ module RGame
|
|
|
115
143
|
end
|
|
116
144
|
end
|
|
117
145
|
|
|
118
|
-
# Bakes
|
|
119
|
-
# recording,
|
|
120
|
-
|
|
146
|
+
# Bakes one layer's static tiles into a recording. An empty layer bakes an
|
|
147
|
+
# empty recording, which replays as nothing — so a map with a spacer layer
|
|
148
|
+
# in it needs no special case here or at the call site.
|
|
149
|
+
def bake(renderer, index)
|
|
121
150
|
renderer.record do
|
|
122
151
|
each_tile do |layer, col, row, local|
|
|
123
|
-
next unless
|
|
152
|
+
next unless layer == index
|
|
124
153
|
next if @tileset.animations.key?(local)
|
|
125
154
|
|
|
126
|
-
renderer.image_at(@tiles[local], col * @map.tile_width, row * @map.tile_height
|
|
127
|
-
z: BELOW_Z)
|
|
155
|
+
renderer.image_at(@tiles[local], col * @map.tile_width, row * @map.tile_height)
|
|
128
156
|
end
|
|
129
157
|
end
|
|
130
158
|
end
|
|
131
159
|
|
|
132
|
-
def draw_animated(renderer, tiles,
|
|
133
|
-
z, elapsed)
|
|
160
|
+
def draw_animated(renderer, tiles, cull_x, cull_y, cull_width, cull_height, elapsed)
|
|
134
161
|
tile_width = @map.tile_width
|
|
135
162
|
tile_height = @map.tile_height
|
|
136
163
|
|
|
@@ -143,16 +170,18 @@ module RGame
|
|
|
143
170
|
# a no-op, leaving the last column of tiles undrawn: a one-tile strip of
|
|
144
171
|
# nothing along the right and bottom edges of the screen, and only when
|
|
145
172
|
# the camera happens to be on a whole pixel.
|
|
146
|
-
col_start =
|
|
147
|
-
row_start =
|
|
148
|
-
col_end = (
|
|
149
|
-
row_end = (
|
|
173
|
+
col_start = cull_x.fdiv(tile_width).floor
|
|
174
|
+
row_start = cull_y.fdiv(tile_height).floor
|
|
175
|
+
col_end = (cull_x + cull_width).fdiv(tile_width).ceil
|
|
176
|
+
row_end = (cull_y + cull_height).fdiv(tile_height).ceil
|
|
150
177
|
|
|
151
178
|
tiles.each do |col, row, local|
|
|
152
179
|
next if col < col_start || col >= col_end || row < row_start || row >= row_end
|
|
153
180
|
|
|
181
|
+
# World coordinates, like the baked band above it: the caller's
|
|
182
|
+
# transform is what puts either on screen.
|
|
154
183
|
renderer.image_at(@tiles[@tileset.frame_local_id(local, ms)],
|
|
155
|
-
|
|
184
|
+
col * tile_width, row * tile_height)
|
|
156
185
|
end
|
|
157
186
|
end
|
|
158
187
|
end
|
data/lib/rgame/engine/camera.rb
CHANGED
|
@@ -2,29 +2,74 @@
|
|
|
2
2
|
|
|
3
3
|
module RGame
|
|
4
4
|
module Engine
|
|
5
|
-
# A 2D camera:
|
|
6
|
-
#
|
|
5
|
+
# A 2D camera: a point in the world to look at, plus the world bounds it may
|
|
6
|
+
# not show past. Pure; the offset it produces is applied as a draw-time
|
|
7
|
+
# transform, never baked into a node.
|
|
8
|
+
#
|
|
9
|
+
# camera = Camera.new(world_width: map.pixel_width, world_height: map.pixel_height)
|
|
10
|
+
# camera.center_on(player.abs_x, player.abs_y) # in update, every tick
|
|
11
|
+
# camera.resolve(view_width, view_height) # at draw, for one viewport
|
|
12
|
+
# camera.x, camera.y # the offset to translate by
|
|
13
|
+
#
|
|
14
|
+
# ## Why the viewport size is an argument and not state
|
|
15
|
+
#
|
|
16
|
+
# A camera used to be built with its viewport size and clamp against it in
|
|
17
|
+
# `center_on`. That cannot survive split-screen: the same world is drawn
|
|
18
|
+
# through several viewports whose rects come from the layout and change when
|
|
19
|
+
# a player joins or the window resizes. Clamping has to happen against the
|
|
20
|
+
# rect actually being drawn into, and the difference is visible rather than
|
|
21
|
+
# theoretical — near a world edge, the same target sits at a different place
|
|
22
|
+
# on screen in a half-width viewport than in a full-width one.
|
|
23
|
+
#
|
|
24
|
+
# So `center_on` records *intent* and `resolve` computes the offset. Nothing
|
|
25
|
+
# calls `resolve` by hand: the platform resolves each camera against the
|
|
26
|
+
# viewport it is about to draw, which is what keeps the two from drifting.
|
|
27
|
+
#
|
|
28
|
+
# ## The camera belongs to a player, not to a scene
|
|
29
|
+
#
|
|
30
|
+
# A scene may have any number of viewers, so it cannot own "the" camera.
|
|
31
|
+
# RGame::Engine::Player owns one; a scene sets its world bounds when it
|
|
32
|
+
# loads a map, and a CameraFollow component in the world points it.
|
|
7
33
|
class Camera
|
|
8
|
-
attr_reader :x, :y, :
|
|
34
|
+
attr_reader :x, :y, :target_x, :target_y
|
|
35
|
+
attr_accessor :world_width, :world_height
|
|
9
36
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
37
|
+
# `world_width` / `world_height` bound what the camera may show. Left nil
|
|
38
|
+
# the camera is unbounded and follows its target exactly — which is the
|
|
39
|
+
# right default: a game that has not declared its world yet gets a camera
|
|
40
|
+
# that visibly works, rather than one silently pinned to the origin.
|
|
41
|
+
def initialize(world_width: nil, world_height: nil)
|
|
13
42
|
@world_width = world_width
|
|
14
43
|
@world_height = world_height
|
|
44
|
+
@target_x = 0.0
|
|
45
|
+
@target_y = 0.0
|
|
15
46
|
@x = 0.0
|
|
16
47
|
@y = 0.0
|
|
17
48
|
end
|
|
18
49
|
|
|
50
|
+
# Look at this world point. Records the target; the offset is worked out
|
|
51
|
+
# by #resolve, which is the only place that knows how big the view is.
|
|
19
52
|
def center_on(world_x, world_y)
|
|
20
|
-
@
|
|
21
|
-
@
|
|
53
|
+
@target_x = world_x
|
|
54
|
+
@target_y = world_y
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Work out the draw offset for a viewport of this size, clamped so the
|
|
59
|
+
# view never shows past the world's edges.
|
|
60
|
+
def resolve(view_width, view_height)
|
|
61
|
+
@x = clamp(@target_x - (view_width / 2.0), @world_width, view_width)
|
|
62
|
+
@y = clamp(@target_y - (view_height / 2.0), @world_height, view_height)
|
|
63
|
+
self
|
|
22
64
|
end
|
|
23
65
|
|
|
24
66
|
private
|
|
25
67
|
|
|
26
|
-
def clamp(value,
|
|
27
|
-
return
|
|
68
|
+
def clamp(value, world_size, view_size)
|
|
69
|
+
return value.to_f if world_size.nil? # unbounded: follow exactly
|
|
70
|
+
|
|
71
|
+
max = world_size - view_size
|
|
72
|
+
return 0.0 if max <= 0 # world smaller than the view -> pin to origin
|
|
28
73
|
|
|
29
74
|
value.clamp(0.0, max.to_f)
|
|
30
75
|
end
|
|
@@ -20,11 +20,21 @@ module RGame
|
|
|
20
20
|
|
|
21
21
|
def control(actions); end
|
|
22
22
|
def update(dt); end
|
|
23
|
-
def draw(renderer); end
|
|
23
|
+
def draw(renderer, view); end
|
|
24
24
|
|
|
25
25
|
# Container components (e.g. SceneStack) that hold nodes off the normal child
|
|
26
26
|
# list override this to forward the deferred-free sweep into them.
|
|
27
27
|
def sweep_freed; end
|
|
28
|
+
|
|
29
|
+
# `control` above receives **one player's** Actions — the actions of
|
|
30
|
+
# whoever owns this component's node — which is what a component wants,
|
|
31
|
+
# since it belongs to exactly one node.
|
|
32
|
+
#
|
|
33
|
+
# A container component holding a whole subtree needs the input *source*
|
|
34
|
+
# instead, so the nodes inside it can each resolve their own owner. There
|
|
35
|
+
# is no hook for that on purpose: it is a system lookup like any other, so
|
|
36
|
+
# such a component pulls `node.system(Players)` in `on_attach` and passes
|
|
37
|
+
# that down. SceneStack is the worked example.
|
|
28
38
|
end
|
|
29
39
|
end
|
|
30
40
|
end
|
|
@@ -9,10 +9,10 @@ module RGame
|
|
|
9
9
|
# AnimationSet built from the sheet's animation table.
|
|
10
10
|
#
|
|
11
11
|
# Like Sprite, it passes NO angle and draws at the node's *world* origin
|
|
12
|
-
# (node.abs_x/abs_y): a
|
|
12
|
+
# (node.abs_x/abs_y): a WorldView ancestor wraps the draw in renderer.translated to
|
|
13
13
|
# map world → screen, so this component never touches the camera. `z` is the render
|
|
14
14
|
# layer (kept as @layer, distinct from the node's transform z); it must sit between
|
|
15
|
-
# the tile
|
|
15
|
+
# the tile map's ground and canopy z bands, so canopies draw in front.
|
|
16
16
|
#
|
|
17
17
|
# `sheet` is the asset's relative path. The component resolves it from the game's
|
|
18
18
|
# asset manager on attach — via node.root.context.assets (the platform seam) — to
|
|
@@ -20,6 +20,8 @@ module RGame
|
|
|
20
20
|
# like CharacterBody can read node.width/height. The renderer resolves the same
|
|
21
21
|
# symbol when drawing, so nothing is registered or passed in by hand.
|
|
22
22
|
class AnimatedSprite < Engine::Component
|
|
23
|
+
include Engine::Culling
|
|
24
|
+
|
|
23
25
|
def initialize(sheet:, z: 0)
|
|
24
26
|
super()
|
|
25
27
|
@sheet = sheet
|
|
@@ -39,7 +41,11 @@ module RGame
|
|
|
39
41
|
@animator.update(dt)
|
|
40
42
|
end
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
# Top-left anchored, and sized by the sheet's frame — so the footprint
|
|
45
|
+
# to cull against is exactly the node's box.
|
|
46
|
+
def draw(renderer, view)
|
|
47
|
+
return if culled?(view, node.abs_x, node.abs_y, node.width, node.height)
|
|
48
|
+
|
|
43
49
|
# Read row/col/flip_x separately (not @animator.frame, which allocates an Array
|
|
44
50
|
# every call) to keep the draw path allocation-free.
|
|
45
51
|
renderer.sprite(@sheet, @animator.row, @animator.col, node.abs_x, node.abs_y,
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
module Components
|
|
6
|
+
# Points a camera at the node it is attached to.
|
|
7
|
+
#
|
|
8
|
+
# player_node.add_component(CameraFollow.new(camera: players.primary.camera))
|
|
9
|
+
#
|
|
10
|
+
# ## Ownership and behaviour are different questions
|
|
11
|
+
#
|
|
12
|
+
# The camera cannot be *owned* by a node in the world — with several
|
|
13
|
+
# viewers there are several cameras, and a world that holds one has to know
|
|
14
|
+
# how many times it is being drawn. But deciding *where a camera points* is
|
|
15
|
+
# exactly a per-node concern, so it belongs here: the player owns the
|
|
16
|
+
# camera, and a component in the world moves it.
|
|
17
|
+
#
|
|
18
|
+
# That also makes "player two's camera follows player two" nothing more
|
|
19
|
+
# than attaching this to their node with their camera.
|
|
20
|
+
#
|
|
21
|
+
# `offset_x` / `offset_y` shift the point being centred on, for a node
|
|
22
|
+
# whose origin is not what should be in the middle of the screen — a
|
|
23
|
+
# bottom-anchored sprite usually wants its feet, not its head.
|
|
24
|
+
class CameraFollow < Engine::Component
|
|
25
|
+
def initialize(camera:, offset_x: 0.0, offset_y: 0.0)
|
|
26
|
+
super()
|
|
27
|
+
@camera = camera
|
|
28
|
+
@offset_x = offset_x
|
|
29
|
+
@offset_y = offset_y
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Reads the absolute origin resolved at the top of this node's update,
|
|
33
|
+
# so the camera trails the node's own movement by one step (a couple of
|
|
34
|
+
# pixels at walking speed). That is deliberate and uniform: everything
|
|
35
|
+
# drawn through this camera trails equally, so nothing drifts apart on
|
|
36
|
+
# screen, and the alternative — re-resolving here — would put this
|
|
37
|
+
# component's ordering among its siblings on show.
|
|
38
|
+
def update(_dt)
|
|
39
|
+
@camera.center_on(node.abs_x + @offset_x, node.abs_y + @offset_y)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -30,11 +30,17 @@ module RGame
|
|
|
30
30
|
@collision_box = nil
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# The feet box, derived from the node's sprite size and memoised.
|
|
34
|
+
#
|
|
35
|
+
# **Only valid once the node is in the tree**, and it says so rather than
|
|
36
|
+
# letting you find out later. The size comes from AnimatedSprite#on_attach,
|
|
37
|
+
# so a read from a constructor sees a 0x0 node and bakes a box anchored to
|
|
38
|
+
# nothing — permanently, because this memoises, and for the collision
|
|
39
|
+
# system too, because it reads the same box. The symptom is an actor that
|
|
40
|
+
# walks through walls it should not, a long way from the call that caused
|
|
41
|
+
# it. Guarding costs one comparison, once.
|
|
33
42
|
def collision_box
|
|
34
|
-
@collision_box ||=
|
|
35
|
-
sprite_width: node.width, sprite_height: node.height,
|
|
36
|
-
width: @feet_width, height: @feet_height
|
|
37
|
-
)
|
|
43
|
+
@collision_box ||= build_collision_box
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
# The TileWorld is a scene-scoped system, reachable once we're in the tree.
|
|
@@ -64,6 +70,21 @@ module RGame
|
|
|
64
70
|
def y=(value)
|
|
65
71
|
node.y = value
|
|
66
72
|
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def build_collision_box
|
|
77
|
+
if node.width.zero? || node.height.zero?
|
|
78
|
+
raise "collision_box needs the node's sprite size, but it is " \
|
|
79
|
+
"#{node.width}x#{node.height}. AnimatedSprite sets that when it attaches, so " \
|
|
80
|
+
'read this after the node is in the tree, not while building it.'
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
Engine::CollisionBox.bottom_anchored(
|
|
84
|
+
sprite_width: node.width, sprite_height: node.height,
|
|
85
|
+
width: @feet_width, height: @feet_height
|
|
86
|
+
)
|
|
87
|
+
end
|
|
67
88
|
end
|
|
68
89
|
end
|
|
69
90
|
end
|