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
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Where each viewport goes on screen: pure rectangle arithmetic, and nothing
6
+ # else.
7
+ #
8
+ # Layout.rects(2, 640, 480) # => [[0, 0, 640, 240], [0, 240, 640, 240]]
9
+ #
10
+ # No state, no anchors, no lifetime — so it is specced on its own with no
11
+ # tree and no window, which is the point of separating it from Viewports.
12
+ # That system holds *which* mode is current and *who* is playing; this only
13
+ # answers "given a count and a window, where do they go".
14
+ #
15
+ # ## Rects tile exactly
16
+ #
17
+ # Edges are computed as `(i * total) / count` rather than by multiplying a
18
+ # rounded size, so three rows of a 481-pixel window are 161, 160 and 160 and
19
+ # the last one still ends exactly at 481. Dividing first leaves a seam at the
20
+ # bottom of the screen that nothing draws into — a one-pixel line that looks
21
+ # like a rendering bug and is really a rounding one.
22
+ module Layout
23
+ # A window's worth of rows, columns or cells for `count` viewports.
24
+ #
25
+ # Two players get rows rather than columns because halving the height of a
26
+ # landscape window leaves each view landscape, while halving the width
27
+ # leaves two tall slots that fit a 2D scene badly. Three and four share a
28
+ # 2x2 grid, with the fourth cell left empty for three.
29
+ # Forwards its block with `&block` rather than re-yielding through
30
+ # `{ |*args| yield(*args) }`, which builds an Array per yield. Passing a
31
+ # block straight on allocates nothing — measured, not assumed; see the
32
+ # allocation example in layout_spec.rb.
33
+ def self.each_rect(count, width, height, &)
34
+ return if count <= 0
35
+ return yield(0, 0, 0, width, height) if count == 1
36
+ return each_row(count, width, height, &) if count == 2
37
+
38
+ each_cell(count, 2, 2, width, height, &)
39
+ end
40
+
41
+ # The same, as an Array of `[x, y, width, height]`. For specs and setup;
42
+ # the per-frame path uses #each_rect, which allocates nothing.
43
+ def self.rects(count, width, height)
44
+ result = []
45
+ each_rect(count, width, height) { |_i, x, y, w, h| result << [x, y, w, h] }
46
+ result
47
+ end
48
+
49
+ # `count` full-width rows, stacked.
50
+ def self.each_row(count, width, height)
51
+ count.times do |i|
52
+ top = edge(i, count, height)
53
+ yield(i, 0, top, width, edge(i + 1, count, height) - top)
54
+ end
55
+ end
56
+
57
+ # `count` full-height columns, side by side.
58
+ def self.each_column(count, width, height)
59
+ count.times do |i|
60
+ left = edge(i, count, width)
61
+ yield(i, left, 0, edge(i + 1, count, width) - left, height)
62
+ end
63
+ end
64
+
65
+ # The first `count` cells of a `cols` x `rows` grid, filled left to right,
66
+ # top to bottom.
67
+ def self.each_cell(count, cols, rows, width, height)
68
+ count.times do |i|
69
+ col = i % cols
70
+ row = i / cols
71
+ left = edge(col, cols, width)
72
+ top = edge(row, rows, height)
73
+ yield(i, left, top, edge(col + 1, cols, width) - left, edge(row + 1, rows, height) - top)
74
+ end
75
+ end
76
+
77
+ # The i-th boundary of `count` even divisions of `total`. Multiplying
78
+ # before dividing is what makes the divisions tile with no seam.
79
+ def self.edge(index, count, total) = (index * total) / count
80
+ end
81
+ end
82
+ end
@@ -9,18 +9,111 @@ module RGame
9
9
  class Node2D
10
10
  extend Engine::Signal::DSL
11
11
 
12
- attr_accessor :x, :y, :z, :angle, :width, :height, :parent
12
+ attr_accessor :x, :y, :angle, :width, :height, :parent
13
13
  attr_writer :scene, :context
14
- attr_reader :children, :components, :abs_x, :abs_y, :abs_z, :abs_angle
15
14
 
