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

@ -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);
}