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:
parent
7b7ffcd278
commit
07be994d97
84 changed files with 17822 additions and 1051 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue