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
|
|
@ -11,6 +11,10 @@ namespace AcDream.App.Rendering;
|
|||
|
||||
public sealed class GameWindow : IDisposable
|
||||
{
|
||||
private static double ClientTimerNow() =>
|
||||
System.Diagnostics.Stopwatch.GetTimestamp()
|
||||
/ (double)System.Diagnostics.Stopwatch.Frequency;
|
||||
|
||||
private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene);
|
||||
|
||||
private readonly AcDream.App.RuntimeOptions _options;
|
||||
|
|
@ -833,6 +837,7 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
|
||||
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
|
||||
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
|
||||
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
|
||||
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
|
||||
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
|
||||
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
|
||||
|
|
@ -1289,6 +1294,8 @@ public sealed class GameWindow : IDisposable
|
|||
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
|
||||
_kbSource, _mouseSource, _keyBindings);
|
||||
_inputDispatcher.Fired += OnInputAction;
|
||||
Combat.CombatModeChanged += SetInputCombatScope;
|
||||
SetInputCombatScope(Combat.CurrentMode);
|
||||
|
||||
// Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 /
|
||||
// 0x00458310): the callback submits a filtered horizontal
|
||||
|
|
@ -2085,11 +2092,8 @@ public sealed class GameWindow : IDisposable
|
|||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||||
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
|
||||
var spellComponentTable =
|
||||
// DBObj file id is 0x0E00000F. Retail's IsComponentPack
|
||||
// passes enum key 0x10000001 to DBObj::GetByEnum; that key is
|
||||
// NOT a portal.dat file id (using it here reads unrelated bytes).
|
||||
_dats!.Get<DatReaderWriter.DBObjs.SpellComponentTable>(0x0E00000Fu);
|
||||
AcDream.App.Spells.MagicCatalog magicCatalog =
|
||||
AcDream.App.Spells.MagicCatalog.Load(_dats!);
|
||||
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
|
||||
Objects,
|
||||
playerGuid: () => _playerServerGuid,
|
||||
|
|
@ -2110,7 +2114,7 @@ public sealed class GameWindow : IDisposable
|
|||
playerOnGround: GetDebugPlayerOnGround,
|
||||
inNonCombatMode: () => Combat.CurrentMode
|
||||
== AcDream.Core.Combat.CombatMode.NonCombat,
|
||||
isComponentPack: wcid => spellComponentTable?.Components.ContainsKey(wcid) == true,
|
||||
isComponentPack: magicCatalog.IsComponentPack,
|
||||
placeInBackpack: SendPickUp,
|
||||
sendSplitToWorld: (item, amount) =>
|
||||
_liveSession?.SendStackableSplitTo3D(item, amount),
|
||||
|
|
@ -2138,6 +2142,24 @@ public sealed class GameWindow : IDisposable
|
|||
sendRaiseVital: (statId, cost) => _liveSession?.SendRaiseVital(statId, cost),
|
||||
sendRaiseSkill: (statId, cost) => _liveSession?.SendRaiseSkill(statId, cost),
|
||||
sendTrainSkill: (statId, credits) => _liveSession?.SendTrainSkill(statId, credits));
|
||||
_magicRuntime = AcDream.App.Spells.MagicRuntime.Create(
|
||||
magicCatalog,
|
||||
SpellBook,
|
||||
Objects,
|
||||
selectedObject: () => _selection.SelectedObjectId,
|
||||
localPlayerId: () => _playerServerGuid,
|
||||
// SpellFormula::Randomize hashes the canonical account spelling
|
||||
// delivered by CharacterList.
|
||||
accountName: () => _liveSession?.Characters?.AccountName
|
||||
?? _options.LiveUser
|
||||
?? string.Empty,
|
||||
stopCompletely: PreparePlayerForAttackRequest,
|
||||
sendUntargeted: spellId => _liveSession?.SendCastUntargetedSpell(spellId),
|
||||
sendTargeted: (target, spellId) => _liveSession?.SendCastTargetedSpell(target, spellId),
|
||||
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
|
||||
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
|
||||
canSend: () => _liveSession is not null
|
||||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld);
|
||||
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
|
||||
|
||||
// Feed Silk input to the UiRoot tree so windows drag / close / select.
|
||||
|
|
@ -2228,6 +2250,31 @@ public sealed class GameWindow : IDisposable
|
|||
_combatAttackController,
|
||||
() => _persistedGameplay,
|
||||
SetRetailCombatGameplay),
|
||||
Magic: new AcDream.App.UI.MagicRuntimeBindings(
|
||||
SpellBook,
|
||||
_magicRuntime.Casting,
|
||||
Objects,
|
||||
() => _playerServerGuid,
|
||||
magicCatalog.Components,
|
||||
(type, icon, under, over, effects) =>
|
||||
iconComposer.GetIcon(type, icon, under, over, effects),
|
||||
(type, icon, under, over, effects) =>
|
||||
iconComposer.GetDragIcon(type, icon, under, over, effects),
|
||||
iconComposer.GetSpellIcon,
|
||||
iconComposer.GetSpellComponentIcon,
|
||||
_selection,
|
||||
magicCatalog.GetSpellLevel,
|
||||
guid => _selection.Select(
|
||||
guid, AcDream.Core.Selection.SelectionChangeSource.Inventory),
|
||||
UseItemByGuid,
|
||||
(tab, position, spellId) =>
|
||||
_liveSession?.SendAddSpellFavorite(spellId, position, tab),
|
||||
(tab, spellId) =>
|
||||
_liveSession?.SendRemoveSpellFavorite(spellId, tab),
|
||||
filters => _liveSession?.SendSpellbookFilter(filters),
|
||||
(componentId, amount) =>
|
||||
_liveSession?.SendSetDesiredComponentLevel(componentId, amount),
|
||||
ClientTimerNow),
|
||||
JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings(
|
||||
() => _playerController?.JumpCharge ?? default),
|
||||
Fps: new AcDream.App.UI.FpsRuntimeBindings(
|
||||
|
|
@ -2763,6 +2810,9 @@ public sealed class GameWindow : IDisposable
|
|||
// them down before dropping the canonical GUID/timestamp snapshots.
|
||||
_equippedChildRenderer?.Clear();
|
||||
Objects.Clear();
|
||||
SpellBook.Clear();
|
||||
_magicRuntime?.Reset();
|
||||
_itemInteractionController?.ClearBusy();
|
||||
_selection.Reset();
|
||||
_pendingPostArrivalAction = null;
|
||||
try
|
||||
|
|
@ -2932,7 +2982,8 @@ public sealed class GameWindow : IDisposable
|
|||
onCharacterOptions: (options1, _) =>
|
||||
_characterOptions1 =
|
||||
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
|
||||
options1);
|
||||
options1,
|
||||
clientTime: ClientTimerNow);
|
||||
|
||||
// Phase I.7: subscribe to CombatState events and emit
|
||||
// retail-faithful "You hit X for Y damage" chat lines into
|
||||
|
|
@ -12423,6 +12474,17 @@ public sealed class GameWindow : IDisposable
|
|||
/// <see cref="OnUpdate"/> via <see cref="InputDispatcher.IsActionHeld"/>;
|
||||
/// this method handles transitional Press/Release events only.
|
||||
/// </summary>
|
||||
private void SetInputCombatScope(AcDream.Core.Combat.CombatMode mode)
|
||||
{
|
||||
_inputDispatcher?.SetCombatScope(mode switch
|
||||
{
|
||||
AcDream.Core.Combat.CombatMode.Melee => AcDream.UI.Abstractions.Input.InputScope.MeleeCombat,
|
||||
AcDream.Core.Combat.CombatMode.Missile => AcDream.UI.Abstractions.Input.InputScope.MissileCombat,
|
||||
AcDream.Core.Combat.CombatMode.Magic => AcDream.UI.Abstractions.Input.InputScope.MagicCombat,
|
||||
_ => null,
|
||||
});
|
||||
}
|
||||
|
||||
private void OnInputAction(
|
||||
AcDream.UI.Abstractions.Input.InputAction action,
|
||||
AcDream.UI.Abstractions.Input.ActivationType activation)
|
||||
|
|
@ -14584,6 +14646,7 @@ public sealed class GameWindow : IDisposable
|
|||
_uiHost = null;
|
||||
_itemInteractionController?.Dispose();
|
||||
_itemInteractionController = null;
|
||||
_magicRuntime = null;
|
||||
|
||||
// Phase A.1: join the streamer worker thread before tearing down GL
|
||||
// state. The worker may still be processing a load job that references
|
||||
|
|
@ -14632,6 +14695,7 @@ public sealed class GameWindow : IDisposable
|
|||
_debugFont?.Dispose();
|
||||
_frameProfiler.Dispose(); // MP0: releases the GpuFrameTimer query ring
|
||||
_dats?.Dispose();
|
||||
Combat.CombatModeChanged -= SetInputCombatScope;
|
||||
_input?.Dispose();
|
||||
_gl?.Dispose();
|
||||
}
|
||||
|
|
|
|||
197
src/AcDream.App/Spells/MagicRuntime.cs
Normal file
197
src/AcDream.App/Spells/MagicRuntime.cs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Spells;
|
||||
|
||||
/// <summary>Immutable presentation metadata for one retail spell component WCID.</summary>
|
||||
public sealed record SpellComponentDescriptor(
|
||||
uint WeenieClassId,
|
||||
string Name,
|
||||
uint Category,
|
||||
uint IconId);
|
||||
|
||||
/// <summary>
|
||||
/// App-layer projection of retail's spell, component, and school-focus DAT tables.
|
||||
/// Keeping this catalog outside <c>GameWindow</c> makes the SCID/WCID boundary and
|
||||
/// rough spell-level calculation one independently testable ownership seam.
|
||||
/// </summary>
|
||||
public sealed class MagicCatalog
|
||||
{
|
||||
private const uint SpellTableDid = 0x0E00000Eu;
|
||||
private const uint SpellComponentTableDid = 0x0E00000Fu;
|
||||
private const uint ComponentIdMapDid = 0x27000002u;
|
||||
|
||||
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulaDefinitions;
|
||||
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
|
||||
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
|
||||
private readonly IReadOnlyDictionary<uint, int> _spellLevels;
|
||||
|
||||
private MagicCatalog(
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
|
||||
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulaDefinitions,
|
||||
IReadOnlyDictionary<uint, uint> wcidByScid,
|
||||
IReadOnlyDictionary<uint, uint> magicPackWcidBySchool,
|
||||
IReadOnlyDictionary<uint, int> spellLevels)
|
||||
{
|
||||
Components = components;
|
||||
_formulaDefinitions = formulaDefinitions;
|
||||
_wcidByScid = wcidByScid;
|
||||
_magicPackWcidBySchool = magicPackWcidBySchool;
|
||||
_spellLevels = spellLevels;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<uint, SpellComponentDescriptor> Components { get; }
|
||||
|
||||
public bool IsComponentPack(uint weenieClassId)
|
||||
=> Components.ContainsKey(weenieClassId);
|
||||
|
||||
public int GetSpellLevel(uint spellId)
|
||||
=> _spellLevels.TryGetValue(spellId, out int level) ? level : 0;
|
||||
|
||||
public SpellComponentRequirementService CreateRequirementService(
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<string> accountName)
|
||||
=> new(
|
||||
objects,
|
||||
playerGuid,
|
||||
accountName,
|
||||
_formulaDefinitions,
|
||||
_wcidByScid,
|
||||
_magicPackWcidBySchool);
|
||||
|
||||
public static MagicCatalog Load(DatCollection dats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dats);
|
||||
|
||||
var components = new Dictionary<uint, SpellComponentDescriptor>();
|
||||
var wcidByScid = new Dictionary<uint, uint>();
|
||||
SpellComponentTable? componentTable = dats.Get<SpellComponentTable>(SpellComponentTableDid);
|
||||
DualEnumIDMap? componentIds = dats.Get<DualEnumIDMap>(ComponentIdMapDid);
|
||||
if (componentTable is not null && componentIds is not null)
|
||||
{
|
||||
foreach (var pair in componentTable.Components)
|
||||
{
|
||||
if (!componentIds.ClientEnumToID.TryGetValue(pair.Key, out uint wcid))
|
||||
continue;
|
||||
|
||||
wcidByScid[pair.Key] = wcid;
|
||||
components[wcid] = new SpellComponentDescriptor(
|
||||
wcid,
|
||||
pair.Value.Name.Value,
|
||||
pair.Value.Category,
|
||||
pair.Value.Icon.DataId);
|
||||
}
|
||||
}
|
||||
|
||||
var formulaDefinitions = new Dictionary<uint, SpellFormulaDefinition>();
|
||||
var spellLevels = new Dictionary<uint, int>();
|
||||
DatReaderWriter.DBObjs.SpellTable? spellTable =
|
||||
dats.Get<DatReaderWriter.DBObjs.SpellTable>(SpellTableDid);
|
||||
if (spellTable is not null)
|
||||
{
|
||||
foreach (var pair in spellTable.Spells)
|
||||
{
|
||||
uint[] formula = pair.Value.Components.Take(8).ToArray();
|
||||
formulaDefinitions[pair.Key] = new SpellFormulaDefinition(
|
||||
pair.Value.FormulaVersion,
|
||||
formula,
|
||||
(uint)pair.Value.School);
|
||||
spellLevels[pair.Key] =
|
||||
RetailSpellFormula.InqSpellLevelByRoughHeuristic(formula);
|
||||
}
|
||||
}
|
||||
|
||||
var magicPackWcidBySchool = new Dictionary<uint, uint>();
|
||||
// Retail IsComponentPack resolves enum key 0x10000001 in category 0x28;
|
||||
// that key is not itself a portal.dat file id.
|
||||
uint magicPackMapDid = AcDream.App.UI.RetailDataIdResolver.Resolve(
|
||||
dats, 0x10000001u, 0x28u);
|
||||
if (magicPackMapDid != 0u
|
||||
&& dats.Portal.TryGet<EnumIDMap>(magicPackMapDid, out EnumIDMap? magicPackMap)
|
||||
&& magicPackMap is not null)
|
||||
{
|
||||
foreach (var pair in magicPackMap.ClientEnumToID)
|
||||
magicPackWcidBySchool[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
return new MagicCatalog(
|
||||
components,
|
||||
formulaDefinitions,
|
||||
wcidByScid,
|
||||
magicPackWcidBySchool,
|
||||
spellLevels);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// App-layer owner for the live retail magic request path. The server owns
|
||||
/// turning, motion, fizzle, mana/component consumption, effects, and results.
|
||||
/// </summary>
|
||||
public sealed class MagicRuntime
|
||||
{
|
||||
private MagicRuntime(MagicCatalog catalog, SpellCastingController casting)
|
||||
{
|
||||
Catalog = catalog;
|
||||
Casting = casting;
|
||||
}
|
||||
|
||||
public MagicCatalog Catalog { get; }
|
||||
public SpellCastingController Casting { get; }
|
||||
|
||||
public static MagicRuntime Create(
|
||||
MagicCatalog catalog,
|
||||
Spellbook spellbook,
|
||||
ClientObjectTable objects,
|
||||
Func<uint?> selectedObject,
|
||||
Func<uint> localPlayerId,
|
||||
Func<string> accountName,
|
||||
Action stopCompletely,
|
||||
Action<uint> sendUntargeted,
|
||||
Action<uint, uint> sendTargeted,
|
||||
Action<string> displayMessage,
|
||||
Action incrementBusy,
|
||||
Func<bool> canSend)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(objects);
|
||||
ArgumentNullException.ThrowIfNull(displayMessage);
|
||||
|
||||
SpellComponentRequirementService requirements = catalog.CreateRequirementService(
|
||||
objects, localPlayerId, accountName);
|
||||
bool TargetCompatible(uint target, SpellMetadata spell, bool showMessage)
|
||||
{
|
||||
if (objects.Get(target) is not { } targetObject)
|
||||
return false;
|
||||
|
||||
SpellTargetPolicyResult result = RetailSpellTargetPolicy.Evaluate(
|
||||
localPlayerId(), targetObject, spell);
|
||||
if (showMessage && !result.Allowed && result.Message is { } message)
|
||||
displayMessage(message);
|
||||
return result.Allowed;
|
||||
}
|
||||
|
||||
var casting = new SpellCastingController(
|
||||
spellbook,
|
||||
selectedObject,
|
||||
localPlayerId,
|
||||
stopCompletely,
|
||||
sendUntargeted,
|
||||
sendTargeted,
|
||||
displayMessage,
|
||||
targetCompatible: (target, spell) => TargetCompatible(target, spell, true),
|
||||
hasRequiredComponents: requirements.HasRequiredComponents,
|
||||
incrementBusy: incrementBusy,
|
||||
canSend: canSend,
|
||||
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
|
||||
return new MagicRuntime(catalog, casting);
|
||||
}
|
||||
|
||||
public void Reset() => Casting.Reset();
|
||||
}
|
||||
45
src/AcDream.App/Spells/RetailSpellTargetPolicy.cs
Normal file
45
src/AcDream.App/Spells/RetailSpellTargetPolicy.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.Spells;
|
||||
|
||||
public readonly record struct SpellTargetPolicyResult(bool Allowed, string? Message)
|
||||
{
|
||||
public static SpellTargetPolicyResult Accept { get; } = new(true, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure projection of ClientMagicSystem::ObjectCompatibleWithSpellTargetType
|
||||
/// (0x00567230). The caller owns target lookup and message presentation.
|
||||
/// </summary>
|
||||
public static class RetailSpellTargetPolicy
|
||||
{
|
||||
private const uint SpecialTargetMask = 0x00008107u;
|
||||
|
||||
public static SpellTargetPolicyResult Evaluate(
|
||||
uint localPlayerId,
|
||||
ClientObject target,
|
||||
SpellMetadata spell)
|
||||
{
|
||||
uint mask = spell.TargetMask;
|
||||
uint special = mask & SpecialTargetMask;
|
||||
if (target.ObjectId == localPlayerId && special == 0u)
|
||||
return new(false, "You cannot cast this spell upon yourself.");
|
||||
if (target.StackSize > 1)
|
||||
return new(false, "Cannot cast spell on a stack of items.");
|
||||
|
||||
if (((uint)target.Type & mask) == 0u && special == 0u)
|
||||
return new(false, $"This spell cannot be cast on {target.Name}.");
|
||||
|
||||
// Retail joins the ordinary type match and the 0x8107 special-mask
|
||||
// bypass before these two gates. IsPlayer is PWD bit 3; otherwise the
|
||||
// object must carry BF_ATTACKABLE. Pets are never legal spell targets.
|
||||
var flags = (PublicWeenieFlags)target.PublicWeenieBitfield.GetValueOrDefault();
|
||||
bool playerOrAttackable = (flags
|
||||
& (PublicWeenieFlags.Player | PublicWeenieFlags.Attackable)) != 0;
|
||||
if (!playerOrAttackable || target.PetOwnerId != 0u)
|
||||
return new(false, $"This spell cannot be cast on {target.Name}.");
|
||||
|
||||
return SpellTargetPolicyResult.Accept;
|
||||
}
|
||||
}
|
||||
157
src/AcDream.App/Spells/SpellCastingController.cs
Normal file
157
src/AcDream.App/Spells/SpellCastingController.cs
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
using System;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// App-layer owner for retail cast intent. It validates the client-owned
|
||||
/// selection rules, stops movement, and emits exactly one targeted or
|
||||
/// untargeted request. ACE owns every gameplay result after that boundary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Port of <c>ClientMagicSystem::CastSpell</c> (0x00568040) and
|
||||
/// <c>FreeHandsAndCastSpell</c> (0x00566EF0). This controller deliberately
|
||||
/// does not consume mana/components or start local effects.
|
||||
/// </remarks>
|
||||
public sealed class SpellCastingController
|
||||
{
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly Func<uint?> _selectedObject;
|
||||
private readonly Func<uint> _localPlayerId;
|
||||
private readonly Action _stopCompletely;
|
||||
private readonly Action<uint> _sendUntargeted;
|
||||
private readonly Action<uint, uint> _sendTargeted;
|
||||
private readonly Action<string> _displayMessage;
|
||||
private readonly Func<uint, SpellMetadata, bool> _targetCompatible;
|
||||
private readonly Func<uint, SpellMetadata, bool> _targetCompatibleSilent;
|
||||
private readonly Func<uint, bool> _hasRequiredComponents;
|
||||
private readonly Action _incrementBusy;
|
||||
private readonly Func<bool> _canSend;
|
||||
|
||||
public SpellCastingController(
|
||||
Spellbook spellbook,
|
||||
Func<uint?> selectedObject,
|
||||
Func<uint> localPlayerId,
|
||||
Action stopCompletely,
|
||||
Action<uint> sendUntargeted,
|
||||
Action<uint, uint> sendTargeted,
|
||||
Action<string> displayMessage,
|
||||
Func<uint, SpellMetadata, bool>? targetCompatible = null,
|
||||
Func<uint, bool>? hasRequiredComponents = null,
|
||||
Action? incrementBusy = null,
|
||||
Func<bool>? canSend = null,
|
||||
Func<uint, SpellMetadata, bool>? targetCompatibleSilent = null)
|
||||
{
|
||||
_spellbook = spellbook ?? throw new ArgumentNullException(nameof(spellbook));
|
||||
_selectedObject = selectedObject ?? throw new ArgumentNullException(nameof(selectedObject));
|
||||
_localPlayerId = localPlayerId ?? throw new ArgumentNullException(nameof(localPlayerId));
|
||||
_stopCompletely = stopCompletely ?? throw new ArgumentNullException(nameof(stopCompletely));
|
||||
_sendUntargeted = sendUntargeted ?? throw new ArgumentNullException(nameof(sendUntargeted));
|
||||
_sendTargeted = sendTargeted ?? throw new ArgumentNullException(nameof(sendTargeted));
|
||||
_displayMessage = displayMessage ?? throw new ArgumentNullException(nameof(displayMessage));
|
||||
_targetCompatible = targetCompatible ?? ((_, _) => true);
|
||||
_targetCompatibleSilent = targetCompatibleSilent ?? _targetCompatible;
|
||||
_hasRequiredComponents = hasRequiredComponents ?? (_ => true);
|
||||
_incrementBusy = incrementBusy ?? (() => { });
|
||||
_canSend = canSend ?? (() => true);
|
||||
}
|
||||
|
||||
public uint? LastRequestedSpellId { get; private set; }
|
||||
public uint? LastRequestedTargetId { get; private set; }
|
||||
|
||||
public bool IsTargetReady(uint spellId)
|
||||
{
|
||||
if (!_spellbook.Knows(spellId)
|
||||
|| !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
|
||||
return false;
|
||||
if (spell.IsSelfTargeted || spell.IsUntargeted || spell.TargetMask == 0u)
|
||||
return true;
|
||||
return _selectedObject() is uint target and not 0u
|
||||
&& _targetCompatibleSilent(target, spell);
|
||||
}
|
||||
|
||||
public CastRequestResult Cast(uint spellId)
|
||||
{
|
||||
if (!_spellbook.Knows(spellId) || !_spellbook.TryGetMetadata(spellId, out SpellMetadata spell))
|
||||
{
|
||||
_displayMessage("You do not know that spell.");
|
||||
return CastRequestResult.UnknownSpell;
|
||||
}
|
||||
|
||||
if (!_hasRequiredComponents(spellId))
|
||||
{
|
||||
_displayMessage("You do not have all of this spell's components.");
|
||||
return CastRequestResult.MissingComponents;
|
||||
}
|
||||
|
||||
uint? target;
|
||||
bool untargeted;
|
||||
if (spell.IsSelfTargeted)
|
||||
{
|
||||
uint playerId = _localPlayerId();
|
||||
target = playerId == 0 ? null : playerId;
|
||||
untargeted = target is null;
|
||||
}
|
||||
else if (spell.IsUntargeted || spell.TargetMask == 0)
|
||||
{
|
||||
target = null;
|
||||
untargeted = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
target = _selectedObject();
|
||||
untargeted = false;
|
||||
if (target is null or 0)
|
||||
{
|
||||
_displayMessage("You must select a suitable target.");
|
||||
return CastRequestResult.NoTarget;
|
||||
}
|
||||
if (!_targetCompatible(target.Value, spell))
|
||||
return CastRequestResult.IncompatibleTarget;
|
||||
}
|
||||
|
||||
if (!_canSend())
|
||||
{
|
||||
_displayMessage("You cannot cast a spell right now.");
|
||||
return CastRequestResult.Unavailable;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_stopCompletely();
|
||||
LastRequestedSpellId = spellId;
|
||||
LastRequestedTargetId = target;
|
||||
if (untargeted)
|
||||
_sendUntargeted(spellId);
|
||||
else
|
||||
_sendTargeted(target!.Value, spellId);
|
||||
// FreeHandsAndCastSpell @ 0x00566EF0 increments the shared UI
|
||||
// busy reference only after Event_Cast has been emitted. The
|
||||
// matching server UseDone event decrements it.
|
||||
_incrementBusy();
|
||||
}
|
||||
catch
|
||||
{
|
||||
LastRequestedSpellId = null;
|
||||
LastRequestedTargetId = null;
|
||||
throw;
|
||||
}
|
||||
return CastRequestResult.Sent;
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
LastRequestedSpellId = null;
|
||||
LastRequestedTargetId = null;
|
||||
}
|
||||
}
|
||||
|
||||
public enum CastRequestResult
|
||||
{
|
||||
Sent,
|
||||
UnknownSpell,
|
||||
NoTarget,
|
||||
IncompatibleTarget,
|
||||
MissingComponents,
|
||||
Unavailable,
|
||||
}
|
||||
121
src/AcDream.App/Spells/SpellComponentRequirementService.cs
Normal file
121
src/AcDream.App/Spells/SpellComponentRequirementService.cs
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.Spells;
|
||||
|
||||
public sealed record SpellFormulaDefinition(
|
||||
uint FormulaVersion,
|
||||
IReadOnlyList<uint> ComponentIds,
|
||||
uint School = 0u);
|
||||
|
||||
/// <summary>
|
||||
/// Client-side retail component preflight for ClientMagicSystem::CastSpell
|
||||
/// (0x00568040). Spell formula entries are SCIDs; carried inventory objects and
|
||||
/// SetDesiredComponent use WCIDs, so all comparisons cross the DAT enum map.
|
||||
/// </summary>
|
||||
public sealed class SpellComponentRequirementService
|
||||
{
|
||||
public const uint SpellComponentsRequiredProperty = 68u;
|
||||
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<string> _accountName;
|
||||
private readonly IReadOnlyDictionary<uint, SpellFormulaDefinition> _formulas;
|
||||
private readonly IReadOnlyDictionary<uint, uint> _wcidByScid;
|
||||
private readonly IReadOnlyDictionary<uint, uint> _magicPackWcidBySchool;
|
||||
|
||||
public SpellComponentRequirementService(
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<string> accountName,
|
||||
IReadOnlyDictionary<uint, SpellFormulaDefinition> formulas,
|
||||
IReadOnlyDictionary<uint, uint> wcidByScid,
|
||||
IReadOnlyDictionary<uint, uint>? magicPackWcidBySchool = null)
|
||||
{
|
||||
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
|
||||
_accountName = accountName ?? throw new ArgumentNullException(nameof(accountName));
|
||||
_formulas = formulas ?? throw new ArgumentNullException(nameof(formulas));
|
||||
_wcidByScid = wcidByScid ?? throw new ArgumentNullException(nameof(wcidByScid));
|
||||
_magicPackWcidBySchool = magicPackWcidBySchool
|
||||
?? new Dictionary<uint, uint>();
|
||||
}
|
||||
|
||||
public bool HasRequiredComponents(uint spellId)
|
||||
{
|
||||
uint playerGuid = _playerGuid();
|
||||
ClientObject? player = _objects.Get(playerGuid);
|
||||
if (player is not null
|
||||
&& !player.Properties.GetBool(SpellComponentsRequiredProperty, true))
|
||||
return true;
|
||||
if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
|
||||
return false;
|
||||
|
||||
HashSet<uint> ownedWcids = _objects.Objects
|
||||
.Where(item => IsOwnedByPlayer(item, playerGuid))
|
||||
.Select(item => item.WeenieClassId)
|
||||
.ToHashSet();
|
||||
IReadOnlyList<uint> components = GetAppropriateFormula(
|
||||
formula, player, playerGuid);
|
||||
foreach (uint scid in components)
|
||||
{
|
||||
if (scid == 0u) continue;
|
||||
if (!_wcidByScid.TryGetValue(scid, out uint wcid)
|
||||
|| !ownedWcids.Contains(wcid))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>ClientMagicSystem::GetAppropriateSpellFormula</c>
|
||||
/// (0x00567D50): an infused school or a directly carried school focus uses
|
||||
/// the scarab-only formula; otherwise the account-customized formula wins.
|
||||
/// </summary>
|
||||
private IReadOnlyList<uint> GetAppropriateFormula(
|
||||
SpellFormulaDefinition formula,
|
||||
ClientObject? player,
|
||||
uint playerGuid)
|
||||
{
|
||||
bool infused = SchoolInfusionProperty(formula.School) is uint propertyId
|
||||
&& player?.Properties.GetInt(propertyId) > 0;
|
||||
bool carriesMagicPack = _magicPackWcidBySchool.TryGetValue(
|
||||
formula.School, out uint packWcid)
|
||||
&& _objects.Objects.Any(item =>
|
||||
item.ContainerId == playerGuid
|
||||
&& item.WeenieClassId == packWcid);
|
||||
return infused || carriesMagicPack
|
||||
? RetailSpellFormula.InqScarabOnlyFormula(formula.ComponentIds)
|
||||
: RetailSpellFormula.CustomizeForAccount(
|
||||
formula.ComponentIds, formula.FormulaVersion, _accountName());
|
||||
}
|
||||
|
||||
private static uint? SchoolInfusionProperty(uint school) => school switch
|
||||
{
|
||||
1u => 0x129u, // AugmentationInfusedWarMagic
|
||||
2u => 0x128u, // AugmentationInfusedLifeMagic
|
||||
3u => 0x127u, // AugmentationInfusedItemMagic
|
||||
4u => 0x126u, // AugmentationInfusedCreatureMagic
|
||||
5u => 0x148u, // AugmentationInfusedVoidMagic
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
|
||||
{
|
||||
if (playerGuid == 0u) return false;
|
||||
ClientObject current = item;
|
||||
for (int depth = 0; depth < 16; depth++)
|
||||
{
|
||||
if (current.WielderId == playerGuid || current.ContainerId == playerGuid)
|
||||
return true;
|
||||
if (current.ContainerId == 0u
|
||||
|| _objects.Get(current.ContainerId) is not { } parent)
|
||||
return false;
|
||||
current = parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ using System.Numerics;
|
|||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Textures;
|
||||
using AcDream.Core.Spells;
|
||||
using DatReaderWriter;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
|
|
@ -35,6 +36,8 @@ public sealed class IconComposer
|
|||
private readonly TextureCache _cache;
|
||||
private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new();
|
||||
private readonly Dictionary<(uint, uint, uint), ComposedIcon> _dragByTuple = new();
|
||||
private readonly Dictionary<uint, uint> _spellIcons = new();
|
||||
private readonly Dictionary<uint, uint> _componentIcons = new();
|
||||
|
||||
private sealed record ComposedIcon(byte[] Rgba, int Width, int Height, uint Texture);
|
||||
|
||||
|
|
@ -298,4 +301,71 @@ public sealed class IconComposer
|
|||
var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null);
|
||||
layers.Add((decoded.Rgba8, decoded.Width, decoded.Height));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail ClientMagicSystem::CompositeSpellIcon (0x00567550): power-level
|
||||
/// backing, spell art, reversed/normal recolor, then self/fellow overlay.
|
||||
/// </summary>
|
||||
public uint GetSpellIcon(uint spellId)
|
||||
{
|
||||
if (_spellIcons.TryGetValue(spellId, out uint cached)) return cached;
|
||||
DatReaderWriter.DBObjs.SpellTable? table =
|
||||
_dats.Get<DatReaderWriter.DBObjs.SpellTable>(0x0E00000Eu);
|
||||
if (table is null || !table.Spells.TryGetValue(spellId, out var spell)) return 0u;
|
||||
|
||||
uint power = spell.Components.Count == 0
|
||||
? 0u
|
||||
: RetailSpellFormula.DeterminePowerLevelOfComponent(spell.Components[0]);
|
||||
uint powerBacking = RetailDataIdResolver.Resolve(_dats, power, 0x10000006u);
|
||||
var layers = new List<(byte[] rgba, int w, int h)>();
|
||||
AddLayer(layers, powerBacking);
|
||||
AddLayer(layers, spell.Icon);
|
||||
if (layers.Count == 0) return 0u;
|
||||
|
||||
var composed = Compose(layers);
|
||||
uint tintIndex = (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.Reversed) != 0
|
||||
? 1u : 2u;
|
||||
uint tintDid = RetailDataIdResolver.Resolve(_dats, tintIndex, 0x10000007u);
|
||||
if (TryDecode(tintDid, out DecodedTexture tint))
|
||||
ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h,
|
||||
tint.Rgba8, tint.Width, tint.Height);
|
||||
|
||||
uint overlayIndex = (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.FellowshipSpell) != 0
|
||||
? 4u
|
||||
: (spell.Bitfield & DatReaderWriter.Enums.SpellIndex.SelfTargeted) != 0
|
||||
? 3u : 0u;
|
||||
if (overlayIndex != 0u)
|
||||
{
|
||||
uint overlayDid = RetailDataIdResolver.Resolve(_dats, overlayIndex, 0x10000007u);
|
||||
if (TryDecode(overlayDid, out DecodedTexture overlay))
|
||||
composed = Compose([
|
||||
(composed.rgba, composed.w, composed.h),
|
||||
(overlay.Rgba8, overlay.Width, overlay.Height)]);
|
||||
}
|
||||
|
||||
uint texture = _cache.UploadRgba8(composed.rgba, composed.w, composed.h, nearest: true);
|
||||
_spellIcons[spellId] = texture;
|
||||
return texture;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail ClientMagicSystem::CompositeSpellComponentIcon (0x00567720).
|
||||
/// Components use their raw DAT art with pure white replaced by black.
|
||||
/// </summary>
|
||||
public uint GetSpellComponentIcon(uint iconId)
|
||||
{
|
||||
if (iconId == 0u) return 0u;
|
||||
if (_componentIcons.TryGetValue(iconId, out uint cached)) return cached;
|
||||
if (!TryDecode(iconId, out DecodedTexture icon)) return 0u;
|
||||
byte[] rgba = (byte[])icon.Rgba8.Clone();
|
||||
for (int i = 0; i + 3 < rgba.Length; i += 4)
|
||||
{
|
||||
if (rgba[i] != 255 || rgba[i + 1] != 255 || rgba[i + 2] != 255 || rgba[i + 3] != 255)
|
||||
continue;
|
||||
rgba[i] = rgba[i + 1] = rgba[i + 2] = 0;
|
||||
}
|
||||
uint texture = _cache.UploadRgba8(rgba, icon.Width, icon.Height, nearest: true);
|
||||
_componentIcons[iconId] = texture;
|
||||
return texture;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,6 +125,18 @@ public sealed class ItemInteractionController : IDisposable
|
|||
public bool CanMakeInventoryRequest =>
|
||||
_readyForInventoryRequest() && _busyCount == 0 && !_autoWield.IsBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Increments retail's shared <c>ClientUISystem</c> busy reference after a
|
||||
/// request issued by another retained controller has been sent. The
|
||||
/// corresponding UseDone releases it. Retail spell casts complete through
|
||||
/// Item__UseDone too; AttackDone belongs only to the combat attack owner.
|
||||
/// </summary>
|
||||
public void IncrementBusyCount()
|
||||
{
|
||||
_busyCount++;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised for retail confirmation/auxiliary actions whose retained panel is
|
||||
/// owned outside this controller (PK/NPK altar, volatile rare, trade, salvage).
|
||||
|
|
@ -514,6 +526,14 @@ public sealed class ItemInteractionController : IDisposable
|
|||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Session teardown drops every outstanding client UI busy reference.</summary>
|
||||
public void ClearBusy()
|
||||
{
|
||||
if (_busyCount == 0) return;
|
||||
_busyCount = 0;
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private bool ConsumeUseThrottle()
|
||||
{
|
||||
long now = _nowMs();
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
{
|
||||
public const uint LayoutId = 0x21000073u;
|
||||
public const uint BasicPanelId = 0x1000005Cu;
|
||||
public const uint AdvancedPanelId = 0x10000061u;
|
||||
public const uint SpellcastingPanelId = 0x10000061u;
|
||||
public const uint AdvancedPanelId = SpellcastingPanelId;
|
||||
public const uint PowerControlId = 0x1000004Fu;
|
||||
public const uint SpeedLabelId = 0x10000051u;
|
||||
public const uint PowerLabelId = 0x10000052u;
|
||||
|
|
@ -36,7 +37,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
|
||||
private readonly UiElement _root;
|
||||
private readonly UiElement _basicPanel;
|
||||
private readonly UiElement _advancedPanel;
|
||||
private readonly UiElement _spellcastingPanel;
|
||||
private readonly UiScrollbar _powerControl;
|
||||
private readonly UiButton _high;
|
||||
private readonly UiButton _medium;
|
||||
|
|
@ -54,7 +55,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
private CombatUiController(
|
||||
ImportedLayout layout,
|
||||
UiElement basicPanel,
|
||||
UiElement advancedPanel,
|
||||
UiElement spellcastingPanel,
|
||||
UiScrollbar powerControl,
|
||||
UiButton high,
|
||||
UiButton medium,
|
||||
|
|
@ -71,7 +72,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
{
|
||||
_root = layout.Root;
|
||||
_basicPanel = basicPanel;
|
||||
_advancedPanel = advancedPanel;
|
||||
_spellcastingPanel = spellcastingPanel;
|
||||
_powerControl = powerControl;
|
||||
_high = high;
|
||||
_medium = medium;
|
||||
|
|
@ -85,11 +86,10 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
_setGameplay = setGameplay;
|
||||
_setWindowVisible = setWindowVisible;
|
||||
|
||||
// PlayerModule::AdvancedCombatUI false selects gmCombatUI's authored
|
||||
// basic page. The advanced page belongs to the separate advanced-
|
||||
// combat flow and stays hidden in the basic panel.
|
||||
// Retail layout 0x21000073 contains two sibling pages: gmCombatUI's
|
||||
// physical-attack controls and gmSpellcastingUI's favorite-spell bar.
|
||||
_basicPanel.Visible = true;
|
||||
_advancedPanel.Visible = false;
|
||||
_spellcastingPanel.Visible = false;
|
||||
|
||||
_powerControl.SetScalarPosition(_attacks.DesiredPower);
|
||||
_powerControl.ScalarChanged = _attacks.SetDesiredPower;
|
||||
|
|
@ -140,7 +140,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
ArgumentNullException.ThrowIfNull(setWindowVisible);
|
||||
|
||||
if (layout.FindElement(BasicPanelId) is not { } basic
|
||||
|| layout.FindElement(AdvancedPanelId) is not { } advanced
|
||||
|| layout.FindElement(SpellcastingPanelId) is not { } spellcasting
|
||||
|| layout.FindElement(PowerControlId) is not UiScrollbar power
|
||||
|| layout.FindElement(HighButtonId) is not UiButton high
|
||||
|| layout.FindElement(MediumButtonId) is not UiButton medium
|
||||
|
|
@ -151,7 +151,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
return null;
|
||||
|
||||
return new CombatUiController(
|
||||
layout, basic, advanced, power, high, medium, low,
|
||||
layout, basic, spellcasting, power, high, medium, low,
|
||||
repeatAttacks, autoTarget, keepInView,
|
||||
combat, attacks, gameplay, setGameplay, labels, setWindowVisible);
|
||||
}
|
||||
|
|
@ -166,7 +166,9 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
|
||||
private void OnCombatModeChanged(CombatMode mode)
|
||||
{
|
||||
bool visible = CombatInputPlanner.SupportsTargetedAttack(mode);
|
||||
bool visible = mode is CombatMode.Melee or CombatMode.Missile or CombatMode.Magic;
|
||||
_basicPanel.Visible = mode is CombatMode.Melee or CombatMode.Missile;
|
||||
_spellcastingPanel.Visible = mode == CombatMode.Magic;
|
||||
if (_root is IUiDatStateful stateful)
|
||||
{
|
||||
if (mode == CombatMode.Melee)
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ public static class DatWidgetFactory
|
|||
{
|
||||
UiRadar.RetailClassId => new UiRadar(), // gmRadarUI (Register 0x004D8B80)
|
||||
1 => BuildButton(info, resolve, elementFont, stringResolve), // UIElement_Button
|
||||
EffectsIndicatorController.RetailClassId => BuildButton(
|
||||
info, resolve, elementFont, stringResolve), // gmUIElement_EffectsIndicator
|
||||
6 => new UiMenu(), // UIElement_Menu (reg :120163)
|
||||
7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter
|
||||
0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf
|
||||
|
|
|
|||
86
src/AcDream.App/UI/Layout/EffectsIndicatorController.cs
Normal file
86
src/AcDream.App/UI/Layout/EffectsIndicatorController.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Port of retail <c>gmUIElement_EffectsIndicator::PostInit @ 0x004E6930</c>
|
||||
/// and <c>Update @ 0x004E6A50</c>: the authored Helpful and Harmful buttons
|
||||
/// reflect whether matching enchantments exist and send panel visibility notices
|
||||
/// for gmPanelUI slots 4 and 5.
|
||||
/// </summary>
|
||||
public sealed class EffectsIndicatorController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x21000071u;
|
||||
public const uint RetailClassId = 0x10000002u;
|
||||
public const uint HelpfulButtonId = 0x100000F5u;
|
||||
public const uint HarmfulButtonId = 0x100000F6u;
|
||||
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly UiButton _helpful;
|
||||
private readonly UiButton _harmful;
|
||||
private readonly Action<uint> _togglePanel;
|
||||
private bool _disposed;
|
||||
|
||||
private EffectsIndicatorController(
|
||||
Spellbook spellbook,
|
||||
UiButton helpful,
|
||||
UiButton harmful,
|
||||
Action<uint> togglePanel)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_helpful = helpful;
|
||||
_harmful = harmful;
|
||||
_togglePanel = togglePanel;
|
||||
_helpful.OnClick = () => _togglePanel(RetailPanelCatalog.PositiveEffects);
|
||||
_harmful.OnClick = () => _togglePanel(RetailPanelCatalog.NegativeEffects);
|
||||
_spellbook.EnchantmentsChanged += Update;
|
||||
Update();
|
||||
}
|
||||
|
||||
public static EffectsIndicatorController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
Action<uint> togglePanel)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(layout);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(togglePanel);
|
||||
if (layout.FindElement(HelpfulButtonId) is not UiButton helpful
|
||||
|| layout.FindElement(HarmfulButtonId) is not UiButton harmful)
|
||||
return null;
|
||||
return new EffectsIndicatorController(spellbook, helpful, harmful, togglePanel);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
bool helpful = HasMatchingEffect(beneficial: true);
|
||||
bool harmful = HasMatchingEffect(beneficial: false);
|
||||
_helpful.TrySetRetailState(helpful
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
_harmful.TrySetRetailState(harmful
|
||||
? UiButtonStateMachine.Normal
|
||||
: UiButtonStateMachine.Ghosted);
|
||||
}
|
||||
|
||||
private bool HasMatchingEffect(bool beneficial)
|
||||
// Retail SpellEffectMatchesUIType @ 0x004B76C0 supplies this helpful/harmful split.
|
||||
=> _spellbook.ActiveEnchantmentSnapshot.Any(record =>
|
||||
// Retail CountSpellsInList @ 0x00593CD0 counts every raw mult/add
|
||||
// node. Unlike gmEffectsUI's list, indicator totals are not dueled
|
||||
// by spell category first.
|
||||
record.Bucket is 1u or 2u
|
||||
&& _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
|
||||
&& metadata.IsBeneficial == beneficial);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_spellbook.EnchantmentsChanged -= Update;
|
||||
_helpful.OnClick = null;
|
||||
_harmful.OnClick = null;
|
||||
}
|
||||
}
|
||||
201
src/AcDream.App/UI/Layout/EffectsUiController.cs
Normal file
201
src/AcDream.App/UI/Layout/EffectsUiController.cs
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>Retail gmEffectsUI positive/negative instance binding.</summary>
|
||||
public sealed class EffectsUiController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x2100001Bu;
|
||||
// gmPanelUI's 0x10000184/185 are panel-slot IDs, not roots in this LayoutDesc.
|
||||
// The authored positive/negative gmEffectsUI instances inherit template 0x10000122.
|
||||
public const uint PositiveRootId = 0x1000011Fu;
|
||||
public const uint NegativeRootId = 0x10000121u;
|
||||
public const uint CloseId = 0x100000FCu;
|
||||
public const uint ListId = 0x10000123u;
|
||||
public const uint InfoTextId = 0x10000126u;
|
||||
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly bool _positive;
|
||||
private readonly Func<double> _serverTime;
|
||||
private readonly Func<uint, uint> _resolveSpellIcon;
|
||||
private readonly UiItemList _list;
|
||||
private readonly UiText? _info;
|
||||
private readonly UiButton? _close;
|
||||
private readonly Dictionary<uint, UiCatalogSlot> _rows = new();
|
||||
private uint? _selectedSpellId;
|
||||
private double _lastDurationUpdate = double.NaN;
|
||||
private bool _disposed;
|
||||
|
||||
internal uint? SelectedSpellId => _selectedSpellId;
|
||||
|
||||
private EffectsUiController(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
bool positive,
|
||||
Func<double> serverTime,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
UiItemList list,
|
||||
Action? close)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_positive = positive;
|
||||
_serverTime = serverTime;
|
||||
_resolveSpellIcon = resolveSpellIcon;
|
||||
_list = list;
|
||||
_info = layout.FindElement(InfoTextId) as UiText;
|
||||
_close = layout.FindElement(CloseId) as UiButton;
|
||||
if (_close is not null) _close.OnClick = close;
|
||||
_list.Columns = 1;
|
||||
_list.CellWidth = Math.Max(1f, _list.Width);
|
||||
_list.CellHeight = 32f;
|
||||
ConfigureInfo();
|
||||
_spellbook.EnchantmentsChanged += Rebuild;
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public static EffectsUiController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
bool positive,
|
||||
Func<double> serverTime,
|
||||
Func<uint, (uint Texture, int Width, int Height)> spriteResolve,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Action? close = null)
|
||||
{
|
||||
UiElement? host = layout.FindElement(ListId);
|
||||
if (host is null) return null;
|
||||
UiItemList list;
|
||||
if (host is UiItemList itemList)
|
||||
list = itemList;
|
||||
else
|
||||
{
|
||||
list = new UiItemList(spriteResolve)
|
||||
{
|
||||
Width = host.Width,
|
||||
Height = host.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
};
|
||||
host.AddChild(list);
|
||||
}
|
||||
return new EffectsUiController(
|
||||
layout, spellbook, positive, serverTime, resolveSpellIcon, list, close);
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
double now = _serverTime();
|
||||
if (!double.IsFinite(now)) return;
|
||||
if (double.IsFinite(_lastDurationUpdate)
|
||||
&& now >= _lastDurationUpdate
|
||||
&& now - _lastDurationUpdate < 1.0)
|
||||
return;
|
||||
_lastDurationUpdate = now;
|
||||
foreach (ActiveEnchantmentRecord enchantment in VisibleEnchantments())
|
||||
if (_rows.TryGetValue(enchantment.Identity, out UiCatalogSlot? row))
|
||||
row.Detail = FormatRemaining(enchantment, now);
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
_lastDurationUpdate = double.NaN;
|
||||
_rows.Clear();
|
||||
ActiveEnchantmentRecord[] enchantments = VisibleEnchantments().ToArray();
|
||||
using (_list.DeferLayout())
|
||||
{
|
||||
_list.Flush();
|
||||
foreach (ActiveEnchantmentRecord enchantment in enchantments)
|
||||
{
|
||||
_spellbook.TryGetMetadata(enchantment.SpellId, out SpellMetadata? metadata);
|
||||
uint identity = enchantment.Identity;
|
||||
var row = new UiCatalogSlot
|
||||
{
|
||||
EntryId = enchantment.SpellId,
|
||||
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(enchantment.SpellId),
|
||||
Label = metadata?.Name ?? $"Spell {enchantment.SpellId}",
|
||||
Detail = FormatRemaining(enchantment, _serverTime()),
|
||||
ShowLabel = true,
|
||||
SpriteResolve = _list.SpriteResolve,
|
||||
};
|
||||
row.Clicked = () => Select(enchantment.SpellId);
|
||||
_rows[identity] = row;
|
||||
_list.AddItem(row);
|
||||
}
|
||||
}
|
||||
if (_selectedSpellId is uint selected
|
||||
&& !_rows.Values.Any(row => row.EntryId == selected))
|
||||
_selectedSpellId = null;
|
||||
SyncSelection();
|
||||
}
|
||||
|
||||
private IEnumerable<ActiveEnchantmentRecord> VisibleEnchantments()
|
||||
=> _spellbook.EnchantmentsInEffectSnapshot
|
||||
.Where(record =>
|
||||
{
|
||||
if (!_spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata))
|
||||
return false;
|
||||
return metadata.IsBeneficial == _positive;
|
||||
})
|
||||
.OrderBy(record => _spellbook.TryGetMetadata(record.SpellId, out SpellMetadata metadata)
|
||||
? metadata.Name : record.SpellId.ToString(CultureInfo.InvariantCulture),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static string FormatRemaining(ActiveEnchantmentRecord enchantment, double now)
|
||||
{
|
||||
if (enchantment.Duration < 0) return "Permanent";
|
||||
double remaining = Math.Max(0, enchantment.StartTime + enchantment.Duration - now);
|
||||
if (!double.IsFinite(remaining)) return "--:--";
|
||||
remaining = Math.Min(remaining, TimeSpan.MaxValue.TotalSeconds);
|
||||
TimeSpan time = TimeSpan.FromSeconds(remaining);
|
||||
return time.TotalHours >= 1
|
||||
? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"
|
||||
: $"{time.Minutes}:{time.Seconds:00}";
|
||||
}
|
||||
|
||||
private void Select(uint spellId)
|
||||
{
|
||||
// Retail gmEffectsUI::SetSelectedSpell @ 0x004B8290 stores the
|
||||
// token's spell stat, not its enchantment layer identity.
|
||||
_selectedSpellId = _selectedSpellId == spellId ? null : spellId;
|
||||
SyncSelection();
|
||||
}
|
||||
|
||||
private void SyncSelection()
|
||||
{
|
||||
foreach (UiCatalogSlot row in _rows.Values)
|
||||
row.Selected = row.EntryId == _selectedSpellId;
|
||||
}
|
||||
|
||||
private void ConfigureInfo()
|
||||
{
|
||||
if (_info is null) return;
|
||||
_info.LinesProvider = () =>
|
||||
{
|
||||
if (_selectedSpellId is not uint spellId)
|
||||
return Array.Empty<UiText.Line>();
|
||||
ActiveEnchantmentRecord? selected = _spellbook.ActiveEnchantmentSnapshot
|
||||
.Cast<ActiveEnchantmentRecord?>()
|
||||
.FirstOrDefault(record => record?.SpellId == spellId);
|
||||
if (selected is null || !_spellbook.TryGetMetadata(selected.Value.SpellId, out SpellMetadata metadata))
|
||||
return Array.Empty<UiText.Line>();
|
||||
return
|
||||
[
|
||||
new UiText.Line(metadata.Name, _info.DefaultColor),
|
||||
new UiText.Line(metadata.Description, _info.DefaultColor),
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
public void OnShown() => Rebuild();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_spellbook.EnchantmentsChanged -= Rebuild;
|
||||
if (_close is not null) _close.OnClick = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -131,8 +131,9 @@ public sealed class ElementInfo
|
|||
public string DefaultStateName = "";
|
||||
|
||||
/// <summary>
|
||||
/// Resolved child elements (populated by the importer in Task 5).
|
||||
/// Children come from the derived element's own tree, not the base element's.
|
||||
/// Resolved child elements. The importer ports retail child-table incorporation:
|
||||
/// base-only children remain, same-ID children merge recursively, and derived-only
|
||||
/// children append after the retained inherited entries.
|
||||
/// </summary>
|
||||
public List<ElementInfo> Children = new();
|
||||
|
||||
|
|
@ -264,7 +265,9 @@ public static class ElementReader
|
|||
/// base entries are the default; derived entries override (or add) per state name key.
|
||||
/// </description></item>
|
||||
/// <item><description>
|
||||
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
|
||||
/// <see cref="ElementInfo.Children"/>: are not combined by this scalar merge helper;
|
||||
/// <see cref="LayoutImporter"/> separately ports retail's recursive child-table
|
||||
/// incorporation.
|
||||
/// </description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
|
|
@ -306,10 +309,9 @@ public static class ElementReader
|
|||
FontColor = derived.FontColor ?? base_.FontColor,
|
||||
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
|
||||
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
|
||||
// Children come from the derived element's own tree, not the base prototype's.
|
||||
// Defensive copy: prevent a later mutation of either the merged result or the input
|
||||
// from corrupting the other. Safe because derived.Children is fully
|
||||
// populated by the recursive importer BEFORE Merge is called and never mutated after).
|
||||
// This helper merges one element snapshot only. LayoutImporter separately
|
||||
// incorporates the child tables after the scalar/state merge.
|
||||
// Defensive copy prevents later mutation of either input.
|
||||
Children = new List<ElementInfo>(derived.Children),
|
||||
};
|
||||
// Start with base StateMedia as defaults, then let derived entries override.
|
||||
|
|
|
|||
|
|
@ -295,11 +295,9 @@ public static class LayoutImporter
|
|||
|
||||
// ── Inheritance resolution ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>True when a pure-container leaf should inherit its base's subtree (the
|
||||
/// gmInventoryUI sub-window mount): the derived element has no own children, no own
|
||||
/// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors
|
||||
/// (close button / title), elements with their own children, and childless style
|
||||
/// prototypes (vitals/chat/toolbar text — the base prototype has no children).</summary>
|
||||
/// <summary>True when a pure-container inheritor needs the mounted-base Z-layer
|
||||
/// correction. Child inheritance itself is unconditional and follows retail
|
||||
/// <c>ElementDesc::Incorporate</c> (0x0069B5A0).</summary>
|
||||
internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount)
|
||||
=> derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0;
|
||||
|
||||
|
|
@ -316,7 +314,7 @@ public static class LayoutImporter
|
|||
// Read this element's own fields + media (no inheritance, no children yet).
|
||||
var self = ToInfo(d);
|
||||
var result = self;
|
||||
List<ElementInfo>? baseChildren = null;
|
||||
ElementInfo? baseInfo = null;
|
||||
|
||||
// Apply BaseElement / BaseLayoutId inheritance if present.
|
||||
if (d.BaseElement != 0 && d.BaseLayoutId != 0
|
||||
|
|
@ -327,32 +325,23 @@ public static class LayoutImporter
|
|||
if (baseDesc is not null)
|
||||
{
|
||||
// Recurse the base chain (already guarded by the HashSet add above).
|
||||
var baseInfo = Resolve(dats, baseDesc, baseChain);
|
||||
baseInfo = Resolve(dats, baseDesc, baseChain);
|
||||
// Derived fields override the base; children are attached below.
|
||||
result = ElementReader.Merge(baseInfo, self);
|
||||
baseChildren = baseInfo.Children; // capture for the sub-window mount
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve + attach children. Each child gets a FRESH base-chain set:
|
||||
// the cycle guard is per-element, not shared across siblings.
|
||||
foreach (var kv in d.Children)
|
||||
{
|
||||
var child = Resolve(dats, kv.Value, new HashSet<(uint, uint)>());
|
||||
SetOriginalParentSize(child, result.Width, result.Height);
|
||||
result.Children.Add(child);
|
||||
}
|
||||
// Retail LayoutDesc::InqFullDesc (0x0069A520) recursively resolves the base,
|
||||
// then ElementDesc::Incorporate (0x0069B5A0) merges the complete child table:
|
||||
// base-only children remain, same-ID children incorporate recursively, and
|
||||
// derived-only children append after the retained base entries.
|
||||
IncorporateChildren(dats, result, baseInfo?.Children, d);
|
||||
|
||||
// Sub-window mount: a pure-container leaf (no own children, no own media) that inherits
|
||||
// from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels
|
||||
// (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window);
|
||||
// inert for media-bearing inheritors (close button/title) and childless style prototypes
|
||||
// (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec.
|
||||
if (baseChildren is not null
|
||||
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count))
|
||||
// A pure-container sub-window mount needs one additional layer correction.
|
||||
// Child-table incorporation has already happened above for every inheritor.
|
||||
if (baseInfo is not null
|
||||
&& ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseInfo.Children.Count))
|
||||
{
|
||||
result.Children.AddRange(baseChildren);
|
||||
|
||||
// The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted
|
||||
// sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone
|
||||
// top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own
|
||||
|
|
@ -372,6 +361,57 @@ public static class LayoutImporter
|
|||
return result;
|
||||
}
|
||||
|
||||
private static void IncorporateChildren(
|
||||
DatCollection dats,
|
||||
ElementInfo result,
|
||||
IReadOnlyList<ElementInfo>? baseChildren,
|
||||
ElementDesc derived)
|
||||
{
|
||||
baseChildren ??= Array.Empty<ElementInfo>();
|
||||
var baseById = baseChildren.ToDictionary(child => child.Id);
|
||||
int retainedBaseCount = baseChildren.Count(child =>
|
||||
!derived.Children.ContainsKey(child.Id));
|
||||
|
||||
foreach (ElementInfo baseChild in baseChildren)
|
||||
{
|
||||
bool hasOverlay = derived.Children.TryGetValue(
|
||||
baseChild.Id, out ElementDesc? overlay);
|
||||
ElementInfo child = hasOverlay
|
||||
? IncorporateResolvedChild(dats, baseChild, overlay!)
|
||||
: baseChild;
|
||||
// Base-only descendants retain the base layout's design parent size;
|
||||
// UiLayoutPolicy then performs retail's base-to-derived parent resize.
|
||||
if (hasOverlay)
|
||||
SetOriginalParentSize(child, result.Width, result.Height);
|
||||
result.Children.Add(child);
|
||||
}
|
||||
|
||||
// Every new child receives a fresh base-chain set. Retail offsets its read order
|
||||
// by the count of inherited children not replaced by a same-ID overlay.
|
||||
foreach (var pair in derived.Children)
|
||||
{
|
||||
if (baseById.ContainsKey(pair.Key)) continue;
|
||||
ElementInfo child = Resolve(dats, pair.Value, new HashSet<(uint, uint)>());
|
||||
child.ReadOrder += checked((uint)retainedBaseCount);
|
||||
SetOriginalParentSize(child, result.Width, result.Height);
|
||||
result.Children.Add(child);
|
||||
}
|
||||
}
|
||||
|
||||
private static ElementInfo IncorporateResolvedChild(
|
||||
DatCollection dats,
|
||||
ElementInfo baseChild,
|
||||
ElementDesc derivedChild)
|
||||
{
|
||||
// ElementDesc::Incorporate consumes the partial child directly. It does not
|
||||
// independently re-resolve that child's BaseElement when the inherited table
|
||||
// already contains the same identity.
|
||||
ElementInfo self = ToInfo(derivedChild);
|
||||
ElementInfo result = ElementReader.Merge(baseChild, self);
|
||||
IncorporateChildren(dats, result, baseChild.Children, derivedChild);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read an <see cref="ElementDesc"/>'s own scalar fields + state media into a
|
||||
/// fresh <see cref="ElementInfo"/>. No inheritance is applied; children are not
|
||||
|
|
|
|||
144
src/AcDream.App/UI/Layout/RetailPanelUiController.cs
Normal file
144
src/AcDream.App/UI/Layout/RetailPanelUiController.cs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retained-window port of retail <c>gmPanelUI::RecvNotice_SetPanelVisibility</c>
|
||||
/// at <c>0x004BC6F0</c>. The original owns one active child panel and an optional
|
||||
/// deferred child; this owner applies the same lifecycle to registered retained
|
||||
/// windows without making the toolbar authoritative for non-toolbar panels.
|
||||
/// </summary>
|
||||
public sealed class RetailPanelUiController
|
||||
{
|
||||
public const uint RestorePreviousPropertyId = 0x10000049u;
|
||||
|
||||
private readonly Func<string, bool> _isVisible;
|
||||
private readonly Func<string, bool> _show;
|
||||
private readonly Func<string, bool> _hide;
|
||||
private readonly Dictionary<uint, PanelEntry> _byPanel = new();
|
||||
private readonly Dictionary<string, uint> _byWindow = new(StringComparer.Ordinal);
|
||||
private uint? _activePanel;
|
||||
private uint? _deferredPanel;
|
||||
private bool _applying;
|
||||
|
||||
public RetailPanelUiController(
|
||||
Func<string, bool> isVisible,
|
||||
Func<string, bool> show,
|
||||
Func<string, bool> hide)
|
||||
{
|
||||
_isVisible = isVisible ?? throw new ArgumentNullException(nameof(isVisible));
|
||||
_show = show ?? throw new ArgumentNullException(nameof(show));
|
||||
_hide = hide ?? throw new ArgumentNullException(nameof(hide));
|
||||
}
|
||||
|
||||
public uint? ActivePanelId => _activePanel;
|
||||
|
||||
/// <param name="restorePrevious">
|
||||
/// Effective DAT bool property <c>0x10000049</c>. Retail retains the previous
|
||||
/// panel only when the newly shown panel has this property and the previous
|
||||
/// panel does not.
|
||||
/// </param>
|
||||
public void Register(uint panelId, string windowName, bool restorePrevious = false)
|
||||
{
|
||||
if (panelId == 0) throw new ArgumentOutOfRangeException(nameof(panelId));
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(windowName);
|
||||
if (_byPanel.ContainsKey(panelId) || _byWindow.ContainsKey(windowName))
|
||||
throw new InvalidOperationException(
|
||||
$"Panel {panelId} or retained window '{windowName}' is already registered.");
|
||||
|
||||
_byPanel.Add(panelId, new PanelEntry(windowName, restorePrevious));
|
||||
_byWindow.Add(windowName, panelId);
|
||||
if (_isVisible(windowName))
|
||||
ObserveWindowVisibility(windowName, visible: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a panel directly from its fully inherited retail root so the
|
||||
/// authored deferred-panel behavior cannot be dropped at the mount seam.
|
||||
/// </summary>
|
||||
public void Register(uint panelId, string windowName, ElementInfo authoredRoot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(authoredRoot);
|
||||
Register(
|
||||
panelId,
|
||||
windowName,
|
||||
authoredRoot.TryGetEffectiveBool(
|
||||
RestorePreviousPropertyId,
|
||||
out bool restorePrevious)
|
||||
&& restorePrevious);
|
||||
}
|
||||
|
||||
public bool IsPanelVisible(uint panelId)
|
||||
=> _byPanel.TryGetValue(panelId, out PanelEntry entry)
|
||||
&& _isVisible(entry.WindowName);
|
||||
|
||||
public bool TogglePanel(uint panelId)
|
||||
{
|
||||
bool visible = !IsPanelVisible(panelId);
|
||||
return SetPanelVisibility(panelId, visible) && visible;
|
||||
}
|
||||
|
||||
public bool SetPanelVisibility(uint panelId, bool visible)
|
||||
{
|
||||
if (!_byPanel.TryGetValue(panelId, out PanelEntry requested)) return false;
|
||||
|
||||
_applying = true;
|
||||
try
|
||||
{
|
||||
if (visible)
|
||||
{
|
||||
if (_activePanel == panelId)
|
||||
return _show(requested.WindowName);
|
||||
|
||||
uint? previousId = _activePanel;
|
||||
PanelEntry? previous = previousId is uint id
|
||||
&& _byPanel.TryGetValue(id, out PanelEntry found)
|
||||
&& _isVisible(found.WindowName)
|
||||
? found
|
||||
: null;
|
||||
|
||||
_deferredPanel = requested.RestorePrevious
|
||||
&& previous is { RestorePrevious: false }
|
||||
? previousId
|
||||
: null;
|
||||
_activePanel = panelId;
|
||||
if (previous is not null)
|
||||
_hide(previous.Value.WindowName);
|
||||
return _show(requested.WindowName);
|
||||
}
|
||||
|
||||
if (_activePanel != panelId)
|
||||
{
|
||||
if (_deferredPanel == panelId) _deferredPanel = null;
|
||||
return _hide(requested.WindowName);
|
||||
}
|
||||
|
||||
bool hidden = _hide(requested.WindowName);
|
||||
_activePanel = null;
|
||||
if (_deferredPanel is not uint deferredId
|
||||
|| !_byPanel.TryGetValue(deferredId, out PanelEntry deferred))
|
||||
return hidden;
|
||||
|
||||
_deferredPanel = null;
|
||||
_activePanel = deferredId;
|
||||
return _show(deferred.WindowName) || hidden;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Feeds visibility changes made by persistence or another retained-window
|
||||
/// owner back through the canonical panel lifecycle.
|
||||
/// </summary>
|
||||
public void ObserveWindowVisibility(string windowName, bool visible)
|
||||
{
|
||||
if (_applying || !_byWindow.TryGetValue(windowName, out uint panelId)) return;
|
||||
SetPanelVisibility(panelId, visible);
|
||||
}
|
||||
|
||||
private readonly record struct PanelEntry(string WindowName, bool RestorePrevious);
|
||||
}
|
||||
457
src/AcDream.App/UI/Layout/SpellbookWindowController.cs
Normal file
457
src/AcDream.App/UI/Layout/SpellbookWindowController.cs
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
public enum SpellbookWindowPage { Spells, Components }
|
||||
|
||||
/// <summary>
|
||||
/// Binds retail's combined spellbook/component-book LayoutDesc 0x21000034.
|
||||
/// The spell page mirrors gmSpellbookUI filters; the component page mirrors
|
||||
/// ComponentTracker and exposes the 0..5000 desired purchase level field.
|
||||
/// </summary>
|
||||
public sealed class SpellbookWindowController : IRetainedPanelController
|
||||
{
|
||||
public const uint LayoutId = 0x21000034u;
|
||||
public const uint RootId = 0x100002A8u;
|
||||
public const uint SpellTabId = 0x100002A9u;
|
||||
public const uint ComponentTabId = 0x100002AAu;
|
||||
public const uint CloseId = 0x100002ABu;
|
||||
public const uint SpellPageId = 0x100002ACu;
|
||||
public const uint ComponentPageId = 0x100002ADu;
|
||||
public const uint SpellListId = 0x10000295u;
|
||||
public const uint ComponentListId = 0x10000464u;
|
||||
public const uint ComponentScrollbarId = 0x10000465u;
|
||||
|
||||
private static readonly (uint Id, uint Mask)[] FilterButtons =
|
||||
[
|
||||
(0x10000298u, 0x0001u), (0x10000299u, 0x0002u),
|
||||
(0x1000029Au, 0x0004u), (0x1000029Bu, 0x0008u),
|
||||
(0x100005C0u, 0x2000u),
|
||||
(0x1000029Cu, 0x0010u), (0x1000029Du, 0x0020u),
|
||||
(0x1000029Eu, 0x0040u), (0x1000029Fu, 0x0080u),
|
||||
(0x100002A0u, 0x0100u), (0x100002A1u, 0x0200u),
|
||||
(0x100002A2u, 0x0400u), (0x1000054Eu, 0x0800u),
|
||||
];
|
||||
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly IReadOnlyDictionary<uint, SpellComponentDescriptor> _components;
|
||||
private readonly Func<uint, uint> _resolveSpellIcon;
|
||||
private readonly Func<uint, uint> _resolveComponentIcon;
|
||||
private readonly Func<uint, int> _spellLevel;
|
||||
private readonly Action<uint> _selectObject;
|
||||
private readonly Action<uint> _addFavorite;
|
||||
private readonly Action<uint> _sendFilter;
|
||||
private readonly Action<uint, uint> _setDesiredComponent;
|
||||
private readonly Action _close;
|
||||
private readonly UiElement _spellPage;
|
||||
private readonly UiElement _componentPage;
|
||||
private readonly UiButton _spellTab;
|
||||
private readonly UiButton _componentTab;
|
||||
private readonly UiButton _closeButton;
|
||||
private readonly UiItemList _spellList;
|
||||
private readonly UiItemList _componentList;
|
||||
private readonly List<(UiButton Button, uint Mask)> _filters = new();
|
||||
private uint? _selectedSpell;
|
||||
private bool _spellsDirty;
|
||||
private bool _componentsDirty;
|
||||
private bool _componentEditActive;
|
||||
private bool _disposed;
|
||||
|
||||
private SpellbookWindowController(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Func<uint, uint> resolveComponentIcon,
|
||||
Func<uint, int> spellLevel,
|
||||
Action<uint> selectObject,
|
||||
Action<uint> addFavorite,
|
||||
Action<uint> sendFilter,
|
||||
Action<uint, uint> setDesiredComponent,
|
||||
Action close,
|
||||
UiElement spellPage,
|
||||
UiElement componentPage,
|
||||
UiButton spellTab,
|
||||
UiButton componentTab,
|
||||
UiButton closeButton,
|
||||
UiItemList spellList,
|
||||
UiItemList componentList)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_components = components;
|
||||
_resolveSpellIcon = resolveSpellIcon;
|
||||
_resolveComponentIcon = resolveComponentIcon;
|
||||
_spellLevel = spellLevel;
|
||||
_selectObject = selectObject;
|
||||
_addFavorite = addFavorite;
|
||||
_sendFilter = sendFilter;
|
||||
_setDesiredComponent = setDesiredComponent;
|
||||
_close = close;
|
||||
_spellPage = spellPage;
|
||||
_componentPage = componentPage;
|
||||
_spellTab = spellTab;
|
||||
_componentTab = componentTab;
|
||||
_closeButton = closeButton;
|
||||
_spellList = spellList;
|
||||
_componentList = componentList;
|
||||
|
||||
spellTab.OnClick = () => ShowPage(SpellbookWindowPage.Spells);
|
||||
componentTab.OnClick = () => ShowPage(SpellbookWindowPage.Components);
|
||||
closeButton.OnClick = close;
|
||||
foreach ((uint id, uint mask) in FilterButtons)
|
||||
{
|
||||
if (layout.FindElement(id) is not UiButton button) continue;
|
||||
uint filterMask = mask;
|
||||
button.OnClick = () => ToggleFilter(filterMask);
|
||||
_filters.Add((button, mask));
|
||||
}
|
||||
|
||||
ConfigureSpellList();
|
||||
ConfigureComponentList(layout);
|
||||
_spellbook.SpellbookChanged += OnSpellbookChanged;
|
||||
_spellbook.DesiredComponentsChanged += OnDesiredComponentsChanged;
|
||||
_objects.ObjectAdded += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_objects.ObjectRemoved += OnObjectRemoved;
|
||||
_objects.ObjectMoved += OnObjectMoved;
|
||||
_objects.ContainerContentsReplaced += OnContainerContentsReplaced;
|
||||
ShowPage(SpellbookWindowPage.Spells);
|
||||
RebuildAll();
|
||||
}
|
||||
|
||||
public SpellbookWindowPage CurrentPage { get; private set; }
|
||||
|
||||
public static SpellbookWindowController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> components,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Func<uint, uint> resolveComponentIcon,
|
||||
Func<uint, int> spellLevel,
|
||||
Action<uint> selectObject,
|
||||
Action<uint> addFavorite,
|
||||
Action<uint> sendFilter,
|
||||
Action<uint, uint> setDesiredComponent,
|
||||
Action close)
|
||||
{
|
||||
if (layout.FindElement(SpellPageId) is not { } spellPage
|
||||
|| layout.FindElement(ComponentPageId) is not { } componentPage
|
||||
|| layout.FindElement(SpellTabId) is not UiButton spellTab
|
||||
|| layout.FindElement(ComponentTabId) is not UiButton componentTab
|
||||
|| layout.FindElement(CloseId) is not UiButton closeButton
|
||||
|| layout.FindElement(SpellListId) is not UiItemList spellList)
|
||||
return null;
|
||||
|
||||
UiElement? componentHost = layout.FindElement(ComponentListId);
|
||||
if (componentHost is null) return null;
|
||||
UiItemList componentList;
|
||||
if (componentHost is UiItemList itemList)
|
||||
componentList = itemList;
|
||||
else
|
||||
{
|
||||
componentList = new UiItemList(spellList.SpriteResolve)
|
||||
{
|
||||
Width = componentHost.Width,
|
||||
Height = componentHost.Height,
|
||||
Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom,
|
||||
};
|
||||
componentHost.AddChild(componentList);
|
||||
}
|
||||
|
||||
return new SpellbookWindowController(
|
||||
layout, spellbook, objects, playerGuid, components,
|
||||
resolveSpellIcon, resolveComponentIcon,
|
||||
spellLevel, selectObject,
|
||||
addFavorite, sendFilter, setDesiredComponent, close,
|
||||
spellPage, componentPage, spellTab, componentTab, closeButton,
|
||||
spellList, componentList);
|
||||
}
|
||||
|
||||
public void ShowPage(SpellbookWindowPage page)
|
||||
{
|
||||
CurrentPage = page;
|
||||
_spellPage.Visible = page == SpellbookWindowPage.Spells;
|
||||
_componentPage.Visible = page == SpellbookWindowPage.Components;
|
||||
_spellTab.Selected = page == SpellbookWindowPage.Spells;
|
||||
_componentTab.Selected = page == SpellbookWindowPage.Components;
|
||||
}
|
||||
|
||||
private void ConfigureSpellList()
|
||||
{
|
||||
_spellList.Columns = 6;
|
||||
_spellList.CellWidth = 32f;
|
||||
_spellList.CellHeight = 32f;
|
||||
}
|
||||
|
||||
private void ConfigureComponentList(ImportedLayout layout)
|
||||
{
|
||||
_componentList.Columns = 1;
|
||||
_componentList.CellWidth = Math.Max(1f, _componentList.Width);
|
||||
_componentList.CellHeight = 32f;
|
||||
if (layout.FindElement(ComponentScrollbarId) is UiScrollbar scrollbar)
|
||||
scrollbar.Model = _componentList.Scroll;
|
||||
}
|
||||
|
||||
private void ToggleFilter(uint mask)
|
||||
{
|
||||
uint filters = _spellbook.SpellbookFilters ^ mask;
|
||||
_spellbook.SetSpellbookFilters(filters);
|
||||
_sendFilter(filters);
|
||||
}
|
||||
|
||||
private void RebuildAll()
|
||||
{
|
||||
RebuildSpells();
|
||||
RebuildComponents();
|
||||
_spellsDirty = false;
|
||||
_componentsDirty = false;
|
||||
}
|
||||
|
||||
private void RebuildSpells()
|
||||
{
|
||||
uint effective = _spellbook.SpellbookFilters;
|
||||
foreach ((UiButton button, uint mask) in _filters)
|
||||
button.Selected = (effective & mask) != 0;
|
||||
|
||||
SpellMetadata[] spells = _spellbook.LearnedSpells
|
||||
.Select(id => _spellbook.TryGetMetadata(id, out SpellMetadata metadata) ? metadata : null)
|
||||
.Where(metadata => metadata is not null && IsVisible(metadata, effective))
|
||||
.OrderBy(metadata => metadata!.SortKey)
|
||||
.ThenBy(metadata => metadata!.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Cast<SpellMetadata>()
|
||||
.ToArray();
|
||||
|
||||
using (_spellList.DeferLayout())
|
||||
{
|
||||
_spellList.Flush();
|
||||
foreach (SpellMetadata metadata in spells)
|
||||
{
|
||||
uint spellId = metadata.SpellId;
|
||||
var slot = new UiCatalogSlot
|
||||
{
|
||||
EntryId = spellId,
|
||||
CatalogIconTexture = _resolveSpellIcon(spellId),
|
||||
Label = metadata.Name,
|
||||
SpriteResolve = _spellList.SpriteResolve,
|
||||
};
|
||||
slot.Clicked = () => SelectSpell(spellId);
|
||||
// gmSpellbookUI double-click publishes AddSpellShortcut; the open
|
||||
// gmSpellcastingUI tab chooses the server favorite-list destination.
|
||||
slot.DoubleClicked = () => { SelectSpell(spellId); _addFavorite(spellId); };
|
||||
_spellList.AddItem(slot);
|
||||
}
|
||||
}
|
||||
SyncSpellSelection();
|
||||
}
|
||||
|
||||
private void RebuildComponents()
|
||||
{
|
||||
uint player = _playerGuid();
|
||||
var packs = _objects.Objects
|
||||
.Where(item => (item.IsComponentPack || _components.ContainsKey(item.WeenieClassId))
|
||||
&& IsOwnedByPlayer(item, player))
|
||||
.GroupBy(item => item.WeenieClassId)
|
||||
.Select(group => new
|
||||
{
|
||||
ComponentId = group.Key,
|
||||
First = group.First(),
|
||||
Quantity = group.Sum(item => Math.Max(1, item.StackSize)),
|
||||
})
|
||||
.OrderBy(group => _components.TryGetValue(group.ComponentId, out var descriptor) ? descriptor.Category : uint.MaxValue)
|
||||
.ThenBy(group => group.First.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
|
||||
float width = Math.Max(96f, _componentList.Width);
|
||||
_componentList.CellWidth = width;
|
||||
using (_componentList.DeferLayout())
|
||||
{
|
||||
_componentList.Flush();
|
||||
uint? category = null;
|
||||
foreach (var packGroup in packs)
|
||||
{
|
||||
ClientObject pack = packGroup.First;
|
||||
uint componentId = packGroup.ComponentId;
|
||||
_components.TryGetValue(componentId, out SpellComponentDescriptor? descriptor);
|
||||
uint nextCategory = descriptor?.Category ?? 8u;
|
||||
if (category != nextCategory)
|
||||
{
|
||||
category = nextCategory;
|
||||
_componentList.AddItem(new UiCatalogSlot
|
||||
{
|
||||
EntryId = uint.MaxValue,
|
||||
Label = ComponentCategoryName(nextCategory),
|
||||
ShowLabel = true,
|
||||
SpriteResolve = _componentList.SpriteResolve,
|
||||
});
|
||||
}
|
||||
uint desired = _spellbook.DesiredComponents.TryGetValue(componentId, out uint amount) ? amount : 0u;
|
||||
var row = new UiCatalogSlot
|
||||
{
|
||||
EntryId = componentId,
|
||||
CatalogIconTexture = _resolveComponentIcon(
|
||||
pack.IconId != 0 ? pack.IconId : descriptor?.IconId ?? 0u),
|
||||
Label = string.IsNullOrWhiteSpace(pack.Name) ? descriptor?.Name ?? $"Component {componentId}" : pack.Name,
|
||||
Detail = packGroup.Quantity.ToString(CultureInfo.InvariantCulture),
|
||||
ShowLabel = true,
|
||||
SpriteResolve = _componentList.SpriteResolve,
|
||||
};
|
||||
var desiredField = new UiField
|
||||
{
|
||||
Left = width - 47f,
|
||||
Top = 4f,
|
||||
Width = 43f,
|
||||
Height = 24f,
|
||||
MaxCharacters = 4,
|
||||
ClearOnSubmit = false,
|
||||
RecordHistory = false,
|
||||
SelectAllOnFocus = true,
|
||||
RightAligned = true,
|
||||
CharacterFilter = char.IsAsciiDigit,
|
||||
BackgroundColor = new Vector4(0f, 0f, 0f, 0.65f),
|
||||
};
|
||||
desiredField.SetText(desired.ToString(CultureInfo.InvariantCulture));
|
||||
desiredField.OnFocusGained = () => _componentEditActive = true;
|
||||
uint committed = desired;
|
||||
void Commit(string value)
|
||||
{
|
||||
if (!uint.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out uint parsed)
|
||||
|| parsed > 5000u)
|
||||
{
|
||||
desiredField.SetText(committed.ToString(CultureInfo.InvariantCulture));
|
||||
return;
|
||||
}
|
||||
if (parsed == committed) return;
|
||||
committed = parsed;
|
||||
_spellbook.SetDesiredComponent(componentId, parsed);
|
||||
_setDesiredComponent(componentId, parsed);
|
||||
}
|
||||
desiredField.OnSubmit = Commit;
|
||||
desiredField.OnFocusLost = value =>
|
||||
{
|
||||
Commit(value);
|
||||
_componentEditActive = false;
|
||||
};
|
||||
row.AddChild(desiredField);
|
||||
row.Clicked = () => _selectObject(pack.ObjectId);
|
||||
_componentList.AddItem(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComponentCategoryName(uint category) => category switch
|
||||
{
|
||||
0u => "Scarabs",
|
||||
1u => "Herbs",
|
||||
2u => "Powdered Gems",
|
||||
3u => "Alchemical Substances",
|
||||
4u => "Talismans",
|
||||
5u => "Tapers",
|
||||
6u => "Prismatic Components",
|
||||
_ => "Other Components",
|
||||
};
|
||||
|
||||
private bool IsOwnedByPlayer(ClientObject item, uint playerGuid)
|
||||
{
|
||||
if (playerGuid == 0) return item.ContainerId != 0 || item.WielderId != 0;
|
||||
ClientObject current = item;
|
||||
for (int depth = 0; depth < 16; depth++)
|
||||
{
|
||||
if (current.WielderId == playerGuid || current.ContainerId == playerGuid) return true;
|
||||
if (current.ContainerId == 0 || _objects.Get(current.ContainerId) is not { } parent) return false;
|
||||
current = parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool IsVisible(SpellMetadata metadata, uint filters)
|
||||
{
|
||||
uint school = metadata.School switch
|
||||
{
|
||||
"Creature Enchantment" => 0x0001u,
|
||||
"Item Enchantment" => 0x0002u,
|
||||
"Life Magic" => 0x0004u,
|
||||
"War Magic" => 0x0008u,
|
||||
"Void Magic" => 0x2000u,
|
||||
_ => 0u,
|
||||
};
|
||||
int spellLevel = _spellLevel(metadata.SpellId);
|
||||
if (spellLevel is < 1 or > 8) return false;
|
||||
uint level = 0x10u << (spellLevel - 1);
|
||||
return school != 0 && (filters & school) != 0 && (filters & level) != 0;
|
||||
}
|
||||
|
||||
private void SelectSpell(uint spellId)
|
||||
{
|
||||
_selectedSpell = spellId;
|
||||
SyncSpellSelection();
|
||||
}
|
||||
|
||||
private void SyncSpellSelection()
|
||||
{
|
||||
for (int i = 0; i < _spellList.GetNumUIItems(); i++)
|
||||
if (_spellList.GetItem(i) is UiCatalogSlot slot)
|
||||
slot.Selected = slot.EntryId == _selectedSpell;
|
||||
}
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (_spellsDirty)
|
||||
{
|
||||
_spellsDirty = false;
|
||||
RebuildSpells();
|
||||
}
|
||||
if (_componentsDirty && !_componentEditActive)
|
||||
{
|
||||
_componentsDirty = false;
|
||||
RebuildComponents();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSpellbookChanged() => _spellsDirty = true;
|
||||
private void OnDesiredComponentsChanged() => _componentsDirty = true;
|
||||
private void OnObjectChanged(ClientObject item)
|
||||
{
|
||||
if (IsComponent(item)) _componentsDirty = true;
|
||||
}
|
||||
private void OnObjectRemoved(ClientObject item)
|
||||
{
|
||||
if (IsComponent(item)) _componentsDirty = true;
|
||||
}
|
||||
private void OnObjectMoved(ClientObjectMove _) => _componentsDirty = true;
|
||||
private void OnContainerContentsReplaced(uint _) => _componentsDirty = true;
|
||||
private bool IsComponent(ClientObject item)
|
||||
=> item.IsComponentPack || _components.ContainsKey(item.WeenieClassId);
|
||||
public void OnShown() => RebuildAll();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_spellbook.SpellbookChanged -= OnSpellbookChanged;
|
||||
_spellbook.DesiredComponentsChanged -= OnDesiredComponentsChanged;
|
||||
_objects.ObjectAdded -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_objects.ObjectRemoved -= OnObjectRemoved;
|
||||
_objects.ObjectMoved -= OnObjectMoved;
|
||||
_objects.ContainerContentsReplaced -= OnContainerContentsReplaced;
|
||||
_spellTab.OnClick = null;
|
||||
_componentTab.OnClick = null;
|
||||
_closeButton.OnClick = null;
|
||||
foreach ((UiButton button, _) in _filters) button.OnClick = null;
|
||||
}
|
||||
}
|
||||
472
src/AcDream.App/UI/Layout/SpellcastingUiController.cs
Normal file
472
src/AcDream.App/UI/Layout/SpellcastingUiController.cs
Normal file
|
|
@ -0,0 +1,472 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
/// <summary>
|
||||
/// Retail gmSpellcastingUI binding for the authored magic page inside combat
|
||||
/// LayoutDesc 0x21000073. Favorites remain server-persisted; casting emits one
|
||||
/// request through <see cref="SpellCastingController"/>.
|
||||
/// </summary>
|
||||
public sealed class SpellcastingUiController : IRetainedPanelController
|
||||
{
|
||||
public const uint PageId = 0x10000061u;
|
||||
public const uint SpellNameId = 0x1000048Bu;
|
||||
public const uint EndowmentId = 0x100000B1u;
|
||||
public const uint CastButtonId = 0x100000B2u;
|
||||
public const uint FavoriteListId = 0x100000B6u;
|
||||
|
||||
private static readonly uint[] TabIds =
|
||||
[
|
||||
0x100000A3u, 0x100000A4u, 0x100000A5u, 0x100000A6u,
|
||||
0x100000A7u, 0x100000A8u, 0x100000A9u, 0x100005C2u,
|
||||
];
|
||||
|
||||
private static readonly uint[] GroupIds =
|
||||
[
|
||||
0x100000AAu, 0x100000ABu, 0x100000ACu, 0x100000ADu,
|
||||
0x100000AEu, 0x100000AFu, 0x100000B0u, 0x100005C3u,
|
||||
];
|
||||
|
||||
private readonly Spellbook _spellbook;
|
||||
private readonly SpellCastingController _casting;
|
||||
private readonly SelectionState _selection;
|
||||
private readonly ClientObjectTable _objects;
|
||||
private readonly Func<uint> _playerGuid;
|
||||
private readonly Func<uint, uint> _resolveSpellIcon;
|
||||
private readonly Func<ClientObject, uint> _resolveItemDragIcon;
|
||||
private readonly Action<uint> _useItem;
|
||||
private readonly Action<int, int, uint>? _addFavorite;
|
||||
private readonly Action<int, uint>? _removeFavorite;
|
||||
private readonly UiElement[] _tabs;
|
||||
private readonly UiElement[] _groups;
|
||||
private readonly UiItemList?[] _lists;
|
||||
private readonly UiButton _cast;
|
||||
private readonly UiText? _spellName;
|
||||
private readonly UiElement _endowmentHost;
|
||||
private readonly UiCatalogSlot _endowmentSlot;
|
||||
private int _activeTab;
|
||||
private readonly uint?[] _selected = new uint?[8];
|
||||
private readonly bool[] _endowmentSelected = new bool[8];
|
||||
private uint _endowmentItemId;
|
||||
private uint _endowmentSpellId;
|
||||
private bool _disposed;
|
||||
private bool _favoritesDirty;
|
||||
private bool _endowmentDirty;
|
||||
|
||||
private SpellcastingUiController(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
SpellCastingController casting,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Func<ClientObject, uint> resolveItemDragIcon,
|
||||
Action<uint> useItem,
|
||||
SelectionState selection,
|
||||
Action<int, int, uint>? addFavorite,
|
||||
Action<int, uint>? removeFavorite,
|
||||
UiElement[] tabs,
|
||||
UiElement[] groups,
|
||||
UiItemList?[] lists,
|
||||
UiButton cast,
|
||||
UiElement endowmentHost)
|
||||
{
|
||||
_spellbook = spellbook;
|
||||
_casting = casting;
|
||||
_selection = selection;
|
||||
_objects = objects;
|
||||
_playerGuid = playerGuid;
|
||||
_resolveSpellIcon = resolveSpellIcon;
|
||||
_resolveItemDragIcon = resolveItemDragIcon;
|
||||
_useItem = useItem;
|
||||
_addFavorite = addFavorite;
|
||||
_removeFavorite = removeFavorite;
|
||||
_tabs = tabs;
|
||||
_groups = groups;
|
||||
_lists = lists;
|
||||
_cast = cast;
|
||||
_spellName = layout.FindElement(SpellNameId) as UiText;
|
||||
_endowmentHost = endowmentHost;
|
||||
foreach (UiElement child in _endowmentHost.Children) child.Visible = false;
|
||||
_endowmentSlot = new UiCatalogSlot
|
||||
{
|
||||
Left = 0f,
|
||||
Top = 0f,
|
||||
Width = endowmentHost.Width,
|
||||
Height = endowmentHost.Height,
|
||||
SpriteResolve = lists.FirstOrDefault(list => list is not null)?.SpriteResolve,
|
||||
};
|
||||
_endowmentSlot.Clicked = SelectEndowment;
|
||||
_endowmentSlot.DoubleClicked = () => { SelectEndowment(); CastSelected(); };
|
||||
_endowmentHost.AddChild(_endowmentSlot);
|
||||
|
||||
for (int i = 0; i < tabs.Length; i++)
|
||||
{
|
||||
int index = i;
|
||||
SetClick(tabs[i], () => SelectTab(index));
|
||||
}
|
||||
_cast.OnClick = CastSelected;
|
||||
ConfigureSpellName();
|
||||
_spellbook.SpellbookChanged += OnSpellbookChanged;
|
||||
_selection.Changed += OnSelectionChanged;
|
||||
_objects.ObjectAdded += OnObjectChanged;
|
||||
_objects.ObjectUpdated += OnObjectChanged;
|
||||
_objects.ObjectRemoved += OnObjectChanged;
|
||||
_objects.Cleared += OnObjectsCleared;
|
||||
UpdateEndowment();
|
||||
SelectTab(0);
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
public int ActiveTab => _activeTab;
|
||||
|
||||
public static SpellcastingUiController? Bind(
|
||||
ImportedLayout layout,
|
||||
Spellbook spellbook,
|
||||
SpellCastingController casting,
|
||||
ClientObjectTable objects,
|
||||
Func<uint> playerGuid,
|
||||
Func<uint, uint> resolveSpellIcon,
|
||||
Func<ClientObject, uint> resolveItemDragIcon,
|
||||
Action<uint> useItem,
|
||||
SelectionState selection,
|
||||
Action<int, int, uint>? addFavorite,
|
||||
Action<int, uint>? removeFavorite)
|
||||
{
|
||||
if (layout.FindElement(CastButtonId) is not UiButton cast
|
||||
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
|
||||
return null;
|
||||
|
||||
var tabs = new UiElement[8];
|
||||
var groups = new UiElement[8];
|
||||
var lists = new UiItemList?[8];
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
if (layout.FindElement(TabIds[i]) is not { } tab
|
||||
|| layout.FindElement(GroupIds[i]) is not { } group)
|
||||
return null;
|
||||
tabs[i] = tab;
|
||||
groups[i] = group;
|
||||
lists[i] = Descendants(group).OfType<UiItemList>().FirstOrDefault();
|
||||
}
|
||||
|
||||
return new SpellcastingUiController(
|
||||
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
|
||||
resolveItemDragIcon, useItem, selection,
|
||||
addFavorite, removeFavorite,
|
||||
tabs, groups, lists, cast, endowmentHost);
|
||||
}
|
||||
|
||||
public void AddFavorite(uint spellId)
|
||||
{
|
||||
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
|
||||
if (spells.Contains(spellId))
|
||||
{
|
||||
SelectSpell(spellId);
|
||||
return;
|
||||
}
|
||||
int position = spells.Count;
|
||||
_addFavorite?.Invoke(_activeTab, position, spellId);
|
||||
_spellbook.SetFavorite(_activeTab, position, spellId);
|
||||
SelectSpell(spellId);
|
||||
}
|
||||
|
||||
public bool Handle(InputAction action)
|
||||
{
|
||||
if (action is >= InputAction.UseSpellSlot_1 and <= InputAction.UseSpellSlot_9)
|
||||
{
|
||||
int index = (int)action - (int)InputAction.UseSpellSlot_1;
|
||||
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
|
||||
if (index < spells.Count)
|
||||
{
|
||||
SelectSpell(spells[index]);
|
||||
CastSelected();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (action)
|
||||
{
|
||||
case InputAction.CombatPrevSpellTab: SelectTab((_activeTab + 7) % 8); return true;
|
||||
case InputAction.CombatNextSpellTab: SelectTab((_activeTab + 1) % 8); return true;
|
||||
case InputAction.CombatFirstSpellTab: SelectTab(0); return true;
|
||||
case InputAction.CombatLastSpellTab: SelectTab(7); return true;
|
||||
case InputAction.CombatPrevSpell: MoveSelection(-1, false); return true;
|
||||
case InputAction.CombatNextSpell: MoveSelection(1, false); return true;
|
||||
case InputAction.CombatFirstSpell: MoveSelection(0, true); return true;
|
||||
case InputAction.CombatLastSpell: MoveSelection(-1, true); return true;
|
||||
case InputAction.CombatCastCurrentSpell: CastSelected(); return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SelectTab(int tab)
|
||||
{
|
||||
_activeTab = Math.Clamp(tab, 0, 7);
|
||||
for (int i = 0; i < 8; i++)
|
||||
{
|
||||
SetSelected(_tabs[i], i == _activeTab);
|
||||
_groups[i].Visible = i == _activeTab;
|
||||
}
|
||||
IReadOnlyList<uint> favorites = _spellbook.GetFavorites(_activeTab);
|
||||
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
|
||||
_selected[_activeTab] = null;
|
||||
else if (_selected[_activeTab] is not uint selected || !favorites.Contains(selected))
|
||||
{
|
||||
_selected[_activeTab] = favorites.Count == 0 ? null : favorites[0];
|
||||
_endowmentSelected[_activeTab] = favorites.Count == 0 && _endowmentItemId != 0u;
|
||||
}
|
||||
SyncSelection();
|
||||
UpdateCastAvailability();
|
||||
}
|
||||
|
||||
private void MoveSelection(int delta, bool edge)
|
||||
{
|
||||
IReadOnlyList<uint> spells = _spellbook.GetFavorites(_activeTab);
|
||||
int count = spells.Count + (_endowmentItemId != 0u ? 1 : 0);
|
||||
if (count == 0) return;
|
||||
int current = _endowmentSelected[_activeTab]
|
||||
? 0
|
||||
: (_selected[_activeTab] is uint id ? spells.IndexOf(id) : -1)
|
||||
+ (_endowmentItemId != 0u ? 1 : 0);
|
||||
int next = edge ? (delta < 0 ? count - 1 : 0) : (current + delta + count) % count;
|
||||
if (_endowmentItemId != 0u && next == 0) SelectEndowment();
|
||||
else SelectSpell(spells[next - (_endowmentItemId != 0u ? 1 : 0)]);
|
||||
}
|
||||
|
||||
private void SelectSpell(uint spellId)
|
||||
{
|
||||
_endowmentSelected[_activeTab] = false;
|
||||
_selected[_activeTab] = spellId;
|
||||
SyncSelection();
|
||||
UpdateCastAvailability();
|
||||
}
|
||||
|
||||
private void CastSelected()
|
||||
{
|
||||
if (_endowmentSelected[_activeTab] && _endowmentItemId != 0u)
|
||||
_useItem(_endowmentItemId);
|
||||
else if (_selected[_activeTab] is uint spellId)
|
||||
_casting.Cast(spellId);
|
||||
}
|
||||
|
||||
private void SelectEndowment()
|
||||
{
|
||||
if (_endowmentItemId == 0u) return;
|
||||
_endowmentSelected[_activeTab] = true;
|
||||
_selected[_activeTab] = null;
|
||||
SyncSelection();
|
||||
UpdateCastAvailability();
|
||||
}
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
for (int tab = 0; tab < 8; tab++)
|
||||
{
|
||||
UiItemList? list = _lists[tab];
|
||||
if (list is null) continue;
|
||||
IReadOnlyList<uint> favorites = _spellbook.GetFavorites(tab);
|
||||
using (list.DeferLayout())
|
||||
{
|
||||
list.Flush();
|
||||
list.Columns = 9;
|
||||
list.CellWidth = 32f;
|
||||
list.CellHeight = 32f;
|
||||
foreach (uint spellId in favorites)
|
||||
{
|
||||
uint id = spellId;
|
||||
int position = list.GetNumUIItems();
|
||||
_spellbook.TryGetMetadata(id, out SpellMetadata? metadata);
|
||||
var slot = new UiCatalogSlot
|
||||
{
|
||||
EntryId = id,
|
||||
CatalogIconTexture = metadata is null ? 0u : _resolveSpellIcon(id),
|
||||
Label = metadata?.Name ?? $"Spell {id}",
|
||||
SpriteResolve = list.SpriteResolve,
|
||||
CatalogDragPayload = new SpellFavoriteDragPayload(tab, position, id),
|
||||
DragBegan = payload => BeginFavoriteDrag((SpellFavoriteDragPayload)payload),
|
||||
DragEnded = payload => EndFavoriteDrag((SpellFavoriteDragPayload)payload),
|
||||
Dropped = payload =>
|
||||
{
|
||||
if (payload is SpellFavoriteDragPayload favorite)
|
||||
DropFavorite(favorite, tab, position);
|
||||
},
|
||||
};
|
||||
slot.Clicked = () => SelectSpell(id);
|
||||
slot.DoubleClicked = () => { SelectSpell(id); CastSelected(); };
|
||||
list.AddItem(slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
SelectTab(_activeTab);
|
||||
}
|
||||
|
||||
private void BeginFavoriteDrag(SpellFavoriteDragPayload payload)
|
||||
=> _removeFavorite?.Invoke(payload.SourceTab, payload.SpellId);
|
||||
|
||||
private void EndFavoriteDrag(SpellFavoriteDragPayload payload)
|
||||
=> _spellbook.RemoveFavorite(payload.SourceTab, payload.SpellId);
|
||||
|
||||
private void DropFavorite(SpellFavoriteDragPayload payload, int targetTab, int targetPosition)
|
||||
{
|
||||
_addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
|
||||
_spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
|
||||
_selected[targetTab] = payload.SpellId;
|
||||
}
|
||||
|
||||
private void OnSpellbookChanged() => _favoritesDirty = true;
|
||||
|
||||
private void OnObjectChanged(ClientObject _) => _endowmentDirty = true;
|
||||
|
||||
private void OnObjectsCleared() => _endowmentDirty = true;
|
||||
|
||||
public void Tick()
|
||||
{
|
||||
if (_endowmentDirty)
|
||||
{
|
||||
_endowmentDirty = false;
|
||||
UpdateEndowment();
|
||||
SelectTab(_activeTab);
|
||||
}
|
||||
if (_favoritesDirty)
|
||||
{
|
||||
_favoritesDirty = false;
|
||||
Rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncSelection()
|
||||
{
|
||||
for (int tab = 0; tab < 8; tab++)
|
||||
{
|
||||
UiItemList? list = _lists[tab];
|
||||
if (list is null) continue;
|
||||
for (int i = 0; i < list.GetNumUIItems(); i++)
|
||||
if (list.GetItem(i) is UiCatalogSlot slot)
|
||||
slot.Selected = slot.EntryId == _selected[tab];
|
||||
_endowmentSlot.Selected = _endowmentItemId != 0u
|
||||
&& _endowmentSelected[_activeTab];
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSelectionChanged(SelectionTransition _) => UpdateCastAvailability();
|
||||
|
||||
private void UpdateCastAvailability()
|
||||
=> _cast.Enabled = _endowmentSelected[_activeTab]
|
||||
? _endowmentItemId != 0u
|
||||
: _selected[_activeTab] is uint spellId
|
||||
&& _casting.IsTargetReady(spellId);
|
||||
|
||||
private void ConfigureSpellName()
|
||||
{
|
||||
if (_spellName is null) return;
|
||||
_spellName.OneLine = true;
|
||||
_spellName.Centered = true;
|
||||
_spellName.Padding = 0;
|
||||
_spellName.LinesProvider = () =>
|
||||
{
|
||||
uint? chosenSpell = _endowmentSelected[_activeTab]
|
||||
? (_endowmentSpellId == 0u ? null : _endowmentSpellId)
|
||||
: _selected[_activeTab];
|
||||
string name = chosenSpell is uint spellId
|
||||
&& _spellbook.TryGetMetadata(spellId, out SpellMetadata metadata)
|
||||
? metadata.Name : string.Empty;
|
||||
return [new UiText.Line(name, _spellName.DefaultColor)];
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateEndowment()
|
||||
{
|
||||
ClientObject? endowment = _objects.GetEquippedBy(_playerGuid())
|
||||
.FirstOrDefault(item =>
|
||||
(item.CurrentlyEquippedLocation & EquipMask.Held) != 0
|
||||
&& (item.Type & ItemType.Caster) != 0
|
||||
&& item.SpellId.GetValueOrDefault() != 0u);
|
||||
|
||||
_endowmentItemId = endowment?.ObjectId ?? 0u;
|
||||
_endowmentSpellId = endowment?.SpellId ?? 0u;
|
||||
_endowmentHost.Visible = endowment is not null;
|
||||
_endowmentSlot.EntryId = _endowmentItemId;
|
||||
_endowmentSlot.CatalogIconTexture = _endowmentSpellId == 0u
|
||||
? 0u : _resolveSpellIcon(_endowmentSpellId);
|
||||
_endowmentSlot.CatalogOverlayTexture = endowment is null
|
||||
? 0u : _resolveItemDragIcon(endowment);
|
||||
string spellName = _spellbook.TryGetMetadata(_endowmentSpellId, out SpellMetadata metadata)
|
||||
? metadata.Name : $"Spell {_endowmentSpellId}";
|
||||
_endowmentSlot.Label = endowment is null
|
||||
? string.Empty : $"{endowment.GetAppropriateName()} ({spellName})";
|
||||
|
||||
for (int tab = 0; tab < 8; tab++)
|
||||
{
|
||||
if (_endowmentItemId != 0u && _selected[tab] is null)
|
||||
_endowmentSelected[tab] = true;
|
||||
else if (_endowmentItemId == 0u && _endowmentSelected[tab])
|
||||
_endowmentSelected[tab] = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<UiElement> Descendants(UiElement root)
|
||||
{
|
||||
foreach (UiElement child in root.Children)
|
||||
{
|
||||
yield return child;
|
||||
foreach (UiElement nested in Descendants(child)) yield return nested;
|
||||
}
|
||||
}
|
||||
|
||||
public void OnShown() => SyncSelection();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_spellbook.SpellbookChanged -= OnSpellbookChanged;
|
||||
_selection.Changed -= OnSelectionChanged;
|
||||
_objects.ObjectAdded -= OnObjectChanged;
|
||||
_objects.ObjectUpdated -= OnObjectChanged;
|
||||
_objects.ObjectRemoved -= OnObjectChanged;
|
||||
_objects.Cleared -= OnObjectsCleared;
|
||||
foreach (UiElement tab in _tabs) SetClick(tab, null);
|
||||
_cast.OnClick = null;
|
||||
}
|
||||
|
||||
private static void SetClick(UiElement element, Action? action)
|
||||
{
|
||||
element.ClickThrough = action is null;
|
||||
switch (element)
|
||||
{
|
||||
case UiButton button: button.OnClick = action; break;
|
||||
case UiText text: text.OnClick = action; break;
|
||||
case UiDatElement dat: dat.OnClick = action; break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetSelected(UiElement element, bool selected)
|
||||
{
|
||||
if (element is UiButton button)
|
||||
button.Selected = selected;
|
||||
else if (element is UiText text)
|
||||
text.DefaultColor = selected
|
||||
? new System.Numerics.Vector4(1f, 1f, 1f, 1f)
|
||||
: new System.Numerics.Vector4(0.65f, 0.65f, 0.65f, 1f);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SpellFavoriteDragPayload(int SourceTab, int SourcePosition, uint SpellId);
|
||||
|
||||
internal static class FavoriteListExtensions
|
||||
{
|
||||
public static int IndexOf(this IReadOnlyList<uint> values, uint value)
|
||||
{
|
||||
for (int i = 0; i < values.Count; i++) if (values[i] == value) return i;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +1,36 @@
|
|||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Retail toolbar panel ids carried by DAT property <c>0x10000029</c>.
|
||||
/// Retail reference: <c>gmToolbarUI::PostInit @ 0x004BEA80</c> discovers the
|
||||
/// seven buttons and reads this property from each one.
|
||||
/// Only panels mounted in the retained runtime are mapped; other authored
|
||||
/// toolbar buttons remain present but ghosted until their panel ships.
|
||||
/// Retail <c>gmPanelUI</c> panel ids carried by DAT property <c>0x10000029</c>.
|
||||
/// The toolbar exposes only a subset of these ids; Helpful/Harmful effects use
|
||||
/// the authored <c>gmUIElement_EffectsIndicator</c> buttons instead.
|
||||
/// </summary>
|
||||
public static class RetailPanelCatalog
|
||||
{
|
||||
public const uint PositiveEffects = 4u;
|
||||
public const uint NegativeEffects = 5u;
|
||||
public const uint Inventory = 7u;
|
||||
public const uint Character = 11u;
|
||||
public const uint Magic = 13u;
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Mounted =
|
||||
{
|
||||
(PositiveEffects, WindowNames.PositiveEffects),
|
||||
(NegativeEffects, WindowNames.NegativeEffects),
|
||||
(Inventory, WindowNames.Inventory),
|
||||
(Character, WindowNames.Character),
|
||||
(Magic, WindowNames.Spellbook),
|
||||
};
|
||||
|
||||
private static readonly (uint PanelId, string WindowName)[] Toolbar =
|
||||
{
|
||||
(Inventory, WindowNames.Inventory),
|
||||
(Character, WindowNames.Character),
|
||||
(Magic, WindowNames.Spellbook),
|
||||
};
|
||||
|
||||
public static IReadOnlyList<(uint PanelId, string WindowName)> MountedPanels => Mounted;
|
||||
public static IReadOnlyList<(uint PanelId, string WindowName)> ToolbarPanels => Toolbar;
|
||||
|
||||
public static bool TryGetWindowName(uint panelId, out string windowName)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ using AcDream.App.Plugins;
|
|||
using AcDream.App.Combat;
|
||||
using AcDream.App.Input;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.App.Spells;
|
||||
using AcDream.App.UI.Layout;
|
||||
using AcDream.App.UI.Testing;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Selection;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.UI.Abstractions;
|
||||
using AcDream.UI.Abstractions.Panels.Chat;
|
||||
using AcDream.UI.Abstractions.Panels.Settings;
|
||||
|
|
@ -43,6 +45,26 @@ public sealed record CombatRuntimeBindings(
|
|||
Func<GameplaySettings> Gameplay,
|
||||
Action<GameplaySettings> SetGameplay);
|
||||
|
||||
public sealed record MagicRuntimeBindings(
|
||||
Spellbook Spellbook,
|
||||
SpellCastingController Casting,
|
||||
ClientObjectTable Objects,
|
||||
Func<uint> PlayerGuid,
|
||||
IReadOnlyDictionary<uint, SpellComponentDescriptor> Components,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
|
||||
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
|
||||
Func<uint, uint> ResolveSpellIcon,
|
||||
Func<uint, uint> ResolveComponentIcon,
|
||||
SelectionState Selection,
|
||||
Func<uint, int> SpellLevel,
|
||||
Action<uint> SelectObject,
|
||||
Action<uint> UseItem,
|
||||
Action<int, int, uint> AddFavorite,
|
||||
Action<int, uint> RemoveFavorite,
|
||||
Action<uint> SendSpellbookFilter,
|
||||
Action<uint, uint> SetDesiredComponent,
|
||||
Func<double> ServerTime);
|
||||
|
||||
public sealed record JumpPowerbarRuntimeBindings(Func<JumpChargeSnapshot> Snapshot);
|
||||
|
||||
public sealed record FpsRuntimeBindings(
|
||||
|
|
@ -116,6 +138,7 @@ public sealed record RetailUiRuntimeBindings(
|
|||
ChatRuntimeBindings Chat,
|
||||
RadarRuntimeBindings Radar,
|
||||
CombatRuntimeBindings Combat,
|
||||
MagicRuntimeBindings Magic,
|
||||
JumpPowerbarRuntimeBindings JumpPowerbar,
|
||||
FpsRuntimeBindings Fps,
|
||||
ToolbarRuntimeBindings Toolbar,
|
||||
|
|
@ -139,6 +162,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private StackSplitQuantityState StackSplitQuantity => _bindings.StackSplitQuantity;
|
||||
private readonly RetailWindowLayoutPersistence? _persistence;
|
||||
private readonly RetailUiAutomationScriptRunner? _automation;
|
||||
private readonly RetailPanelUiController _panelUi;
|
||||
private GameplayConfirmationController? _gameplayConfirmationController;
|
||||
private RetailItemConfirmationController? _itemConfirmationController;
|
||||
private RetailSkillTrainingConfirmationController? _skillTrainingConfirmationController;
|
||||
|
|
@ -147,12 +171,19 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private RetailUiRuntime(RetailUiRuntimeBindings bindings)
|
||||
{
|
||||
_bindings = bindings;
|
||||
_panelUi = new RetailPanelUiController(
|
||||
bindings.Host.IsWindowVisible,
|
||||
bindings.Host.ShowWindow,
|
||||
bindings.Host.HideWindow);
|
||||
MountFpsDisplay();
|
||||
MountVitals();
|
||||
MountRadar();
|
||||
MountChat();
|
||||
MountToolbar();
|
||||
MountCombat();
|
||||
MountSpellbook();
|
||||
MountEffects();
|
||||
MountIndicators();
|
||||
MountJumpPowerbar();
|
||||
MountDialogFactory();
|
||||
MountCharacter();
|
||||
|
|
@ -192,6 +223,11 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public ToolbarController? ToolbarController { get; private set; }
|
||||
public ToolbarInputController? ToolbarInputController { get; private set; }
|
||||
public CombatUiController? CombatUiController { get; private set; }
|
||||
public SpellcastingUiController? SpellcastingUiController { get; private set; }
|
||||
public SpellbookWindowController? SpellbookWindowController { get; private set; }
|
||||
public EffectsUiController? PositiveEffectsController { get; private set; }
|
||||
public EffectsUiController? NegativeEffectsController { get; private set; }
|
||||
public EffectsIndicatorController? EffectsIndicatorController { get; private set; }
|
||||
public JumpPowerbarController? JumpPowerbarController { get; private set; }
|
||||
public RetailFpsController? FpsController { get; private set; }
|
||||
public SelectedObjectController? SelectedObjectController { get; private set; }
|
||||
|
|
@ -216,6 +252,10 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public void Tick(double deltaSeconds)
|
||||
{
|
||||
FpsController?.Tick();
|
||||
SpellbookWindowController?.Tick();
|
||||
SpellcastingUiController?.Tick();
|
||||
PositiveEffectsController?.Tick();
|
||||
NegativeEffectsController?.Tick();
|
||||
JumpPowerbarController?.Tick();
|
||||
SelectedObjectController?.Tick(deltaSeconds);
|
||||
DialogFactory?.Tick();
|
||||
|
|
@ -226,7 +266,33 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public void Draw(System.Numerics.Vector2 screenSize) => Host.Draw(screenSize);
|
||||
|
||||
public bool HandleInputAction(AcDream.UI.Abstractions.Input.InputAction action)
|
||||
=> ToolbarInputController?.Handle(action) == true;
|
||||
{
|
||||
if (SpellcastingUiController?.Handle(action) == true)
|
||||
return true;
|
||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellbookPanel)
|
||||
{
|
||||
OpenSpellbook(SpellbookWindowPage.Spells);
|
||||
return true;
|
||||
}
|
||||
if (action == AcDream.UI.Abstractions.Input.InputAction.ToggleSpellComponentsPanel)
|
||||
{
|
||||
OpenSpellbook(SpellbookWindowPage.Components);
|
||||
return true;
|
||||
}
|
||||
return ToolbarInputController?.Handle(action) == true;
|
||||
}
|
||||
|
||||
private void OpenSpellbook(SpellbookWindowPage page)
|
||||
{
|
||||
bool visible = Host.IsWindowVisible(WindowNames.Spellbook);
|
||||
if (visible && SpellbookWindowController?.CurrentPage == page)
|
||||
CloseWindow(WindowNames.Spellbook);
|
||||
else
|
||||
{
|
||||
SpellbookWindowController?.ShowPage(page);
|
||||
_panelUi.SetPanelVisibility(RetailPanelCatalog.Magic, visible: true);
|
||||
}
|
||||
}
|
||||
|
||||
public bool HandleConfirmationRequest(GameEvents.CharacterConfirmationRequest request)
|
||||
=> _gameplayConfirmationController?.HandleRequest(request) == true;
|
||||
|
|
@ -258,15 +324,22 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
public void RestoreNamedLayout(string profileName) => _persistence?.RestoreNamed(profileName);
|
||||
|
||||
public bool ToggleWindow(string name)
|
||||
=> Host.ToggleWindow(name);
|
||||
=> RetailPanelCatalog.TryGetPanelId(name, out uint panelId)
|
||||
? _panelUi.TogglePanel(panelId)
|
||||
: Host.ToggleWindow(name);
|
||||
|
||||
public void CloseWindow(string name)
|
||||
=> Host.HideWindow(name);
|
||||
{
|
||||
if (RetailPanelCatalog.TryGetPanelId(name, out uint panelId))
|
||||
_panelUi.SetPanelVisibility(panelId, visible: false);
|
||||
else
|
||||
Host.HideWindow(name);
|
||||
}
|
||||
|
||||
public void SyncToolbarWindowButtons()
|
||||
{
|
||||
if (ToolbarController is null) return;
|
||||
foreach (var (panelId, windowName) in RetailPanelCatalog.MountedPanels)
|
||||
foreach (var (panelId, windowName) in RetailPanelCatalog.ToolbarPanels)
|
||||
ToolbarController.SetPanelOpen(panelId, Host.IsWindowVisible(windowName));
|
||||
}
|
||||
|
||||
|
|
@ -284,6 +357,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
|
||||
private void OnWindowVisibilityChanged(string windowName, bool visible)
|
||||
{
|
||||
_panelUi.ObserveWindowVisibility(windowName, visible);
|
||||
if (RetailPanelCatalog.TryGetPanelId(windowName, out uint panelId))
|
||||
ToolbarController?.SetPanelOpen(panelId, visible);
|
||||
}
|
||||
|
|
@ -583,7 +657,28 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
SpellcastingUiController? spellcasting = Layout.SpellcastingUiController.Bind(
|
||||
layout,
|
||||
_bindings.Magic.Spellbook,
|
||||
_bindings.Magic.Casting,
|
||||
_bindings.Magic.Objects,
|
||||
_bindings.Magic.PlayerGuid,
|
||||
_bindings.Magic.ResolveSpellIcon,
|
||||
item => _bindings.Magic.ResolveDragIcon(
|
||||
item.Type, item.IconId, item.IconUnderlayId,
|
||||
item.IconOverlayId, item.Effects),
|
||||
_bindings.Magic.UseItem,
|
||||
_bindings.Magic.Selection,
|
||||
_bindings.Magic.AddFavorite,
|
||||
_bindings.Magic.RemoveFavorite);
|
||||
if (spellcasting is null)
|
||||
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
|
||||
|
||||
CombatUiController = controller;
|
||||
SpellcastingUiController = spellcasting;
|
||||
IRetainedPanelController controllerOwner = spellcasting is null
|
||||
? controller
|
||||
: new RetainedPanelControllerGroup(controller, spellcasting);
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
|
|
@ -601,10 +696,190 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ResizeY = false,
|
||||
ConstrainDragToParent = true,
|
||||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
Controller = controllerOwner,
|
||||
});
|
||||
controller.SyncVisibility();
|
||||
Console.WriteLine("[M2] retail combat bar from gmCombatUI LayoutDesc 0x21000073.");
|
||||
Console.WriteLine(spellcasting is null
|
||||
? "[M2] retail combat from LayoutDesc 0x21000073; magic binding unavailable."
|
||||
: "[M3] retail combat + spell bar from LayoutDesc 0x21000073.");
|
||||
}
|
||||
|
||||
private void MountSpellbook()
|
||||
{
|
||||
ImportedLayout? layout;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
layout = LayoutImporter.Import(
|
||||
_bindings.Assets.Dats,
|
||||
SpellbookWindowController.LayoutId,
|
||||
SpellbookWindowController.RootId,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont);
|
||||
}
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine("[M3] spellbook: LayoutDesc 0x21000034 not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
SpellbookWindowController? controller = Layout.SpellbookWindowController.Bind(
|
||||
layout,
|
||||
_bindings.Magic.Spellbook,
|
||||
_bindings.Magic.Objects,
|
||||
_bindings.Magic.PlayerGuid,
|
||||
_bindings.Magic.Components,
|
||||
_bindings.Magic.ResolveSpellIcon,
|
||||
_bindings.Magic.ResolveComponentIcon,
|
||||
_bindings.Magic.SpellLevel,
|
||||
_bindings.Magic.SelectObject,
|
||||
spellId => SpellcastingUiController?.AddFavorite(spellId),
|
||||
_bindings.Magic.SendSpellbookFilter,
|
||||
_bindings.Magic.SetDesiredComponent,
|
||||
() => CloseWindow(WindowNames.Spellbook));
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[M3] spellbook: required controls missing in LayoutDesc 0x21000034.");
|
||||
return;
|
||||
}
|
||||
|
||||
SpellbookWindowController = controller;
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Spellbook,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = 18f,
|
||||
Top = 18f,
|
||||
Visible = false,
|
||||
Resizable = false,
|
||||
ResizeX = false,
|
||||
ResizeY = false,
|
||||
ConstrainDragToParent = true,
|
||||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
});
|
||||
_panelUi.Register(RetailPanelCatalog.Magic, WindowNames.Spellbook);
|
||||
Console.WriteLine("[M3] retail spellbook/component book from LayoutDesc 0x21000034.");
|
||||
}
|
||||
|
||||
private void MountEffects()
|
||||
{
|
||||
MountEffectsInstance(positive: true);
|
||||
MountEffectsInstance(positive: false);
|
||||
}
|
||||
|
||||
private void MountEffectsInstance(bool positive)
|
||||
{
|
||||
uint rootId = positive ? EffectsUiController.PositiveRootId : EffectsUiController.NegativeRootId;
|
||||
ElementInfo? rootInfo;
|
||||
ImportedLayout? layout;
|
||||
lock (_bindings.Assets.DatLock)
|
||||
{
|
||||
rootInfo = LayoutImporter.ImportInfos(
|
||||
_bindings.Assets.Dats,
|
||||
EffectsUiController.LayoutId,
|
||||
rootId);
|
||||
layout = rootInfo is null
|
||||
? null
|
||||
: LayoutImporter.Build(
|
||||
rootInfo,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.DefaultFont,
|
||||
_bindings.Assets.ResolveFont,
|
||||
new DatStringResolver(_bindings.Assets.Dats).Resolve);
|
||||
}
|
||||
if (rootInfo is null || layout is null)
|
||||
{
|
||||
Console.WriteLine($"[M3] effects: root 0x{rootId:X8} not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
EffectsUiController? controller = Layout.EffectsUiController.Bind(
|
||||
layout,
|
||||
_bindings.Magic.Spellbook,
|
||||
positive,
|
||||
_bindings.Magic.ServerTime,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Magic.ResolveSpellIcon,
|
||||
close: () => CloseWindow(
|
||||
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects));
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine($"[M3] effects: list missing under root 0x{rootId:X8}.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (positive) PositiveEffectsController = controller;
|
||||
else NegativeEffectsController = controller;
|
||||
UiElement root = layout.Root;
|
||||
RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = Math.Max(0f, Host.Root.Width - root.Width - 12f),
|
||||
Top = 18f,
|
||||
Visible = false,
|
||||
Resizable = false,
|
||||
ResizeX = false,
|
||||
ResizeY = false,
|
||||
ConstrainDragToParent = true,
|
||||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
});
|
||||
_panelUi.Register(
|
||||
positive ? RetailPanelCatalog.PositiveEffects : RetailPanelCatalog.NegativeEffects,
|
||||
positive ? WindowNames.PositiveEffects : WindowNames.NegativeEffects,
|
||||
rootInfo);
|
||||
}
|
||||
|
||||
private void MountIndicators()
|
||||
{
|
||||
ImportedLayout? layout = Import(EffectsIndicatorController.LayoutId);
|
||||
if (layout is null)
|
||||
{
|
||||
Console.WriteLine("[M3] effects indicators: LayoutDesc 0x21000071 not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
EffectsIndicatorController? controller = Layout.EffectsIndicatorController.Bind(
|
||||
layout,
|
||||
_bindings.Magic.Spellbook,
|
||||
panelId => _panelUi.TogglePanel(panelId));
|
||||
if (controller is null)
|
||||
{
|
||||
Console.WriteLine("[M3] effects indicators: authored Helpful/Harmful buttons missing.");
|
||||
return;
|
||||
}
|
||||
|
||||
EffectsIndicatorController = controller;
|
||||
RetailWindowFrame.Mount(
|
||||
Host.Root,
|
||||
layout.Root,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
new RetailWindowFrame.Options
|
||||
{
|
||||
WindowName = WindowNames.Indicators,
|
||||
Chrome = RetailWindowChrome.Imported,
|
||||
Left = 10f,
|
||||
Top = 96f,
|
||||
Visible = true,
|
||||
Resizable = false,
|
||||
ResizeX = false,
|
||||
ResizeY = false,
|
||||
ConstrainDragToParent = true,
|
||||
ContentClickThrough = false,
|
||||
Controller = controller,
|
||||
});
|
||||
Console.WriteLine("[M3] retail Helpful/Harmful effect indicators from LayoutDesc 0x21000071.");
|
||||
}
|
||||
|
||||
private void MountJumpPowerbar()
|
||||
|
|
@ -825,6 +1100,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||
ContentClickThrough = false,
|
||||
});
|
||||
_panelUi.Register(RetailPanelCatalog.Character, WindowNames.Character);
|
||||
Console.WriteLine("[D.2b-C] retail character window from LayoutDesc importer (0x2100002E).");
|
||||
}
|
||||
|
||||
|
|
@ -906,6 +1182,7 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
Host.Root.Height - root.Top),
|
||||
ContentAnchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom,
|
||||
});
|
||||
_panelUi.Register(RetailPanelCatalog.Inventory, WindowNames.Inventory);
|
||||
|
||||
uint contents, sideBag, mainPack;
|
||||
IReadOnlyDictionary<uint, uint> paperdollEmptySprites;
|
||||
|
|
|
|||
97
src/AcDream.App/UI/UiCatalogSlot.cs
Normal file
97
src/AcDream.App/UI/UiCatalogSlot.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Icon-list cell for non-weenie catalog entries such as spells and components.
|
||||
/// It reuses the retail UIItemList geometry without pretending a spell id is an
|
||||
/// object GUID and without participating in item drag/drop.
|
||||
/// </summary>
|
||||
public sealed class UiCatalogSlot : UiItemSlot
|
||||
{
|
||||
public uint EntryId { get; set; }
|
||||
public uint CatalogIconTexture { get; set; }
|
||||
/// <summary>Optional second 32x32 layer (retail spell endowment item icon).</summary>
|
||||
public uint CatalogOverlayTexture { get; set; }
|
||||
public string Label { get; set; } = string.Empty;
|
||||
public string? Detail { get; set; }
|
||||
public bool ShowLabel { get; init; }
|
||||
|
||||
public new Action? Clicked { get; set; }
|
||||
public new Action? DoubleClicked { get; set; }
|
||||
public object? CatalogDragPayload { get; init; }
|
||||
public Action<object>? DragBegan { get; init; }
|
||||
public Action<object>? DragEnded { get; init; }
|
||||
public Action<object>? Dropped { get; init; }
|
||||
|
||||
public override string? GetTooltipText() => string.IsNullOrWhiteSpace(Label) ? null : Label;
|
||||
|
||||
public override bool IsDragSource => CatalogDragPayload is not null;
|
||||
public override object? GetDragPayload() => CatalogDragPayload;
|
||||
public override (uint tex, int w, int h)? GetDragGhost() =>
|
||||
CatalogDragPayload is not null && CatalogIconTexture != 0u
|
||||
? (CatalogIconTexture, 32, 32)
|
||||
: null;
|
||||
|
||||
internal override void SetDragSourceActive(bool active, object? payload)
|
||||
{
|
||||
if (!active && payload is not null) DragEnded?.Invoke(payload);
|
||||
}
|
||||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.MouseDown:
|
||||
return true;
|
||||
case UiEventType.Click:
|
||||
Clicked?.Invoke();
|
||||
return true;
|
||||
case UiEventType.DoubleClick:
|
||||
DoubleClicked?.Invoke();
|
||||
return true;
|
||||
case UiEventType.DragBegin:
|
||||
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
|
||||
return true;
|
||||
case UiEventType.DragEnter:
|
||||
case UiEventType.DragOver:
|
||||
return true;
|
||||
case UiEventType.DropReleased:
|
||||
if (e.Payload is not null) Dropped?.Invoke(e.Payload);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
float iconSize = ShowLabel ? MathF.Min(32f, Height) : Width;
|
||||
if (CatalogIconTexture != 0)
|
||||
ctx.DrawSprite(CatalogIconTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
else if (SpriteResolve is not null && EmptySprite != 0)
|
||||
{
|
||||
var (texture, _, _) = SpriteResolve(EmptySprite);
|
||||
if (texture != 0)
|
||||
ctx.DrawSprite(texture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
if (CatalogOverlayTexture != 0)
|
||||
ctx.DrawSprite(CatalogOverlayTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
|
||||
if (Selected && SpriteResolve is not null && SelectedSprite != 0)
|
||||
{
|
||||
var (texture, _, _) = SpriteResolve(SelectedSprite);
|
||||
if (texture != 0)
|
||||
ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
|
||||
}
|
||||
|
||||
if (ShowLabel)
|
||||
{
|
||||
ctx.DrawString(Label, iconSize + 4f, 3f, new Vector4(0.92f, 0.88f, 0.70f, 1f));
|
||||
if (!string.IsNullOrWhiteSpace(Detail))
|
||||
ctx.DrawString(Detail!, MathF.Max(iconSize + 4f, Width - 48f), 3f, Vector4.One);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ public sealed class UiField : UiElement
|
|||
private int _caret;
|
||||
private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret
|
||||
public string Text => _text;
|
||||
public bool IsFocused => _focused;
|
||||
public int CaretPos => _caret;
|
||||
|
||||
private readonly List<string> _history = new();
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ namespace AcDream.App.UI;
|
|||
/// pre-composited icon texture (set by the controller). Holds the bound weenie
|
||||
/// guid (retail UIElement_UIItem::itemID, +0x5FC).
|
||||
/// </summary>
|
||||
public sealed class UiItemSlot : UiElement
|
||||
public class UiItemSlot : UiElement
|
||||
{
|
||||
public UiItemSlot() { ClickThrough = false; }
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ namespace AcDream.App.UI;
|
|||
/// </summary>
|
||||
public sealed class UiText : UiElement
|
||||
{
|
||||
/// <summary>Optional base-element click notice used by authored text tabs.</summary>
|
||||
public Action? OnClick { get; set; }
|
||||
public override bool HandlesClick => OnClick is not null || base.HandlesClick;
|
||||
/// <summary>Dat element id for imported UIElement_Text widgets. 0 for synthesized text.</summary>
|
||||
public uint ElementId { get; set; }
|
||||
|
||||
|
|
@ -360,6 +363,11 @@ public sealed class UiText : UiElement
|
|||
|
||||
public override bool OnEvent(in UiEvent e)
|
||||
{
|
||||
if (e.Type == UiEventType.Click && OnClick is not null)
|
||||
{
|
||||
OnClick();
|
||||
return true;
|
||||
}
|
||||
switch (e.Type)
|
||||
{
|
||||
case UiEventType.Scroll:
|
||||
|
|
|
|||
|
|
@ -12,4 +12,8 @@ public static class WindowNames
|
|||
public const string Radar = "radar";
|
||||
public const string Combat = "combat";
|
||||
public const string JumpPowerbar = "jump-powerbar";
|
||||
public const string Spellbook = "spellbook";
|
||||
public const string Indicators = "indicators";
|
||||
public const string PositiveEffects = "effects-positive";
|
||||
public const string NegativeEffects = "effects-negative";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using AcDream.Core.Chat;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
|
|
@ -77,13 +78,15 @@ public static class GameEventWiring
|
|||
FriendsState? friends = null,
|
||||
SquelchState? squelch = null,
|
||||
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
|
||||
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null)
|
||||
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null,
|
||||
Func<double>? clientTime = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(dispatcher);
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
ArgumentNullException.ThrowIfNull(combat);
|
||||
ArgumentNullException.ThrowIfNull(spellbook);
|
||||
ArgumentNullException.ThrowIfNull(chat);
|
||||
clientTime ??= static () => 0d;
|
||||
|
||||
// ── Chat ──────────────────────────────────────────────────
|
||||
dispatcher.Register(GameEventType.ChannelBroadcast, e =>
|
||||
|
|
@ -283,21 +286,44 @@ public static class GameEventWiring
|
|||
dispatcher.Register(GameEventType.MagicUpdateEnchantment, e =>
|
||||
{
|
||||
var p = GameEvents.ParseMagicUpdateEnchantment(e.Payload.Span);
|
||||
if (p is not null) spellbook.OnEnchantmentAdded(
|
||||
p.Value.SpellId, p.Value.LayerId, p.Value.Duration, p.Value.CasterGuid);
|
||||
if (p is not null) spellbook.OnEnchantmentAdded(ToActiveEnchantment(p.Value, clientTime()));
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicUpdateMultipleEnchantments, e =>
|
||||
{
|
||||
var entries = GameEvents.ParseMagicUpdateMultipleEnchantments(e.Payload.Span);
|
||||
if (entries is not null)
|
||||
{
|
||||
double receivedAt = clientTime();
|
||||
spellbook.OnEnchantmentsAdded(entries.Select(entry =>
|
||||
ToActiveEnchantment(entry, receivedAt)));
|
||||
}
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicRemoveEnchantment, e =>
|
||||
{
|
||||
var p = GameEvents.ParseMagicRemoveEnchantment(e.Payload.Span);
|
||||
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
|
||||
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicRemoveMultipleEnchantments, e =>
|
||||
{
|
||||
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
|
||||
if (entries is not null)
|
||||
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicDispelEnchantment, e =>
|
||||
{
|
||||
var p = GameEvents.ParseMagicDispelEnchantment(e.Payload.Span);
|
||||
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.LayerId, p.Value.SpellId);
|
||||
if (p is not null) spellbook.OnEnchantmentRemoved(p.Value.Layer, p.Value.SpellId);
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicDispelMultipleEnchantments, e =>
|
||||
{
|
||||
var entries = GameEvents.ParseMagicLayeredSpellList(e.Payload.Span);
|
||||
if (entries is not null)
|
||||
spellbook.OnEnchantmentsRemoved(entries.Select(item => ((uint)item.SpellId, (uint)item.Layer)));
|
||||
});
|
||||
dispatcher.Register(GameEventType.MagicPurgeEnchantments,
|
||||
_ => spellbook.OnPurgeAll());
|
||||
dispatcher.Register(GameEventType.MagicPurgeBadEnchantments,
|
||||
_ => spellbook.OnPurgeBadEnchantments());
|
||||
|
||||
// ── Inventory ─────────────────────────────────────────────
|
||||
dispatcher.Register(GameEventType.WieldObject, e =>
|
||||
|
|
@ -407,8 +433,8 @@ public static class GameEventWiring
|
|||
// spellbook arrives via PlayerDescription (0x0013), which uses
|
||||
// a different wire format (see WorldSession + LocalPlayerState
|
||||
// — feeds vitals from PrivateUpdateVital instead).
|
||||
foreach (uint sid in p.Value.SpellBook)
|
||||
spellbook.OnSpellLearned(sid);
|
||||
// The appraised spellbook belongs to that item. The local player's
|
||||
// learned spell manifest arrives only in PlayerDescription.
|
||||
});
|
||||
|
||||
// ── Player ────────────────────────────────────────────────
|
||||
|
|
@ -441,6 +467,17 @@ public static class GameEventWiring
|
|||
onCharacterOptions?.Invoke(p.Value.Options1, p.Value.Options2);
|
||||
onDesiredComponents?.Invoke(p.Value.DesiredComps);
|
||||
|
||||
double receivedAt = clientTime();
|
||||
ActiveEnchantmentRecord[] enchantments = p.Value.Enchantments
|
||||
.Select(entry => ToActiveEnchantment(entry, receivedAt))
|
||||
.ToArray();
|
||||
spellbook.ReplaceManifest(
|
||||
p.Value.Spells,
|
||||
enchantments,
|
||||
p.Value.HotbarSpells,
|
||||
p.Value.DesiredComps,
|
||||
p.Value.SpellbookFilters);
|
||||
|
||||
// B-Wire: deliver the player's OWN properties to the player ClientObject.
|
||||
// (PD's "membership manifest" rule is about ITEMS, whose data comes from
|
||||
// CreateObject; the player's own stats legitimately come from PD.) Upsert
|
||||
|
|
@ -508,9 +545,6 @@ public static class GameEventWiring
|
|||
}
|
||||
}
|
||||
|
||||
foreach (uint sid in p.Value.Spells.Keys)
|
||||
spellbook.OnSpellLearned(sid);
|
||||
|
||||
// K-fix7 (2026-04-26): push Run + Jump skill values to the
|
||||
// PlayerMovementController so the runRate / jump-arc formulas
|
||||
// use the SERVER's authoritative skill instead of our
|
||||
|
|
@ -566,21 +600,6 @@ public static class GameEventWiring
|
|||
// Issue #7 — enchantment block: feed each entry into the
|
||||
// Spellbook with full StatMod data so EnchantmentMath can
|
||||
// aggregate buffs in vital-max calc (issue #6 lights up).
|
||||
foreach (var ench in p.Value.Enchantments)
|
||||
{
|
||||
spellbook.OnEnchantmentAdded(new AcDream.Core.Spells.ActiveEnchantmentRecord(
|
||||
SpellId: ench.SpellId,
|
||||
LayerId: ench.Layer,
|
||||
Duration: (float)ench.Duration,
|
||||
CasterGuid: ench.CasterGuid,
|
||||
StatModType: ench.StatModType,
|
||||
StatModKey: ench.StatModKey,
|
||||
StatModValue: ench.StatModValue,
|
||||
Bucket: (uint)ench.Bucket));
|
||||
if (dumpPd)
|
||||
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
|
||||
}
|
||||
|
||||
// D.5.4: PlayerDescription is a membership MANIFEST, not the data
|
||||
// source. Record existence (+ equip slot); CreateObject fills the
|
||||
// actual weenie data via ObjectTableWiring. (Previously this seeded
|
||||
|
|
@ -630,4 +649,46 @@ public static class GameEventWiring
|
|||
onShortcuts?.Invoke(p.Value.Shortcuts);
|
||||
});
|
||||
}
|
||||
|
||||
private static ActiveEnchantmentRecord ToActiveEnchantment(
|
||||
PlayerDescriptionParser.EnchantmentEntry enchantment,
|
||||
double receivedAt) => new(
|
||||
SpellId: enchantment.SpellId,
|
||||
LayerId: enchantment.Layer,
|
||||
Duration: enchantment.Duration,
|
||||
CasterGuid: enchantment.CasterGuid,
|
||||
StatModType: enchantment.StatModType,
|
||||
StatModKey: enchantment.StatModKey,
|
||||
StatModValue: enchantment.StatModValue,
|
||||
Bucket: enchantment.Bucket == 0
|
||||
? ClassifyLiveEnchantmentBucket(enchantment.StatModType)
|
||||
: (uint)enchantment.Bucket,
|
||||
// Retail Enchantment::UnPack (0x005CB040) converts both relative
|
||||
// wire timestamps to the monotonic client Timer domain at receipt.
|
||||
StartTime: receivedAt + enchantment.StartTime,
|
||||
SpellCategory: enchantment.SpellCategory,
|
||||
PowerLevel: enchantment.PowerLevel,
|
||||
DegradeModifier: enchantment.DegradeModifier,
|
||||
DegradeLimit: enchantment.DegradeLimit,
|
||||
LastTimeDegraded: receivedAt + enchantment.LastTimeDegraded,
|
||||
SpellSetId: enchantment.SpellSetId);
|
||||
|
||||
/// <summary>
|
||||
/// Live 0x02C2/0x02C4 records do not carry PlayerDescription's outer
|
||||
/// EnchantmentMask bucket. Retail reconstructs the registry list from the
|
||||
/// StatMod type flags; this is the same ordering used by ACE's
|
||||
/// EnchantmentRegistry.BuildCategories.
|
||||
/// </summary>
|
||||
private static uint ClassifyLiveEnchantmentBucket(uint statModType)
|
||||
{
|
||||
const uint Multiplicative = 0x00004000u;
|
||||
const uint Additive = 0x00008000u;
|
||||
const uint Vitae = 0x00800000u;
|
||||
const uint Cooldown = 0x01000000u;
|
||||
if ((statModType & Vitae) != 0) return 4u;
|
||||
if ((statModType & Cooldown) != 0) return 8u;
|
||||
if ((statModType & Multiplicative) != 0) return 1u;
|
||||
if ((statModType & Additive) != 0) return 2u;
|
||||
return 0u;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,9 @@ public static class ClientCommandRequests
|
|||
public const uint DisplayConsentOpcode = 0x0217u;
|
||||
public const uint RemoveConsentOpcode = 0x0218u;
|
||||
public const uint SetDesiredComponentLevelOpcode = 0x0224u;
|
||||
public const uint AddSpellFavoriteOpcode = 0x01E3u;
|
||||
public const uint RemoveSpellFavoriteOpcode = 0x01E4u;
|
||||
public const uint SpellbookFilterOpcode = 0x0286u;
|
||||
public const uint LegacyFriendsOpcode = 0xF7CDu;
|
||||
|
||||
// Named-retail anchors:
|
||||
|
|
@ -177,6 +180,31 @@ public static class ClientCommandRequests
|
|||
return body;
|
||||
}
|
||||
|
||||
// CM_Character::Event_AddSpellFavorite @ 0x006A0F70.
|
||||
public static byte[] BuildAddSpellFavorite(
|
||||
uint sequence, uint spellId, int position, int tabIndex)
|
||||
{
|
||||
byte[] body = CreateBody(sequence, AddSpellFavoriteOpcode, 12);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(16), position);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(20), tabIndex);
|
||||
return body;
|
||||
}
|
||||
|
||||
// CM_Character::Event_RemoveSpellFavorite @ 0x006A1890.
|
||||
public static byte[] BuildRemoveSpellFavorite(
|
||||
uint sequence, uint spellId, int tabIndex)
|
||||
{
|
||||
byte[] body = CreateBody(sequence, RemoveSpellFavoriteOpcode, 8);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), spellId);
|
||||
BinaryPrimitives.WriteInt32LittleEndian(body.AsSpan(16), tabIndex);
|
||||
return body;
|
||||
}
|
||||
|
||||
// CM_Character::Event_SpellbookFilterEvent @ 0x006A1A30.
|
||||
public static byte[] BuildSpellbookFilter(uint sequence, uint filters) =>
|
||||
BuildUInt32(sequence, SpellbookFilterOpcode, filters);
|
||||
|
||||
private static byte[] BuildParameterless(uint sequence, uint opcode)
|
||||
{
|
||||
byte[] body = new byte[12];
|
||||
|
|
|
|||
|
|
@ -207,6 +207,9 @@ public static class CreateObject
|
|||
// PublicWeenieDesc._ammoType, gated by WeenieHeader flag 0x100.
|
||||
// AMMO_NONE is the explicit wire value zero; null means absent.
|
||||
ushort? AmmoType = null,
|
||||
// PublicWeenieDesc._spellID, gated by PWD_Packed_SpellID. The packed
|
||||
// wire field is u16 and widens into retail's u32 runtime member.
|
||||
uint? SpellId = null,
|
||||
// Complete immutable PhysicsDesc projection. Kept alongside the
|
||||
// legacy convenience fields while live-entity ownership migrates to
|
||||
// LiveEntityRuntime in Step 2.
|
||||
|
|
@ -862,6 +865,7 @@ public static class CreateObject
|
|||
byte? radarBehavior = null;
|
||||
byte? combatUse = null;
|
||||
ushort? ammoType = null;
|
||||
uint? spellId = null;
|
||||
uint iconOverlayId = 0;
|
||||
uint iconUnderlayId = 0;
|
||||
uint uiEffects = 0;
|
||||
|
|
@ -1011,6 +1015,7 @@ public static class CreateObject
|
|||
if ((weenieFlags & 0x00400000u) != 0) // Spell u16
|
||||
{
|
||||
if (body.Length - pos < 2) throw new FormatException("trunc Spell");
|
||||
spellId = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos));
|
||||
pos += 2;
|
||||
}
|
||||
if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32
|
||||
|
|
@ -1113,6 +1118,7 @@ public static class CreateObject
|
|||
PluralName: pluralName,
|
||||
PetOwnerId: petOwnerId,
|
||||
AmmoType: ammoType,
|
||||
SpellId: spellId,
|
||||
Physics: physics);
|
||||
}
|
||||
catch
|
||||
|
|
|
|||
115
src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs
Normal file
115
src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>Enchantment::UnPack</c> reader shared by PlayerDescription and
|
||||
/// the 0x02C2/0x02C4 magic update events. The record is 60 bytes, plus the
|
||||
/// optional four-byte spell-set id.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Retail reference: <c>CM_Magic::DispatchUI_UpdateEnchantment</c>
|
||||
/// (0x006A32F0). Layout cross-checked against ACE
|
||||
/// <c>Network/Structure/Enchantment.cs</c>.
|
||||
/// </remarks>
|
||||
public static class EnchantmentWireReader
|
||||
{
|
||||
public static PlayerDescriptionParser.EnchantmentEntry Read(
|
||||
ReadOnlySpan<byte> source,
|
||||
ref int position,
|
||||
PlayerDescriptionParser.EnchantmentBucket bucket = 0)
|
||||
{
|
||||
if (source.Length - position < 60)
|
||||
throw new FormatException("truncated enchantment record");
|
||||
|
||||
ushort spellId = ReadU16(source, ref position);
|
||||
ushort layer = ReadU16(source, ref position);
|
||||
ushort spellCategory = ReadU16(source, ref position);
|
||||
ushort hasSpellSetId = ReadU16(source, ref position);
|
||||
uint powerLevel = ReadU32(source, ref position);
|
||||
double startTime = ReadF64(source, ref position);
|
||||
double duration = ReadF64(source, ref position);
|
||||
uint casterGuid = ReadU32(source, ref position);
|
||||
float degradeModifier = ReadF32(source, ref position);
|
||||
float degradeLimit = ReadF32(source, ref position);
|
||||
double lastDegraded = ReadF64(source, ref position);
|
||||
uint statModType = ReadU32(source, ref position);
|
||||
uint statModKey = ReadU32(source, ref position);
|
||||
float statModValue = ReadF32(source, ref position);
|
||||
uint? spellSetId = hasSpellSetId != 0 ? ReadU32(source, ref position) : null;
|
||||
|
||||
if (!double.IsFinite(startTime)
|
||||
|| !double.IsFinite(duration)
|
||||
|| !float.IsFinite(degradeModifier)
|
||||
|| !float.IsFinite(degradeLimit)
|
||||
|| !double.IsFinite(lastDegraded)
|
||||
|| !float.IsFinite(statModValue))
|
||||
{
|
||||
throw new FormatException("non-finite enchantment value");
|
||||
}
|
||||
|
||||
return new PlayerDescriptionParser.EnchantmentEntry(
|
||||
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
|
||||
startTime, duration, casterGuid, degradeModifier, degradeLimit,
|
||||
lastDegraded, statModType, statModKey, statModValue, spellSetId,
|
||||
bucket);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry> ReadList(
|
||||
ReadOnlySpan<byte> source,
|
||||
ref int position,
|
||||
PlayerDescriptionParser.EnchantmentBucket bucket = 0)
|
||||
{
|
||||
if (source.Length - position < 4)
|
||||
throw new FormatException("truncated enchantment list count");
|
||||
|
||||
uint count = ReadU32(source, ref position);
|
||||
if (count > 0x4000)
|
||||
throw new FormatException("unreasonable enchantment list count");
|
||||
|
||||
var result = new List<PlayerDescriptionParser.EnchantmentEntry>((int)count);
|
||||
for (uint i = 0; i < count; i++)
|
||||
result.Add(Read(source, ref position, bucket));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ushort ReadU16(ReadOnlySpan<byte> source, ref int position)
|
||||
{
|
||||
Require(source, position, 2);
|
||||
ushort value = BinaryPrimitives.ReadUInt16LittleEndian(source.Slice(position, 2));
|
||||
position += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static uint ReadU32(ReadOnlySpan<byte> source, ref int position)
|
||||
{
|
||||
Require(source, position, 4);
|
||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(source.Slice(position, 4));
|
||||
position += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static float ReadF32(ReadOnlySpan<byte> source, ref int position)
|
||||
{
|
||||
Require(source, position, 4);
|
||||
float value = BinaryPrimitives.ReadSingleLittleEndian(source.Slice(position, 4));
|
||||
position += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static double ReadF64(ReadOnlySpan<byte> source, ref int position)
|
||||
{
|
||||
Require(source, position, 8);
|
||||
double value = BinaryPrimitives.ReadDoubleLittleEndian(source.Slice(position, 8));
|
||||
position += 8;
|
||||
return value;
|
||||
}
|
||||
|
||||
private static void Require(ReadOnlySpan<byte> source, int position, int count)
|
||||
{
|
||||
if (position < 0 || source.Length - position < count)
|
||||
throw new FormatException("truncated enchantment record");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Net.Messages;
|
||||
|
|
@ -271,14 +272,19 @@ public static class GameEvents
|
|||
/// <summary>
|
||||
/// 0x02C3 MagicRemoveEnchantment — (layerId, spellId).
|
||||
/// </summary>
|
||||
public readonly record struct MagicRemoveEnchantment(uint LayerId, uint SpellId);
|
||||
public readonly record struct LayeredSpellId(ushort SpellId, ushort Layer)
|
||||
{
|
||||
public uint Packed => SpellId | ((uint)Layer << 16);
|
||||
}
|
||||
|
||||
public readonly record struct MagicRemoveEnchantment(ushort SpellId, ushort Layer);
|
||||
|
||||
public static MagicRemoveEnchantment? ParseMagicRemoveEnchantment(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 8) return null;
|
||||
if (payload.Length < 4) return null;
|
||||
return new MagicRemoveEnchantment(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)));
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(2)));
|
||||
}
|
||||
|
||||
/// <summary>0x01A8 MagicRemoveSpell — spell id removed from spellbook.</summary>
|
||||
|
|
@ -294,26 +300,20 @@ public static class GameEvents
|
|||
/// stat mods. We expose the first few fields that drive the enchant
|
||||
/// bar UI; the rest is available via the raw payload view.
|
||||
/// </summary>
|
||||
public readonly record struct EnchantmentSummary(
|
||||
uint SpellId,
|
||||
uint LayerId,
|
||||
float Duration,
|
||||
uint CasterGuid);
|
||||
|
||||
public static EnchantmentSummary? ParseMagicUpdateEnchantment(ReadOnlySpan<byte> payload)
|
||||
public static PlayerDescriptionParser.EnchantmentEntry? ParseMagicUpdateEnchantment(
|
||||
ReadOnlySpan<byte> payload)
|
||||
{
|
||||
// Layout (ACE Enchantment.Pack):
|
||||
// u32 spellId
|
||||
// u32 layerId
|
||||
// f32 duration
|
||||
// u32 casterGuid
|
||||
// ... (stat mods, category, power, etc.)
|
||||
if (payload.Length < 16) return null;
|
||||
return new EnchantmentSummary(
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)),
|
||||
BinaryPrimitives.ReadSingleLittleEndian(payload.Slice(8)),
|
||||
BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12)));
|
||||
int position = 0;
|
||||
try { return EnchantmentWireReader.Read(payload, ref position); }
|
||||
catch (FormatException) { return null; }
|
||||
}
|
||||
|
||||
public static IReadOnlyList<PlayerDescriptionParser.EnchantmentEntry>?
|
||||
ParseMagicUpdateMultipleEnchantments(ReadOnlySpan<byte> payload)
|
||||
{
|
||||
int position = 0;
|
||||
try { return EnchantmentWireReader.ReadList(payload, ref position); }
|
||||
catch (FormatException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -323,6 +323,23 @@ public static class GameEvents
|
|||
public static MagicRemoveEnchantment? ParseMagicDispelEnchantment(ReadOnlySpan<byte> payload)
|
||||
=> ParseMagicRemoveEnchantment(payload);
|
||||
|
||||
public static IReadOnlyList<LayeredSpellId>? ParseMagicLayeredSpellList(
|
||||
ReadOnlySpan<byte> payload)
|
||||
{
|
||||
if (payload.Length < 4) return null;
|
||||
uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload);
|
||||
if (count > 0x4000 || payload.Length - 4 < checked((int)count * 4)) return null;
|
||||
var result = new LayeredSpellId[count];
|
||||
for (int i = 0; i < result.Length; i++)
|
||||
{
|
||||
int offset = 4 + i * 4;
|
||||
result[i] = new LayeredSpellId(
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset, 2)),
|
||||
BinaryPrimitives.ReadUInt16LittleEndian(payload.Slice(offset + 2, 2)));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Appraise / identify ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>0x00C9 IdentifyObjectResponse header.</summary>
|
||||
|
|
|
|||
|
|
@ -328,7 +328,9 @@ public static class PlayerDescriptionParser
|
|||
CharacterOptionDataFlag optionFlags = CharacterOptionDataFlag.None;
|
||||
uint options1 = 0;
|
||||
uint options2 = 0;
|
||||
uint spellbookFilters = 0;
|
||||
// Retail CPlayerModule ctor (0x005D5245) enables every school and
|
||||
// level filter before any optional PlayerDescription override.
|
||||
uint spellbookFilters = 0x3FFFu;
|
||||
List<ShortcutEntry> shortcuts = new();
|
||||
List<IReadOnlyList<uint>> hotbarSpells = new();
|
||||
List<(uint, uint)> desiredComps = new();
|
||||
|
|
@ -687,10 +689,7 @@ public static class PlayerDescriptionParser
|
|||
ReadOnlySpan<byte> src, ref int pos, List<EnchantmentEntry> dest,
|
||||
EnchantmentBucket bucket)
|
||||
{
|
||||
uint count = ReadU32(src, ref pos);
|
||||
if (count > 0x4000) throw new FormatException("unreasonable enchantment list count");
|
||||
for (int i = 0; i < count; i++)
|
||||
dest.Add(ReadEnchantment(src, ref pos, bucket));
|
||||
dest.AddRange(EnchantmentWireReader.ReadList(src, ref pos, bucket));
|
||||
}
|
||||
|
||||
private static EnchantmentEntry ReadEnchantment(
|
||||
|
|
@ -703,29 +702,7 @@ public static class PlayerDescriptionParser
|
|||
// f32 degrade_modifier, f32 degrade_limit, f64 last_time_degraded,
|
||||
// u32 stat_mod_type, u32 stat_mod_key, f32 stat_mod_value, (28)
|
||||
// if has_spell_set_id != 0: u32 spell_set_id (0 or 4)
|
||||
if (src.Length - pos < 60) throw new FormatException("truncated enchantment record");
|
||||
ushort spellId = ReadU16(src, ref pos);
|
||||
ushort layer = ReadU16(src, ref pos);
|
||||
ushort spellCategory = ReadU16(src, ref pos);
|
||||
ushort hasSpellSetId = ReadU16(src, ref pos);
|
||||
uint powerLevel = ReadU32(src, ref pos);
|
||||
double startTime = ReadF64(src, ref pos);
|
||||
double duration = ReadF64(src, ref pos);
|
||||
uint casterGuid = ReadU32(src, ref pos);
|
||||
float degradeModifier= ReadF32(src, ref pos);
|
||||
float degradeLimit = ReadF32(src, ref pos);
|
||||
double lastDegraded = ReadF64(src, ref pos);
|
||||
uint statModType = ReadU32(src, ref pos);
|
||||
uint statModKey = ReadU32(src, ref pos);
|
||||
float statModValue = ReadF32(src, ref pos);
|
||||
uint? spellSetId = null;
|
||||
if (hasSpellSetId != 0)
|
||||
spellSetId = ReadU32(src, ref pos);
|
||||
return new EnchantmentEntry(
|
||||
spellId, layer, spellCategory, hasSpellSetId, powerLevel,
|
||||
startTime, duration, casterGuid, degradeModifier, degradeLimit,
|
||||
lastDegraded, statModType, statModKey, statModValue, spellSetId,
|
||||
bucket);
|
||||
return EnchantmentWireReader.Read(src, ref pos, bucket);
|
||||
}
|
||||
|
||||
/// <summary>Strict inventory + equipped block reader. Returns true if
|
||||
|
|
|
|||
|
|
@ -138,5 +138,6 @@ public static class ObjectTableWiring
|
|||
CombatUse: s.CombatUse,
|
||||
PluralName: s.PluralName,
|
||||
PetOwnerId: s.PetOwnerId,
|
||||
AmmoType: s.AmmoType);
|
||||
AmmoType: s.AmmoType,
|
||||
SpellId: s.SpellId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ public sealed class WorldSession : IDisposable
|
|||
string? PluralName = null,
|
||||
uint? PetOwnerId = null,
|
||||
ushort? AmmoType = null,
|
||||
uint? SpellId = null,
|
||||
PhysicsSpawnData? Physics = null);
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -191,6 +192,7 @@ public sealed class WorldSession : IDisposable
|
|||
PluralName: parsed.PluralName,
|
||||
PetOwnerId: parsed.PetOwnerId,
|
||||
AmmoType: parsed.AmmoType,
|
||||
SpellId: parsed.SpellId,
|
||||
Physics: parsed.Physics);
|
||||
|
||||
/// <summary>Fires when the session finishes parsing a CreateObject.</summary>
|
||||
|
|
@ -1451,6 +1453,45 @@ public sealed class WorldSession : IDisposable
|
|||
seq, componentId: 0u, amount: uint.MaxValue));
|
||||
}
|
||||
|
||||
public void SendSetDesiredComponentLevel(uint componentId, uint amount)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSetDesiredComponentLevel(
|
||||
seq, componentId, amount));
|
||||
}
|
||||
|
||||
public void SendAddSpellFavorite(uint spellId, int position, int tabIndex)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildAddSpellFavorite(
|
||||
seq, spellId, position, tabIndex));
|
||||
}
|
||||
|
||||
public void SendRemoveSpellFavorite(uint spellId, int tabIndex)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildRemoveSpellFavorite(
|
||||
seq, spellId, tabIndex));
|
||||
}
|
||||
|
||||
public void SendSpellbookFilter(uint filters)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(ClientCommandRequests.BuildSpellbookFilter(seq, filters));
|
||||
}
|
||||
|
||||
public void SendCastUntargetedSpell(uint spellId)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(CastSpellRequest.BuildUntargeted(seq, spellId));
|
||||
}
|
||||
|
||||
public void SendCastTargetedSpell(uint targetGuid, uint spellId)
|
||||
{
|
||||
uint seq = NextGameActionSequence();
|
||||
SendGameAction(CastSpellRequest.BuildTargeted(seq, targetGuid, spellId));
|
||||
}
|
||||
|
||||
/// <summary>Send retail ChangeCombatMode (0x0053).</summary>
|
||||
public void SendChangeCombatMode(CombatMode mode)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -191,6 +191,8 @@ public sealed class ClientObject
|
|||
public byte? CombatUse { get; set; }
|
||||
/// <summary>Retail <c>PublicWeenieDesc._ammoType</c>; zero is <c>AMMO_NONE</c>.</summary>
|
||||
public ushort? AmmoType { get; set; }
|
||||
/// <summary>Retail <c>PublicWeenieDesc._spellID</c>; used by caster endowments.</summary>
|
||||
public uint? SpellId { get; set; }
|
||||
/// <summary>Client-side trade state used by ItemHolder legality gates.</summary>
|
||||
public int TradeState { get; set; }
|
||||
/// <summary>Resolved membership in SpellComponentTable (retail IsComponentPack).</summary>
|
||||
|
|
@ -258,7 +260,8 @@ public readonly record struct WeenieData(
|
|||
byte? CombatUse = null,
|
||||
string? PluralName = null,
|
||||
uint? PetOwnerId = null,
|
||||
ushort? AmmoType = null);
|
||||
ushort? AmmoType = null,
|
||||
uint? SpellId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
|
||||
|
|
|
|||
|
|
@ -700,6 +700,7 @@ public sealed class ClientObjectTable
|
|||
if (d.PetOwnerId is { } petOwnerId) obj.PetOwnerId = petOwnerId;
|
||||
if (d.CombatUse is { } combatUse) obj.CombatUse = combatUse;
|
||||
if (d.AmmoType is { } ammoType) obj.AmmoType = ammoType;
|
||||
if (d.SpellId is { } spellId) obj.SpellId = spellId;
|
||||
if (d.RadarBlipColor is { } radarBlipColor) obj.RadarBlipColor = radarBlipColor;
|
||||
if (d.RadarBehavior is { } radarBehavior) obj.RadarBehavior = radarBehavior;
|
||||
if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic;
|
||||
|
|
|
|||
67
src/AcDream.Core/Spells/EnchantmentRegistryProjection.cs
Normal file
67
src/AcDream.Core/Spells/EnchantmentRegistryProjection.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AcDream.Core.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Pure port of retail's ordinary in-effect registry projection:
|
||||
/// <c>CEnchantmentRegistry::GetEnchantmentsInEffect @ 0x00594540</c>,
|
||||
/// <c>GetEnchantmentsInEffectFromList @ 0x00594430</c>,
|
||||
/// <c>Duel @ 0x005942B0</c>, and <c>Enchantment::Duel @ 0x005CACE0</c>.
|
||||
/// </summary>
|
||||
public static class EnchantmentRegistryProjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Merges multiplicative then additive records and retains one winner per
|
||||
/// spell category. Higher power wins; equal power is replaced only by a
|
||||
/// later start time. Vitae and cooldown registries do not participate.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<ActiveEnchantmentRecord> GetEnchantmentsInEffect(
|
||||
IEnumerable<ActiveEnchantmentRecord> enchantments)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(enchantments);
|
||||
ActiveEnchantmentRecord[] source = enchantments as ActiveEnchantmentRecord[]
|
||||
?? [.. enchantments];
|
||||
var result = new List<ActiveEnchantmentRecord>();
|
||||
DuelList(source, bucket: 1u, result);
|
||||
DuelList(source, bucket: 2u, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void DuelList(
|
||||
IReadOnlyList<ActiveEnchantmentRecord> source,
|
||||
uint bucket,
|
||||
List<ActiveEnchantmentRecord> result)
|
||||
{
|
||||
for (int sourceIndex = 0; sourceIndex < source.Count; sourceIndex++)
|
||||
{
|
||||
ActiveEnchantmentRecord candidate = source[sourceIndex];
|
||||
if (candidate.Bucket != bucket) continue;
|
||||
|
||||
int incumbentIndex = result.FindIndex(existing =>
|
||||
existing.SpellCategory == candidate.SpellCategory);
|
||||
if (incumbentIndex >= 0)
|
||||
{
|
||||
ActiveEnchantmentRecord incumbent = result[incumbentIndex];
|
||||
if (!CandidateWins(incumbent, candidate))
|
||||
continue;
|
||||
// Retail removes the losing node, then appends the winner.
|
||||
result.RemoveAt(incumbentIndex);
|
||||
}
|
||||
result.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CandidateWins(
|
||||
ActiveEnchantmentRecord incumbent,
|
||||
ActiveEnchantmentRecord candidate)
|
||||
{
|
||||
// Retail Enchantment::_power_level is signed int even though the wire
|
||||
// field is read as four raw bytes.
|
||||
int incumbentPower = unchecked((int)incumbent.PowerLevel);
|
||||
int candidatePower = unchecked((int)candidate.PowerLevel);
|
||||
return candidatePower > incumbentPower
|
||||
|| (candidatePower == incumbentPower
|
||||
&& candidate.StartTime > incumbent.StartTime);
|
||||
}
|
||||
}
|
||||
190
src/AcDream.Core/Spells/RetailSpellFormula.cs
Normal file
190
src/AcDream.Core/Spells/RetailSpellFormula.cs
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace AcDream.Core.Spells;
|
||||
|
||||
/// <summary>
|
||||
/// Pure retail spell-formula helpers. The formula is an eight-entry list of
|
||||
/// spell-component enum IDs (SCIDs), not inventory weenie class IDs.
|
||||
/// </summary>
|
||||
public static class RetailSpellFormula
|
||||
{
|
||||
private const uint LowestTaperId = 63u;
|
||||
private const uint PrismaticTaperId = 0xBCu;
|
||||
|
||||
/// <summary>
|
||||
/// Retail MagicSystem::DeterminePowerLevelOfComponent (0x005BD240).
|
||||
/// </summary>
|
||||
public static uint DeterminePowerLevelOfComponent(uint componentId) => componentId switch
|
||||
{
|
||||
>= 1u and <= 6u => componentId,
|
||||
0x6Eu => 7u,
|
||||
0x70u => 8u,
|
||||
0xC0u => 9u,
|
||||
0xC1u => 10u,
|
||||
_ => 0u,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Retail CSpellBase::InqSpellLevelByRoughHeuristic (0x00597260).
|
||||
/// This is the I-VIII level used by gmSpellbookUI's level filters.
|
||||
/// </summary>
|
||||
public static int InqSpellLevelByRoughHeuristic(IReadOnlyList<uint> components)
|
||||
{
|
||||
uint power = components.Count == 0
|
||||
? 0u
|
||||
: DeterminePowerLevelOfComponent(components[0]);
|
||||
return power switch
|
||||
{
|
||||
< 7u => (int)power,
|
||||
>= 9u => (int)power - 2,
|
||||
_ => (int)power - 1,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>CSpellBase::InqScarabOnlyFormula</c> (0x00597050).
|
||||
/// Foci and the infused-magic augmentations replace the ordinary taper
|
||||
/// formula with its power components plus one to four prismatic tapers.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<uint> InqScarabOnlyFormula(
|
||||
IReadOnlyList<uint> components)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(components);
|
||||
|
||||
var result = new List<uint>(8);
|
||||
uint strongestPower = 0u;
|
||||
foreach (uint component in components.Take(8))
|
||||
{
|
||||
if (component == 0u)
|
||||
break;
|
||||
if (!IsPowerComponent(component))
|
||||
continue;
|
||||
|
||||
result.Add(component);
|
||||
strongestPower = Math.Max(
|
||||
strongestPower,
|
||||
DeterminePowerLevelOfComponent(component));
|
||||
}
|
||||
|
||||
int taperCount = strongestPower switch
|
||||
{
|
||||
1u => 1,
|
||||
2u => 2,
|
||||
3u or 4u or 7u => 3,
|
||||
5u or 6u or 8u or 9u or 10u => 4,
|
||||
_ => 0,
|
||||
};
|
||||
while (taperCount-- > 0 && result.Count < 8)
|
||||
result.Add(PrismaticTaperId);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool IsPowerComponent(uint component) => component is
|
||||
>= 1u and <= 6u or 0x6Eu or 0x6Fu or 0x70u or 0xC0u or 0xC1u;
|
||||
|
||||
/// <summary>
|
||||
/// Retail PString's CP-1252 hash used by SpellFormula::RandomizeForName.
|
||||
/// </summary>
|
||||
public static uint ComputeNameHash(string value)
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
long result = 0;
|
||||
foreach (byte raw in Encoding.GetEncoding(1252).GetBytes(value ?? string.Empty))
|
||||
{
|
||||
sbyte character = unchecked((sbyte)raw);
|
||||
result = character + (result << 4);
|
||||
if ((result & 0xF0000000L) != 0)
|
||||
result = (result ^ ((result & 0xF0000000L) >> 24)) & 0x0FFFFFFF;
|
||||
}
|
||||
return unchecked((uint)result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail SpellFormula::RandomizeForName (0x005BD050), cross-checked with
|
||||
/// ACE.DatLoader SpellTable.GetSpellFormula. Only taper SCIDs change.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<uint> CustomizeForAccount(
|
||||
IReadOnlyList<uint> components,
|
||||
uint formulaVersion,
|
||||
string accountName)
|
||||
{
|
||||
uint[] result = components.Take(8).ToArray();
|
||||
return formulaVersion switch
|
||||
{
|
||||
1u => RandomizeVersion1(result, accountName),
|
||||
2u => RandomizeVersion2(result, accountName),
|
||||
3u => RandomizeVersion3(result, accountName),
|
||||
_ => result,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<uint> RandomizeVersion1(uint[] c, string accountName)
|
||||
{
|
||||
int count = c.Count(value => value != 0);
|
||||
if (count < 5) return c;
|
||||
|
||||
uint seed = ComputeNameHash(accountName) % 0x13D573u;
|
||||
uint scarab = c[0];
|
||||
int herbIndex = count > 5 ? 2 : 1;
|
||||
uint herb = c[herbIndex];
|
||||
int powderIndex = herbIndex + 1 + (count > 6 ? 1 : 0);
|
||||
if (powderIndex + 1 >= c.Length) return c;
|
||||
uint powder = c[powderIndex];
|
||||
uint potion = c[powderIndex + 1];
|
||||
int talismanIndex = powderIndex + 2 + (count > 7 ? 1 : 0);
|
||||
if (talismanIndex >= c.Length) return c;
|
||||
uint talisman = c[talismanIndex];
|
||||
|
||||
if (count > 5)
|
||||
c[1] = unchecked(powder + 2u * herb + potion + talisman + scarab) % 12u + LowestTaperId;
|
||||
if (count > 6)
|
||||
{
|
||||
uint denominator = unchecked(scarab + powder + potion);
|
||||
if (denominator != 0)
|
||||
c[3] = unchecked((scarab + herb + talisman + 2u * (powder + potion))
|
||||
* (seed / denominator)) % 12u + LowestTaperId;
|
||||
}
|
||||
if (count > 7)
|
||||
{
|
||||
uint denominator = unchecked(talisman + scarab);
|
||||
if (denominator != 0)
|
||||
c[6] = unchecked((powder + 2u * talisman + potion + herb + scarab)
|
||||
* (seed / denominator)) % 12u + LowestTaperId;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<uint> RandomizeVersion2(uint[] c, string accountName)
|
||||
{
|
||||
if (c.Length < 8) return c;
|
||||
uint seed = ComputeNameHash(accountName) % 0x13D573u;
|
||||
uint p1 = c[0], c4 = c[4], x = c[5], a = c[7];
|
||||
c[3] = unchecked(a + 3u * p1 + 2u * c4 * x + c[2] + c[1]) % 12u + LowestTaperId;
|
||||
uint denominator = unchecked(c[1] * a + 2u * c4);
|
||||
if (denominator != 0)
|
||||
c[6] = unchecked((a + 3u * p1 * c[2] + 2u * x + c4)
|
||||
* (seed / denominator)) % 12u + LowestTaperId;
|
||||
return c;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<uint> RandomizeVersion3(uint[] c, string accountName)
|
||||
{
|
||||
if (c.Length < 7) return c;
|
||||
uint hash = ComputeNameHash(accountName);
|
||||
uint h0 = unchecked(hash % 0x13D573u + c[0]) % 12u;
|
||||
uint h1 = unchecked(hash % 0x4AEFDu + c[1]) % 12u;
|
||||
uint h2 = unchecked(hash % 0x96A7Fu + c[2]) % 12u;
|
||||
uint h4 = unchecked(hash % 0x100A03u + c[4]) % 12u;
|
||||
uint h5 = unchecked(hash % 0xEB2EFu + c[5]) % 12u;
|
||||
uint h7 = unchecked(hash % 0x121E7Du + (c.Length > 7 ? c[7] : 0u)) % 12u;
|
||||
c[3] = unchecked(h0 + h1 + h2 + h4 + h5 + h2 * h5 + h0 * h1 + h7 * (h4 + 1u))
|
||||
% 12u + LowestTaperId;
|
||||
c[6] = unchecked(h0 + h1 + h2 + h4 + hash % 0x65039u % 12u
|
||||
+ h7 * (h4 * (h0 * h1 * h2 * h5 + 7u) + 1u)
|
||||
+ h5 + 5u * h0 * h1 + 11u * h2 * h5) % 12u + LowestTaperId;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
|
@ -41,4 +41,21 @@ public sealed record SpellMetadata(
|
|||
int ManaCost,
|
||||
bool IsDebuff,
|
||||
bool IsFellowship,
|
||||
string Description);
|
||||
string Description,
|
||||
int SortKey,
|
||||
int Difficulty,
|
||||
uint Flags,
|
||||
int Generation,
|
||||
bool IsFastWindup,
|
||||
bool IsOffensive,
|
||||
bool IsUntargeted,
|
||||
float Speed,
|
||||
uint CasterEffect,
|
||||
uint TargetEffect,
|
||||
uint TargetMask,
|
||||
int SpellType)
|
||||
{
|
||||
public bool IsSelfTargeted => (Flags & 0x00000008u) != 0;
|
||||
public bool IsBeneficial => (Flags & 0x00000004u) != 0;
|
||||
public bool IsProjectile => (Flags & 0x00000100u) != 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,9 @@ public enum MagicSchool : uint
|
|||
None = 0,
|
||||
WarMagic = 1,
|
||||
LifeMagic = 2,
|
||||
CreatureEnchantment = 3,
|
||||
ItemEnchantment = 4,
|
||||
PortalMagic = 5,
|
||||
// VoidMagic added in later retail revisions; uses LifeMagic skill.
|
||||
VoidMagic = 6,
|
||||
ItemEnchantment = 3,
|
||||
CreatureEnchantment = 4,
|
||||
VoidMagic = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -55,14 +53,23 @@ public enum SpellCategory : uint
|
|||
public enum SpellFlags : uint
|
||||
{
|
||||
None = 0,
|
||||
Beneficial = 0x00000001,
|
||||
Resistable = 0x00000002,
|
||||
Projectile = 0x00000004,
|
||||
EnchantmentDispel = 0x00000008,
|
||||
PurgeOnReset = 0x00000010,
|
||||
Resistable = 0x00000001,
|
||||
PKSensitive = 0x00000002,
|
||||
Beneficial = 0x00000004,
|
||||
SelfTargeted = 0x00000008,
|
||||
Reversed = 0x00000010,
|
||||
NotIndoors = 0x00000020,
|
||||
Melee = 0x00000040,
|
||||
Missile = 0x00000080,
|
||||
NotOutdoors = 0x00000040,
|
||||
NotResearchable = 0x00000080,
|
||||
Projectile = 0x00000100,
|
||||
CreatureSpell = 0x00000200,
|
||||
ExcludedFromItemDescriptions = 0x00000400,
|
||||
IgnoresManaConversion = 0x00000800,
|
||||
NonTrackingProjectile = 0x00001000,
|
||||
FellowshipSpell = 0x00002000,
|
||||
FastCast = 0x00004000,
|
||||
IndoorLongRange = 0x00008000,
|
||||
DamageOverTime = 0x00010000,
|
||||
// more flags in r01 §1
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,18 @@ public sealed class SpellTable
|
|||
int? iIsDebuff = Get("IsDebuff");
|
||||
int? iIsFellow = Get("IsFellowship");
|
||||
int? iDescription = Get("Description");
|
||||
int? iSortKey = Get("SortKey");
|
||||
int? iDifficulty = Get("Difficulty");
|
||||
int? iFlags = Get("Flags [Hex]");
|
||||
int? iGeneration = Get("Generation");
|
||||
int? iFastWindup = Get("IsFastWindup");
|
||||
int? iOffensive = Get("IsOffensive");
|
||||
int? iUntargeted = Get("IsUntargetted");
|
||||
int? iSpeed = Get("Speed");
|
||||
int? iCasterFx = Get("CasterEffect");
|
||||
int? iTargetFx = Get("TargetEffect");
|
||||
int? iTargetMask = Get("TargetMask [Hex]");
|
||||
int? iSpellType = Get("Type");
|
||||
|
||||
if (iSpellId is null || iName is null) return new SpellTable(byId);
|
||||
|
||||
|
|
@ -125,10 +137,24 @@ public sealed class SpellTable
|
|||
bool isDebuff = ParseBool(fields, iIsDebuff);
|
||||
bool isFellow = ParseBool(fields, iIsFellow);
|
||||
string description = iDescription is int d && d < fields.Count ? fields[d] : "";
|
||||
int sortKey = (int)ParseUInt(fields, iSortKey);
|
||||
int difficulty = (int)ParseUInt(fields, iDifficulty);
|
||||
uint flags = ParseHexUInt(fields, iFlags);
|
||||
int generation = (int)ParseUInt(fields, iGeneration);
|
||||
bool fastWindup = ParseBool(fields, iFastWindup);
|
||||
bool offensive = ParseBool(fields, iOffensive);
|
||||
bool untargeted = ParseBool(fields, iUntargeted);
|
||||
float speed = ParseFloat(fields, iSpeed);
|
||||
uint casterEffect = ParseUInt(fields, iCasterFx);
|
||||
uint targetEffect = ParseUInt(fields, iTargetFx);
|
||||
uint targetMask = ParseHexUInt(fields, iTargetMask);
|
||||
int spellType = (int)ParseUInt(fields, iSpellType);
|
||||
|
||||
byId[spellId] = new SpellMetadata(
|
||||
spellId, name, school, family, iconId, words, duration,
|
||||
mana, isDebuff, isFellow, description);
|
||||
mana, isDebuff, isFellow, description, sortKey, difficulty,
|
||||
flags, generation, fastWindup, offensive, untargeted, speed,
|
||||
casterEffect, targetEffect, targetMask, spellType);
|
||||
}
|
||||
|
||||
return new SpellTable(byId);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AcDream.Core.Spells;
|
||||
|
||||
|
|
@ -17,9 +17,14 @@ namespace AcDream.Core.Spells;
|
|||
/// </summary>
|
||||
public sealed class Spellbook
|
||||
{
|
||||
private readonly HashSet<uint> _learnedSpells = new();
|
||||
private readonly ConcurrentDictionary<uint, ActiveEnchantmentRecord> _activeByLayer = new();
|
||||
private readonly Dictionary<uint, float> _learnedSpells = new();
|
||||
private readonly Dictionary<uint, ActiveEnchantmentRecord> _activeById = new();
|
||||
private readonly Dictionary<uint, List<uint>> _enchantmentOrderByBucket = new();
|
||||
private readonly List<uint>[] _favoriteSpells = Enumerable.Range(0, 8)
|
||||
.Select(_ => new List<uint>()).ToArray();
|
||||
private readonly Dictionary<uint, uint> _desiredComponents = new();
|
||||
private readonly SpellTable _table;
|
||||
private uint _spellbookFilters = 0x3FFFu;
|
||||
|
||||
/// <summary>
|
||||
/// Build a Spellbook with an optional <see cref="SpellTable"/>
|
||||
|
|
@ -69,39 +74,98 @@ public sealed class Spellbook
|
|||
/// <summary>Fires when an enchantment is removed (expired / dispelled).</summary>
|
||||
public event Action<ActiveEnchantmentRecord>? EnchantmentRemoved;
|
||||
|
||||
/// <summary>Fires when any manifest-backed magic UI state changes.</summary>
|
||||
public event Action? StateChanged;
|
||||
|
||||
/// <summary>Fires when learned spells, favorites, or spellbook filters change.</summary>
|
||||
public event Action? SpellbookChanged;
|
||||
|
||||
/// <summary>Fires when the active enchantment set changes.</summary>
|
||||
public event Action? EnchantmentsChanged;
|
||||
|
||||
/// <summary>Fires when the desired spell-component levels change.</summary>
|
||||
public event Action? DesiredComponentsChanged;
|
||||
|
||||
/// <summary>All currently learned spell ids.</summary>
|
||||
public IReadOnlyCollection<uint> LearnedSpells => _learnedSpells;
|
||||
public IReadOnlyCollection<uint> LearnedSpells => _learnedSpells.Keys;
|
||||
|
||||
/// <summary>Stable learned-spell snapshot, including server spell power.</summary>
|
||||
public IReadOnlyList<LearnedSpellRecord> LearnedSpellSnapshot => _learnedSpells
|
||||
.Select(pair => new LearnedSpellRecord(pair.Key, pair.Value))
|
||||
.OrderBy(spell => spell.SpellId)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>All currently-active enchantments.</summary>
|
||||
public IEnumerable<ActiveEnchantmentRecord> ActiveEnchantments => _activeByLayer.Values;
|
||||
public IEnumerable<ActiveEnchantmentRecord> ActiveEnchantments => _activeById.Values;
|
||||
|
||||
public IReadOnlyList<ActiveEnchantmentRecord> ActiveEnchantmentSnapshot =>
|
||||
_activeById.Values.OrderBy(record => record.Identity).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Retail ordinary-effect projection after mult/add filtering and the
|
||||
/// spell-category duel. This is the source for effects-list rows; retail's
|
||||
/// indicator counts scan the raw mult/add records before this projection.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ActiveEnchantmentRecord> EnchantmentsInEffectSnapshot
|
||||
{
|
||||
get
|
||||
{
|
||||
var ordered = new List<ActiveEnchantmentRecord>();
|
||||
AppendBucket(bucket: 1u, ordered);
|
||||
AppendBucket(bucket: 2u, ordered);
|
||||
return EnchantmentRegistryProjection.GetEnchantmentsInEffect(ordered);
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<uint> GetFavorites(int tabIndex)
|
||||
{
|
||||
if ((uint)tabIndex >= _favoriteSpells.Length) return Array.Empty<uint>();
|
||||
return _favoriteSpells[tabIndex].ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<uint, uint> DesiredComponents => _desiredComponents;
|
||||
|
||||
public uint SpellbookFilters => _spellbookFilters;
|
||||
|
||||
public int LearnedCount => _learnedSpells.Count;
|
||||
public int ActiveCount => _activeByLayer.Count;
|
||||
public int ActiveCount => _activeById.Count;
|
||||
|
||||
public bool Knows(uint spellId) => _learnedSpells.Contains(spellId);
|
||||
public bool Knows(uint spellId) => _learnedSpells.ContainsKey(spellId);
|
||||
|
||||
// ── Inbound handlers ─────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>0x02C1 MagicUpdateSpell: learn a spell.</summary>
|
||||
public void OnSpellLearned(uint spellId)
|
||||
public void OnSpellLearned(uint spellId, float power = 0f)
|
||||
{
|
||||
if (_learnedSpells.Add(spellId))
|
||||
if (_learnedSpells.TryAdd(spellId, power))
|
||||
{
|
||||
SpellLearned?.Invoke(spellId);
|
||||
NotifySpellbookChanged();
|
||||
}
|
||||
else if (_learnedSpells[spellId] != power)
|
||||
{
|
||||
_learnedSpells[spellId] = power;
|
||||
NotifySpellbookChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x01A8 MagicRemoveSpell: forget a spell.</summary>
|
||||
public void OnSpellForgotten(uint spellId)
|
||||
{
|
||||
if (_learnedSpells.Remove(spellId))
|
||||
{
|
||||
SpellForgotten?.Invoke(spellId);
|
||||
NotifySpellbookChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x02C2 MagicUpdateEnchantment: enchantment added / refreshed.</summary>
|
||||
public void OnEnchantmentAdded(uint spellId, uint layerId, float duration, uint casterGuid)
|
||||
{
|
||||
var record = new ActiveEnchantmentRecord(spellId, layerId, duration, casterGuid);
|
||||
_activeByLayer[layerId] = record;
|
||||
UpsertLiveEnchantment(record);
|
||||
EnchantmentAdded?.Invoke(record);
|
||||
NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -113,56 +177,305 @@ public sealed class Spellbook
|
|||
/// </summary>
|
||||
public void OnEnchantmentAdded(ActiveEnchantmentRecord record)
|
||||
{
|
||||
_activeByLayer[record.LayerId] = record;
|
||||
UpsertLiveEnchantment(record);
|
||||
EnchantmentAdded?.Invoke(record);
|
||||
NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
/// <summary>0x02C3 / 0x02C7 MagicRemove/DispelEnchantment.</summary>
|
||||
public void OnEnchantmentRemoved(uint layerId, uint spellId)
|
||||
{
|
||||
if (_activeByLayer.TryRemove(layerId, out var record))
|
||||
uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layerId);
|
||||
if (RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record))
|
||||
{
|
||||
EnchantmentRemoved?.Invoke(record);
|
||||
else
|
||||
EnchantmentRemoved?.Invoke(new ActiveEnchantmentRecord(spellId, layerId, 0f, 0));
|
||||
NotifyEnchantmentsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>0x02C6 MagicPurgeEnchantments: clear all active buffs.</summary>
|
||||
public void OnEnchantmentsAdded(IEnumerable<ActiveEnchantmentRecord> records)
|
||||
{
|
||||
foreach (ActiveEnchantmentRecord record in records)
|
||||
{
|
||||
UpsertLiveEnchantment(record);
|
||||
EnchantmentAdded?.Invoke(record);
|
||||
}
|
||||
NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
public void OnEnchantmentsRemoved(IEnumerable<(uint SpellId, uint Layer)> spells)
|
||||
{
|
||||
bool changed = false;
|
||||
foreach ((uint spellId, uint layer) in spells)
|
||||
{
|
||||
uint identity = ActiveEnchantmentRecord.MakeIdentity(spellId, layer);
|
||||
if (!RemoveActiveEnchantment(identity, out ActiveEnchantmentRecord record)) continue;
|
||||
changed = true;
|
||||
EnchantmentRemoved?.Invoke(record);
|
||||
}
|
||||
if (changed) NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail EnchantmentRegistry::PurgeEnchantments (0x00594DE0): purge only
|
||||
/// finite-duration multiplicative/additive enchantments. Vitae, cooldowns,
|
||||
/// and permanent enchantments live in separate registries and survive.
|
||||
/// </summary>
|
||||
public void OnPurgeAll()
|
||||
{
|
||||
foreach (var rec in _activeByLayer.Values)
|
||||
ActiveEnchantmentRecord[] removed = _activeById.Values
|
||||
.Where(IsPurgeable)
|
||||
.ToArray();
|
||||
foreach (ActiveEnchantmentRecord record in removed)
|
||||
RemoveActiveEnchantment(record.Identity, out _);
|
||||
foreach (var rec in removed)
|
||||
EnchantmentRemoved?.Invoke(rec);
|
||||
_activeByLayer.Clear();
|
||||
if (removed.Length != 0) NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
public void OnPurgeBadEnchantments()
|
||||
{
|
||||
// Retail PurgeBadEnchantments (0x00594E00) tests the StatMod
|
||||
// Beneficial bit directly; spell-table labels do not participate.
|
||||
const uint BeneficialStatMod = 0x02000000u;
|
||||
ActiveEnchantmentRecord[] removed = _activeById.Values
|
||||
.Where(record => IsPurgeable(record)
|
||||
&& (record.StatModType.GetValueOrDefault() & BeneficialStatMod) == 0)
|
||||
.ToArray();
|
||||
foreach (ActiveEnchantmentRecord record in removed)
|
||||
RemoveActiveEnchantment(record.Identity, out _);
|
||||
foreach (ActiveEnchantmentRecord record in removed)
|
||||
EnchantmentRemoved?.Invoke(record);
|
||||
if (removed.Length != 0) NotifyEnchantmentsChanged();
|
||||
}
|
||||
|
||||
private static bool IsPurgeable(ActiveEnchantmentRecord record) =>
|
||||
(record.Bucket is 1u or 2u) && record.Duration != -1f;
|
||||
|
||||
/// <summary>
|
||||
/// PlayerDescription is a complete character manifest. Replace every
|
||||
/// magic collection atomically so relogging or switching characters cannot
|
||||
/// retain state from the prior snapshot.
|
||||
/// </summary>
|
||||
public void ReplaceManifest(
|
||||
IReadOnlyDictionary<uint, float> learnedSpells,
|
||||
IEnumerable<ActiveEnchantmentRecord> enchantments,
|
||||
IReadOnlyList<IReadOnlyList<uint>> favorites,
|
||||
IReadOnlyList<(uint Id, uint Amount)> desiredComponents,
|
||||
uint spellbookFilters)
|
||||
{
|
||||
_learnedSpells.Clear();
|
||||
foreach ((uint spellId, float power) in learnedSpells)
|
||||
_learnedSpells[spellId] = power;
|
||||
|
||||
_activeById.Clear();
|
||||
_enchantmentOrderByBucket.Clear();
|
||||
foreach (ActiveEnchantmentRecord enchantment in enchantments)
|
||||
UpsertManifestEnchantment(enchantment);
|
||||
|
||||
for (int i = 0; i < _favoriteSpells.Length; i++)
|
||||
{
|
||||
_favoriteSpells[i].Clear();
|
||||
if (i < favorites.Count)
|
||||
_favoriteSpells[i].AddRange(favorites[i]);
|
||||
}
|
||||
|
||||
_desiredComponents.Clear();
|
||||
foreach ((uint id, uint amount) in desiredComponents)
|
||||
_desiredComponents[id] = amount;
|
||||
_spellbookFilters = spellbookFilters;
|
||||
SpellbookChanged?.Invoke();
|
||||
EnchantmentsChanged?.Invoke();
|
||||
DesiredComponentsChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void SetFavorite(int tabIndex, int position, uint spellId)
|
||||
{
|
||||
if ((uint)tabIndex >= _favoriteSpells.Length || position < 0) return;
|
||||
List<uint> tab = _favoriteSpells[tabIndex];
|
||||
if (position > tab.Count) position = tab.Count;
|
||||
tab.Remove(spellId);
|
||||
tab.Insert(Math.Min(position, tab.Count), spellId);
|
||||
NotifySpellbookChanged();
|
||||
}
|
||||
|
||||
public void RemoveFavorite(int tabIndex, uint spellId)
|
||||
{
|
||||
if ((uint)tabIndex >= _favoriteSpells.Length) return;
|
||||
if (_favoriteSpells[tabIndex].Remove(spellId)) NotifySpellbookChanged();
|
||||
}
|
||||
|
||||
public void SetSpellbookFilters(uint filters)
|
||||
{
|
||||
if (_spellbookFilters == filters) return;
|
||||
_spellbookFilters = filters;
|
||||
NotifySpellbookChanged();
|
||||
}
|
||||
|
||||
public void SetDesiredComponent(uint componentId, uint amount)
|
||||
{
|
||||
uint current = _desiredComponents.TryGetValue(componentId, out uint existing)
|
||||
? existing : 0u;
|
||||
if (current == amount) return;
|
||||
if (amount == 0) _desiredComponents.Remove(componentId);
|
||||
else _desiredComponents[componentId] = amount;
|
||||
DesiredComponentsChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_learnedSpells.Clear();
|
||||
_activeByLayer.Clear();
|
||||
_activeById.Clear();
|
||||
_enchantmentOrderByBucket.Clear();
|
||||
foreach (List<uint> tab in _favoriteSpells) tab.Clear();
|
||||
_desiredComponents.Clear();
|
||||
_spellbookFilters = 0x3FFFu;
|
||||
SpellbookChanged?.Invoke();
|
||||
EnchantmentsChanged?.Invoke();
|
||||
DesiredComponentsChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void NotifySpellbookChanged()
|
||||
{
|
||||
SpellbookChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void NotifyEnchantmentsChanged()
|
||||
{
|
||||
EnchantmentsChanged?.Invoke();
|
||||
StateChanged?.Invoke();
|
||||
}
|
||||
|
||||
private void AppendBucket(uint bucket, List<ActiveEnchantmentRecord> destination)
|
||||
{
|
||||
if (!_enchantmentOrderByBucket.TryGetValue(bucket, out List<uint>? order)) return;
|
||||
foreach (uint identity in order)
|
||||
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord record)
|
||||
&& record.Bucket == bucket)
|
||||
destination.Add(record);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retail <c>AddEnchantmentToList @ 0x00593DF0</c> inserts a new live
|
||||
/// mult/add record at the list head. Refreshing an existing identity keeps
|
||||
/// its node position; moving buckets creates a new head node.
|
||||
/// </summary>
|
||||
private void UpsertLiveEnchantment(ActiveEnchantmentRecord record)
|
||||
{
|
||||
uint identity = record.Identity;
|
||||
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous))
|
||||
{
|
||||
_activeById[identity] = record;
|
||||
if (previous.Bucket == record.Bucket)
|
||||
{
|
||||
EnsureOrdered(identity, record.Bucket, insertAtHead: true);
|
||||
return;
|
||||
}
|
||||
RemoveFromOrder(previous);
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeById.Add(identity, record);
|
||||
}
|
||||
GetBucketOrder(record.Bucket).Insert(0, identity);
|
||||
}
|
||||
|
||||
/// <summary>Bulk UnPack preserves the received head-to-tail list order.</summary>
|
||||
private void UpsertManifestEnchantment(ActiveEnchantmentRecord record)
|
||||
{
|
||||
uint identity = record.Identity;
|
||||
if (_activeById.TryGetValue(identity, out ActiveEnchantmentRecord previous))
|
||||
{
|
||||
_activeById[identity] = record;
|
||||
if (previous.Bucket == record.Bucket)
|
||||
{
|
||||
EnsureOrdered(identity, record.Bucket, insertAtHead: false);
|
||||
return;
|
||||
}
|
||||
RemoveFromOrder(previous);
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeById.Add(identity, record);
|
||||
}
|
||||
GetBucketOrder(record.Bucket).Add(identity);
|
||||
}
|
||||
|
||||
private bool RemoveActiveEnchantment(
|
||||
uint identity,
|
||||
out ActiveEnchantmentRecord record)
|
||||
{
|
||||
if (!_activeById.Remove(identity, out record)) return false;
|
||||
RemoveFromOrder(record);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void EnsureOrdered(uint identity, uint bucket, bool insertAtHead)
|
||||
{
|
||||
List<uint> order = GetBucketOrder(bucket);
|
||||
if (order.Contains(identity)) return;
|
||||
if (insertAtHead) order.Insert(0, identity);
|
||||
else order.Add(identity);
|
||||
}
|
||||
|
||||
private List<uint> GetBucketOrder(uint bucket)
|
||||
{
|
||||
if (_enchantmentOrderByBucket.TryGetValue(bucket, out List<uint>? order))
|
||||
return order;
|
||||
order = new List<uint>();
|
||||
_enchantmentOrderByBucket.Add(bucket, order);
|
||||
return order;
|
||||
}
|
||||
|
||||
private void RemoveFromOrder(ActiveEnchantmentRecord record)
|
||||
{
|
||||
if (!_enchantmentOrderByBucket.TryGetValue(record.Bucket, out List<uint>? order))
|
||||
return;
|
||||
order.Remove(record.Identity);
|
||||
if (order.Count == 0)
|
||||
_enchantmentOrderByBucket.Remove(record.Bucket);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct LearnedSpellRecord(uint SpellId, float Power);
|
||||
|
||||
/// <summary>
|
||||
/// Summary of one active enchantment layer on the player. The
|
||||
/// optional StatMod fields (issue #12) carry the wire-level
|
||||
/// `_smod` triad <c>(type, key, val)</c> when available — only
|
||||
/// `PlayerDescription`'s enchantment block currently populates these
|
||||
/// (<see cref="AcDream.Core.Net.Messages.PlayerDescriptionParser"/>).
|
||||
/// `MagicUpdateEnchantment` events still produce records with these
|
||||
/// fields null until the wire parser is extended.
|
||||
/// `_smod` triad <c>(type, key, val)</c>. Both the complete
|
||||
/// `PlayerDescription` snapshot and live MagicUpdateEnchantment events
|
||||
/// populate the same record shape.
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="Bucket"/> tells <c>EnchantmentMath</c> whether this
|
||||
/// enchantment's StatMod is multiplicative (<c>0x01</c>), additive
|
||||
/// (<c>0x02</c>), cooldown (<c>0x04</c>), or vitae (<c>0x08</c>) per
|
||||
/// (<c>0x02</c>), vitae (<c>0x04</c>), or cooldown (<c>0x08</c>) per
|
||||
/// the retail <c>EnchantmentMask</c> classification.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public readonly record struct ActiveEnchantmentRecord(
|
||||
uint SpellId,
|
||||
uint LayerId,
|
||||
float Duration,
|
||||
double Duration,
|
||||
uint CasterGuid,
|
||||
uint? StatModType = null,
|
||||
uint? StatModKey = null,
|
||||
float? StatModValue = null,
|
||||
uint Bucket = 0);
|
||||
uint Bucket = 0,
|
||||
double StartTime = 0,
|
||||
uint SpellCategory = 0,
|
||||
uint PowerLevel = 0,
|
||||
float DegradeModifier = 0,
|
||||
float DegradeLimit = 0,
|
||||
double LastTimeDegraded = 0,
|
||||
uint? SpellSetId = null)
|
||||
{
|
||||
public uint Identity => MakeIdentity(SpellId, LayerId);
|
||||
|
||||
public static uint MakeIdentity(uint spellId, uint layerId) =>
|
||||
(spellId & 0xFFFFu) | ((layerId & 0xFFFFu) << 16);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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