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,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # Who is playing, as a root-scoped system.
6
+ #
7
+ # players = node.system(Players)
8
+ # players.primary.camera
9
+ # players.each_active { |player| ... }
10
+ #
11
+ # A Component on the root node, so any node reaches it by walking the tree
12
+ # rather than having it threaded through a constructor — the same shape
13
+ # CollisionWorld and TileWorld use (see docs/api/systems.md).
14
+ #
15
+ # It owns the list, polls every player's mapper once per tick, and decides
16
+ # who a newly used controller belongs to. It does **not** own the screen
17
+ # rects — those come from the layout, because they depend on how many
18
+ # players are active and change without the players doing so.
19
+ #
20
+ # ## Seats, and how a device comes to occupy one
21
+ #
22
+ # Every seat exists from the start; the unfilled ones are inactive and draw
23
+ # no viewport. So the number of seats is also the maximum number of players,
24
+ # rather than a separate cap that could disagree with the list.
25
+ #
26
+ # **A device is seated when someone uses it, not when it is plugged in.** A
27
+ # connect says something about hardware; seating a player creates a camera, a
28
+ # viewport and a screen split, and that should follow a statement of intent.
29
+ # Seating on connect drops a pad a solo player plugs in (no seat is free),
30
+ # splits the screen when a spare pad wakes up, and cannot be refused during a
31
+ # cutscene.
32
+ #
33
+ # players.on_unassigned_input = :join # :join | :takeover | :ignore
34
+ # players.accepting_joins = false # temporarily refuse either
35
+ #
36
+ # - `:join` — a press on an unassigned device fills the next free seat.
37
+ # Couch co-op, and the default when a game asks for more than one seat.
38
+ # - `:takeover` — it becomes the *primary* player's device instead. Single
39
+ # player, where picking up a controller is not a second person arriving,
40
+ # and the default when there is one seat.
41
+ # - `:ignore` — the game seats devices itself, with #seat.
42
+ #
43
+ # The trigger is a **`ui_confirm` press**, read through the map of whoever
44
+ # would receive the device. One action rather than "any input", because a
45
+ # stick resting slightly off centre must never seat a player, and an edge
46
+ # rather than held so one press does one thing.
47
+ class Players < Component
48
+ extend Engine::Signal::DSL
49
+
50
+ Controls = RGame::Util::Controls
51
+
52
+ include Enumerable
53
+
54
+ # Fires when a device is seated, with the player who got it. A scene
55
+ # listens to spawn that player's avatar — which is how a game gains a
56
+ # second character mid-session without polling for one.
57
+ signal :on_joined, Engine::Signal.define(:player)
58
+
59
+ attr_reader :list
60
+ attr_accessor :on_unassigned_input, :accepting_joins
61
+
62
+ def initialize(players = [])
63
+ super()
64
+ @list = players
65
+ # One seat means there is no second player to become, so an unassigned
66
+ # device is that player picking up a controller. More than one means the
67
+ # game expects company.
68
+ @on_unassigned_input = players.size > 1 ? :join : :takeover
69
+ @accepting_joins = true
70
+ @connected = []
71
+ @confirm_held = {}
72
+ end
73
+
74
+ # The player a single-player game means, and the one an unowned node reads
75
+ # from. Always present: a game with no players declared still has this one,
76
+ # which is what keeps single-player free of ceremony.
77
+ def primary = @list.first
78
+
79
+ def each(&) = @list.each(&)
80
+
81
+ # Players with a device driving them. An empty seat waiting for a
82
+ # controller is in `list` but not here, so a viewport loop skips it.
83
+ def each_active(&) = @list.select(&:active?).each(&)
84
+
85
+ def active_count = @list.count(&:active?)
86
+
87
+ def [](id) = @list.find { |player| player.id == id }
88
+
89
+ def add(player)
90
+ @list << player
91
+ player
92
+ end
93
+
94
+ # The input a node owned by `player` should read this tick.
95
+ #
96
+ # Nobody in particular means the primary player, which is what makes the
97
+ # single-player path free: no node claims ownership, every node resolves
98
+ # to nil, and every nil resolves to the one player there is.
99
+ # hot-path
100
+ def actions_for(player)
101
+ owner = player || primary
102
+ raise 'no players are registered, so nothing can read input' if owner.nil?
103
+
104
+ owner.actions
105
+ end
106
+
107
+ # Every player's input for this tick, in one call. Each has their own
108
+ # mapper and their own previous-frame state, so one player's press cannot
109
+ # consume another's edge.
110
+ # Then the devices nobody holds are checked for someone starting to use
111
+ # one. Here rather than in a hot-plug hook because a *press* is a per-tick
112
+ # idea, and this is the one place that already has the backend and runs
113
+ # once a tick.
114
+ def poll(backend)
115
+ @list.each { |player| player.poll(backend) }
116
+ admit(backend)
117
+ self
118
+ end
119
+
120
+ # A controller arrived in a slot. Recorded, not seated: this is what makes
121
+ # the slot *scannable*, and someone using it is what seats it.
122
+ def device_connected(slot)
123
+ @connected << slot unless @connected.include?(slot)
124
+ self
125
+ end
126
+
127
+ # A controller left its slot. Whoever was on it loses it; their camera,
128
+ # bindings and UI stay exactly as they were, so plugging back in and
129
+ # pressing confirm resumes rather than restarts.
130
+ #
131
+ # Under `:takeover` there is no second player to become, so the seat falls
132
+ # back to the keyboard rather than the game going dead in someone's hands.
133
+ def device_disconnected(slot)
134
+ @connected.delete(slot)
135
+ device = Controls.gamepad(slot)
136
+ seated = @list.find { |player| player.device == device }
137
+ seated&.device = @on_unassigned_input == :takeover ? Controls::KEYBOARD : nil
138
+ seated
139
+ end
140
+
141
+ # Give `device` to whoever should have it, and say who that was. The join
142
+ # path's own last step, and the one call a game running `:ignore` uses to
143
+ # seat devices on its own terms.
144
+ #
145
+ # Refused while `accepting_joins` is false — which covers taking over as
146
+ # well as joining, since both change who is holding what.
147
+ def seat(device)
148
+ return nil unless @accepting_joins
149
+
150
+ player = candidate
151
+ return nil if player.nil?
152
+
153
+ player.device = device
154
+ on_joined_signal.emit(player)
155
+ player
156
+ end
157
+
158
+ private
159
+
160
+ # Watch the devices nobody is holding, and seat one when it is used.
161
+ #
162
+ # Costs nothing when there is nobody to seat: with every seat full, or the
163
+ # policy set to ignore, there is no candidate and no device is looked at.
164
+ def admit(backend)
165
+ return if @on_unassigned_input == :ignore || candidate.nil?
166
+
167
+ each_unassigned_device do |device|
168
+ down = confirm_down?(backend, device)
169
+ was_down = @confirm_held[device]
170
+ @confirm_held[device] = down
171
+ seat(device) if down && !was_down
172
+ end
173
+ end
174
+
175
+ # Who the next unassigned device would go to, and therefore whose bindings
176
+ # decide what counts as a press. Nil when nobody could take one.
177
+ def candidate
178
+ return primary if @on_unassigned_input == :takeover
179
+
180
+ @list.find { |player| !player.active? }
181
+ end
182
+
183
+ # hot-path
184
+ def confirm_down?(backend, device)
185
+ player = candidate
186
+ return false if player.nil?
187
+
188
+ buttons = player.input_map[:ui_confirm]&.buttons
189
+ return false if buttons.nil?
190
+
191
+ buttons.any? { |id| backend.down?(id, device: device) }
192
+ end
193
+
194
+ # Connected pads nobody holds — plus the keyboard, but only while taking
195
+ # over. The keyboard is always "connected", so under `:join` it would sit
196
+ # waiting to seat whoever pressed Return, which is right for some games and
197
+ # a surprise in most; one that wants a keyboard player seats it explicitly.
198
+ def each_unassigned_device
199
+ @connected.each do |slot|
200
+ device = Controls.gamepad(slot)
201
+ yield device unless assigned?(device)
202
+ end
203
+ return unless @on_unassigned_input == :takeover && !assigned?(Controls::KEYBOARD)
204
+
205
+ yield Controls::KEYBOARD
206
+ end
207
+
208
+ # hot-path
209
+ def assigned?(device) = @list.any? { |player| player.device == device }
210
+ end
211
+ end
212
+ end
@@ -7,8 +7,13 @@ module RGame
7
7
  def initialize
