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
|
@@ -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
|
-
|
|
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
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
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
|