16
- def initialize(x: 0, y: 0, z: 0, angle: 0, width: 0, height: 0)
15
+ # Insertion order among siblings, the tie-breaker for equal `z`. Engine
16
+ # bookkeeping, set by the parent's #add_node the way `parent` is — not for
17
+ # game code, and meaningless on a node with no parent.
18
+ attr_accessor :sibling_order
19
+ attr_reader :children, :components, :abs_x, :abs_y, :abs_angle, :abs_input_owner, :z,
20
+ :band, :abs_band
21
+
22
+ # Where this node sits among its **siblings**, and nowhere else.
23
+ #
24
+ # The tree is drawn depth-first with siblings in `z` order, so a node's
25
+ # whole subtree is drawn before or after a sibling's whole subtree —
26
+ # never interleaved with it. Clouds over birds over people is three
27
+ # children of one node at `z` 2, 1 and 0, and each of them may be built
28
+ # out of as many parts as it likes without any of those parts escaping.
29
+ #
30
+ # Only the *comparison* matters. `z` is never added to anything and never
31
+ # reaches the renderer, so its magnitude means nothing: 1 and 1_000_000
32
+ # behave identically if they are the only two children, and a negative is
33
+ # ordinary. Equal `z` keeps the order the nodes were added in.
34
+ #
35
+ # This is deliberately unlike the additive relative z it replaces
36
+ # (`abs_z = parent.abs_z + z`), where a node at z 2 with a child at z 5
37
+ # resolved to 7 and overtook a sibling at 4 — some of a node's parts in
38
+ # front of something the node itself was behind. See RGame::Util::Z.
39
+ def z=(value)
40
+ @z = value
41
+ @parent&.children_unsorted!
42
+ end
43
+
44
+ # Which band this node and everything under it draws in — `:world` (the
45
+ # default), `:hud`, `:overlay` or `:debug`. A band beats every `z` in the
46
+ # tree: nothing in `:world` can draw over anything in `:hud`.
47
+ #
48
+ # Inherited like `input_owner`, and normally set by a node that exists to
49
+ # mark one: WorldView is `:world`, PlayerLayer is `:hud`. Setting it
50
+ # directly is the escape hatch — a node inside the world that must draw
51
+ # over the HUD says `band: :overlay` and does, still clipped to whatever
52
+ # its ancestors allowed. That is explicit and named, which is the whole
53
+ # difference from the Integer bases this replaces.
54
+ def band=(value)
55
+ Util::Z.band!(value) unless value.nil?
56
+ @band = value
57
+ end
58
+
59
+ # Whose input drives this node: an RGame::Engine::Player, or nil.
60
+ #
61
+ # Inherited down the tree exactly like the transform. Set it on a node and
62
+ # its whole subtree reads that player, so `ship.input_owner = players[1]`
63
+ # is all it takes for everything under the ship to answer to player two. A
64
+ # node that sets none inherits its parent's, and a tree that sets none
65
+ # anywhere reads the primary player — which is why single player needs no
66
+ # mention of this at all.
67
+ #
68
+ # **Not `player`**, deliberately, and not `controller` either. `@player` is
69
+ # what a game's own code calls its hero node (`examples/15_tiled_world`
70
+ # does), so an `attr_accessor :player` here would quietly claim that ivar
71
+ # out from under every scene that has one — which it did, and the symptom
72
+ # was the input system being handed a Node2D. `controller` is taken too:
73
+ # Actor#controller is the thing that produces movement intent, a different
74
+ # idea entirely. This name says exactly what it decides and collides with
75
+ # neither.
76
+ attr_accessor :input_owner
77
+
78
+ # A paused node skips `control` and `update` — and so does everything
79
+ # under it, because a subtree is only ever reached through its parent.
80
+ # It still **draws**: pausing is about time, not visibility, which is what
81
+ # lets a frozen world sit under a cutscene overlay that keeps animating.
82
+ #
83
+ # world_view.paused = true # the world stops; the overlay above it does not
84
+ #
85
+ # There is no `abs_paused` to go with `abs_input_owner`. Ownership has to
86
+ # be resolved because a node needs to know whose input it reads even when
87
+ # its parent claims nobody; pausing needs no resolution at all, because a
88
+ # paused node simply never descends.
89
+ attr_accessor :paused
90
+
91
+ def initialize(x: 0, y: 0, z: 0, angle: 0, width: 0, height: 0, input_owner: nil,
92
+ band: nil)
93
+ @input_owner = input_owner
94
+ @paused = false
17
95
  @x = x
18
96
  @y = y
19
97
  @z = z
98
+ self.band = band
20
99
  @angle = angle
21
100
  @width = width
22
101
  @height = height
