main's P6 row reads 'Looping is blocked on HANDOFF Q10' while main's OWN HANDOFF.md line 39 marks Q10 answered -- the row was stale, not a decision, so the resolution keeps the answered status rather than silently un-resolving it. P5 takes main's line verbatim: that is the human's gate call and not mine. P6 explicitly does NOT claim the gate -- the same play-test found the SFX mix wrong, and 'sound on the P5 gate' means the RIGHT sound.
248 lines
11 KiB
GDScript
248 lines
11 KiB
GDScript
class_name Gamepad
|
||
extends RefCounted
|
||
|
||
## The physical controller: the two buttons Godot does not bind, and the one
|
||
## input that is not an edge.
|
||
##
|
||
## 🔴 BOTH DEFECTS WERE REPORTED BY A HUMAN PLAYING THE PORT (2026-09-01), and
|
||
## neither could have been caught by the `--script` harness, because that harness
|
||
## sends `InputEventAction` — which bypasses the input map and is not an analog
|
||
## axis. The unattended P5 walk passed on every iteration while Ⓐ did nothing at
|
||
## all on a real pad. **A synthetic-input test asserts the code after the input
|
||
## map, never the input map itself.**
|
||
##
|
||
## ## 1. Godot 4.7.2 binds no joypad button to `ui_accept` or `ui_cancel`
|
||
##
|
||
## Measured on this exact build rather than remembered, because the answer has
|
||
## changed between Godot versions and the remembered one was wrong:
|
||
##
|
||
## ```
|
||
## ui_accept key:Enter, key:Kp Enter, key:Space <- no joypad at all
|
||
## ui_cancel key:Escape <- no joypad at all
|
||
## ui_up key:Up, JOYBTN:11, JOYAXIS:1- <- d-pad AND left stick
|
||
## ui_down key:Down, JOYBTN:12, JOYAXIS:1+
|
||
## ui_left key:Left, JOYBTN:13, JOYAXIS:0-
|
||
## ui_right key:Right, JOYBTN:14, JOYAXIS:0+
|
||
## ```
|
||
##
|
||
## That asymmetry is the whole bug report: navigation worked on the pad and Ⓐ/Ⓑ
|
||
## did nothing, which reads like a broken controller and is a complete input map
|
||
## for four actions out of six.
|
||
##
|
||
## The events are **added to** the built-in actions, never redefined. Declaring
|
||
## `ui_accept` in `project.godot` replaces the built-in wholesale, so the
|
||
## keyboard bindings would have to be restated there and would silently rot the
|
||
## next time Godot changes them.
|
||
##
|
||
## ## 2. A stick is not a button
|
||
##
|
||
## `ui_up`/`ui_down` are bound to **axis 1**, so the left stick navigates — which
|
||
## is correct, the real game accepts it too. But an axis emits a fresh
|
||
## `InputEventJoypadMotion` every time the value *changes*, and a real stick held
|
||
## at deflection jitters continuously. Every one of those events reports the
|
||
## action as pressed, so a held stick was one cursor step per jitter: the human's
|
||
## words were "moves the cursor too fast", and on a five-item menu it crosses
|
||
## faster than the eye follows.
|
||
##
|
||
## So the stick is **latched**: it fires once when it leaves the neutral zone and
|
||
## not again until it comes back. That makes it behave exactly like the d-pad,
|
||
## which needs no latch because a button already is an edge.
|
||
##
|
||
## ⚠️ ~~AUTHORED, NOT MEASURED — and deliberately the conservative half.~~
|
||
## 🔴 **THE GAME DOES REPEAT, and this paragraph predicted its own refutation.**
|
||
## It said: *"If the game does repeat, this is a difference a human will notice
|
||
## as 'I have to flick it again'."* On 2026-09-02 a human who has played both
|
||
## reported exactly that — *"holding only moves one item. In game it actually
|
||
## continues to move when holding up/down, just at a medium pace"*.
|
||
##
|
||
## So one-step-per-deflection is no longer the conservative reading; it is a
|
||
## known defect, and keeping it would be choosing a wrong behaviour over an
|
||
## approximate one. The repeat is implemented below. **Its RATE is authored and
|
||
## its FACT is not** — see `REPEAT_DELAY`.
|
||
|
||
## ✅ DECODED 2026-09-01, and it replaces an authored value.
|
||
##
|
||
## This was **0.5**, chosen as a *floor* rather than as a value: Godot's action
|
||
## deadzone for the `ui_*` actions is 0.50, so the latch must not arm below it —
|
||
## the action itself would not read as pressed and the step would be swallowed
|
||
## anyway, leaving the latch armed against a press that never happened. That
|
||
## reasoning still holds and 0.61 is comfortably above the floor.
|
||
##
|
||
## The game's own threshold is now measured: it **digitises the left stick to
|
||
## four direction bits at 61 % deflection**, so it never sees a velocity at all
|
||
## (`docs/re/input-button-numbering-is-remapped.md` and the corrected
|
||
## `input-pad-read-path.md`). Between 0.50 and 0.61 Godot reports `ui_down`
|
||
## pressed and the real game reports nothing; at 0.5 this port stepped there.
|
||
##
|
||
## 📌 The mechanism also corroborates the human's fix rather than merely
|
||
## agreeing with it: a control that digitises to bits cannot express a rate, so
|
||
## "one step per deflection" is what the hardware layer *can* produce, not a
|
||
## conservative guess that happened to look right.
|
||
##
|
||
## ⚠️ **The 0.11 gap to `RELEASE` stays AUTHORED.** Nothing measured says the
|
||
## game has hysteresis at all, let alone how wide. Only the arm threshold moved.
|
||
##
|
||
## 🔴 **This changes how the stick feels and a human chose the old number.**
|
||
## Asserted at the device level below and in `tools/port/verify-input`, but a
|
||
## feel-test is the real check: revert this one constant to 0.5 if 0.61 reads as
|
||
## a stick that needs pushing too far.
|
||
const ENTER := 0.61
|
||
|
||
## Release lower than it arms. Without the gap a stick resting near 0.5 chatters
|
||
## across the boundary and re-arms on noise, which is the original bug wearing a
|
||
## smaller number.
|
||
const RELEASE := 0.4
|
||
|
||
## ## 3. A held direction repeats
|
||
##
|
||
## 🔴 **THE FACT IS REPORTED, THE RATE IS AUTHORED. Do not read the second as
|
||
## carried by the first.** A human who has played both said the game repeats at
|
||
## *"a medium pace … slow enough to see which item is selected"* — that settles
|
||
## THAT it repeats and gives an order of magnitude, nothing more. Nobody has
|
||
## measured an interval off the running game, and `pad-repeat` stays open for the
|
||
## Decoder.
|
||
##
|
||
## 📌 **A constant interval is the right SHAPE, and that part is measured.** The
|
||
## game digitises the left stick to four direction bits at 61 % deflection
|
||
## (`ENTER` above), so it cannot see a deflection magnitude at all — a repeat it
|
||
## drives cannot be rate-by-how-far-you-push. That excludes the one alternative
|
||
## model, so only the constants are open.
|
||
##
|
||
## The delay exists so a deliberate single step never repeats by accident: a
|
||
## flick to move one item is held for well under 0.4 s.
|
||
##
|
||
## ⚠️ **These two numbers change how the menu feels and only a human can judge
|
||
## them** — the same standing as `ENTER`'s 0.61. Too fast reads as a cursor that
|
||
## runs away; too slow reads as the defect this replaces.
|
||
const REPEAT_DELAY := 0.40
|
||
const REPEAT_INTERVAL := 0.20
|
||
|
||
## Only the left stick. The triggers are axes too, and latching them here would
|
||
## silently swallow input the port does not read yet but might.
|
||
const STICK := [JOY_AXIS_LEFT_X, JOY_AXIS_LEFT_Y]
|
||
|
||
var _latched: Dictionary = {}
|
||
var _repeat_direction := 0
|
||
var _repeat_clock := 0.0
|
||
|
||
|
||
## Add the joypad buttons the built-in map omits. Returns a human-readable line,
|
||
## or "" if nothing needed adding — so a future Godot that ships these bindings
|
||
## makes this quietly stop reporting rather than double-binding.
|
||
static func bind_missing() -> String:
|
||
var added := PackedStringArray()
|
||
for pair in [["ui_accept", JOY_BUTTON_A, "Ⓐ"], ["ui_cancel", JOY_BUTTON_B, "Ⓑ"]]:
|
||
var action: String = pair[0]
|
||
var button: int = pair[1]
|
||
if not InputMap.has_action(action):
|
||
# Not a warning we can act on, but silence here would present as the
|
||
# original bug and send the next person back to the controller.
|
||
push_warning("gamepad: no such action %s -- pad button unbound" % action)
|
||
continue
|
||
if _has_button(action, button):
|
||
continue
|
||
var ev := InputEventJoypadButton.new()
|
||
ev.button_index = button
|
||
InputMap.action_add_event(action, ev)
|
||
added.append("%s -> %s" % [pair[2], action])
|
||
if added.is_empty():
|
||
return ""
|
||
return "pad: bound %s (Godot 4.7.2 binds no joypad button to either)" % \
|
||
", ".join(added)
|
||
|
||
|
||
static func _has_button(action: String, button: int) -> bool:
|
||
for e in InputMap.action_get_events(action):
|
||
if e is InputEventJoypadButton and e.button_index == button:
|
||
return true
|
||
return false
|
||
|
||
|
||
## True if this event should be acted on. Everything that is already an edge —
|
||
## keys, d-pad, mouse — passes straight through; only the analog stick is
|
||
## latched, and only on the two axes the navigation actions are bound to.
|
||
func accepts(event: InputEvent) -> bool:
|
||
if not (event is InputEventJoypadMotion):
|
||
return true
|
||
var axis: int = event.axis
|
||
if not STICK.has(axis):
|
||
return true
|
||
var value: float = event.axis_value
|
||
var direction := 0
|
||
if value >= ENTER:
|
||
direction = 1
|
||
elif value <= -ENTER:
|
||
direction = -1
|
||
|
||
if direction == 0:
|
||
# Neutral enough to re-arm? The gap between RELEASE and ENTER is the
|
||
# hysteresis band: inside it the stick is neither a new press nor
|
||
# released, so the latch is left exactly as it was.
|
||
if absf(value) <= RELEASE:
|
||
_latched[axis] = 0
|
||
return false
|
||
if int(_latched.get(axis, 0)) == direction:
|
||
return false # still held in the same direction: not a new press
|
||
_latched[axis] = direction
|
||
return true
|
||
|
||
|
||
## Which way a direction is being HELD right now, as -1 (up), 0 or +1 (down).
|
||
##
|
||
## 🔴 **Polled at the DEVICE, never through `Input.is_action_pressed`.** `ui_up`
|
||
## and `ui_down` are bound to the stick axis at Godot's 0.50 action deadzone,
|
||
## while this port steps at the game's measured 0.61. Polling the action would
|
||
## repeat throughout the 0.50–0.61 band — the exact band `ENTER` exists to
|
||
## exclude — so the repeat would contradict the threshold on the same stick.
|
||
## That is the input-map lesson again: assert the device, not the layer above it.
|
||
func held_direction() -> int:
|
||
# The stick, from the latch `accepts()` already maintains, so the repeat and
|
||
# the first step read one state and cannot disagree about hysteresis.
|
||
var stick := int(_latched.get(JOY_AXIS_LEFT_Y, 0))
|
||
if stick != 0:
|
||
return stick
|
||
for device in Input.get_connected_joypads():
|
||
if Input.is_joy_button_pressed(device, JOY_BUTTON_DPAD_UP):
|
||
return -1
|
||
if Input.is_joy_button_pressed(device, JOY_BUTTON_DPAD_DOWN):
|
||
return 1
|
||
if Input.is_key_pressed(KEY_UP):
|
||
return -1
|
||
if Input.is_key_pressed(KEY_DOWN):
|
||
return 1
|
||
return 0
|
||
|
||
|
||
## One repeat step, or 0. Call once per frame with the frame's delta.
|
||
##
|
||
## The FIRST step is not this function's: it comes from the event edge in
|
||
## `_unhandled_input`, and the clock below starts from that same frame, so a held
|
||
## direction gives one step now and the next only after `REPEAT_DELAY`. A change
|
||
## of direction restarts the delay rather than inheriting the old cadence.
|
||
func repeat_due(delta: float) -> int:
|
||
var direction := held_direction()
|
||
if direction == 0 or direction != _repeat_direction:
|
||
_repeat_direction = direction
|
||
_repeat_clock = 0.0
|
||
return 0
|
||
_repeat_clock += delta
|
||
if _repeat_clock < REPEAT_DELAY:
|
||
return 0
|
||
# Subtract rather than reset, so the cadence cannot drift with the frame rate
|
||
# -- at 140 fps and at 30 fps the same number of steps happen per second.
|
||
_repeat_clock -= REPEAT_INTERVAL
|
||
return direction
|
||
|
||
|
||
## The pads Godot can see, for the startup line. A run where the human believes
|
||
## a controller is connected and Godot disagrees should say so on its own,
|
||
## rather than presenting as unresponsive buttons.
|
||
static func report_devices() -> String:
|
||
var pads := Input.get_connected_joypads()
|
||
if pads.is_empty():
|
||
return "pad: none connected -- keyboard only (Enter/Space = Ⓐ, Escape = Ⓑ)"
|
||
var names := PackedStringArray()
|
||
for j in pads:
|
||
names.append("[%d] %s" % [j, Input.get_joy_name(j)])
|
||
return "pad: " + ", ".join(names)
|