Game/InputState.cs
namespace BlockParty;
/// <summary>
/// Per-frame input snapshot mapping sbox actions onto the original game's directional
/// model (the original read a single analog stick with IsPositive / JustBecame edges).
///
/// Held booleans are refreshed every rendered frame by <see cref="Sample"/>. Edge ("just
/// pressed") flags are LATCHED: set on a rising edge and only cleared by
/// <see cref="ClearEdges"/>, which <see cref="GameManager"/> calls after a fixed sim step
/// actually runs. This buffers presses across frames that run zero sim steps (display
/// refresh > 60Hz), so jumps/wall-jumps are never dropped.
///
/// Mapping: the dedicated Jump action ("Jump": Space / gamepad A) RESOLVES each frame to the screen
/// direction away from the controlled player's effective floor — or away from the charged surface
/// while a charge jump winds, making it the wind-up's cancel key (<see cref="JumpDirectionResolver"/>,
/// default Up) — so it jumps/floats in reverse gravity, flips the Flipper off the ceiling, and jumps
/// the Shifter off any adopted surface. Resolution happens HERE, before the byte is recorded, so
/// replays carry the resolved direction and the sim/replay format never see the raw button.
///
/// Directional ACTIONS (keyboard WASD/arrows AND gamepad d-pad — one merged bind per direction)
/// contribute their direction fully, held AND edge, so directional jumping (up-key normally,
/// down-key when reversed, the away-key for the Shifter) works identically on both devices. The
/// ANALOG STICK is read separately (<see cref="Input.AnalogMove"/> — the engine never routes it into
/// the actions) and merged as a virtual d-pad with press/release hysteresis. Toward the jump
/// direction the stick contributes the HELD bit like any key (aiming, floating, winding Spring's
/// ceiling/wall charges) but never the EDGE — the only thing that fires jumps — and a deliberate
/// deflection that way (past a drift floor) also vetoes a coinciding ACTION edge as a
/// Steam-Input-translated stick press (see the veto comment in <see cref="Sample"/>): the stick can
/// never jump, even when a Steam config translates it into d-pad/arrow presses upstream. Deliberately no
/// <see cref="Input.UsingController"/> gating anywhere — that flag flips false on any key/mouse
/// press and stick up/left deflections never set it, so it cannot be trusted frame-to-frame; its one
/// sanctioned use is as one-directional evidence inside <see cref="GamepadIsPrimary"/> (glyph display).
/// "AllDirections" (right mouse / gamepad Y) emits all four press edges once, without holding directions. "DoubleTap"
/// (left mouse / gamepad X) scripts a complete tap-release-tap into the stream over the next few sim
/// ticks, aimed by the analog stick or, when neutral, held directional actions (keyboard / d-pad).
/// With no aim, it uses the jump direction — see the DTAP fields. Confirm = "Confirm" (Space / gamepad A) OR
/// "Enter". Arrow-key and Enter actions are separate bindings (the engine allows only one key per
/// action); Enter is menu-only.
/// </summary>
public static class InputState
{
public static bool Left { get; private set; }
public static bool Right { get; private set; }
public static bool Up { get; private set; }
public static bool Down { get; private set; }
public static bool LeftJust { get; private set; }
public static bool RightJust { get; private set; }
public static bool UpJust { get; private set; }
public static bool DownJust { get; private set; }
// Menu navigation edges. These read the raw "Up"/"Down" actions (W/S, dpad) — distinct from
// the jump-merged gameplay <see cref="Up"/> — plus the dedicated Confirm/Back actions. Latched
// exactly like the gameplay edges so menu input survives frames that run zero sim steps.
public static bool NavUpJust { get; private set; }
public static bool NavDownJust { get; private set; }
public static bool ConfirmJust { get; private set; }
public static bool BackJust { get; private set; }
// Held (not edge) menu-nav state, refreshed every Sample and never cleared by ClearEdges. NavUp/
// NavDown feed the shared hold-to-repeat (see NavRepeater) so the title/options menus scroll while
// W/S is held; held Confirm exists for PopupDismissHeld (Enter isn't part of any gameplay held bit).
public static bool NavUp { get; private set; }
public static bool NavDown { get; private set; }
public static bool Confirm { get; private set; }
/// <summary>One-shot gesture for dismissing non-interactive prompts: Space/Enter/controller Confirm
/// or any WASD/arrow/controller direction. Includes raw menu Up so W/Up still dismisses when
/// directional jumping is disabled.</summary>
public static bool PopupDismissJust => ConfirmJust || LeftJust || RightJust || UpJust || DownJust
|| NavUpJust || NavDownJust;
/// <summary>Held counterpart of <see cref="PopupDismissJust"/>, for hold-to-fill prompt dismissal:
/// true while Space/Enter/controller Confirm or any WASD/arrow/controller direction is down.</summary>
public static bool PopupDismissHeld => Confirm || Left || Right || Up || Down || NavUp || NavDown;
/// <summary>Which device the player is actually PLAYING with, for DISPLAY only (input glyphs):
/// true = show gamepad glyphs. A sticky latch over gameplay evidence, because raw
/// <see cref="Input.UsingController"/> flips false on any key or MOUSE event — a pad player
/// clicking a HUD button with the mouse saw keyboard glyphs. Only that flag's TRUE state is
/// trustworthy (nothing but controller input sets it), so it counts as gamepad evidence, alongside
/// stick presses (which never set the engine flag at all); keyboard evidence is a rising action
/// edge while the flag is false — a d-pad press sets the flag on its own event, so such an edge
/// can only be a physical key, and a mouse click produces no action edge. Never read this in
/// gameplay logic — it exists so glyphs stop mis-flipping, not to gate behavior.</summary>
public static bool GamepadIsPrimary { get; private set; }
/// <summary>Resolves the screen direction the dedicated Jump action maps to this frame — away from
/// the controlled player's effective floor. Set once by <see cref="GameManager"/>; null or a None
/// result falls back to Up (menus, no active run).</summary>
public static System.Func<Direction> JumpDirectionResolver;
/// <summary>Resolves the screen direction whose held input sustains a winding charge jump (see
/// <see cref="Player.ChargeHoldScreenDirection"/>); None when nothing is charging. Set once by
/// <see cref="GameManager"/>.</summary>
public static System.Func<Direction> ChargeHoldDirectionResolver;
// Analog-stick digital state. A direction presses when the stick is deflected far enough overall
// (magnitude) AND points near enough that way (the direction's share of the magnitude — sector
// logic, so a diagonal holds BOTH its directions), each with press/release hysteresis so a stick
// ridden near a boundary can't machine-gun edges. Ratios rather than absolute per-axis components
// matter: Spring charging on a wall while aiming steeply up must keep the toward-wall hold — an
// absolute per-axis threshold dropped it once the horizontal component thinned out (released the
// charge mid-aim), and it also mis-read partially-deflected sticks. RATIO_RELEASE 0.25 keeps a
// held direction until the stick is within ~15° of perpendicular to it. Raw axes: LeftStickX +1 =
// right, LeftStickY +1 = down (SDL convention; the engine's deadzone preference has already
// zeroed small per-axis deflections before we see them).
private const float STICK_MAG_PRESS = 0.5f;
private const float STICK_MAG_RELEASE = 0.35f;
private const float STICK_RATIO_PRESS = 0.45f; // within ~63° of the direction axis to press
private const float STICK_RATIO_RELEASE = 0.25f; // held until ~75° off-axis
// While a charge jump winds, the hold-direction latch releases only when the stick points
// CLEARLY away (component below this share of the magnitude, >110° off-axis) or relaxes.
private const float CHARGE_HOLD_AWAY_RATIO = -0.35f;
// A deflection component must clear this floor before it can veto a jump-direction action edge
// (see the veto comment in Sample): above what stick drift leaks past the deadzone preference,
// below any practical translator click-zone. Drift heavier than this is the deadzone setting's
// job to absorb.
private const float STICK_VETO_MIN = 0.2f;
private static bool _stickLeft, _stickRight, _stickUp, _stickDown;
// "DoubleTap" (left mouse / gamepad X) script: writes a complete double tap into the directional
// stream over the next DTAP_LENGTH sim ticks — a released tap, a gap, then a one-tick second press.
// Both bindings share this timing. The second press lands on tick 3 (50ms after tick 0), inside every
// gesture window in the game (strictest: 0.1s first-tap release / 0.07s second-tap gap), yet
// still one tick slower than a keyboard press-release-press on consecutive ticks (second press
// on tick 2). One fixed shape
// for all abilities — InputState can't know which of a character's gestures the press is aimed
// at. The first tap has an edge but NO held bit, like a physical tap released between sim steps.
// This lets the detector observe its release immediately even if a recently pressed aim key has
// already started a first-tap timer. Holding the scripted first tap for two more ticks could let
// that older timer expire before the release, losing the gesture (e.g. click 5–6 ticks after D).
// While the script runs it OWNS its direction bits (other sources masked out): the RELEASE
// between taps must be visible in the recorded stream even though the player is still holding
// the stick that direction, or the gesture never leaves its first-tap phase.
private const int DTAP_SECOND_PRESS_TICK = 3;
private const int DTAP_SECOND_HOLD_TICKS = 1;
private const int DTAP_LENGTH = DTAP_SECOND_PRESS_TICK + DTAP_SECOND_HOLD_TICKS;
private static int _dtapTick = -1; // -1 idle, else the script's current sim-tick index
private static bool _dtapLeft, _dtapRight, _dtapUp, _dtapDown; // scripted direction(s); two = diagonal
// The real (non-script) composed held bits from the latest Sample, kept so ClearEdges can restore
// a direction the finished script owned without waiting for the next Sample.
private static bool _realLeft, _realRight, _realUp, _realDown;
private static bool _pActionLeft, _pActionRight, _pActionUp, _pActionDown;
private static bool _pStickLeft, _pStickRight, _pStickUp, _pStickDown;
private static bool _pJump, _pAll, _pDoubleTapButton;
private static bool _pNavUp, _pNavDown, _pConfirm, _pBack;
// Pending edges by source. A direction can have BOTH a physical and a shortcut press before
// the next sim step; cancelling the shortcut must preserve the physical contribution.
private static int _physicalEdges, _abilityEdges;
private static bool StickDirectionHeld( bool held, float amount, float magnitude )
{
if ( magnitude < (held ? STICK_MAG_RELEASE : STICK_MAG_PRESS) ) return false;
return amount / magnitude >= (held ? STICK_RATIO_RELEASE : STICK_RATIO_PRESS);
}
// --- charge_trace support: snapshot of the last live Sample's stick pipeline (see Player's
// charge_trace ConCmd, which flips this on). Read-only diagnostics; never affects the sim.
public static bool StickTraceEnabled;
private static string _stickTrace = "(no live sample yet)";
/// <summary>The stick pipeline as of the last live <see cref="Sample"/> — raw AnalogMove, the
/// decontaminated per-direction amounts, magnitude, hysteresis and charge-latch results, the
/// resolved charge-hold direction and the raw action bits. For charge_trace logging.</summary>
public static string DescribeStickForTrace() => _stickTrace;
private static bool DtapActive => _dtapTick >= 0;
private static bool DtapHeldAt( int tick ) => tick >= DTAP_SECOND_PRESS_TICK;
private static bool DtapEdgeAt( int tick ) => tick == 0 || tick == DTAP_SECOND_PRESS_TICK;
public static void Sample( bool allowAbilityShortcuts )
{
if ( !allowAbilityShortcuts ) SuppressAbilityShortcuts();
Direction jumpDirection = JumpDirectionResolver?.Invoke() ?? Direction.Up;
if ( jumpDirection == Direction.None ) jumpDirection = Direction.Up;
bool jump = Input.Down( "Jump" );
// "AllDirections" (right mouse / gamepad Y): a chord button for the all-four-directions gestures (Swapper
// portal swap, Rewind return, Slicer line clear), which a single d-pad thumb can't physically
// hit. A fresh press fires all four edges at once, without contributing any held directions —
// deliberately including directions already held from another source, so the gesture always
// registers — and, like any all-four mash, that includes the jump direction (the abilities
// consume the directional input on a successful trigger, which swallows the jump, same as
// keyboard). Edges stay latched until a sim step consumes them; holding the button cannot
// sustain movement or repeat the gesture. Merged before recording, so replays keep their
// original held/edge bits, including held directions from older recordings.
bool all = Input.Down( "AllDirections" );
bool allEdge = allowAbilityShortcuts && all && !_pAll;
// Analog stick, read via Input.AnalogMove — the engine recomputes it every frame from the raw
// axes with the user deadzone already applied and, unlike Input.GetAnalog, no UsingController
// gate: x = forward (stick up = +1), y = left (stick left = +1). CONTAMINATION: the engine also
// folds any action literally named "forward"/"backward"/"left"/"right" (case-insensitive) into
// AnalogMove, and this game's "Left"/"Right" binds match — a held A/D key or d-pad left/right
// adds ±1 to y. Subtract that exactly, leaving the pure stick (x needs nothing: no action here
// is named "forward"/"backward"). Without this, a d-pad/keyboard Left press read as a stick
// deflection and the jump-direction veto below ate the Shifter's away-key wall jumps.
float stickY = Input.AnalogMove.y
- (Input.Down( "Left" ) ? 1f : 0f)
+ (Input.Down( "Right" ) ? 1f : 0f);
float stickLeftAmount = stickY;
float stickRightAmount = -stickY;
float stickUpAmount = Input.AnalogMove.x;
float stickDownAmount = -Input.AnalogMove.x;
float stickMagnitude = System.MathF.Sqrt( stickY * stickY + Input.AnalogMove.x * Input.AnalogMove.x );
_stickLeft = StickDirectionHeld( _stickLeft, stickLeftAmount, stickMagnitude );
_stickRight = StickDirectionHeld( _stickRight, stickRightAmount, stickMagnitude );
_stickUp = StickDirectionHeld( _stickUp, stickUpAmount, stickMagnitude );
_stickDown = StickDirectionHeld( _stickDown, stickDownAmount, stickMagnitude );
// While a charge jump is winding, LATCH the stick's HELD bit toward the charge's hold
// direction: aiming sweeps the stick through angles where that direction's share of the
// deflection thins to nothing (pure vertical mid wall-charge — no threshold can survive a
// zero component), which read as "released the hold" and fired the charge early. As long as
// the stick stays deflected and isn't pointing clearly AWAY, the hold sustains; firing =
// relax the stick (or flick it opposite). Held bit only — edges below keep reading the
// natural per-direction state, so the latch can never fabricate a press.
bool stickHeldLeft = _stickLeft, stickHeldRight = _stickRight;
bool stickHeldUp = _stickUp, stickHeldDown = _stickDown;
Direction chargeHold = ChargeHoldDirectionResolver?.Invoke() ?? Direction.None;
if ( chargeHold != Direction.None && stickMagnitude >= STICK_MAG_RELEASE )
{
float chargeAmount = chargeHold switch
{
Direction.Left => stickLeftAmount,
Direction.Right => stickRightAmount,
Direction.Up => stickUpAmount,
_ => stickDownAmount,
};
if ( chargeAmount / stickMagnitude > CHARGE_HOLD_AWAY_RATIO )
{
switch ( chargeHold )
{
case Direction.Left: stickHeldLeft = true; break;
case Direction.Right: stickHeldRight = true; break;
case Direction.Up: stickHeldUp = true; break;
case Direction.Down: stickHeldDown = true; break;
}
}
}
// Stick deflection toward the jump direction VETOES that direction's ACTION EDGE this frame.
// Upstream translators (Steam Input configs mapping the stick to d-pad or arrow keys) press
// the same merged actions as the real d-pad, indistinguishable game-side; since the same
// thumb can't deflect the stick and press the d-pad at once, an action press coinciding with
// a real deflection can only BE the stick. The veto reads the raw amount rather than the
// hysteresis bool (a translator can press below the hysteresis magnitude) but only above
// STICK_VETO_MIN: an idle pad drifting past the deadzone preference must not silently eat
// keyboard/d-pad jump presses — the same-thumb premise fails when nothing is touching the
// stick. It only needs to cover the action's RISING edge (edges are gated at press time
// below), so release-threshold ordering between the two can't leak an edge.
bool vetoLeft = jumpDirection == Direction.Left && stickLeftAmount >= STICK_VETO_MIN;
bool vetoRight = jumpDirection == Direction.Right && stickRightAmount >= STICK_VETO_MIN;
bool vetoUp = jumpDirection == Direction.Up && stickUpAmount >= STICK_VETO_MIN;
bool vetoDown = jumpDirection == Direction.Down && stickDownAmount >= STICK_VETO_MIN;
// Directional sources, split into HELD and EDGE shapes per direction. HELD is permissive:
// actions (keyboard keys + gamepad d-pad, merged per bind) and the stick all contribute in
// every direction — holding the stick toward the jump direction aims, floats and winds charge
// jumps (Spring's ceiling charge and wall-charge aiming read the held bit) exactly like a held
// keyboard key. Only the EDGE toward the jump direction is jump-proofed, because every jump
// (grounded/wall/air/mantle) fires off the edge: the stick never produces that edge, and the
// veto above swallows a coinciding ACTION edge as a translated stick press — so a stick push
// can never jump, while a real d-pad press (necessarily deflection-free) still does.
// EDGEs are PER-SOURCE rising edges with the veto / jump-direction filter applied at PRESS
// TIME ONLY. Filtering the held composite instead fabricated edges with zero physical input:
// a jump-direction change under a held stick (Reverse field entry, Flipper flip, Shifter
// adoption) flipped the stick term true, and a veto engaging then releasing around a held key
// re-fired its edge. Like the Jump button, a mid-hold direction change re-targets held bits
// but never fabricates an edge. An edge from one source while the OTHER source already held
// that direction last frame is the same physical press seen twice (a translator config emits
// the action AND the native stick crosses its threshold a few frames apart), so it is dropped
// — that is physical state, not a filter, so it can't fabricate anything either.
bool actionLeft = Input.Down( "Left" ) || Input.Down( "LeftArrow" );
bool actionRight = Input.Down( "Right" ) || Input.Down( "RightArrow" );
bool actionUp = Input.Down( "Up" ) || Input.Down( "UpArrow" );
bool actionDown = Input.Down( "Down" ) || Input.Down( "DownArrow" );
bool heldLeft = actionLeft || stickHeldLeft;
bool heldRight = actionRight || stickHeldRight;
bool heldUp = actionUp || stickHeldUp;
bool heldDown = actionDown || stickHeldDown;
bool edgeLeft = (actionLeft && !_pActionLeft && !_pStickLeft && !vetoLeft) || (_stickLeft && !_pStickLeft && !_pActionLeft && jumpDirection != Direction.Left);
bool edgeRight = (actionRight && !_pActionRight && !_pStickRight && !vetoRight) || (_stickRight && !_pStickRight && !_pActionRight && jumpDirection != Direction.Right);
bool edgeUp = (actionUp && !_pActionUp && !_pStickUp && !vetoUp) || (_stickUp && !_pStickUp && !_pActionUp && jumpDirection != Direction.Up);
bool edgeDown = (actionDown && !_pActionDown && !_pStickDown && !vetoDown) || (_stickDown && !_pStickDown && !_pActionDown && jumpDirection != Direction.Down);
if ( StickTraceEnabled )
{
_stickTrace = $"analog=({Input.AnalogMove.x:0.00},{Input.AnalogMove.y:0.00})"
+ $" amt(L={stickLeftAmount:0.00} R={stickRightAmount:0.00} U={stickUpAmount:0.00} D={stickDownAmount:0.00}) mag={stickMagnitude:0.00}"
+ $" nat(L={(_stickLeft ? 1 : 0)} R={(_stickRight ? 1 : 0)} U={(_stickUp ? 1 : 0)} D={(_stickDown ? 1 : 0)})"
+ $" latch(L={(stickHeldLeft ? 1 : 0)} R={(stickHeldRight ? 1 : 0)} U={(stickHeldUp ? 1 : 0)} D={(stickHeldDown ? 1 : 0)})"
+ $" chargeHold={chargeHold} jumpDir={jumpDirection}"
+ $" act(L={(actionLeft ? 1 : 0)} R={(actionRight ? 1 : 0)} U={(actionUp ? 1 : 0)} D={(actionDown ? 1 : 0)}) jumpBtn={(jump ? 1 : 0)}"
+ $" usingController={Input.UsingController}";
}
// "DoubleTap" (left mouse / gamepad X): start the shared script on a fresh press while idle.
// Keep analog-stick aim first; with a neutral stick use held directional actions so keyboard
// + mouse (and d-pad) can aim cardinal or diagonal taps too. Opposing actions cancel per axis.
// With no aim, fall back to the jump direction (Up normally, Down reversed, away from a wall
// for the Shifter). Snapshot the aim once so moving during the script cannot split its taps.
bool doubleTapButton = Input.Down( "DoubleTap" );
if ( allowAbilityShortcuts && doubleTapButton && !_pDoubleTapButton && !DtapActive )
{
_dtapLeft = _stickLeft; _dtapRight = _stickRight; _dtapUp = _stickUp; _dtapDown = _stickDown;
if ( !(_dtapLeft || _dtapRight || _dtapUp || _dtapDown) )
{
_dtapLeft = actionLeft && !actionRight;
_dtapRight = actionRight && !actionLeft;
_dtapUp = actionUp && !actionDown;
_dtapDown = actionDown && !actionUp;
}
if ( !(_dtapLeft || _dtapRight || _dtapUp || _dtapDown) )
{
_dtapLeft = jumpDirection == Direction.Left;
_dtapRight = jumpDirection == Direction.Right;
_dtapUp = jumpDirection == Direction.Up;
_dtapDown = jumpDirection == Direction.Down;
}
_dtapTick = 0;
}
_pDoubleTapButton = doubleTapButton;
// Held = directional OR the Jump action steered into its resolved direction (so holding Jump
// floats in every gravity frame). Latch rising edges (do NOT clear here — ClearEdges() does
// that once consumed) with SEPARATE per-source histories: holding a directional key must not
// hide a fresh Jump press, and vice versa. Jump's edge fires only on the button's own physical
// rising edge, into the direction current at press time — a jump direction change mid-hold
// (Flipper invert flips the frame under a held A) re-targets the held bit but never fabricates
// an edge, or the flip would instantly re-trigger. A running double-tap script OWNS its
// direction(s): their held state and edges come from the script alone, so the tap-release-tap
// shape stays intact no matter what the player holds.
bool jumpEdge = jump && !_pJump;
_realLeft = heldLeft || (jump && jumpDirection == Direction.Left);
_realRight = heldRight || (jump && jumpDirection == Direction.Right);
_realUp = heldUp || (jump && jumpDirection == Direction.Up);
_realDown = heldDown || (jump && jumpDirection == Direction.Down);
bool ownLeft = DtapActive && _dtapLeft;
bool ownRight = DtapActive && _dtapRight;
bool ownUp = DtapActive && _dtapUp;
bool ownDown = DtapActive && _dtapDown;
bool scriptHeld = DtapActive && DtapHeldAt( _dtapTick );
bool scriptEdge = DtapActive && DtapEdgeAt( _dtapTick );
if ( ownLeft ? scriptEdge : allEdge ) _abilityEdges |= 1;
if ( ownRight ? scriptEdge : allEdge ) _abilityEdges |= 2;
if ( ownUp ? scriptEdge : allEdge ) _abilityEdges |= 4;
if ( ownDown ? scriptEdge : allEdge ) _abilityEdges |= 8;
if ( !ownLeft && (edgeLeft || (jumpEdge && jumpDirection == Direction.Left)) ) _physicalEdges |= 1;
if ( !ownRight && (edgeRight || (jumpEdge && jumpDirection == Direction.Right)) ) _physicalEdges |= 2;
if ( !ownUp && (edgeUp || (jumpEdge && jumpDirection == Direction.Up)) ) _physicalEdges |= 4;
if ( !ownDown && (edgeDown || (jumpEdge && jumpDirection == Direction.Down)) ) _physicalEdges |= 8;
int pendingEdges = _physicalEdges | _abilityEdges;
if ( (pendingEdges & 1) != 0 ) LeftJust = true;
if ( (pendingEdges & 2) != 0 ) RightJust = true;
if ( (pendingEdges & 4) != 0 ) UpJust = true;
if ( (pendingEdges & 8) != 0 ) DownJust = true;
Left = ownLeft ? scriptHeld : _realLeft;
Right = ownRight ? scriptHeld : _realRight;
Up = ownUp ? scriptHeld : _realUp;
Down = ownDown ? scriptHeld : _realDown;
// Device evidence for GamepadIsPrimary, captured before the per-source histories update:
// unfiltered rising edges (a vetoed/jump-direction press is still device evidence).
bool stickPressEdge = (_stickLeft && !_pStickLeft) || (_stickRight && !_pStickRight)
|| (_stickUp && !_pStickUp) || (_stickDown && !_pStickDown);
bool keyEdge = (actionLeft && !_pActionLeft) || (actionRight && !_pActionRight)
|| (actionUp && !_pActionUp) || (actionDown && !_pActionDown) || jumpEdge;
_pActionLeft = actionLeft; _pActionRight = actionRight; _pActionUp = actionUp; _pActionDown = actionDown;
_pStickLeft = _stickLeft; _pStickRight = _stickRight; _pStickUp = _stickUp; _pStickDown = _stickDown;
_pJump = jump; _pAll = all;
// Menu edges. The stick joins nav (unlike its jump-direction gameplay exclusion) so menus
// scroll with it like they do with W/S and the d-pad.
bool navUp = Input.Down( "Up" ) || Input.Down( "UpArrow" ) || _stickUp;
bool navDown = Input.Down( "Down" ) || Input.Down( "DownArrow" ) || _stickDown;
bool confirm = Input.Down( "Confirm" ) || Input.Down( "Enter" );
bool back = Input.Down( "Back" );
if ( navUp && !_pNavUp ) NavUpJust = true;
if ( navDown && !_pNavDown ) NavDownJust = true;
if ( confirm && !_pConfirm ) ConfirmJust = true;
if ( back && !_pBack ) BackJust = true;
// Latch the glyph device (see GamepadIsPrimary). Confirm/Back join the key evidence — like the
// merged directional actions, a rising edge with UsingController false can only be a keyboard
// key (a gamepad press sets the flag on its own event; a mouse click makes no edge).
keyEdge |= (confirm && !_pConfirm) || (back && !_pBack);
if ( Input.UsingController || stickPressEdge )
GamepadIsPrimary = true;
else if ( keyEdge )
GamepadIsPrimary = false;
NavUp = navUp; NavDown = navDown; Confirm = confirm;
_pNavUp = navUp; _pNavDown = navDown; _pConfirm = confirm; _pBack = back;
}
/// <summary>Cancel pending ability shortcuts and baseline their buttons while gameplay is inactive.
/// Also called on frames that skip Sample (engine pause / recorded replay), so a held menu click
/// cannot become a new ability press on resume. Does not modify recorded input when no shortcut is pending.</summary>
public static void SuppressAbilityShortcuts()
{
if ( (_abilityEdges & 1) != 0 ) LeftJust = (_physicalEdges & 1) != 0;
if ( (_abilityEdges & 2) != 0 ) RightJust = (_physicalEdges & 2) != 0;
if ( (_abilityEdges & 4) != 0 ) UpJust = (_physicalEdges & 4) != 0;
if ( (_abilityEdges & 8) != 0 ) DownJust = (_physicalEdges & 8) != 0;
_abilityEdges = 0;
if ( DtapActive )
{
if ( _dtapLeft ) Left = _realLeft;
if ( _dtapRight ) Right = _realRight;
if ( _dtapUp ) Up = _realUp;
if ( _dtapDown ) Down = _realDown;
_dtapTick = -1;
}
_pAll = Input.Down( "AllDirections" );
_pDoubleTapButton = Input.Down( "DoubleTap" );
}
/// <summary>Clear latched edge flags after a sim step has consumed them. Also advances the
/// double-tap script one tick — this is the per-consumed-step hook — and re-applies its owned
/// direction bits immediately, so when several sim steps run inside one rendered frame each step
/// still sees the script's correct held/edge state without waiting for the next Sample.</summary>
public static void ClearEdges()
{
LeftJust = RightJust = UpJust = DownJust = false;
NavUpJust = NavDownJust = ConfirmJust = BackJust = false;
_physicalEdges = _abilityEdges = 0;
if ( !DtapActive ) return;
_dtapTick++;
if ( _dtapTick >= DTAP_LENGTH )
{
// Script over: hand the owned directions back to the real input composed at the last Sample.
_dtapTick = -1;
if ( _dtapLeft ) Left = _realLeft;
if ( _dtapRight ) Right = _realRight;
if ( _dtapUp ) Up = _realUp;
if ( _dtapDown ) Down = _realDown;
return;
}
bool held = DtapHeldAt( _dtapTick );
bool edge = DtapEdgeAt( _dtapTick );
if ( _dtapLeft ) { Left = held; if ( edge ) { LeftJust = true; _abilityEdges |= 1; } }
if ( _dtapRight ) { Right = held; if ( edge ) { RightJust = true; _abilityEdges |= 2; } }
if ( _dtapUp ) { Up = held; if ( edge ) { UpJust = true; _abilityEdges |= 4; } }
if ( _dtapDown ) { Down = held; if ( edge ) { DownJust = true; _abilityEdges |= 8; } }
}
/// <summary>Swallow this frame's whole menu input on behalf of a modal overlay: the latched edges
/// AND the HELD bits, because menu navigation is hold-to-repeat (see <see cref="NavRepeater"/>) —
/// clearing only the edges would still let the screen behind the overlay walk its selection while
/// a direction is held. <see cref="Sample"/> re-reads everything next frame, so this suppresses
/// exactly one frame. Menus only; the gameplay sim must never have its input taken like this.</summary>
public static void ConsumeMenuInput()
{
SuppressAbilityShortcuts();
ClearEdges();
Left = Right = Up = Down = false;
NavUp = NavDown = Confirm = false;
}
/// <summary>Re-baseline edge detection to the current physical input WITHOUT emitting any "just"
/// edges. Used when control is handed to the live player mid-replay (hijack): recorded playback never
/// updates the previous-state trackers, so the first <see cref="Sample"/> would otherwise treat a key
/// already held at the handover as a fresh press (e.g. holding Space to retry would auto-jump).</summary>
public static void Prime()
{
Sample( allowAbilityShortcuts: false ); // baseline physical input without starting a shortcut
ClearEdges(); // ...then drop the edges so nothing held at handover counts as a fresh press
}
// --- replay recording / playback ----------------------------------------------------
// The gameplay sim only reads the four directional held bits + their four "just" edges, so the
// whole input a step consumes packs into one byte. The edges are stored explicitly (not derived
// from held transitions) because a sub-frame tap can leave a "just" edge latched while its held
// bit reads false at the consumed step. Menu (Nav/Confirm/Back) edges are not part of the
// gameplay sim and are intentionally excluded.
/// <summary>Pack the gameplay-relevant input the current step consumes into one byte (for recording).</summary>
public static byte PackByte()
{
byte b = 0;
if ( Left ) b |= 1 << 0;
if ( Right ) b |= 1 << 1;
if ( Up ) b |= 1 << 2;
if ( Down ) b |= 1 << 3;
if ( LeftJust ) b |= 1 << 4;
if ( RightJust ) b |= 1 << 5;
if ( UpJust ) b |= 1 << 6;
if ( DownJust ) b |= 1 << 7;
return b;
}
/// <summary>Overwrite the snapshot with a recorded step's input (for replay), bypassing
/// <see cref="Sample"/>. Menu edges are cleared so a replay can't drive menus.</summary>
public static void ApplyReplayFrame( byte b )
{
// Recorded edges are authoritative, with no pending live-source contributions.
_physicalEdges = _abilityEdges = 0;
Left = (b & (1 << 0)) != 0;
Right = (b & (1 << 1)) != 0;
Up = (b & (1 << 2)) != 0;
Down = (b & (1 << 3)) != 0;
LeftJust = (b & (1 << 4)) != 0;
RightJust = (b & (1 << 5)) != 0;
UpJust = (b & (1 << 6)) != 0;
DownJust = (b & (1 << 7)) != 0;
NavUpJust = NavDownJust = ConfirmJust = BackJust = false;
}
}