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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/README.md +130 -233
  4. data/docs/api/README.md +116 -69
  5. data/docs/api/assets.md +11 -12
  6. data/docs/api/components.md +58 -34
  7. data/docs/api/drawing.md +77 -9
  8. data/docs/api/game.md +34 -13
  9. data/docs/api/input.md +232 -51
  10. data/docs/api/scene_graph.md +242 -15
  11. data/docs/api/systems.md +20 -0
  12. data/docs/api/toolbox.md +19 -15
  13. data/docs/api/ui.md +98 -0
  14. data/docs/api/values.md +32 -0
  15. data/ext/README.md +6 -5
  16. data/ext/rgame_core/app/app.c +182 -8
  17. data/ext/rgame_core/audio/audio.c +74 -0
  18. data/ext/rgame_core/example.rb +17 -6
  19. data/ext/rgame_core/extconf.rb +52 -24
  20. data/ext/rgame_core/graphics/canvas.c +45 -4
  21. data/ext/rgame_core/graphics/canvas.h +65 -10
  22. data/ext/rgame_core/graphics/clip.c +22 -13
  23. data/ext/rgame_core/include/rgame/core.h +113 -3
  24. data/ext/rgame_core/input/gamepad.c +57 -3
  25. data/ext/rgame_core/ruby/core_ext.c +16 -0
  26. data/ext/rgame_core/ruby/renderer_ext.c +23 -0
  27. data/ext/rgame_util/color_ext.c +12 -3
  28. data/lib/rgame/core/app.rb +2 -0
  29. data/lib/rgame/core/input.rb +35 -41
  30. data/lib/rgame/core/recording.rb +3 -1
  31. data/lib/rgame/core/renderer.rb +76 -28
  32. data/lib/rgame/core/tile_map_renderer.rb +84 -55
  33. data/lib/rgame/engine/camera.rb +55 -10
  34. data/lib/rgame/engine/component.rb +11 -1
  35. data/lib/rgame/engine/components/animated_sprite.rb +9 -3
  36. data/lib/rgame/engine/components/camera_follow.rb +44 -0
  37. data/lib/rgame/engine/components/character_body.rb +25 -4
  38. data/lib/rgame/engine/components/sprite.rb +11 -1
  39. data/lib/rgame/engine/components/tile_world.rb +31 -18
  40. data/lib/rgame/engine/culling.rb +47 -0
  41. data/lib/rgame/engine/debug_overlay.rb +20 -9
  42. data/lib/rgame/engine/input/action_mapper.rb +101 -21
  43. data/lib/rgame/engine/input/actions.rb +69 -12
  44. data/lib/rgame/engine/input/input_map.rb +178 -0
  45. data/lib/rgame/engine/layout.rb +82 -0
  46. data/lib/rgame/engine/node2d.rb +205 -36
  47. data/lib/rgame/engine/player.rb +69 -0
  48. data/lib/rgame/engine/player_layer.rb +70 -0
  49. data/lib/rgame/engine/players.rb +212 -0
  50. data/lib/rgame/engine/scene/scene_stack.rb +25 -3
  51. data/lib/rgame/engine/spatial_hash.rb +17 -4
  52. data/lib/rgame/engine/tile_map_layer.rb +84 -0
  53. data/lib/rgame/engine/ui/menu.rb +115 -0
  54. data/lib/rgame/engine/ui/menu_item.rb +84 -0
  55. data/lib/rgame/engine/view.rb +76 -0
  56. data/lib/rgame/engine/viewports.rb +174 -0
  57. data/lib/rgame/engine/world_view.rb +70 -0
  58. data/lib/rgame/engine.rb +13 -1
  59. data/lib/rgame/game.rb +81 -11
  60. data/lib/rgame/util/controls.rb +117 -41
  61. data/lib/rgame/util/z.rb +133 -0
  62. data/lib/rgame/util.rb +1 -0
  63. data/lib/rgame/version.rb +1 -1
  64. metadata +26 -11
  65. data/lib/rgame/engine/camera_view.rb +0 -28
