fix(input): Phase K live-test fixes pt3 — fly→chase round-trip, Shift coexists, run-speed for backward + strafe
Four issues from the K-fix2 launch (2026-04-26 user report): 1. Can't return from free-fly to player view. CameraController.ToggleFly only swaps Fly↔Orbit, so a user who flew out of player mode landed in orbit (Holtburg) on toggle-back instead of the chase camera. Added ToggleFlyOrChase() helper that prefers Fly→Chase / Chase→Fly when _playerMode is true and a chase camera is available; falls back to the original Fly↔Orbit toggle for offline / pre-login flows. Wired into all three free-fly entry points: keyboard shortcut (Ctrl+Shift+F), Camera menu item, and DebugPanel button. 2. Shift while moving STOPS instead of dropping to walk. Root cause: InputDispatcher.IsChordHeld required _keyboard.CurrentModifiers to match chord.Modifiers EXACTLY. So with W bound as (W, None), holding W and then pressing Shift made CurrentModifiers=Shift mismatch chord (None) → IsActionHeld(MovementForward) returned false → Forward flag dropped → player stopped. Fixed by relaxing IsChordHeld: when chord.Modifiers is None, Shift is allowed to coexist (it's the retail walk-modifier). Other modifiers (Ctrl, Alt, Win) still mismatch strictly so Ctrl+W stays a distinct chord from W. +2 tests pinning the new permissive-Shift / strict-Ctrl semantics. 3. Backwards too slow when running. forwardCmdSpeed for the WalkBackward branch was hardcoded to 1.0; localY was hardcoded to -(WalkAnimSpeed * 0.65). Neither honored input.Run. With Run=true (default), backward now scales by runRate (~2.4×) so X = "run backwards" matches the forward run pace × the 0.65 backward animation cycle ratio. 4. Strafe too slow when running. localX for SideStepLeft / SideStepRight was hardcoded to ±SidestepAnimSpeed regardless of Run. Same fix: when Run is held, scale by runRate so strafe at default speed matches the run-forward pace. Tests: 1220 → 1222 (the two new IsChordHeld tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6481169cb9
commit
785dd92378
4 changed files with 130 additions and 28 deletions
|
|
@ -272,8 +272,16 @@ public sealed class PlayerMovementController
|
||||||
}
|
}
|
||||||
else if (input.Backward)
|
else if (input.Backward)
|
||||||
{
|
{
|
||||||
forwardCmd = MotionCommand.WalkBackward;
|
forwardCmd = MotionCommand.WalkBackward;
|
||||||
forwardCmdSpeed = 1.0f;
|
// K-fix3 (2026-04-26): backward also honors Run. Without
|
||||||
|
// this, holding X with Run=true (default) still produced
|
||||||
|
// walk-tier backward speed because forwardCmdSpeed was
|
||||||
|
// hardcoded to 1.0. Now scale by runRate the same way
|
||||||
|
// RunForward does.
|
||||||
|
if (input.Run && _weenie.InqRunRate(out float runRateBack))
|
||||||
|
forwardCmdSpeed = runRateBack;
|
||||||
|
else
|
||||||
|
forwardCmdSpeed = 1.0f;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -307,16 +315,30 @@ public sealed class PlayerMovementController
|
||||||
float localY = 0f;
|
float localY = 0f;
|
||||||
float localX = 0f;
|
float localX = 0f;
|
||||||
|
|
||||||
|
// K-fix3 (2026-04-26): unified run-multiplier for backward
|
||||||
|
// + strafe. Forward already scales correctly because it uses
|
||||||
|
// stateVel.Y (which the motion state machine fed runRate
|
||||||
|
// into via DoMotion). Backward + strafe bypass the state
|
||||||
|
// machine and hardcoded speed; previously they capped at
|
||||||
|
// walk speed regardless of Run, which made the ~2.4×
|
||||||
|
// forward-vs-back/strafe ratio feel wrong. Now both scale
|
||||||
|
// with the same runRate the forward branch uses.
|
||||||
|
float runMul = 1.0f;
|
||||||
|
if (input.Run && _weenie.InqRunRate(out float vrr))
|
||||||
|
runMul = vrr;
|
||||||
|
|
||||||
if (input.Forward)
|
if (input.Forward)
|
||||||
localY = stateVel.Y;
|
localY = stateVel.Y;
|
||||||
else if (input.Backward)
|
else if (input.Backward)
|
||||||
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f);
|
localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * runMul);
|
||||||
|
|
||||||
// Full-speed strafe to match retail sidestep pace.
|
// Strafe scales with the same runMul so sidestep matches
|
||||||
|
// the forward pace at run speed (retail uses speed=1.0 for
|
||||||
|
// SideStep + the same hold-key-driven run/walk multiplier).
|
||||||
if (input.StrafeRight)
|
if (input.StrafeRight)
|
||||||
localX = MotionInterpreter.SidestepAnimSpeed;
|
localX = MotionInterpreter.SidestepAnimSpeed * runMul;
|
||||||
else if (input.StrafeLeft)
|
else if (input.StrafeLeft)
|
||||||
localX = -MotionInterpreter.SidestepAnimSpeed;
|
localX = -MotionInterpreter.SidestepAnimSpeed * runMul;
|
||||||
|
|
||||||
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
|
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -885,14 +885,7 @@ public sealed class GameWindow : IDisposable
|
||||||
// auto-entry if the user opts out of player mode before
|
// auto-entry if the user opts out of player mode before
|
||||||
// it fires, so the chase camera doesn't snap on top of
|
// it fires, so the chase camera doesn't snap on top of
|
||||||
// the fly camera mid-inspection.
|
// the fly camera mid-inspection.
|
||||||
_debugVm.ToggleFlyMode = () =>
|
_debugVm.ToggleFlyMode = ToggleFlyOrChase;
|
||||||
{
|
|
||||||
// K.2: manual fly toggle pre-empts the auto-entry
|
|
||||||
// trigger (user's choice wins). Cancel is no-op when
|
|
||||||
// not yet armed.
|
|
||||||
_playerModeAutoEntry?.Cancel();
|
|
||||||
_cameraController?.ToggleFly();
|
|
||||||
};
|
|
||||||
_debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
|
_debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
|
||||||
_panelHost.Register(_debugPanel);
|
_panelHost.Register(_debugPanel);
|
||||||
|
|
||||||
|
|
@ -4348,10 +4341,7 @@ public sealed class GameWindow : IDisposable
|
||||||
string flyLabel = _cameraController.IsFlyMode
|
string flyLabel = _cameraController.IsFlyMode
|
||||||
? "Exit Free-Fly Mode" : "Enter Free-Fly Mode";
|
? "Exit Free-Fly Mode" : "Enter Free-Fly Mode";
|
||||||
if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F"))
|
if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F"))
|
||||||
{
|
ToggleFlyOrChase();
|
||||||
_playerModeAutoEntry?.Cancel();
|
|
||||||
_cameraController.ToggleFly();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ImGuiNET.ImGui.EndMenu();
|
ImGuiNET.ImGui.EndMenu();
|
||||||
}
|
}
|
||||||
|
|
@ -5348,13 +5338,13 @@ public sealed class GameWindow : IDisposable
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode:
|
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode:
|
||||||
// K-fix2 (2026-04-26): manual fly toggle pre-empts the
|
// K-fix3 (2026-04-26): proper round-trip when player has
|
||||||
// auto-entry trigger so the chase camera doesn't snap on
|
// an active chase camera. ToggleFly() only swaps
|
||||||
// top of the fly camera mid-inspection. Mirrors the
|
// Fly↔Orbit, so a user who flew out of player mode used
|
||||||
// DebugPanel "Toggle Free-Fly Mode" button + Camera menu
|
// to land in Holtburg-orbit on toggle-back. With a chase
|
||||||
// entry.
|
// camera available, prefer Fly→Chase / Chase→Fly so the
|
||||||
_playerModeAutoEntry?.Cancel();
|
// user round-trips back to the same player view.
|
||||||
_cameraController?.ToggleFly();
|
ToggleFlyOrChase();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode:
|
case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode:
|
||||||
|
|
@ -5457,6 +5447,38 @@ public sealed class GameWindow : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// K-fix3 (2026-04-26): the right "toggle free-fly mode" routine
|
||||||
|
/// when a chase camera is in play. <see cref="CameraController.ToggleFly"/>
|
||||||
|
/// only knows Fly↔Orbit and would strand a player-mode user in the
|
||||||
|
/// orbit camera (Holtburg view) when they exit fly. This wrapper
|
||||||
|
/// gives the round-trip the user actually wants:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Chase → Fly: cancel auto-entry (user's choice wins) and
|
||||||
|
/// switch to fly camera while keeping <c>_playerMode = true</c> +
|
||||||
|
/// the chase camera alive so we can return.</item>
|
||||||
|
/// <item>Fly → Chase: when <c>_playerMode</c> is still true and the
|
||||||
|
/// chase camera survived, re-enter chase via
|
||||||
|
/// <see cref="CameraController.EnterChaseMode"/>.</item>
|
||||||
|
/// <item>Otherwise (no chase available): the original Fly↔Orbit
|
||||||
|
/// toggle for offline / pre-login flows.</item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
private void ToggleFlyOrChase()
|
||||||
|
{
|
||||||
|
if (_cameraController is null) return;
|
||||||
|
_playerModeAutoEntry?.Cancel();
|
||||||
|
|
||||||
|
if (_cameraController.IsFlyMode
|
||||||
|
&& _playerMode
|
||||||
|
&& _chaseCamera is not null)
|
||||||
|
{
|
||||||
|
_cameraController.EnterChaseMode(_chaseCamera);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_cameraController.ToggleFly();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// K.2: shared "construct controller + chase camera + enter chase
|
/// K.2: shared "construct controller + chase camera + enter chase
|
||||||
/// mode" body extracted from the on-enter branch of
|
/// mode" body extracted from the on-enter branch of
|
||||||
|
|
|
||||||
|
|
@ -142,8 +142,18 @@ public sealed class InputDispatcher
|
||||||
|
|
||||||
/// <summary>True iff the given chord's primary key is currently down on
|
/// <summary>True iff the given chord's primary key is currently down on
|
||||||
/// the appropriate device AND the keyboard's current modifier mask
|
/// the appropriate device AND the keyboard's current modifier mask
|
||||||
/// equals the chord's required modifier mask exactly. Modifiers must
|
/// matches the chord's required modifier mask. Match semantics:
|
||||||
/// match precisely — Ctrl+A held does NOT count Shift+Ctrl+A as held.</summary>
|
/// <list type="bullet">
|
||||||
|
/// <item>If <see cref="KeyChord.Modifiers"/> is <see cref="ModifierMask.None"/>,
|
||||||
|
/// any Shift state is allowed — Shift is the retail walk-modifier and
|
||||||
|
/// must coexist with movement chords (W held + Shift pressed = run
|
||||||
|
/// gracefully drops to walk, not "stop"). Other modifiers
|
||||||
|
/// (Ctrl, Alt, Win) still mismatch strictly so Ctrl+W stays a
|
||||||
|
/// distinct chord.</item>
|
||||||
|
/// <item>If <see cref="KeyChord.Modifiers"/> includes any non-Shift
|
||||||
|
/// modifier (or includes Shift explicitly), the match is exact —
|
||||||
|
/// Ctrl+A held does NOT count Shift+Ctrl+A as held.</item>
|
||||||
|
/// </list></summary>
|
||||||
private bool IsChordHeld(KeyChord chord)
|
private bool IsChordHeld(KeyChord chord)
|
||||||
{
|
{
|
||||||
if (chord.Device == 0)
|
if (chord.Device == 0)
|
||||||
|
|
@ -160,7 +170,14 @@ public sealed class InputDispatcher
|
||||||
// Unknown device — never held.
|
// Unknown device — never held.
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return _keyboard.CurrentModifiers == chord.Modifiers;
|
var current = _keyboard.CurrentModifiers;
|
||||||
|
if (chord.Modifiers == ModifierMask.None)
|
||||||
|
{
|
||||||
|
// K-fix3 (2026-04-26): permissive Shift handling for bare-key
|
||||||
|
// chords. See the XML doc above for rationale.
|
||||||
|
current &= ~ModifierMask.Shift;
|
||||||
|
}
|
||||||
|
return current == chord.Modifiers;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Inverse of <see cref="MouseButtonToKey"/>: decode a chord
|
/// <summary>Inverse of <see cref="MouseButtonToKey"/>: decode a chord
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,47 @@ public class InputDispatcherIsActionHeldTests
|
||||||
Assert.False(dispatcher.IsActionHeld(InputAction.None));
|
Assert.False(dispatcher.IsActionHeld(InputAction.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsActionHeld_None_chord_remains_held_when_user_adds_Shift()
|
||||||
|
{
|
||||||
|
// K-fix3 (2026-04-26): Shift is the walk modifier in retail AC.
|
||||||
|
// The user holding W (default = run) and then pressing Shift
|
||||||
|
// should drop them to walk speed, NOT stop forward motion. Prior
|
||||||
|
// to this fix, IsChordHeld required CurrentModifiers to match
|
||||||
|
// chord.Modifiers EXACTLY — so (W, None) failed to match while
|
||||||
|
// CurrentModifiers=Shift, and the player stopped on Shift-press.
|
||||||
|
// Now: when chord requires no modifiers, Shift is allowed to
|
||||||
|
// coexist (other modifiers — Ctrl, Alt, Win — still mismatch).
|
||||||
|
var (dispatcher, kb, _, bindings) = Build();
|
||||||
|
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
||||||
|
|
||||||
|
kb.EmitKeyDown(Key.W, ModifierMask.None);
|
||||||
|
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
|
||||||
|
|
||||||
|
// User now holds Shift while still holding W. CurrentModifiers
|
||||||
|
// becomes Shift; W is still physically down.
|
||||||
|
kb.CurrentModifiers = ModifierMask.Shift;
|
||||||
|
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsActionHeld_None_chord_does_not_fire_when_user_adds_Ctrl()
|
||||||
|
{
|
||||||
|
// Counterpart to the Shift test above: Ctrl is NOT a movement
|
||||||
|
// modifier, so Ctrl+W should be a different chord. Without an
|
||||||
|
// explicit (W, Ctrl) binding the action stays inactive — that's
|
||||||
|
// what makes Ctrl+F* / Ctrl+1-9 / etc. distinct from the bare
|
||||||
|
// F* / 1-9 chords.
|
||||||
|
var (dispatcher, kb, _, bindings) = Build();
|
||||||
|
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
||||||
|
|
||||||
|
kb.EmitKeyDown(Key.W, ModifierMask.None);
|
||||||
|
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
|
||||||
|
|
||||||
|
kb.CurrentModifiers = ModifierMask.Ctrl;
|
||||||
|
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void IsActionHeld_does_not_check_WantCaptureMouse()
|
public void IsActionHeld_does_not_check_WantCaptureMouse()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue