feat: port retail magic lifecycle and retained spell UI

Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 10:55:22 +02:00
parent 7b7ffcd278
commit 07be994d97
84 changed files with 17822 additions and 1051 deletions

View file

@ -13,4 +13,5 @@ namespace AcDream.UI.Abstractions.Input;
public readonly record struct Binding(
KeyChord Chord,
InputAction Action,
ActivationType Activation = ActivationType.Press);
ActivationType Activation = ActivationType.Press,
InputScope Scope = InputScope.Game);

View file

@ -36,6 +36,7 @@ public sealed class InputDispatcher
private readonly IMouseSource _mouse;
private KeyBindings _bindings;
private readonly Stack<InputScope> _scopes = new();
private InputScope? _combatScope;
private readonly HashSet<KeyChord> _heldHoldChords = new();
// Double-click detection. _lastMouseDownButton == null means no recent press.
@ -72,7 +73,33 @@ public sealed class InputDispatcher
}
/// <summary>Topmost scope on the stack — what the dispatcher looks up first.</summary>
public InputScope ActiveScope => _scopes.Peek();
public InputScope ActiveScope => _scopes.Peek() == InputScope.Game && _combatScope is { } combat
? combat
: _scopes.Peek();
/// <summary>Set the mode-dependent combat layer that shadows normal game chords.</summary>
public void SetCombatScope(InputScope? scope)
{
if (scope is not null && scope is not (
InputScope.MeleeCombat or InputScope.MissileCombat or InputScope.MagicCombat))
throw new ArgumentOutOfRangeException(nameof(scope));
if (_combatScope == scope) return;
ReleaseHeldHoldBindings();
_combatScope = scope;
}
private Binding? FindActive(KeyChord chord, ActivationType activation)
{
foreach (InputScope scope in _scopes)
{
if (scope == InputScope.Game && _combatScope is { } combat
&& _bindings.Find(chord, activation, combat) is { } combatBinding)
return combatBinding;
if (_bindings.Find(chord, activation, scope) is { } binding)
return binding;
}
return null;
}
/// <summary>True iff a <see cref="BeginCapture"/> is in progress.</summary>
public bool IsCapturing => _captureCallback is not null;
@ -115,15 +142,18 @@ public sealed class InputDispatcher
/// </summary>
public void SetBindings(KeyBindings bindings)
{
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_heldHoldChords.Clear();
ArgumentNullException.ThrowIfNull(bindings);
ReleaseHeldHoldBindings();
_bindings = bindings;
}
/// <summary>
/// Per-frame "is this action's chord currently held" query. Walks every
/// binding for the given action; returns true if any of them has its
/// chord currently held in the underlying keyboard/mouse state AND the
/// modifier mask matches.
/// modifier mask matches. Scope precedence is resolved through the same
/// <see cref="FindActive"/> path as transition dispatch, so a combat binding
/// on a chord shadows the normal Game action during held polling too.
///
/// <para>
/// Used by per-frame movement polling (<c>MovementInput.Forward</c>
@ -149,11 +179,28 @@ public sealed class InputDispatcher
if (_mouse.WantCaptureKeyboard) return false;
foreach (var b in _bindings.ForAction(action))
{
if (IsChordHeld(b.Chord)) return true;
if (!IsChordHeld(b.Chord)) continue;
Binding? active = FindActiveHeld(b.Chord, b.Activation);
if (active?.Action == action) return true;
}
return false;
}
private Binding? FindActiveHeld(KeyChord candidate, ActivationType activation)
{
// IsChordHeld deliberately lets a bare movement chord coexist with the
// retail Shift-to-walk modifier. Resolve an explicitly bound current
// modifier chord first so a higher combat scope can still shadow it;
// only fall back to the authored bare chord when Shift has no binding.
var actual = candidate with { Modifiers = _keyboard.CurrentModifiers };
Binding? active = FindActive(actual, activation);
if (active is not null) return active;
return candidate.Modifiers == ModifierMask.None
&& _keyboard.CurrentModifiers == ModifierMask.Shift
? FindActive(candidate, activation)
: null;
}
/// <summary>True iff the given chord's primary key is currently down on
/// the appropriate device AND the keyboard's current modifier mask
/// matches the chord's required modifier mask. Match semantics:
@ -208,7 +255,11 @@ public sealed class InputDispatcher
};
/// <summary>Push a scope onto the active stack. Top wins.</summary>
public void PushScope(InputScope scope) => _scopes.Push(scope);
public void PushScope(InputScope scope)
{
ReleaseHeldHoldBindings();
_scopes.Push(scope);
}
/// <summary>Pop the topmost scope. <paramref name="expected"/> is the
/// scope the caller believes is on top; mismatch throws to catch
@ -218,9 +269,22 @@ public sealed class InputDispatcher
if (_scopes.Peek() != expected)
throw new InvalidOperationException(
$"PopScope expected {expected} but top is {_scopes.Peek()}");
ReleaseHeldHoldBindings();
_scopes.Pop();
}
private void ReleaseHeldHoldBindings()
{
if (_heldHoldChords.Count == 0) return;
var releases = new List<Binding>(_heldHoldChords.Count);
foreach (KeyChord chord in _heldHoldChords)
if (FindActive(chord, ActivationType.Hold) is { } binding)
releases.Add(binding);
_heldHoldChords.Clear();
foreach (Binding binding in releases)
Fired?.Invoke(binding.Action, ActivationType.Release);
}
/// <summary>
/// Per-frame tick. Re-fires <see cref="ActivationType.Hold"/>
/// activations for every chord that's currently held. Call once per
@ -238,7 +302,12 @@ public sealed class InputDispatcher
for (int i = 0; i < snapshot.Length; i++)
{
var chord = snapshot[i];
var hold = _bindings.Find(chord, ActivationType.Hold);
// A preceding Hold callback may have changed scope or bindings.
// Those transitions synchronously release and clear every held
// chord; never dispatch a stale snapshot entry afterward.
if (!_heldHoldChords.Contains(chord))
continue;
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
Fired?.Invoke(hold.Value.Action, ActivationType.Hold);
}
@ -273,10 +342,10 @@ public sealed class InputDispatcher
if (_mouse.WantCaptureKeyboard) return;
var chord = new KeyChord(key, mods, Device: 0);
var press = _bindings.Find(chord, ActivationType.Press);
var press = FindActive(chord, ActivationType.Press);
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
var hold = _bindings.Find(chord, ActivationType.Hold);
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
{
// Emit a Press transition so subscribers can latch state, then
@ -305,7 +374,7 @@ public sealed class InputDispatcher
// mid-press.
var chord = new KeyChord(key, mods, Device: 0);
var release = _bindings.Find(chord, ActivationType.Release);
var release = FindActive(chord, ActivationType.Release);
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
// Any matching Hold binding gets a Release transition. Walk the
@ -320,7 +389,7 @@ public sealed class InputDispatcher
foreach (var held in toRemove)
{
_heldHoldChords.Remove(held);
var hold = _bindings.Find(held, ActivationType.Hold);
var hold = FindActive(held, ActivationType.Hold);
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
}
}
@ -330,10 +399,10 @@ public sealed class InputDispatcher
if (_mouse.WantCaptureMouse) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var press = _bindings.Find(chord, ActivationType.Press);
var press = FindActive(chord, ActivationType.Press);
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
var hold = _bindings.Find(chord, ActivationType.Hold);
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
{
Fired?.Invoke(hold.Value.Action, ActivationType.Press);
@ -348,7 +417,7 @@ public sealed class InputDispatcher
if (_lastMouseDownButton == button
&& nowMs - _lastMouseDownTickMs <= DoubleClickThresholdMs)
{
var dbl = _bindings.Find(chord, ActivationType.DoubleClick);
var dbl = FindActive(chord, ActivationType.DoubleClick);
if (dbl is not null) Fired?.Invoke(dbl.Value.Action, ActivationType.DoubleClick);
_lastMouseDownButton = null; // consumed; require fresh pair for next
}
@ -363,7 +432,7 @@ public sealed class InputDispatcher
{
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var release = _bindings.Find(chord, ActivationType.Release);
var release = FindActive(chord, ActivationType.Release);
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
var keyForLookup = MouseButtonToKey(button);
@ -376,7 +445,7 @@ public sealed class InputDispatcher
foreach (var held in toRemove)
{
_heldHoldChords.Remove(held);
var hold = _bindings.Find(held, ActivationType.Hold);
var hold = FindActive(held, ActivationType.Hold);
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
}
}

View file

@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
/// </summary>
public sealed class KeyBindings
{
private const int CurrentSchemaVersion = 3;
private const int CurrentSchemaVersion = 4;
private readonly List<Binding> _bindings = new();
@ -47,11 +47,22 @@ public sealed class KeyBindings
/// activation type matches the requested phase. Null if none.
/// </summary>
public Binding? Find(KeyChord chord, ActivationType activation)
{
for (int i = 0; i < _bindings.Count; i++)
{
Binding binding = _bindings[i];
if (binding.Chord == chord && binding.Activation == activation)
return binding;
}
return null;
}
public Binding? Find(KeyChord chord, ActivationType activation, InputScope scope)
{
for (int i = 0; i < _bindings.Count; i++)
{
var b = _bindings[i];
if (b.Chord == chord && b.Activation == activation) return b;
if (b.Chord == chord && b.Activation == activation && b.Scope == scope) return b;
}
return null;
}
@ -242,37 +253,37 @@ public sealed class KeyBindings
// ── Combat (mode-dependent — dormant in K, lights up in Phase L) ──
b.Add(new(new KeyChord(Key.GraveAccent, ModifierMask.None), InputAction.CombatToggleCombat));
// Melee mode (active when MeleeCombat scope pushed).
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower, Scope: InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower, Scope: InputScope.MeleeCombat));
// Retail HandleCombatAction consumes key-down AND key-up for attack
// height actions: down starts charging, up ends the request. Hold is
// our binding representation for that transition pair.
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold, InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold, InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold, InputScope.MeleeCombat));
// Missile + Magic + Spell-tab — same chords; resolved by scope at
// runtime per InputDispatcher's stack lookup. Add the bindings;
// subscribers arrive in Phase L when CombatState.CurrentMode is
// wired.
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatCastCurrentSpell));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatNextSpell));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.Ctrl), InputAction.CombatFirstSpellTab));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.Ctrl), InputAction.CombatLastSpellTab));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.Ctrl), InputAction.CombatFirstSpell));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.Ctrl), InputAction.CombatLastSpell));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatCastCurrentSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatNextSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.Ctrl), InputAction.CombatFirstSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.Ctrl), InputAction.CombatLastSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.Ctrl), InputAction.CombatFirstSpell, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.Ctrl), InputAction.CombatLastSpell, Scope: InputScope.MagicCombat));
for (int i = 1; i <= 9; i++)
{
var k = (Key)((int)Key.Number0 + i);
var action = (InputAction)((int)InputAction.UseSpellSlot_1 + i - 1);
b.Add(new(new KeyChord(k, ModifierMask.None), action));
b.Add(new(new KeyChord(k, ModifierMask.None), action, Scope: InputScope.MagicCombat));
}
// ── Emotes ──────────────────────────────────────────────
@ -424,7 +435,17 @@ public sealed class KeyBindings
var chord = new KeyChord(silkKey, mods, device);
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
activation = MigrateCombatAttackActivation(version, action, activation);
loaded.Add(new(chord, action, activation));
InputScope scope = defaults.ForAction(action)
.Select(binding => binding.Scope)
.DefaultIfEmpty(InputScope.Game)
.First();
if (bindingEl.TryGetProperty("scope", out var scopeEl)
&& scopeEl.ValueKind == JsonValueKind.String
&& Enum.TryParse(scopeEl.GetString(), out InputScope parsedScope))
{
scope = parsedScope;
}
loaded.Add(new(chord, action, activation, scope));
}
}
}
@ -480,6 +501,8 @@ public sealed class KeyBindings
entry["device"] = (int)binding.Chord.Device;
if (binding.Activation != ActivationType.Press)
entry["activation"] = binding.Activation.ToString();
if (binding.Scope != InputScope.Game)
entry["scope"] = binding.Scope.ToString();
list.Add(entry);
}

View file

@ -253,7 +253,10 @@ public sealed class SettingsVM
// captured chord + same activation type, but on a DIFFERENT
// action. (Same-action bindings are fine — that's already in
// _draft for this action and gets removed when we apply.)
var existing = _draft.Find(chord, RebindOriginal!.Value.Activation);
var existing = _draft.Find(
chord,
RebindOriginal!.Value.Activation,
RebindOriginal.Value.Scope);
if (existing is not null && existing.Value.Action != RebindInProgress!.Value)
{
PendingConflict = new ConflictPrompt(
@ -293,7 +296,11 @@ public sealed class SettingsVM
private void ApplyRebind(KeyChord chord)
{
_draft.Remove(RebindOriginal!.Value);
_draft.Add(new Binding(chord, RebindInProgress!.Value, RebindOriginal.Value.Activation));
_draft.Add(new Binding(
chord,
RebindInProgress!.Value,
RebindOriginal.Value.Activation,
RebindOriginal.Value.Scope));
RebindInProgress = null;
RebindOriginal = null;
}