102
+ # Resolved by #resolve_origin at the top of every phase. Seeded here so
103
+ # a node that has not been driven yet reads as being at the origin
104
+ # rather than as nil — which is the same answer resolve_origin gives an
105
+ # unparented node, and saves every reader of abs_* from a NoMethodError
106
+ # on a node built but not yet ticked.
107
+ @abs_x = @abs_y = @abs_angle = 0
108
+ @abs_input_owner = @input_owner
109
+ @abs_band = @band || Util::Z::DEFAULT
23
110
  @children = []
111
+ # Siblings are drawn in `z` order, and in insertion order within one
112
+ # `z`. Ruby's sort is not stable, so insertion order is carried as a
113
+ # number rather than relied on — the same reason the C draw queue
114
+ # compares (z, order) instead of trusting qsort.
115
+ @child_seq = 0
116
+ @children_sorted = true
24
117
  @components = []
25
118
  @component_slots = {} # slot (Class by default, or a Symbol name) => component
26
119
  @parent = nil
@@ -32,6 +125,8 @@ module RGame
32
125
  def add_node(node)
33
126
  @children << node
34
127
  node.parent = self
128
+ node.sibling_order = (@child_seq += 1)
129
+ @children_sorted = false
35
130
  # Defer the entered-tree cascade until this node is itself live; otherwise it
36
131
  # fires when an ancestor enters (see #enter_tree). This is the construct-vs-enter
37
132
  # split — a node built inside another node's initialize is not yet in the tree.
@@ -39,6 +134,12 @@ module RGame
39
134
  node
40
135
  end
41
136
 
137
+ # A child was added, or one changed its `z`, so the child order is stale.
138
+ # The sort is deferred to the next traversal rather than done here, so
139
+ # building a scene of a thousand nodes costs one sort rather than a
140
+ # thousand. Called by the engine; a game only ever assigns `z`.
141
+ def children_unsorted! = @children_sorted = false
142
+
42
143
  def remove_node(node)
43
144
  node.exit_tree if @in_tree
44
145
  @children.delete(node)
@@ -123,38 +224,70 @@ module RGame
123
224
  # transform flowing downward: a component or hook that moves this node
124
225
  # does so before children resolve their origin from it.
125
226
 
126
- def control(actions)
227
+ # `input` is an input *source*, not one player's snapshot: an
228
+ # RGame::Engine::Players registry, or a bare Actions when there is only
229
+ # ever one answer (which is what a spec usually passes).
230
+ #
231
+ # Each node asks the source for the actions of whichever player owns it,
232
+ # and hands its components and its own hook that plain Actions. So a
233
+ # component never learns there is more than one player — `control(actions)`
234
+ # means the same thing it always did — while two subtrees under one tick
235
+ # can read two different controllers.
236
+ #
237
+ # The source is what descends, not the resolved snapshot, because
238
+ # ownership can change further down.
239
+ def control(input)
240
+ return if @paused
241
+
127
242
  resolve_origin
243
+ actions = input.actions_for(@abs_input_owner)
128
244
  @components.each { it.control(actions) }
129
245
  on_control(actions)
130
- @children.each { it.control(actions) }
246
+ children_in_order.each { it.control(input) }
131
247
  end
132
248
 
133
249
  # update game logic and physics (might become two calls with
134
250
  # time, but for now works in one step). This runs second in a
135
251
  # game tick
136
252
  def update(dt)
253
+ return if @paused
254
+
137
255
  resolve_origin
138
256
  @components.each { it.update(dt) }
139
257
  on_update(dt)
140
- @children.each { it.update(dt) }
258
+ children_in_order.each { it.update(dt) }
141
259
  end
142
260
 
143
261
  # update visual game state, drawing the node. This runs last in
144
262
  # a game tick
145
- def draw(renderer)
263
+ # `view` is the viewport being drawn into: its rectangle, and the camera
264
+ # (if any) it is seen through. Every node gets it, because a node cannot
265
+ # otherwise know where the edges of its own region are — a HUD laying out
266
+ # against the whole window is wrong the moment the window is one player's
267
+ # half of it — and because culling needs it once the world is drawn more
268
+ # than once. Most nodes ignore it and simply draw.
269
+ def draw(renderer, view)
146
270
  resolve_origin
