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
@@ -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
- def draw(renderer)
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
- # Drawing splits into two z bands so actors can sit between them: the below band
15
- # (ground, same-level detail) at GROUND_Z and the above band (canopies, roofs) at
16
- # OVERLAY_Z. Actors draw at a z in between (the renderer sorts by z, so the
17
- # draw-call order doesn't matter). The camera is centred by the scene before any
18
- # drawing.
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 also owns the map's **animation clock**. Nothing below reads a wall clock —
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
- GROUND_Z = 0
27
- OVERLAY_Z = 20
26
+ attr_reader :tilemap_id, :elapsed
28
27
 
29
- def initialize(map:, tilemap_id:, camera:)
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
- def draw(renderer, width, height, fps)
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
- right_x = width - PAD
67
- top_y = height - PAD - (line_h * 3)
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
- draw_line(renderer, FPS_LABEL, fps, right_x, top_y)
70
- draw_line(renderer, OBJ_LABEL, allocated, right_x, top_y + line_h)
71
- draw_line(renderer, DELTA_LABEL, delta, right_x, top_y + (line_h * 2))
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, z: Z, color: COLOR)
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, z: Z, color: COLOR)
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 an InputBackend through a binding map and produces an Actions snapshot.
6
- # The map is `{ action_name => { axis: %i[neg pos] } | { button: %i[ids] } }`;
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
- # Pure logic: the backend is duck-typed — the whole interface is
11
- # `down?(physical_id)` — so a test passes a fake and a game passes
12
- # `RGame::Core::Input`.
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
- attr_accessor :map
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
- # One reusable snapshot: there's exactly one input state per tick, so we
19
- # mutate these in place each poll instead of allocating (after the first
20
- # poll warms the hash keys, steady-state polling allocates nothing).
21
- # `@prev_held` carries last frame's button state so Actions can answer edge
22
- # queries (pressed?/released?).
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 once the keys exist).
51
+ # (in-place copy: no allocation, the keys already exist).
32
52
  @held.each { |name, down| @prev_held[name] = down }
33
53
 
34
- @map.each do |name, binding|
35
- if (axis = binding[:axis])
36
- neg, pos = axis
37
- @axes[name] = (backend.down?(pos) ? 1.0 : 0.0) - (backend.down?(neg) ? 1.0 : 0.0)
38
- end
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 in
7
- # tests with a fake).
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 held
10
- # state, so a one-shot action (menu confirm, jump) fires exactly once per press
11
- # rather than every frame it's held.
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 place
14
- # each poll, so the snapshot stays a single reused, allocation-free object.
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, false)
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, false) && !@prev_held.fetch(name, false)
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, false) && @prev_held.fetch(name, false)
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 if unbound/neutral.
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, 0.0)
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