8
8
  super
9
9
  @stack = []
10
+ @players = nil
10
11
  end
11
12
 
13
+ # See #control: the scenes this holds need the input source, and a
14
+ # component is only handed one player's snapshot.
15
+ def on_attach = @players = node.system(Engine::Players)
16
+
12
17
  def push(scene)
13
18
  @stack.push(scene)
14
19
  scene.parent = node # so scene.root resolves up to the host
@@ -36,10 +41,24 @@ module RGame
36
41
  @stack.last
37
42
  end
38
43
 
44
+ # Scenes live off the host's child list, so the traversal does not reach
45
+ # them on its own — and what has to reach them is the input *source*,
46
+ # not the snapshot this component was handed.
47
+ #
48
+ # A component receives one player's resolved Actions, which is right for
49
+ # a component: it belongs to exactly one node. A scene is a whole subtree
50
+ # and may contain nodes owned by different players, so handing it a
51
+ # single snapshot would flatten all of them onto whoever owns the host.
52
+ # The registry is pulled from the tree instead, the same way any system
53
+ # is, and passed down so each node in the scene resolves its own.
54
+ #
55
+ # Without a registry — a spec driving a stack with a bare snapshot — the
56
+ # snapshot is passed on, which is exactly what it means: one answer for
57
+ # everyone.
39
58
  def control(actions)
40
59
  return unless (current_scene = current)
41
60
 
42
- current_scene.control(actions)
61
+ current_scene.control(@players || actions)
43
62
  end
44
63
 
45
64
  def update(dt)
@@ -48,9 +67,12 @@ module RGame
48
67
  current_scene.update(dt)
49
68
  end
50
69
 
51
- def draw(renderer)
70
+ # Every scene in the stack, not just the current one — that asymmetry
71
+ # with control/update is what lets a menu pushed on top keep the world
72
+ # visible underneath while freezing it.
73
+ def draw(renderer, view)
52
74
  @stack.each do |scene|
53
- scene.draw(renderer)
75
+ scene.draw(renderer, view)
54
76
  end
55
77
  end
56
78
 
@@ -14,10 +14,23 @@ module RGame
14
14
  # hash.query(*bullet.aabb) { |rock| ...narrowphase... }
15
15
  class SpatialHash
16
16
  # Cell (col, row) → one integer key. The offset keeps negative cells (objects
17
- # off-screen / mid-wrap) non-negative; the stride keeps pairs unique. Both fit
18
- # in a tagged Fixnum, so keying allocates nothing.
19
- OFFSET = 1 << 20
20
- STRIDE = 1 << 21
17
+ # off-screen / mid-wrap) non-negative; the stride keeps pairs unique. Both are
18
+ # sized to keep every packed key inside a tagged Fixnum, so keying allocates
19
+ # nothing on the query path (CLAUDE.md: never allocate on the per-frame path).
20
+ #
21
+ # The ceiling that sizes them is Windows, not Linux/macOS: CRuby's immediate
22
+ # Fixnum range comes from a C `long`, which is 64 bits on LP64 (Linux, macOS)
23
+ # but stays 32 bits on Windows' LLP64 even in a 64-bit process — so a value
24
+ # comfortably inside Fixnum range on Linux (this packing used to run up to
25
+ # ~2**42) silently becomes a heap-allocated Bignum on Windows instead, one
26
+ # allocation per cell per query. OFFSET/STRIDE here keep the largest possible
27
+ # packed key (both coordinates at the far corner) under 2**28 — well inside
28
+ # Windows' ~2**30 Fixnum ceiling — while still allowing cell coordinates out
29
+ # to +/-8192, i.e. a world some sixteen million pixels wide at this file's own
30
+ # cell_size: 64 example. A game whose world exceeds that wraps into a
31
+ # neighbouring cell's key instead of raising; see #each_cell below.
32
+ OFFSET = 1 << 13
33
+ STRIDE = 1 << 14
21
34
 
22
35
  def initialize(cell_size:)
23
36
  @cell_size = cell_size
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # One layer of the scene's tile map, drawn in world space, once per viewport.
6
+ #
7
+ # world = scene.add_node(WorldView.new)
8
+ # actors = TileMapLayer.mount(world)
9
+ # actors.add_node(player)
10
+ #
11
+ # A node per layer, and the layers Tiled lists are the layers you get. The
12
+ # scene tree is then what says what covers what: everything mounted before
13
+ # the actors draws under them, everything after draws over them, and the
14
+ # gap `mount` leaves is where the actors go.
15
+ #
16
+ # It belongs **inside a WorldView**, which is the whole point of it existing
17
+ # separately from Components::TileWorld. The map is world content: it
18
+ # scrolls under a camera and every player sees their own part of it, so it
19
+ # has to be drawn where the rest of the world is drawn rather than once for
20
+ # the frame.
21
+ #
22
+ # It carries no state. The map id and the animation clock come from the
23
+ # scene's TileWorld system, and the region worth drawing comes from the view.
24
+ #
25
+ # ## Why a node per layer, rather than two passes
26
+ #
27
+ # There used to be one of these, drawing a "below" band and an "above" band
28
+ # in one go and relying on a global z to slot the actors between them. Draw
29
+ # order is tree order now (see RGame::Util::Z), so a node's drawing is
30
+ # contiguous and "between them" has to mean "between two nodes".
31
+ #
32
+ # That turned out to be the better shape anyway. A designer already orders
33
+ # layers in Tiled and can see the result there; content can go between *any*
34
+ # two of them rather than at one flagged boundary; and the `above` property
35
+ # stops being something to remember on every layer — it is read once, by
36
+ # `mount`, to decide where the gap goes.
37
+ class TileMapLayer < Node2D
38
+ # Mounts one node per layer of the scene's map under `parent`, and returns
39
+ # an empty node sitting in the gap between them — what the scene hangs its
40
+ # actors on. Nothing here picks a z by hand, and neither does the caller.
41
+ #
42
+ # `under` names the first layer that should cover the actors, as a layer
43
+ # index. It defaults to the first layer the map flags `above` in Tiled, so
44
+ # a map that already marks its canopies needs nothing said; a map that
45
+ # marks none puts the actors on top of everything.
46
+ #
47
+ # `parent` must be inside a WorldView, like the nodes themselves.
48
+ def self.mount(parent, under: nil)
49
+ world = parent.system(Components::TileWorld)
50
+ gap = under || world.first_above_layer
51
+
52
+ world.layer_count.times do |index|
53
+ # A layer at or past the gap sits above the actors; z is only ever
54
+ # compared to a sibling's, so the +1 is a gap, not a magnitude.
55
+ parent.add_node(new(layer: index, z: index < gap ? index : index + 1))
56
+ end
57
+ parent.add_node(Node2D.new(z: gap))
58
+ end
59
+
60
+ def initialize(layer:, **)
61
+ super(**)
62
+ @layer = layer
63
+ end
64
+
65
+ def on_add = @world = system(Components::TileWorld)
66
+
67
+ # The view supplies the cull rect: which part of the world this viewport
68
+ # can see. The map draws in world coordinates and the WorldView's
69
+ # translate puts it on screen, so nothing here does camera arithmetic —
70
+ # which is exactly what lets the same map serve every viewport.
71
+ #
72
+ # A screen-space view has no camera and nothing to cull against, so
73
+ # there is nothing sensible to draw; that is a misplaced layer rather than
74
+ # a state to handle, and it says so.
75
+ def on_draw(renderer, view)
76
+ camera = view.camera
77
+ raise 'TileMapLayer must be inside a WorldView — this view has no camera' if camera.nil?
78
+
79
+ renderer.tilemap(@world.tilemap_id, @layer, camera.x, camera.y,
80
+ view.width, view.height, elapsed: @world.elapsed)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # A vertical list of things to choose from, navigated by keyboard or
7
+ # controller.
8
+ #
9
+ # menu = layer.add_node(UI::Menu.new(item_width: 220, item_height: 44))
10
+ # menu.add_item('Resume').on_activated { close }
11
+ # menu.add_item('Quit').on_activated { game.close }
12
+ #
13
+ # ## Focus is the whole design
14
+ #
15
+ # With no pointer there is no hover, so something has to own *which
16
+ # control is focused* and how the directions move it. That is this class,
17
+ # and everything else about a menu follows from it: an item draws
18
+ # differently because it is focused, and `ui_confirm` activates the focused
19
+ # one.
20
+ #
21
+ # ## Focus is per player, and that costs nothing
22
+ #
23
+ # A Menu inside a PlayerLayer inherits that player as its `input_owner`,
24
+ # so the `actions` its `on_control` receives are already that player's.
25
+ # Two players with a menu open at once are independent without either menu
26
+ # knowing the other exists, and without a word of focus-specific
27
+ # per-player machinery. That falls out of ownership being inherited down
28
+ # the tree — see docs/api/scene_graph.md, "Who a node answers to".
29
+ #
30
+ # ## What this is not
31
+ #
32
+ # It is a menu, not a widget library. Items are stacked vertically at a
33
+ # fixed size, and that is the whole of its layout. The package this
34
+ # replaces positioned everything absolutely and hit-tested a mouse; none
35
+ # of it is a reference, and how UI should be laid out in general is still
36
+ # an open question — see docs/api/ui.md, "What this is not".
37
+ class Menu < Node2D
38
+ # Navigation wraps: a short vertical list is quicker to use when the
39
+ # ends join, and every console menu does it.
40
+ def initialize(item_width:, item_height:, spacing: 8,
41
+ style: MenuItem::STYLE, **)
42
+ super(**)
43
+ @item_width = item_width
44
+ @item_height = item_height
45
+ @spacing = spacing
46
+ @style = style
47
+ @items = []
48
+ @focused_index = 0
49
+ end
50
+
51
+ attr_reader :items, :focused_index
52
+
53
+ # Adds an item below the last one and returns it, so a caller can
54
+ # connect to its signal in the same line.
55
+ def add_item(label, enabled: true)
56
+ item = MenuItem.new(label: label, enabled: enabled, style: @style,
57
+ x: 0, y: @items.size * (@item_height + @spacing),
58
+ width: @item_width, height: @item_height)
59
+ @items << item
60
+ add_node(item)
61
+ refocus
62
+ item
63
+ end
64
+
65
+ def focused = @items[@focused_index]
66
+
67
+ # Moves focus by `delta`, skipping anything disabled, and wrapping. Does
68
+ # nothing at all if no item can take focus.
69
+ def focus_by(delta)
70
+ return if @items.empty?
71
+
72
+ index = @focused_index
73
+ @items.size.times do
74
+ index = (index + delta) % @items.size
75
+ next unless @items[index].enabled?
76
+
77
+ focus(index)
78
+ return
79
+ end
80
+ end
81
+
82
+ def focus(index)
83
+ @focused_index = index
84
+ @items.each_with_index { |item, i| item.focused = (i == index) }
85
+ end
86
+
87
+ def on_control(actions)
88
+ focus_by(-1) if actions.pressed?(:ui_up)
89
+ focus_by(1) if actions.pressed?(:ui_down)
90
+
91
+ current = focused
92
+ return if current.nil?
93
+
94
+ current.pressed = actions.held?(:ui_confirm)
95
+ current.activate if actions.pressed?(:ui_confirm)
96
+ end
97
+
98
+ private
99
+
100
+ # Keeps focus on something usable as items arrive: the first item to be
101
+ # added takes it, and a disabled first item hands it on.
102
+ def refocus
103
+ return if focused&.enabled?
104
+
105
+ @items.each_with_index do |item, index|
106
+ next unless item.enabled?
107
+
108
+ return focus(index)
109
+ end
110
+ focus(0)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ module UI
6
+ # One entry in a Menu: a label on a nine-slice, and a signal for when it is
7
+ # chosen.
8
+ #
9
+ # resume = menu.add_item('Resume')
10
+ # resume.on_activated { cutscene.close }
11
+ #
12
+ # It draws itself from its **state** — focused, pressed, disabled or
13
+ # idle — which is why the shipped atlas has an element for each. There is
14
+ # no hover, because there is no pointer: what a mouse-driven control would
15
+ # get from the cursor being over it, this gets from the Menu telling it it
16
+ # is the focused one.
17
+ #
18
+ # Its position is its own, resolved through the tree like any node's, so a
19
+ # Menu inside a PlayerLayer puts its items inside that player's region
20
+ # without either of them arranging it.
21
+ class MenuItem < Node2D
22
+ signal :on_activated # emits no payload; the item is the handle
23
+
24
+ # Atlas element per state. Replaceable per menu, so a game with its own
25
+ # art is not obliged to name it the way the shipped atlas does.
26
+ STYLE = {
27
+ idle: :button_idle,
28
+ focus: :button_focus,
29
+ pressed: :button_pressed,
30
+ disabled: :button_disabled
31
+ }.freeze
32
+
33
+ LABEL_COLOR = [46, 34, 24].freeze
34
+ DISABLED_LABEL_COLOR = [120, 110, 100].freeze
35
+
36
+ attr_accessor :label, :enabled
37
+ attr_writer :focused, :pressed
38
+
39
+ def initialize(label:, style: STYLE, enabled: true, **)
40
+ super(**)
41
+ @label = label
42
+ @style = style
43
+ @enabled = enabled
44
+ @focused = false
45
+ @pressed = false
46
+ end
47
+
48
+ def enabled? = @enabled
49
+ def focused? = @focused
50
+
51
+ # Fires the signal and returns the item, or nil if it is disabled — so a
52
+ # caller never has to check first, and a disabled item cannot be
53
+ # activated by any route.
54
+ def activate
55
+ return nil unless @enabled
56
+
57
+ on_activated_signal.emit
58
+ self
59
+ end
60
+
61
+ # The panel and its label share this node's slot, so the only ordering
62
+ # question is which of the two goes on top — and `z: 1` says exactly
63
+ # that, about this item and nothing else in the frame.
64
+ def on_draw(renderer, _view)
65
+ renderer.nine_slice(@style.fetch(state), abs_x, abs_y, width, height)
66
+ renderer.text(@label, label_x(renderer), label_y(renderer),
67
+ z: 1, color: @enabled ? LABEL_COLOR : DISABLED_LABEL_COLOR)
68
+ end
69
+
70
+ private
71
+
72
+ def state
73
+ return :disabled unless @enabled
74
+ return :idle unless @focused
75
+
76
+ @pressed ? :pressed : :focus
77
+ end
78
+
79
+ def label_x(renderer) = abs_x + ((width - renderer.text_width(@label)) / 2)
80
+ def label_y(renderer) = abs_y + ((height - renderer.text_height) / 2)
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RGame
4
+ module Engine
5
+ # One viewport being drawn: a rectangle of the screen, and (for a world
6
+ # view) the camera to look through it with.
7
+ #
8
+ # view.x, view.y, view.width, view.height # the screen rect
9
+ # view.camera # nil in screen space
10
+ # view.player # whose view this is, or nil
11
+ # view.visible?(x, y, w, h) # is this worth drawing
12
+ #
13
+ # ## What a view is for
14
+ #
15
+ # `draw(renderer, view)` hands every node the view it is being drawn into,
16
+ # which answers two questions nothing else can. **Where the edges are**: a
17
+ # HUD laying itself out against the whole window is wrong the moment the
18
+ # window is a player's half of one, and until this existed there was nothing
19
+ # else to ask. And **what is worth drawing**: with the world drawn once per
20
+ # player, culling stops being an optimisation and starts being the
21
+ # difference between one frame's work and four.
22
+ #
23
+ # ## It is reused, not rebuilt
24
+ #
25
+ # Viewports owns one of these per viewport and mutates it in place each
26
+ # frame, the way ActionMapper reuses its Actions. Building fresh ones would
27
+ # allocate a handful of objects every frame — invisible by every measure
28
+ # except the frame that stutters. **Hold the player or the viewports, never
29
+ # this**: the object a node was handed last frame is the same one, with
30
+ # different numbers in it.
31
+ class View
32
+ attr_reader :x, :y, :width, :height, :camera, :player
33
+
34
+ def initialize(x: 0, y: 0, width: 0, height: 0, camera: nil, player: nil)
35
+ set(x, y, width, height, camera: camera, player: player)
36
+ end
37
+
38
+ # Mutated in place by Viewports once per frame. Not for game code.
39
+ def set(x, y, width, height, camera: nil, player: nil)
40
+ @x = x
41
+ @y = y
42
+ @width = width
43
+ @height = height
44
+ @camera = camera
45
+ @player = player
46
+ self
47
+ end
48
+
49
+ # Where this view's contents start, in the space its nodes draw in: the
50
+ # camera's offset for a world view, and the origin for a screen-space one,
51
+ # whose nodes draw relative to the view's own corner.
52
+ # hot-path
53
+ def origin_x = @camera ? @camera.x : 0
54
+ # hot-path
55
+ def origin_y = @camera ? @camera.y : 0
56
+
57
+ # Does a rectangle overlap what this view shows? Coordinates are in the
58
+ # space the caller draws in — world coordinates under a camera, view-local
59
+ # ones in screen space — which is the same space `origin_x` is in.
60
+ # hot-path
61
+ def visible?(x, y, width, height)
62
+ left = origin_x
63
+ top = origin_y
64
+ x + width > left && x < left + @width &&
65
+ y + height > top && y < top + @height
66
+ end
67
+
68
+ # The offset to translate by so that content at `origin` lands at this
69
+ # view's corner on screen. The whole of split-screen, in two numbers.
70
+ # hot-path
71
+ def offset_x = @x - origin_x
72
+ # hot-path
73
+ def offset_y = @y - origin_y
74
+ end
75
+ end
76
+ end