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
|
@@ -13,6 +13,8 @@ module RGame
|
|
|
13
13
|
# @layer to say so. For an animated sprite (sheet + frame index driving
|
|
14
14
|
# renderer.sprite), add a sibling AnimatedSprite component later.
|
|
15
15
|
class Sprite < Engine::Component
|
|
16
|
+
include Engine::Culling
|
|
17
|
+
|
|
16
18
|
attr_accessor :scale
|
|
17
19
|
|
|
18
20
|
def initialize(id:, scale: 1.0, z: 0)
|
|
@@ -22,7 +24,15 @@ module RGame
|
|
|
22
24
|
@layer = z
|
|
23
25
|
end
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
# Centred on the node's origin and scaled, so the footprint to cull
|
|
28
|
+
# against is the node's box scaled and offset back by half of itself. A
|
|
29
|
+
# node that never set a size is never culled — see Culling.
|
|
30
|
+
def draw(renderer, view)
|
|
31
|
+
width = node.width * @scale
|
|
32
|
+
height = node.height * @scale
|
|
33
|
+
return if culled?(view, node.abs_x - (width / 2.0), node.abs_y - (height / 2.0),
|
|
34
|
+
width, height)
|
|
35
|
+
|
|
26
36
|
renderer.image(@id, node.abs_x, node.abs_y, scale: @scale, z: @layer)
|
|
27
37
|
end
|
|
28
38
|
end
|
|
@@ -11,27 +11,29 @@ module RGame
|
|
|
11
11
|
# Collision reuses Engine::CollisionSystem (TileCollision + a world-bounds clamp);
|
|
12
12
|
# the tile solidity is whatever the map's tileset reports (baked per-tile in Tiled).
|
|
13
13
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
14
|
+
# **It does not draw.** Drawing the map is RGame::Engine::TileMapLayer, one
|
|
15
|
+
# node per Tiled layer, mounted inside the WorldView so the map is drawn
|
|
16
|
+
# once per viewport like the rest of the world. This stays a system — the
|
|
17
|
+
# thing actors ask about collision and bounds — and a system that also
|
|
18
|
+
# drew was always the odd part of it.
|
|
19
19
|
#
|
|
20
|
-
# It
|
|
20
|
+
# It owns the map's **animation clock**. Nothing below reads a wall clock —
|
|
21
21
|
# see CLAUDE.md, "`draw` renders state; time enters through `update`" — so the
|
|
22
22
|
# elapsed seconds animated tiles run on are accumulated here and handed down at
|
|
23
23
|
# draw time. Stop calling `update` and the water freezes, which is what pausing
|
|
24
24
|
# should look like.
|
|
25
25
|
class TileWorld < Engine::Component
|
|
26
|
-
|
|
27
|
-
OVERLAY_Z = 20
|
|
26
|
+
attr_reader :tilemap_id, :elapsed
|
|
28
27
|
|
|
29
|
-
|
|
28
|
+
# `cameras` are the cameras this map bounds — every player's, normally.
|
|
29
|
+
# A camera may not show past the world's edges, and this is what knows
|
|
30
|
+
# how big the world is; the cameras themselves are owned by players.
|
|
31
|
+
def initialize(map:, tilemap_id:, cameras: [])
|
|
30
32
|
super()
|
|
31
33
|
@map = map
|
|
32
34
|
@tilemap_id = tilemap_id
|
|
33
|
-
@camera = camera
|
|
34
35
|
@elapsed = 0.0
|
|
36
|
+
Array(cameras).each { |camera| bound(camera) }
|
|
35
37
|
@collision = Engine::CollisionSystem.new(
|
|
36
38
|
tile_collision: Engine::TileCollision.new(
|
|
37
39
|
tile_width: map.tile_width, tile_height: map.tile_height,
|
|
@@ -44,6 +46,25 @@ module RGame
|
|
|
44
46
|
def world_width = @map.pixel_width
|
|
45
47
|
def world_height = @map.pixel_height
|
|
46
48
|
|
|
49
|
+
def layer_count = @map.layer_count
|
|
50
|
+
|
|
51
|
+
# The first layer Tiled flags `above`, or the layer count if none is —
|
|
52
|
+
# which is where TileMapLayer.mount leaves the gap for the actors, so a
|
|
53
|
+
# map with no flag puts them over everything. Read once at mount rather
|
|
54
|
+
# than per frame: which layers cover the actors is a fact about the
|
|
55
|
+
# scene's arrangement, and the arrangement is made once.
|
|
56
|
+
def first_above_layer
|
|
57
|
+
layer_count.times.find { |index| @map.above_layer?(index) } || layer_count
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Clamp a camera to this map's edges. Called for each camera the scene
|
|
61
|
+
# hands over, and again for one that arrives later (a player joining).
|
|
62
|
+
def bound(camera)
|
|
63
|
+
camera.world_width = @map.pixel_width
|
|
64
|
+
camera.world_height = @map.pixel_height
|
|
65
|
+
camera
|
|
66
|
+
end
|
|
67
|
+
|
|
47
68
|
# Move an actor (responds to x/y/collision_box) by (dx, dy), sliding along solids
|
|
48
69
|
# and clamped inside the world. Delegates to the reused collision system.
|
|
49
70
|
def move(actor, dx, dy) = @collision.move(actor, dx, dy)
|
|
@@ -54,14 +75,6 @@ module RGame
|
|
|
54
75
|
def update(dt)
|
|
55
76
|
@elapsed += dt
|
|
56
77
|
end
|
|
57
|
-
|
|
58
|
-
def draw(renderer)
|
|
59
|
-
renderer.tilemap(@tilemap_id, @camera.x, @camera.y,
|
|
60
|
-
@camera.viewport_width, @camera.viewport_height, elapsed: @elapsed)
|
|
61
|
-
renderer.tilemap_overlay(@tilemap_id, @camera.x, @camera.y,
|
|
62
|
-
@camera.viewport_width, @camera.viewport_height,
|
|
63
|
-
z: OVERLAY_Z, elapsed: @elapsed)
|
|
64
|
-
end
|
|
65
78
|
end
|
|
66
79
|
end
|
|
67
80
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# Skipping a drawable that the viewport cannot show.
|
|
6
|
+
#
|
|
7
|
+
# Mixed into the components that draw a node's own footprint, which are the
|
|
8
|
+
# ones that know both where they put it and how big it is.
|
|
9
|
+
#
|
|
10
|
+
# ## Why it is worth doing at all
|
|
11
|
+
#
|
|
12
|
+
# It was not, while the world was drawn once: a handful of skipped draws
|
|
13
|
+
# against a frame's worth of work. Split-screen changes the arithmetic —
|
|
14
|
+
# the world is walked once per viewport, and an actor at one player's end of
|
|
15
|
+
# the map is off the *other* player's view every frame. What used to be a
|
|
16
|
+
# small saving becomes a saving multiplied by the number of people playing.
|
|
17
|
+
#
|
|
18
|
+
# ## Conservative on purpose
|
|
19
|
+
#
|
|
20
|
+
# Culling one frame too eagerly is a sprite popping in at the edge of the
|
|
21
|
+
# screen, which is worse than the draw it saved. So two rules:
|
|
22
|
+
#
|
|
23
|
+
# - **No size means no culling.** `node.width`/`height` are what a drawable's
|
|
24
|
+
# footprint is measured from, and a node that never set them (nothing
|
|
25
|
+
# requires it — `examples/14_asteroids` does not) reads as 0×0, which would
|
|
26
|
+
# otherwise cull everything instantly. Unknown means draw.
|
|
27
|
+
# - **A rotated node is measured generously.** `Node2D#draw` rotates about the
|
|
28
|
+
# node's absolute origin, so a rotated footprint can reach further than its
|
|
29
|
+
# box in any direction. The margin is `width + height`, which is always at
|
|
30
|
+
# least the diagonal `hypot(width, height)` and costs no square root on a
|
|
31
|
+
# path that runs once per drawable per viewport.
|
|
32
|
+
module Culling
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# Can this box be skipped for `view`? Coordinates are in the space the
|
|
36
|
+
# caller draws in — world coordinates under a camera.
|
|
37
|
+
# hot-path
|
|
38
|
+
def culled?(view, x, y, width, height)
|
|
39
|
+
return false if width.zero? || height.zero?
|
|
40
|
+
return !view.visible?(x, y, width, height) if node.abs_angle.zero?
|
|
41
|
+
|
|
42
|
+
margin = width + height
|
|
43
|
+
!view.visible?(x - margin, y - margin, width + (margin * 2), height + (margin * 2))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -36,7 +36,6 @@ module RGame
|
|
|
36
36
|
DELTA_LABEL = 'Δ/f'
|
|
37
37
|
|
|
38
38
|
COLOR = [80, 255, 120].freeze # frozen so the renderer caches the resolved colour
|
|
39
|
-
Z = 1_000_000 # above any scene content
|
|
40
39
|
PAD = 8 # margin from the screen edge
|
|
41
40
|
GAP = 8 # space between a label and its number
|
|
42
41
|
|
|
@@ -55,7 +54,15 @@ module RGame
|
|
|
55
54
|
@prev_allocated = GC.stat(:total_allocated_objects) if @visible
|
|
56
55
|
end
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
# Laid out against the view it is drawn into rather than against the
|
|
58
|
+
# window, so it stays in the corner of whatever region it is given. It
|
|
59
|
+
# takes `fps` rather than reading it, for the same reason nothing here
|
|
60
|
+
# reads a clock: the number is measured by the shell that owns the loop
|
|
61
|
+
# and handed down. See CLAUDE.md, "`draw` renders state".
|
|
62
|
+
# Its own `:debug` band, which is the last one, so this lands over every
|
|
63
|
+
# other thing in the frame however the scene is arranged. Not a node, so
|
|
64
|
+
# it opens its own layer rather than being given one by the traversal.
|
|
65
|
+
def draw(renderer, view, fps)
|
|
59
66
|
return unless @visible
|
|
60
67
|
|
|
61
68
|
allocated = GC.stat(:total_allocated_objects)
|
|
@@ -63,12 +70,16 @@ module RGame
|
|
|
63
70
|
@prev_allocated = allocated
|
|
64
71
|
|
|
65
72
|
line_h = renderer.text_height
|
|
66
|
-
|
|
67
|
-
|
|
73
|
+
# The view's size, not its position: inside a region that has been
|
|
74
|
+
# translated to its corner, adding `view.x` would offset a second time.
|
|
75
|
+
right_x = view.width - PAD
|
|
76
|
+
top_y = view.height - PAD - (line_h * 3)
|
|
68
77
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
renderer.layered(:debug) do
|
|
79
|
+
draw_line(renderer, FPS_LABEL, fps, right_x, top_y)
|
|
80
|
+
draw_line(renderer, OBJ_LABEL, allocated, right_x, top_y + line_h)
|
|
81
|
+
draw_line(renderer, DELTA_LABEL, delta, right_x, top_y + (line_h * 2))
|
|
82
|
+
end
|
|
72
83
|
end
|
|
73
84
|
|
|
74
85
|
private
|
|
@@ -76,7 +87,7 @@ module RGame
|
|
|
76
87
|
# A right-aligned "label number" row ending at right_x.
|
|
77
88
|
def draw_line(renderer, label, value, right_x, y)
|
|
78
89
|
number_left = draw_uint(renderer, value, right_x, y)
|
|
79
|
-
renderer.text(label, number_left - GAP - label_width(renderer, label), y,
|
|
90
|
+
renderer.text(label, number_left - GAP - label_width(renderer, label), y, color: COLOR)
|
|
80
91
|
end
|
|
81
92
|
|
|
82
93
|
# Draw a non-negative integer right-aligned ending at right_x, digit by digit so no
|
|
@@ -88,7 +99,7 @@ module RGame
|
|
|
88
99
|
digit = value % 10
|
|
89
100
|
value /= 10
|
|
90
101
|
x -= digit_width(renderer, digit)
|
|
91
|
-
renderer.text(DIGITS[digit], x, y,
|
|
102
|
+
renderer.text(DIGITS[digit], x, y, color: COLOR)
|
|
92
103
|
more = value.positive?
|
|
93
104
|
end
|
|
94
105
|
x
|
|
@@ -2,45 +2,125 @@
|
|
|
2
2
|
|
|
3
3
|
module RGame
|
|
4
4
|
module Engine
|
|
5
|
-
# Polls
|
|
6
|
-
#
|
|
7
|
-
# physical ids are decoupled from any backend's constants (the backend resolves
|
|
8
|
-
# them), so remapping is just swapping this data.
|
|
5
|
+
# Polls one player's device through an InputMap and produces their Actions
|
|
6
|
+
# snapshot.
|
|
9
7
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
8
|
+
# mapper = ActionMapper.new(input_map, device: Controls.gamepad(0))
|
|
9
|
+
# actions = mapper.poll(input)
|
|
10
|
+
#
|
|
11
|
+
# **One of these per player.** The device is what makes that work: every
|
|
12
|
+
# query carries it, so two mappers over the same map read two different
|
|
13
|
+
# controllers, and each keeps its own previous-frame state so their edge
|
|
14
|
+
# queries are independent.
|
|
15
|
+
#
|
|
16
|
+
# Pure logic. The backend is duck-typed and the whole interface is
|
|
17
|
+
# `down?(physical_id, device:)` and `axis(axis_id, device:)` — a spec passes
|
|
18
|
+
# a fake and a game passes RGame::Core::Input.
|
|
13
19
|
class ActionMapper
|
|
14
|
-
|
|
20
|
+
# A resting analog stick genuinely reports small non-zero values, so
|
|
21
|
+
# something has to ignore them. Here rather than in the game, because it
|
|
22
|
+
# is a property of the device, and here rather than in Core, because how
|
|
23
|
+
# much to ignore is a judgement rather than a fact about the hardware.
|
|
24
|
+
DEAD_ZONE = 0.15
|
|
25
|
+
|
|
26
|
+
attr_reader :map, :actions
|
|
27
|
+
attr_accessor :device, :dead_zone
|
|
15
28
|
|
|
16
|
-
def initialize(map)
|
|
29
|
+
def initialize(map, device: RGame::Util::Controls::KEYBOARD, dead_zone: DEAD_ZONE)
|
|
17
30
|
@map = map
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
#
|
|
22
|
-
#
|
|
31
|
+
@device = device
|
|
32
|
+
@dead_zone = dead_zone
|
|
33
|
+
|
|
34
|
+
# One reusable snapshot: there is exactly one input state per tick per
|
|
35
|
+
# player, so these are mutated in place each poll instead of allocated.
|
|
36
|
+
# Seeded from the map's action list, so the hashes are warm before the
|
|
37
|
+
# first poll and steady-state polling allocates nothing at all.
|
|
23
38
|
@held = {}
|
|
24
39
|
@prev_held = {}
|
|
25
40
|
@axes = {}
|
|
41
|
+
map.bindings.each_key do |name|
|
|
42
|
+
@held[name] = false
|
|
43
|
+
@prev_held[name] = false
|
|
44
|
+
@axes[name] = 0.0
|
|
45
|
+
end
|
|
26
46
|
@actions = Actions.new(held: @held, axes: @axes, prev_held: @prev_held)
|
|
27
47
|
end
|
|
28
48
|
|
|
29
49
|
def poll(backend)
|
|
30
50
|
# Snapshot this frame's held state as "previous" before recomputing it
|
|
31
|
-
# (in-place copy: no allocation
|
|
51
|
+
# (in-place copy: no allocation, the keys already exist).
|
|
32
52
|
@held.each { |name, down| @prev_held[name] = down }
|
|
33
53
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@held[name] = binding[:button].any? { |b| backend.down?(b) } if binding[:button]
|
|
54
|
+
return rest if @device.nil?
|
|
55
|
+
|
|
56
|
+
@map.bindings.each do |name, binding|
|
|
57
|
+
@held[name] = any_down?(backend, binding.buttons) if binding.buttons
|
|
58
|
+
@axes[name] = axis_value(backend, binding) if binding.pairs || binding.stick
|
|
40
59
|
end
|
|
41
60
|
|
|
42
61
|
@actions
|
|
43
62
|
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# A player with no device — an empty seat waiting for a controller — reads
|
|
67
|
+
# as nothing held and every axis centred. Returning the snapshot rather
|
|
68
|
+
# than refusing to poll is what lets a game show "press a button to join"
|
|
69
|
+
# with no special case, and it releases anything that was held when the
|
|
70
|
+
# controller was unplugged mid-press.
|
|
71
|
+
def rest
|
|
72
|
+
@held.each_key { |name| @held[name] = false }
|
|
73
|
+
@axes.each_key { |name| @axes[name] = 0.0 }
|
|
74
|
+
@actions
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# hot-path
|
|
78
|
+
def any_down?(backend, ids)
|
|
79
|
+
ids.any? { |id| backend.down?(id, device: @device) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A digital axis and an analog one can both be bound to the same action —
|
|
83
|
+
# arrows *and* the left stick — so the larger deflection wins. That needs
|
|
84
|
+
# no per-device branching: a keyboard reads 0.0 for every axis and a stick
|
|
85
|
+
# reads 0.0 for every key, so whichever device the player is on, the other
|
|
86
|
+
# source contributes nothing.
|
|
87
|
+
# hot-path
|
|
88
|
+
def axis_value(backend, binding)
|
|
89
|
+
digital = digital_axis(backend, binding)
|
|
90
|
+
return digital unless binding.stick
|
|
91
|
+
|
|
92
|
+
analog = dead_zoned(backend.axis(binding.stick, device: @device))
|
|
93
|
+
digital.abs >= analog.abs ? digital : analog
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The largest deflection across every pair bound to this axis. Several
|
|
97
|
+
# pairs is how the arrows, WASD and a d-pad all drive one action; a device
|
|
98
|
+
# with only one of them reads 0.0 for the rest, so the others cost nothing.
|
|
99
|
+
# hot-path
|
|
100
|
+
def digital_axis(backend, binding)
|
|
101
|
+
pairs = binding.pairs
|
|
102
|
+
return 0.0 if pairs.nil?
|
|
103
|
+
|
|
104
|
+
best = 0.0
|
|
105
|
+
pairs.each do |pair|
|
|
106
|
+
value = (backend.down?(pair[1], device: @device) ? 1.0 : 0.0) -
|
|
107
|
+
(backend.down?(pair[0], device: @device) ? 1.0 : 0.0)
|
|
108
|
+
best = value if value.abs > best.abs
|
|
109
|
+
end
|
|
110
|
+
best
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Rescaled rather than merely cut off, so the value ramps from zero as the
|
|
114
|
+
# stick leaves the dead zone. Cutting off alone makes it jump to the dead
|
|
115
|
+
# zone's width the moment it starts reading, which is a visible twitch.
|
|
116
|
+
# hot-path
|
|
117
|
+
def dead_zoned(value)
|
|
118
|
+
magnitude = value.abs
|
|
119
|
+
return 0.0 if magnitude <= @dead_zone
|
|
120
|
+
|
|
121
|
+
scaled = (magnitude - @dead_zone) / (1.0 - @dead_zone)
|
|
122
|
+
value.negative? ? -scaled : scaled
|
|
123
|
+
end
|
|
44
124
|
end
|
|
45
125
|
end
|
|
46
126
|
end
|
|
@@ -3,39 +3,96 @@
|
|
|
3
3
|
module RGame
|
|
4
4
|
module Engine
|
|
5
5
|
# An immutable per-frame snapshot of abstract action state. Game logic reads
|
|
6
|
-
# this, never physical keys. Built by ActionMapper (or constructed directly
|
|
7
|
-
#
|
|
6
|
+
# this, never physical keys. Built by ActionMapper (or constructed directly
|
|
7
|
+
# in a spec).
|
|
8
8
|
#
|
|
9
|
-
# Edge queries (`pressed?`/`released?`) compare against the previous frame's
|
|
10
|
-
# state, so a one-shot action (menu confirm, jump) fires exactly once
|
|
11
|
-
# rather than every frame it
|
|
9
|
+
# Edge queries (`pressed?`/`released?`) compare against the previous frame's
|
|
10
|
+
# held state, so a one-shot action (menu confirm, jump) fires exactly once
|
|
11
|
+
# per press rather than every frame it is held.
|
|
12
|
+
#
|
|
13
|
+
# ## Reading an action nobody declared raises
|
|
14
|
+
#
|
|
15
|
+
# A game declares its actions once, in an InputMap. Asking for one that is
|
|
16
|
+
# not in it is a typo, not a question with an answer, so it raises KeyError
|
|
17
|
+
# rather than reading `false` forever:
|
|
18
|
+
#
|
|
19
|
+
# actions.pressed?(:fyre) # KeyError: no such action :fyre
|
|
20
|
+
#
|
|
21
|
+
# This matters because the failure it replaces is silent and remote. A
|
|
22
|
+
# misspelled action reads as "never pressed", and what the player sees is a
|
|
23
|
+
# button that does nothing — a bug that looks like it lives in the code that
|
|
24
|
+
# *would* have run. RGame::Core::Input used to raise KeyError for an unbound
|
|
25
|
+
# action and no longer can: it takes physical ids now, and binding moved up
|
|
26
|
+
# to InputMap. This is where that guarantee went.
|
|
27
|
+
#
|
|
28
|
+
# **The hashes are the declaration.** ActionMapper seeds all three from its
|
|
29
|
+
# map at construction, so every action the map knows answers and nothing
|
|
30
|
+
# else does. A spec constructing one directly declares whatever it passes:
|
|
31
|
+
#
|
|
32
|
+
# Actions.new(axes: { move_x: 1.0, move_y: 0.0 }) # both answer
|
|
33
|
+
# Actions.new(axes: { move_x: 1.0 }).axis(:move_y) # KeyError
|
|
34
|
+
#
|
|
35
|
+
# That is stricter than it needs to be for a spec, and deliberately: a spec
|
|
36
|
+
# that has not said what the action set is cannot claim a component reads
|
|
37
|
+
# the right part of it.
|
|
12
38
|
class Actions
|
|
13
|
-
# `held`, `axes` and `prev_held` are mutable hashes the mapper updates in
|
|
14
|
-
# each poll, so the snapshot stays a single reused, allocation-free
|
|
39
|
+
# `held`, `axes` and `prev_held` are mutable hashes the mapper updates in
|
|
40
|
+
# place each poll, so the snapshot stays a single reused, allocation-free
|
|
41
|
+
# object.
|
|
15
42
|
def initialize(held: {}, axes: {}, prev_held: {})
|
|
16
43
|
@held = held
|
|
17
44
|
@axes = axes
|
|
18
45
|
@prev_held = prev_held
|
|
19
46
|
end
|
|
20
47
|
|
|
48
|
+
# Every action this snapshot can answer for.
|
|
49
|
+
def declared = @held.keys | @axes.keys
|
|
50
|
+
|
|
51
|
+
# A snapshot is also a degenerate input *source*: it answers for whichever
|
|
52
|
+
# player is asking, because there is only one answer. That is what lets
|
|
53
|
+
# `node.control(actions)` keep working unchanged — a tree with nobody
|
|
54
|
+
# claiming ownership, or a spec that has only one player in mind, passes
|
|
55
|
+
# the snapshot itself where a Players registry would otherwise go.
|
|
56
|
+
# hot-path
|
|
57
|
+
def actions_for(_player) = self
|
|
58
|
+
|
|
59
|
+
# hot-path
|
|
21
60
|
def held?(name)
|
|
22
|
-
@held.fetch(name
|
|
61
|
+
@held.fetch(name) { undeclared(name) }
|
|
23
62
|
end
|
|
24
63
|
|
|
25
64
|
# True only on the frame the action transitions up→down.
|
|
65
|
+
# hot-path
|
|
26
66
|
def pressed?(name)
|
|
27
|
-
@held.fetch(name
|
|
67
|
+
@held.fetch(name) { undeclared(name) } && !@prev_held.fetch(name, false)
|
|
28
68
|
end
|
|
29
69
|
|
|
30
70
|
# True only on the frame the action transitions down→up.
|
|
71
|
+
# hot-path
|
|
31
72
|
def released?(name)
|
|
32
|
-
!@held.fetch(name
|
|
73
|
+
!@held.fetch(name) { undeclared(name) } && @prev_held.fetch(name, false)
|
|
33
74
|
end
|
|
34
75
|
|
|
35
|
-
# Analog value in [-1.0, 1.0]; 0.0
|
|
76
|
+
# Analog value in [-1.0, 1.0]; 0.0 for a declared action at rest.
|
|
77
|
+
# hot-path
|
|
36
78
|
def axis(name)
|
|
37
|
-
@axes.fetch(name
|
|
79
|
+
@axes.fetch(name) { undeclared(name) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# A block rather than `fetch(name)`'s bare KeyError, for a message that
|
|
85
|
+
# says what to do about it. The block only runs on a miss, so the reading
|
|
86
|
+
# path stays one hash lookup with nothing allocated.
|
|
87
|
+
def undeclared(name)
|
|
88
|
+
raise KeyError, "no such action #{name.inspect} — declare it in the InputMap " \
|
|
89
|
+
"(this snapshot has #{declared.inspect})"
|
|
38
90
|
end
|
|
91
|
+
|
|
92
|
+
# `prev_held` is read with a default on purpose. It is one frame behind, so
|
|
93
|
+
# on the very first poll after an action is added it legitimately has no
|
|
94
|
+
# entry, and "was not held before" is the right answer rather than an
|
|
95
|
+
# error. The current-frame lookup above is what catches a typo.
|
|
39
96
|
end
|
|
40
97
|
end
|
|
41
98
|
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RGame
|
|
4
|
+
module Engine
|
|
5
|
+
# What physical inputs mean, for one player.
|
|
6
|
+
#
|
|
7
|
+
# map = InputMap.new(
|
|
8
|
+
# thrust: { axis: [Controls::KEY_DOWN, Controls::KEY_UP], stick: Controls::AXIS_TRIGGER_RIGHT },
|
|
9
|
+
# fire: { buttons: [Controls::KEY_SPACE, Controls::PAD_A] }
|
|
10
|
+
# )
|
|
11
|
+
#
|
|
12
|
+
# One entry per action, naming **physical ids from RGame::Util::Controls**
|
|
13
|
+
# directly. That is the whole point of this class: it is the single table a
|
|
14
|
+
# rebinding screen edits, and it holds nothing but integers, so the engine
|
|
15
|
+
# layer may own one outright.
|
|
16
|
+
#
|
|
17
|
+
# Three kinds of source, and an action may combine them:
|
|
18
|
+
#
|
|
19
|
+
# | Key | Reads as | Meaning |
|
|
20
|
+
# |---|---|---|
|
|
21
|
+
# | `buttons:` | `held?` | down if *any* listed id is down |
|
|
22
|
+
# | `axis:` | `axis` | `[negative_id, positive_id]`, or a list of such pairs — a digital axis from buttons |
|
|
23
|
+
# | `stick:` | `axis` | an analog axis id, for a real stick or trigger |
|
|
24
|
+
#
|
|
25
|
+
# ## One table serves every device
|
|
26
|
+
#
|
|
27
|
+
# Listing a key and a pad button in the same entry is safe, and needs no
|
|
28
|
+
# per-device branching, because **a device only answers for its own kind of
|
|
29
|
+
# input** — asking a gamepad about a keyboard scancode is `false`, never the
|
|
30
|
+
# keyboard's answer (see docs/api/input.md). So `fire` can be "Space or A"
|
|
31
|
+
# and each player's device picks out the half that applies to it.
|
|
32
|
+
#
|
|
33
|
+
# ## An axis can have several pairs, like a button can have several ids
|
|
34
|
+
#
|
|
35
|
+
# `axis: [KEY_LEFT, KEY_RIGHT]` is the common case and stays a bare pair.
|
|
36
|
+
# A list of pairs binds more than one control to the same axis:
|
|
37
|
+
#
|
|
38
|
+
# move_x: { axis: [[Controls::KEY_LEFT, Controls::KEY_RIGHT],
|
|
39
|
+
# [Controls::PAD_DPAD_LEFT, Controls::PAD_DPAD_RIGHT]],
|
|
40
|
+
# stick: Controls::AXIS_LEFT_X }
|
|
41
|
+
#
|
|
42
|
+
# Without it a d-pad cannot drive movement at all, because the same action
|
|
43
|
+
# already needed the arrow keys. The largest deflection wins, so the pairs
|
|
44
|
+
# cost nothing on a device that has only one of them.
|
|
45
|
+
#
|
|
46
|
+
# This replaces a two-stage scheme in which a game's action map named
|
|
47
|
+
# RGame::Core::Input's action names, which named physical ids — two tables in
|
|
48
|
+
# series, neither of them the one a config screen wanted, and the lower one
|
|
49
|
+
# unreachable from the engine layer, which may not name Core at all.
|
|
50
|
+
#
|
|
51
|
+
# ## A stick's sign is the device's, not the game's
|
|
52
|
+
#
|
|
53
|
+
# `AXIS_LEFT_Y` is positive *downwards*, like screen coordinates. An action
|
|
54
|
+
# that wants the opposite ("thrust", "climb") negates at the call site or
|
|
55
|
+
# binds a trigger instead — the map stays declarative rather than growing an
|
|
56
|
+
# inversion flag that every reader would then have to check for.
|
|
57
|
+
class InputMap
|
|
58
|
+
Controls = RGame::Util::Controls
|
|
59
|
+
|
|
60
|
+
# One action's resolved sources. Built once, at construction, so polling
|
|
61
|
+
# walks plain attribute reads and allocates nothing.
|
|
62
|
+
#
|
|
63
|
+
# `buttons` is "held if any of these is down"; `pairs` is a list of
|
|
64
|
+
# `[negative, positive]` button pairs, each a digital axis; `stick` is an
|
|
65
|
+
# analog axis id.
|
|
66
|
+
Binding = Struct.new(:buttons, :pairs, :stick)
|
|
67
|
+
|
|
68
|
+
SOURCES = %i[buttons axis stick].freeze
|
|
69
|
+
|
|
70
|
+
# The universal set, merged into every map unless the game overrides it.
|
|
71
|
+
#
|
|
72
|
+
# The UI package navigates and activates through these, so a control can
|
|
73
|
+
# rely on them existing for *every* player without a game having declared
|
|
74
|
+
# them. They are prefixed rather than plain (`ui_up`, not `up`) so a game
|
|
75
|
+
# is free to use `:up` for something of its own.
|
|
76
|
+
#
|
|
77
|
+
# `ui_cancel` is Escape, which is why RGame::Game's quit key is F2: the
|
|
78
|
+
# button a player expects to back out of a menu belongs to the menu.
|
|
79
|
+
UI = {
|
|
80
|
+
ui_up: { buttons: [Controls::KEY_UP, Controls::PAD_DPAD_UP] },
|
|
81
|
+
ui_down: { buttons: [Controls::KEY_DOWN, Controls::PAD_DPAD_DOWN] },
|
|
82
|
+
ui_left: { buttons: [Controls::KEY_LEFT, Controls::PAD_DPAD_LEFT] },
|
|
83
|
+
ui_right: { buttons: [Controls::KEY_RIGHT, Controls::PAD_DPAD_RIGHT] },
|
|
84
|
+
ui_confirm: { buttons: [Controls::KEY_RETURN, Controls::KEY_SPACE, Controls::PAD_A] },
|
|
85
|
+
ui_cancel: { buttons: [Controls::KEY_ESCAPE, Controls::PAD_B] }
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
# A playable starting point: eight-way movement on the arrows or the left
|
|
89
|
+
# stick, and a fire button. A game that wants exactly this declares
|
|
90
|
+
# nothing at all.
|
|
91
|
+
DEFAULT_ACTIONS = {
|
|
92
|
+
move_x: { axis: [[Controls::KEY_LEFT, Controls::KEY_RIGHT],
|
|
93
|
+
[Controls::KEY_A, Controls::KEY_D],
|
|
94
|
+
[Controls::PAD_DPAD_LEFT, Controls::PAD_DPAD_RIGHT]],
|
|
95
|
+
stick: Controls::AXIS_LEFT_X },
|
|
96
|
+
move_y: { axis: [[Controls::KEY_UP, Controls::KEY_DOWN],
|
|
97
|
+
[Controls::KEY_W, Controls::KEY_S],
|
|
98
|
+
[Controls::PAD_DPAD_UP, Controls::PAD_DPAD_DOWN]],
|
|
99
|
+
stick: Controls::AXIS_LEFT_Y },
|
|
100
|
+
fire: { buttons: [Controls::KEY_SPACE, Controls::PAD_A] }
|
|
101
|
+
}.freeze
|
|
102
|
+
|
|
103
|
+
# Every action this map can answer for, as `name => Binding`.
|
|
104
|
+
attr_reader :bindings
|
|
105
|
+
|
|
106
|
+
# `entries` are merged over the universal UI set, so declaring a game's
|
|
107
|
+
# actions never costs it the ones the UI needs.
|
|
108
|
+
def initialize(entries = {})
|
|
109
|
+
@bindings = UI.merge(entries).to_h { |name, entry| [name, build(name, entry)] }.freeze
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The default map: the UI set plus DEFAULT_ACTIONS.
|
|
113
|
+
def self.default = new(DEFAULT_ACTIONS)
|
|
114
|
+
|
|
115
|
+
# A copy with `entries` overriding, which is how a game rebinds one action
|
|
116
|
+
# without restating the rest.
|
|
117
|
+
def merge(entries) = self.class.new(to_h.merge(entries))
|
|
118
|
+
|
|
119
|
+
def [](action) = @bindings[action]
|
|
120
|
+
|
|
121
|
+
def actions = @bindings.keys
|
|
122
|
+
|
|
123
|
+
# The entries in the shape they were declared in, so a map can be edited
|
|
124
|
+
# and rebuilt (a config screen) or merged.
|
|
125
|
+
def to_h
|
|
126
|
+
@bindings.to_h do |name, binding|
|
|
127
|
+
entry = {}
|
|
128
|
+
entry[:buttons] = binding.buttons if binding.buttons
|
|
129
|
+
entry[:axis] = binding.pairs.size == 1 ? binding.pairs.first : binding.pairs if binding.pairs
|
|
130
|
+
entry[:stick] = binding.stick if binding.stick
|
|
131
|
+
[name, entry]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
# Malformed entries raise here rather than reading as "nothing is ever
|
|
138
|
+
# pressed" for the rest of the program. An action name misspelled at a
|
|
139
|
+
# *read* site is still silent — see Actions — but one misspelled in the
|
|
140
|
+
# map is the mistake that is actually easy to make, and this catches it at
|
|
141
|
+
# construction rather than at the first frame nobody can move.
|
|
142
|
+
def build(name, entry)
|
|
143
|
+
unknown = entry.keys - SOURCES
|
|
144
|
+
raise ArgumentError, "#{name}: unknown source #{unknown.first.inspect}" unless unknown.empty?
|
|
145
|
+
|
|
146
|
+
buttons = freeze_ids(name, entry[:buttons])
|
|
147
|
+
pairs = axis_pairs(name, entry[:axis])
|
|
148
|
+
raise ArgumentError, "#{name}: no buttons, axis or stick" if buttons.nil? && pairs.nil? && entry[:stick].nil?
|
|
149
|
+
|
|
150
|
+
Binding.new(buttons, pairs, entry[:stick]).freeze
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def freeze_ids(name, ids)
|
|
154
|
+
return nil if ids.nil?
|
|
155
|
+
raise ArgumentError, "#{name}: buttons must be a list of ids" unless ids.is_a?(Array) && !ids.empty?
|
|
156
|
+
|
|
157
|
+
ids.dup.freeze
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Accepts one `[negative, positive]` pair or a list of them. A bare pair
|
|
161
|
+
# is the common case and stays readable; a list is what lets the arrows,
|
|
162
|
+
# WASD and a d-pad all drive one axis.
|
|
163
|
+
def axis_pairs(name, axis)
|
|
164
|
+
return nil if axis.nil?
|
|
165
|
+
|
|
166
|
+
pairs = axis.is_a?(Array) && axis.first.is_a?(Array) ? axis : [axis]
|
|
167
|
+
pairs.each { |pair| check_pair(name, pair) }
|
|
168
|
+
pairs.map { |pair| pair.dup.freeze }.freeze
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def check_pair(name, pair)
|
|
172
|
+
return if pair.is_a?(Array) && pair.size == 2
|
|
173
|
+
|
|
174
|
+
raise ArgumentError, "#{name}: axis must be [negative_id, positive_id], or a list of those"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|