@@ -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 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.
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 the scene rather than under it. The
48
- # values match the layer this replaces.
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, and the size matches what
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
- # A tile map's below-the-actor band (ground and same-level detail).
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, camera_x, camera_y, viewport_width, viewport_height, elapsed: 0.0)
154
+ def tilemap(id, layer, cull_x, cull_y, cull_width, cull_height, elapsed: 0.0)
127
155
  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)
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 and culled to the viewport.
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
- # tiles.draw(renderer, camera_x, camera_y, view_w, view_h, elapsed: seconds)
11
- # tiles.draw_overlay(renderer, camera_x, camera_y, view_w, view_h, z: 20,
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
- # ## Two bands, with the actors between them
15
+ # ## It draws in world coordinates
15
16
  #
16
- # Layers split by the map's own `above_layer?` flag. The **below** band
17
- # ground, and detail on the actors' levelis drawn under them by `#draw`;
18
- # the **above** band tree canopies, roofs over them by `#draw_overlay`,
19
- # at a `z` the scene picks. Two calls rather than one because the scene
20
- # draws its actors in between, and collapsing them would put every canopy
21
- # behind every character.
17
+ # A tile at column 3 is drawn at `3 * tile_width`, and getting it onto the
18
+ # screen is the caller's transformthe 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 band, every tile that is *not* animated is baked into one
26
- # recording, the first time that band is drawn. Scrolling a baked layer is
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`, `above_layer?`, `width`,
48
- # `height`, `tile_width`, `tile_height`, `gid`, and a `tileset` answering
49
- # `local_id`, `animations` and `frame_local_id`.
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
- @animated_below, @animated_above = collect_animated_tiles
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
- @static_below = nil
65
- @static_above = nil
86
+ @static = Array.new(map.layer_count)
66
87
  end
67
88
 
68
- def draw(renderer, camera_x, camera_y, viewport_width, viewport_height, elapsed: 0.0)
69
- @static_below ||= bake(renderer) { |layer| !@map.above_layer?(layer) }
70
- @static_below.draw(-camera_x, -camera_y, z: BELOW_Z)
71
- draw_animated(renderer, @animated_below, camera_x, camera_y,
72
- viewport_width, viewport_height, BELOW_Z, elapsed)
73
- end
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
- def draw_overlay(renderer, camera_x, camera_y, viewport_width, viewport_height,
76
- z:, elapsed: 0.0)
77
- @static_above ||= bake(renderer) { |layer| @map.above_layer?(layer) }
78
- @static_above.draw(-camera_x, -camera_y, z: z)
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, split into the two bands.
90
- # Walked once at construction: a map is thousands of tiles and a handful
91
- # of animated ones, and finding them again each frame would be the whole
92
- # cost this class exists to avoid.
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
- below = []
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
- (@map.above_layer?(layer) ? above : below) << [col, row, local]
127
+ found[layer] << [col, row, local]
100
128
  end
101
- [below, above]
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 the static tiles of the layers the block accepts into one
119
- # recording, in layer order so a later layer covers an earlier one.
120
- def bake(renderer)
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 yield(layer)
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, camera_x, camera_y, viewport_width, viewport_height,
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 = camera_x.fdiv(tile_width).floor
147
- row_start = camera_y.fdiv(tile_height).floor
148
- col_end = (camera_x + viewport_width).fdiv(tile_width).ceil
149
- row_end = (camera_y + viewport_height).fdiv(tile_height).ceil
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
- (col * tile_width) - camera_x, (row * tile_height) - camera_y, z: z)
184
+ col * tile_width, row * tile_height)
156
185
  end
157
186
  end
158
187
  end
@@ -2,29 +2,74 @@
2
2
 
3
3
  module RGame
4
4
  module Engine
5
- # A 2D camera: follows a target point and clamps to world bounds so it never
6
- # shows past the map edges. Pure; the platform applies (x, y) as a draw offset.
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, :viewport_width, :viewport_height
34
+ attr_reader :x, :y, :target_x, :target_y
35
+ attr_accessor :world_width, :world_height
9
36
 
10
- def initialize(viewport_width:, viewport_height:, world_width:, world_height:)
11
- @viewport_width = viewport_width
12
- @viewport_height = viewport_height
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
- @x = clamp(world_x - @viewport_width / 2.0, @world_width - @viewport_width)
21
- @y = clamp(world_y - @viewport_height / 2.0, @world_height - @viewport_height)
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, max)
27
- return 0.0 if max <= 0 # world smaller than the viewport → pin to origin
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 CameraView ancestor wraps the draw in renderer.translated to
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 world's ground band and its overlay band so canopies draw in front.
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
- def draw(renderer)
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 ||= Engine::CollisionBox.bottom_anchored(
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