diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 4d008f1..e2d0c55 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -272,8 +272,16 @@ public sealed class PlayerMovementController } else if (input.Backward) { - forwardCmd = MotionCommand.WalkBackward; - forwardCmdSpeed = 1.0f; + forwardCmd = MotionCommand.WalkBackward; + // 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 { @@ -307,16 +315,30 @@ public sealed class PlayerMovementController float localY = 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) localY = stateVel.Y; 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) - localX = MotionInterpreter.SidestepAnimSpeed; + localX = MotionInterpreter.SidestepAnimSpeed * runMul; else if (input.StrafeLeft) - localX = -MotionInterpreter.SidestepAnimSpeed; + localX = -MotionInterpreter.SidestepAnimSpeed * runMul; _body.set_local_velocity(new Vector3(localX, localY, savedWorldVz)); } diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 77e5524..ca4dcab 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -885,14 +885,7 @@ public sealed class GameWindow : IDisposable // auto-entry if the user opts out of player mode before // it fires, so the chase camera doesn't snap on top of // the fly camera mid-inspection. - _debugVm.ToggleFlyMode = () => - { - // 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(); - }; + _debugVm.ToggleFlyMode = ToggleFlyOrChase; _debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm); _panelHost.Register(_debugPanel); @@ -4348,10 +4341,7 @@ public sealed class GameWindow : IDisposable string flyLabel = _cameraController.IsFlyMode ? "Exit Free-Fly Mode" : "Enter Free-Fly Mode"; if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F")) - { - _playerModeAutoEntry?.Cancel(); - _cameraController.ToggleFly(); - } + ToggleFlyOrChase(); } ImGuiNET.ImGui.EndMenu(); } @@ -5348,13 +5338,13 @@ public sealed class GameWindow : IDisposable break; case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode: - // K-fix2 (2026-04-26): manual fly toggle pre-empts the - // auto-entry trigger so the chase camera doesn't snap on - // top of the fly camera mid-inspection. Mirrors the - // DebugPanel "Toggle Free-Fly Mode" button + Camera menu - // entry. - _playerModeAutoEntry?.Cancel(); - _cameraController?.ToggleFly(); + // K-fix3 (2026-04-26): proper round-trip when player has + // an active chase camera. ToggleFly() only swaps + // Fly↔Orbit, so a user who flew out of player mode used + // to land in Holtburg-orbit on toggle-back. With a chase + // camera available, prefer Fly→Chase / Chase→Fly so the + // user round-trips back to the same player view. + ToggleFlyOrChase(); break; case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode: @@ -5457,6 +5447,38 @@ public sealed class GameWindow : IDisposable } } + /// + /// K-fix3 (2026-04-26): the right "toggle free-fly mode" routine + /// when a chase camera is in play. + /// 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: + /// + /// Chase → Fly: cancel auto-entry (user's choice wins) and + /// switch to fly camera while keeping _playerMode = true + + /// the chase camera alive so we can return. + /// Fly → Chase: when _playerMode is still true and the + /// chase camera survived, re-enter chase via + /// . + /// Otherwise (no chase available): the original Fly↔Orbit + /// toggle for offline / pre-login flows. + /// + /// + private void ToggleFlyOrChase() + { + if (_cameraController is null) return; + _playerModeAutoEntry?.Cancel(); + + if (_cameraController.IsFlyMode + && _playerMode + && _chaseCamera is not null) + { + _cameraController.EnterChaseMode(_chaseCamera); + return; + } + _cameraController.ToggleFly(); + } + /// /// K.2: shared "construct controller + chase camera + enter chase /// mode" body extracted from the on-enter branch of diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index f8d5ff8..590b9a9 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -142,8 +142,18 @@ public sealed class InputDispatcher /// True iff the given chord's primary key is currently down on /// the appropriate device AND the keyboard's current modifier mask - /// equals the chord's required modifier mask exactly. Modifiers must - /// match precisely — Ctrl+A held does NOT count Shift+Ctrl+A as held. + /// matches the chord's required modifier mask. Match semantics: + /// + /// If is , + /// 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. + /// If includes any non-Shift + /// modifier (or includes Shift explicitly), the match is exact — + /// Ctrl+A held does NOT count Shift+Ctrl+A as held. + /// private bool IsChordHeld(KeyChord chord) { if (chord.Device == 0) @@ -160,7 +170,14 @@ public sealed class InputDispatcher // Unknown device — never held. 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; } /// Inverse of : decode a chord diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs index 4ebedca..d5003bb 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs @@ -106,6 +106,47 @@ public class InputDispatcherIsActionHeldTests 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] public void IsActionHeld_does_not_check_WantCaptureMouse() {