147
- # Draw this node's own visuals oriented by its absolute angle, then descend.
148
- # Children resolve their own world transform (resolve_origin already baked this
149
- # node's rotation into their abs_x/abs_y), so they draw in flat world space and
150
- # must NOT be nested inside this node's rotation nesting would apply that
151
- # rotation to them a second time. Unrotated nodes skip the wrapper entirely.
152
- if abs_angle.zero?
153
- draw_content(renderer)
154
- else
155
- renderer.rotated(abs_angle * 180.0 / Math::PI, abs_x, abs_y) { draw_content(renderer) }
271
+ # This node's own drawing goes in its own layer: the renderer hands out
272
+ # the next slot in the node's band, and every `z:` the node passes is an
273
+ # offset inside it. Because the traversal takes slots in the order it
274
+ # reaches nodes, draw order *is* tree orderand because a slot is
275
+ # narrow, nothing a node draws can reach past itself. The node never
276
+ # asks for this and cannot forget it; see RGame::Util::Z.
277
+ renderer.layered(@abs_band) do
278
+ # Draw this node's own visuals oriented by its absolute angle, then descend.
279
+ # Children resolve their own world transform (resolve_origin already baked this
280
+ # node's rotation into their abs_x/abs_y), so they draw in flat world space and
281
+ # must NOT be nested inside this node's rotation — nesting would apply that
282
+ # rotation to them a second time. Unrotated nodes skip the wrapper entirely.
283
+ if abs_angle.zero?
284
+ draw_content(renderer, view)
285
+ else
286
+ renderer.rotated(abs_angle * 180.0 / Math::PI, abs_x, abs_y) { draw_content(renderer, view) }
287
+ end
156
288
  end
157
- draw_children(renderer)
289
+ # Outside the block: a child takes a slot of its own, after this one.
290
+ draw_children(renderer, view)
158
291
  end
159
292
 
160
293
  def in_tree? = @in_tree
@@ -195,7 +328,7 @@ module RGame
195
328
  @freed = false # revive: a pooled node reacquired after death re-enters here
196
329
  @components.each(&:on_attach)
197
330
  on_add
198
- @children.each(&:enter_tree)
331
+ children_in_order.each(&:enter_tree)
199
332
  end
200
333
 
201
334
  # Leaving-tree cascade: mirror of #enter_tree (children first, then this
@@ -203,7 +336,7 @@ module RGame
203
336
  def exit_tree
204
337
  return unless @in_tree
205
338
 
206
- @children.each(&:exit_tree)
339
+ children_in_order.each(&:exit_tree)
207
340
  on_remove
208
341
  @components.each(&:on_detach)
209
342
  @in_tree = false
@@ -216,7 +349,7 @@ module RGame
216
349
 
217
350
  def on_control(actions); end
218
351
  def on_update(dt); end
219
- def on_draw(renderer); end
352
+ def on_draw(renderer, view); end
220
353
  def on_add; end
221
354
  def on_remove; end
222
355
 
@@ -224,35 +357,72 @@ module RGame
224
357
 
225
358
  # This node's own drawing: its components and its draw hook, in that order.
226
359
  # Wrapped in renderer.rotated by #draw when the node carries an absolute angle.
227
- def draw_content(renderer)
228
- @components.each { it.draw(renderer) }
229
- on_draw(renderer)
360
+ # hot-path
361
+ def draw_content(renderer, view)
362
+ @components.each { it.draw(renderer, view) }
363
+ on_draw(renderer, view)
230
364
  end
231
365
 
232
366
  # Draw the child subtrees. Its own method so a node can wrap the whole subtree's
233
- # draw in a transform e.g. CameraView wraps it in renderer.translated to apply a
234
- # camera offset, without each child knowing about the camera.
235
- def draw_children(renderer)
236
- @children.each { it.draw(renderer) }
367
+ # draw in a transform without each child knowing about it.
368
+ # hot-path
369
+ def draw_children(renderer, view)
370
+ children_in_order.each { it.draw(renderer, view) }
237
371
  end
238
372
 
239
- # TODO: Calculate (and cache?) depth
240
- # depth = z + highest z of children, maybe +1?
373
+ # The children, in the order every phase visits them: by `z`, then by when
374
+ # they were added. Sorted lazily a scene that never touches `z` after
375
+ # building sorts once and then pays one boolean per phase.
376
+ # hot-path
377
+ def children_in_order
378
+ sort_children unless @children_sorted
379
+ @children
380
+ end
381
+
382
+ # Ruby's sort is not stable, so the insertion counter is compared
383
+ # explicitly. Without it two same-z siblings would swap places between
384
+ # frames, which reads on screen as flicker rather than as a sort problem.
385
+ def sort_children
386
+ @children_sorted = true
387
+ # Nothing to order, and the overwhelmingly common case for a leaf or a
388
+ # node with one visual — worth skipping before touching the array.
389
+ return if @children.size < 2
390
+
391
+ @children.sort! do |a, b|
392
+ order = a.z <=> b.z
393
+ order.zero? ? a.sibling_order <=> b.sibling_order : order
394
+ end
395
+ end
241
396
 
242
397
  # Resolve this node's absolute transform from the parent origin passed down by the
243
- # traversal. Relative x/y/z/angle accumulate, so a nested Node offsets, re-layers
244
- # and rotates its whole subtree: a child's local (x, y) is rotated by the parent's
245
- # accumulated angle before being added to the parent's origin.
398
+ # traversal. Relative x/y/angle accumulate, so a nested Node offsets and rotates
399
+ # its whole subtree: a child's local (x, y) is rotated by the parent's accumulated
400
+ # angle before being added to the parent's origin.
401
+ #
402
+ # `z` is **not** among them, and that is the point: depth is decided by
403
+ # where the traversal reaches a node, not by summing what its ancestors
404
+ # picked. See #z= and RGame::Util::Z.
246
405
  # TODO: Do not recalculate every time, but use a @dirty flag
247
- # TODO: Handle z better - offset not by Z of the parent, but their
248
- # depth
249
406
  def resolve_origin
250
407
  if @parent.nil?
251
- @abs_x = @abs_y = @abs_z = 0
408
+ @abs_x = @abs_y = 0
252
409
  @abs_angle = 0 # root pinned to identity, like its position
410
+ @abs_input_owner = @input_owner
411
+ @abs_band = @band || Util::Z::DEFAULT
253
412
  return
254
413
  end
255
414
 
415
+ # Ownership accumulates the same way the transform does: this node's own
416
+ # if it has one, otherwise whatever it inherits. Resolved here rather
417
+ # than walked on demand so it costs one assignment per phase, and so it
418
+ # is equally available in update and draw — a HUD node drawing in its
419
+ # player's corner wants the same answer `control` used.
420
+ @abs_input_owner = @input_owner || @parent.abs_input_owner
421
+ # The band inherits the same way. A node that declares one overrides it
422
+ # for its whole subtree, which is the only way out of a band and is
423
+ # spelled with a name rather than a number.
424
+ @abs_band = @band || @parent.abs_band
425
+
256
426
  pa = @parent.abs_angle
257
427
  if pa.zero? # fast path: parent unrotated -> plain translation, no trig
258
428
  @abs_x = @parent.abs_x + @x
@@ -263,7 +433,6 @@ module RGame
263
433
  @abs_x = @parent.abs_x + (@x * cos) - (@y * sin)
264
434
  @abs_y = @parent.abs_y + (@x * sin) + (@y * cos)
265
435
  end
266
- @abs_z = @parent.abs_z + @z
267
436
  @abs_angle = pa + @angle
268
437
  end
269
438
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # One person playing: their device, their bindings, their camera, and their
6
+ # own corner of the screen.
7
+ #
8
+ # player = Player.new(id: 0, device: Controls.gamepad(0))
9
+ # player.actions.held?(:fire) # what they are doing this tick
10
+ # player.camera # where they are looking
11
+ # player.ui # their HUD and menus, in screen space
12
+ #
13
+ # ## Why the player owns these and the scene does not
14
+ #
15
+ # A world has one simulation and any number of viewers. Two players share
16
+ # every NPC and every tile, but not a camera, not a set of bindings, and not
17
+ # a menu — so those belong to the viewer. Putting the camera on the scene
18
+ # works exactly until there are two of them, and putting it on a node in the
19
+ # world is worse: it forces the world to know how many times it is drawn.
20
+ #
21
+ # This is the model Unreal calls a LocalPlayer and Unity spreads across
22
+ # PlayerInput plus a camera.
23
+ #
24
+ # ## The action *names* are shared, the bindings are not
25
+ #
26
+ # Every player reads `:fire`. What triggers it is per player: one on Space,
27
+ # one on a pad's X button, and the same InputMap can serve both because a
28
+ # device only answers for its own kind of input. Each player gets their own
29
+ # ActionMapper, so their edge queries are independent — one player's press
30
+ # cannot consume another's.
31
+ class Player
32
+ Controls = RGame::Util::Controls
33
+
34
+ attr_reader :id, :camera, :ui, :mapper
35
+ attr_accessor :name
36
+
37
+ # `device` may be nil, meaning "nobody is driving this player yet" — a
38
+ # seat waiting for a controller. Polling one reads as nothing held rather
39
+ # than raising, so a game can show "Player 2: press a button" without a
40
+ # special case.
41
+ def initialize(id: 0, device: Controls::KEYBOARD, input_map: nil, camera: nil)
42
+ @id = id
43
+ @camera = camera || Camera.new
44
+ @mapper = ActionMapper.new(input_map || InputMap.default, device: device)
45
+ @ui = Node2D.new
46
+ @name = nil
47
+ end
48
+
49
+ def device = @mapper.device
50
+ def active? = !@mapper.device.nil?
51
+
52
+ # Reassigning is how a hot-plug lands: the pad that just arrived in a slot
53
+ # becomes this player's, and their bindings and camera carry on unchanged.
54
+ def device=(value)
55
+ @mapper.device = value
56
+ end
57
+
58
+ # This player's input for the current tick. Set by #poll, and a reused
59
+ # object — hold the Player, never this.
60
+ def actions = @mapper.actions
61
+
62
+ def poll(backend) = @mapper.poll(backend)
63
+
64
+ # What this player's map can answer for. The vocabulary is the game's, so
65
+ # it is the same for every player; the bindings behind it are not.
66
+ def input_map = @mapper.map
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # One player's own corner of the screen.
6
+ #
7
+ # layer = scene.add_node(PlayerLayer.new(player: players[1]))
8
+ # layer.add_node(inventory)
9
+ #
10
+ # Its subtree is drawn **once**, clipped to that player's viewport and
11
+ # translated to its corner, in screen space. That is the third kind of
12
+ # content a frame holds: the world is drawn once per viewport under a
13
+ # camera, a global overlay once across the whole window, and this once per
14
+ # player inside their own region.
15
+ #
16
+ # It is also the `:hud` z band's structural counterpart: everything under
17
+ # here draws above everything in the world, whatever either asked for.
18
+ #
19
+ # ## Its children are positioned relative to it
20
+ #
21
+ # A node at (10, 10) under this layer is ten pixels inside *that player's*
22
+ # region, wherever the layout has put it. The layer's translate is what does
23
+ # that, so the same HUD class serves either player with nothing to configure
24
+ # — which is the point of it being a node rather than a rect a HUD looks up.
25
+ #
26
+ # For laying out against the far edge, use the view's **size**:
27
+ # `view.width - margin`. `view.x` and `view.y` are where the region sits on
28
+ # the window and are the clip's business, not a layout origin; adding them
29
+ # would offset a second time.
30
+ #
31
+ # ## It says whose input its subtree reads
32
+ #
33
+ # `input_owner` is set to that player, and ownership is inherited, so a menu
34
+ # anywhere under here reads their controller and nobody else's. Two players
35
+ # with a menu open at once are independent without either one knowing the
36
+ # other exists — see docs/api/scene_graph.md, "Who a node answers to".
37
+ #
38
+ # ## An empty region draws nothing
39
+ #
40
+ # `Viewports#screen_for` is nil for a seat nobody is in, and for everybody
41
+ # while the split is collapsed — a cutscene is everyone looking at one thing,
42
+ # so per-player UI has no place to be. Either way this draws nothing and
43
+ # needs no guard at the call site.
44
+ class PlayerLayer < Node2D
45
+ # `:hud` is this player's own screen space, above every world slot and
46
+ # below a global overlay. It is inherited, so a menu three nodes down is
47
+ # in it without saying so — which is the point of the band being
48
+ # structural rather than a number each widget carries.
49
+ def initialize(player:, **)
50
+ super(band: :hud, **)
51
+ self.input_owner = player
52
+ end
53
+
54
+ # Whose layer this is. The same thing as `input_owner`, and stored only
55
+ # there: two fields would be two things to keep in step.
56
+ def player = input_owner
57
+
58
+ def draw(renderer, _view = nil)
59
+ region = system(Viewports).screen_for(player)
60
+ return if region.nil?
61
+
62
+ renderer.clipped(region.x, region.y, region.width, region.height) do
63
+ renderer.translated(region.offset_x, region.offset_y) do
64
+ super(renderer, region)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end