feat(ui): D.2b item interaction + retail cursors + live character sheet

Lands the codex-worktree D.2b stream plus the extraction the 2026-07-02
UI architecture review mandated before commit:

- ItemInteractionController: single owner of double-click use/equip/
  container-open, targeted-use mode (health kits), drag-out drop;
  toolbar shortcut drags don't drop the real item. ItemEquipRules for
  multi-slot (coat) coverage via equip masks.
- Cursor phase: CursorFeedbackController (semantic priority chain:
  drag > resize > window-move > target-mode > text) + RetailCursorCatalog
  (enums 0x27/0x28/0x29, hotspot 14,14; ClientUISystem::UpdateCursorState
  0x00564630) resolved through the portal EnumIDMap chain by
  RetailCursorResolver; RetailCursorManager applies dat cursor art to the
  OS cursor. Register row AP-72 covers the OS standard-cursor fallback.
- Character window goes live: CharacterSheetProvider owns sheet assembly,
  XP-curve/raise-cost math and the raise flow — extracted out of
  GameWindow per Code Structure Rule 1 instead of committing the ~430-line
  feature body there. Optimistic XP/credit debits go through eventful
  store APIs (new ClientObjectTable.UpdateInt64Property +
  LocalPlayerState.DebitIntProperty/DebitInt64Property) instead of raw
  property-dictionary writes; register row AP-73 covers the still-missing
  raise ledger (#163).
- RetailWindowFrame: the shared nine-slice window mount recipe; the
  character window uses it, remaining windows migrate via #164.
- Status-bar buttons toggle inventory/character windows; retail row-major
  backpack ordering; WorldSession.SendUseWithTarget + raise/train sends.

GameWindow shrinks 14,214 -> 13,877 lines despite the new features; the
sheet/raise logic is unit-tested in CharacterSheetProviderTests instead
of trapped in the god object. Build green; full suite 3,286 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-03 09:18:43 +02:00
parent e3fc7ac5ba
commit b7dc91a053
74 changed files with 6669 additions and 238 deletions

View file

@ -0,0 +1,68 @@
namespace AcDream.App.UI.Layout;
/// <summary>
/// Retail character identity display helpers for gmStatManagementUI.
/// Sources: gmStatManagementUI::UpdateCharacterInfo (0x004f0770) calls
/// AppraisalSystem::InqGenderHeritageDisplay(gender 0x71, heritage 0xBC, 0),
/// then appends the current CharacterTitleTable title when one is active.
/// </summary>
internal static class CharacterIdentityText
{
public const uint GenderPropertyId = 0x71u;
public const uint HeritageGroupPropertyId = 0xBCu;
public static string StatHeaderLine(CharacterSheet sheet)
{
string? heritage = !string.IsNullOrWhiteSpace(sheet.Heritage)
? sheet.Heritage
: sheet.Race;
string? title = StripLeadingArticle(sheet.Title);
if (string.IsNullOrWhiteSpace(sheet.Gender))
return Join(heritage, title);
return Join(sheet.Gender, heritage, title);
}
public static string? GenderDisplayName(int gender) => gender switch
{
1 => "Male",
2 => "Female",
_ => null,
};
public static string? HeritageGroupDisplayName(int heritageGroup) => heritageGroup switch
{
1 => "Aluvian",
2 => "Gharu'ndim",
3 => "Sho",
4 => "Viamontian",
5 => "Umbraen",
6 => "Gearknight",
7 => "Tumerok",
8 => "Lugian",
9 => "Empyrean",
10 => "Penumbraen",
11 => "Undead",
12 => "Olthoi",
13 => "Olthoi",
_ => null,
};
private static string Join(params string?[] parts)
{
return string.Join(" ", parts
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p!.Trim()));
}
private static string? StripLeadingArticle(string? title)
{
if (string.IsNullOrWhiteSpace(title)) return null;
string trimmed = title.Trim();
return trimmed.StartsWith("the ", System.StringComparison.OrdinalIgnoreCase)
? trimmed[4..]
: trimmed;
}
}

View file

@ -26,10 +26,13 @@ public sealed class CharacterSheet
/// <summary>Character level.</summary>
public int Level { get; init; }
/// <summary>Gender display string, e.g. "Female". Null = omit.</summary>
public string? Gender { get; init; }
/// <summary>Race string, e.g. "Aluvian". Null = omit.</summary>
public string? Race { get; init; }
/// <summary>Heritage string, e.g. "Aluvian Heritage". Null = omit.</summary>
/// <summary>Heritage group display string, e.g. "Aluvian". Null = omit.</summary>
public string? Heritage { get; init; }
/// <summary>Title string, e.g. "the Adventurer". Null = omit.</summary>
@ -108,11 +111,11 @@ public sealed class CharacterSheet
public long UnassignedXp { get; init; }
// ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ──
// Retail formula: ExperienceToAttributeLevel(value + 1) ExperienceToAttributeLevel(value).
// We store pre-computed per-attribute costs for the fixture; cost == 0 → attribute is
// at max or not trainable (raise button ghosted/hidden). Ordered to match AttrRows
// (Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana).
// Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910) + CM_Train::Event_TrainAttribute.
// Retail formula for x1: ExperienceToAttributeLevel(value + 1) xpSpent.
// Retail formula for x10: ExperienceToAttributeLevel(value + min(10, remaining)) xpSpent.
// Cost 0 means the attribute is at max or not trainable. Ordered to match AttrRows:
// Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana.
// Source: gmAttributeUI::GetCostToRaise/GetCostToRaise10 (0x0049cb80/0x0049cc70).
/// <summary>
/// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order:
@ -122,6 +125,12 @@ public sealed class CharacterSheet
/// </summary>
public long[] AttributeRaiseCosts { get; init; } = Array.Empty<long>();
/// <summary>
/// XP cost to raise each of the 9 attributes/vitals by up to 10 retail steps,
/// in the same display order as <see cref="AttributeRaiseCosts"/>.
/// </summary>
public long[] AttributeRaise10Costs { get; init; } = Array.Empty<long>();
/// <summary>
/// Skills shown on the Character window Skills tab. Retail gmSkillUI groups these by
/// advancement class, then sorts each group alphabetically by skill name.

View file

@ -0,0 +1,478 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Items;
using AcDream.Core.Player;
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Assembles the live <see cref="CharacterSheet"/> for the retail Character
/// window and owns the raise-request flow (wire send + optimistic local
/// apply). Extracted from <c>GameWindow</c> (Code Structure Rule 1: sheet
/// assembly + XP-curve math is feature logic, not wiring).
///
/// <para>Retail property ids and the decomp anchors for every field are
/// documented on <see cref="CharacterSheet"/>; the raise-cost formulas are
/// cited there too (gmAttributeUI::GetCostToRaise 0x0049cb80 family).</para>
///
/// <para><b>State ownership:</b> optimistic debits go through the owning
/// store's eventful APIs — <see cref="ClientObjectTable.UpdateIntProperty"/> /
/// <see cref="ClientObjectTable.UpdateInt64Property"/> (fires ObjectUpdated)
/// when the player object is in the table, else
/// <see cref="LocalPlayerState.DebitIntProperty"/> /
/// <see cref="LocalPlayerState.DebitInt64Property"/> (fires CharacterChanged).
/// Never write the raw property dictionaries from UI code. The next server
/// snapshot remains authoritative over every optimistic value.</para>
/// </summary>
public sealed class CharacterSheetProvider
{
/// <summary>PropertyInt64 2 = unassigned (banked) XP — CharacterSheet.UnassignedXp.</summary>
private const uint UnassignedXpPropertyId = 2u;
/// <summary>
/// Skill-credit debit fallback chain, most-specific first. Retail reads
/// InqInt(0x18) for the Attributes-tab footer, InqInt(0xc0) for available
/// and InqInt(0xb5) for total skill credits (see CharacterSheet docs);
/// the live server population varies, so debit whichever is present.
/// </summary>
private static readonly uint[] SkillCreditPropertyIds = { 0x18u, 0xC0u, 0xB5u };
private readonly ClientObjectTable _objects;
private readonly LocalPlayerState _localPlayer;
private readonly Func<uint> _playerGuid;
private readonly Func<string?>? _activeToonName;
private readonly Func<string, CharacterSheet>? _fallbackSheet;
private readonly Func<bool>? _canSendRaise;
private readonly Action<uint, ulong>? _sendRaiseAttribute;
private readonly Action<uint, ulong>? _sendRaiseVital;
private readonly Action<uint, ulong>? _sendRaiseSkill;
private readonly Action<uint, uint>? _sendTrainSkill;
/// <summary>Portal SkillTable (0x0E000004) — set by the host once dats load.</summary>
public DatReaderWriter.DBObjs.SkillTable? SkillTable { get; set; }
/// <summary>Portal ExperienceTable (0x0E000018) — set by the host once dats load.</summary>
public DatReaderWriter.DBObjs.ExperienceTable? ExperienceTable { get; set; }
public CharacterSheetProvider(
ClientObjectTable objects,
LocalPlayerState localPlayer,
Func<uint> playerGuid,
Func<string?>? activeToonName = null,
Func<string, CharacterSheet>? fallbackSheet = null,
Func<bool>? canSendRaise = null,
Action<uint, ulong>? sendRaiseAttribute = null,
Action<uint, ulong>? sendRaiseVital = null,
Action<uint, ulong>? sendRaiseSkill = null,
Action<uint, uint>? sendTrainSkill = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_localPlayer = localPlayer ?? throw new ArgumentNullException(nameof(localPlayer));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
_activeToonName = activeToonName;
_fallbackSheet = fallbackSheet;
_canSendRaise = canSendRaise;
_sendRaiseAttribute = sendRaiseAttribute;
_sendRaiseVital = sendRaiseVital;
_sendRaiseSkill = sendRaiseSkill;
_sendTrainSkill = sendTrainSkill;
}
// ── Sheet assembly ─────────────────────────────────────────────────────
/// <summary>Best display name: active toon key, else the live object's name, else "Player".</summary>
public string CharacterName()
{
string? toon = _activeToonName?.Invoke();
if (!string.IsNullOrWhiteSpace(toon) && toon != "default")
return toon;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid)?.Name is { Length: > 0 } objectName)
return objectName;
return "Player";
}
/// <summary>
/// Build the sheet from live state; falls back to the injected sample
/// sheet (Studio data) until any live character data has arrived.
/// Called per repaint by the Character window's bound widgets.
/// </summary>
public CharacterSheet BuildSheet()
{
if (!HasLiveData())
return _fallbackSheet?.Invoke(CharacterName()) ?? new CharacterSheet { Name = CharacterName() };
var props = CurrentPlayerProperties();
int level = props.GetInt(0x19u);
long totalXp = props.GetInt64(1u);
long unassignedXp = props.GetInt64(UnassignedXpPropertyId);
var xp = ComputeLevelXp(level, totalXp);
int skillCredits = props.GetInt(0x18u, props.GetInt(0xC0u, props.GetInt(0xB5u)));
return new CharacterSheet
{
Name = CharacterName(),
Level = level,
Gender = CharacterIdentityText.GenderDisplayName(
props.GetInt(CharacterIdentityText.GenderPropertyId)),
Heritage = CharacterIdentityText.HeritageGroupDisplayName(
props.GetInt(CharacterIdentityText.HeritageGroupPropertyId)),
PkStatus = PkStatusText(props.GetInt(134u, 0)),
TotalXp = totalXp,
XpToNextLevel = xp.toNext,
XpFraction = xp.fraction,
HealthCurrent = VitalCurrent(LocalPlayerState.VitalKind.Health),
HealthMax = VitalMax(LocalPlayerState.VitalKind.Health),
StaminaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Stamina),
StaminaMax = VitalMax(LocalPlayerState.VitalKind.Stamina),
ManaCurrent = VitalCurrent(LocalPlayerState.VitalKind.Mana),
ManaMax = VitalMax(LocalPlayerState.VitalKind.Mana),
Strength = AttrCurrent(LocalPlayerState.AttributeKind.Strength),
Endurance = AttrCurrent(LocalPlayerState.AttributeKind.Endurance),
Coordination = AttrCurrent(LocalPlayerState.AttributeKind.Coordination),
Quickness = AttrCurrent(LocalPlayerState.AttributeKind.Quickness),
Focus = AttrCurrent(LocalPlayerState.AttributeKind.Focus),
Self = AttrCurrent(LocalPlayerState.AttributeKind.Self),
UnspentSkillCredits = props.GetInt(0xB5u, skillCredits),
SpecializedSkillCredits = props.GetInt(0xC0u),
SkillCredits = skillCredits,
UnassignedXp = unassignedXp,
AttributeRaiseCosts = BuildAttributeRaiseCosts(amount: 1),
AttributeRaise10Costs = BuildAttributeRaiseCosts(amount: 10),
Skills = BuildLiveCharacterSkills(),
BurdenCurrent = props.GetInt(5u),
BurdenMax = props.GetInt(96u),
};
}
private bool HasLiveData()
{
var props = CurrentPlayerProperties();
return props.Ints.Count > 0
|| props.Int64s.Count > 0
|| _localPlayer.Skills.Count > 0
|| _localPlayer.GetAttribute(LocalPlayerState.AttributeKind.Strength) is not null
|| _localPlayer.Get(LocalPlayerState.VitalKind.Health) is not null;
}
/// <summary>The player's canonical bundle: the live ClientObject's when the
/// player object has arrived (CreateObject merge-upsert), else the
/// PlayerDescription snapshot on LocalPlayerState.</summary>
private PropertyBundle CurrentPlayerProperties()
{
uint guid = _playerGuid();
return guid != 0u && _objects.Get(guid) is { } player
? player.Properties
: _localPlayer.Properties;
}
/// <summary>
/// Load the portal ExperienceTable (0x0E000018), falling back to a
/// type scan for older or odd dat collections. Failures are logged —
/// never silently swallowed — and leave raise costs unavailable (0).
/// </summary>
public static DatReaderWriter.DBObjs.ExperienceTable? LoadExperienceTable(
DatCollection dats, Action<string>? log = null)
{
if (dats is null) return null;
try
{
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(0x0E000018u);
if (table is not null) return table;
}
catch (Exception ex)
{
log?.Invoke($"[D.2b-C] ExperienceTable 0x0E000018 read failed ({ex.GetType().Name}: {ex.Message}); trying type scan.");
}
try
{
foreach (uint id in dats.GetAllIdsOfType<DatReaderWriter.DBObjs.ExperienceTable>())
{
var table = dats.Get<DatReaderWriter.DBObjs.ExperienceTable>(id);
if (table is not null) return table;
}
}
catch (Exception ex)
{
log?.Invoke($"[D.2b-C] ExperienceTable type scan failed ({ex.GetType().Name}: {ex.Message}); raise costs unavailable.");
}
return null;
}
/// <summary>XP still needed for the next level + fill fraction of the
/// current level band (retail (curbase)/(capbase); CharacterSheet.XpFraction).</summary>
private (long toNext, float fraction) ComputeLevelXp(int level, long totalXp)
{
var levels = ExperienceTable?.Levels;
if (levels is null || level < 0 || level + 1 >= levels.Length)
return (0L, 0f);
long current = ClampToLong(levels[level]);
long next = ClampToLong(levels[level + 1]);
if (next <= current) return (0L, 0f);
long clampedXp = totalXp < current ? current : totalXp > next ? next : totalXp;
long toNext = next - clampedXp;
float fraction = (float)(clampedXp - current) / (next - current);
return (toNext, fraction);
}
/// <summary>Per-attribute/vital raise costs in retail display order
/// (see CharacterSheet.AttributeRaiseCosts). Cost 0 = maxed / unknown.</summary>
private long[] BuildAttributeRaiseCosts(int amount)
{
var xp = ExperienceTable;
return new[]
{
AttributeRaiseCost(LocalPlayerState.AttributeKind.Strength),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Endurance),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Coordination),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Quickness),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Focus),
AttributeRaiseCost(LocalPlayerState.AttributeKind.Self),
VitalRaiseCost(LocalPlayerState.VitalKind.Health),
VitalRaiseCost(LocalPlayerState.VitalKind.Stamina),
VitalRaiseCost(LocalPlayerState.VitalKind.Mana),
};
long AttributeRaiseCost(LocalPlayerState.AttributeKind kind)
{
var attr = _localPlayer.GetAttribute(kind);
return attr is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Attributes, attr.Value.Ranks, attr.Value.Xp, amount);
}
long VitalRaiseCost(LocalPlayerState.VitalKind kind)
{
var vital = _localPlayer.Get(kind);
return vital is null || xp is null ? 0L : RaiseCostFromXpCurve(xp.Vitals, vital.Value.Ranks, vital.Value.Xp, amount);
}
}
private IReadOnlyList<CharacterSkill> BuildLiveCharacterSkills()
{
var result = new List<CharacterSkill>();
var skillTable = SkillTable;
var xp = ExperienceTable;
foreach (var snapshot in _localPlayer.Skills.Values)
{
var advancement = AdvancementFromStatus(snapshot.Status);
if (advancement == CharacterSkillAdvancementClass.Inactive)
continue;
DatReaderWriter.Types.SkillBase? skillBase = null;
if (skillTable?.Skills is not null)
skillTable.Skills.TryGetValue((DatReaderWriter.Enums.SkillId)snapshot.SkillId, out skillBase);
string? name = skillBase?.Name.Value;
if (string.IsNullOrWhiteSpace(name))
name = $"Skill {snapshot.SkillId}";
uint icon = skillBase?.IconId.DataId ?? 0u;
int trainedCost = skillBase?.TrainedCost ?? 0;
int specializedCost = skillBase?.SpecializedCost ?? 0;
long raiseCost = SkillRaiseCost(xp, advancement, snapshot, 1);
long raise10Cost = SkillRaiseCost(xp, advancement, snapshot, 10);
result.Add(new CharacterSkill(
snapshot.SkillId,
name,
icon,
advancement,
checked((int)Math.Min(int.MaxValue, snapshot.BaseLevel)),
checked((int)Math.Min(int.MaxValue, snapshot.CurrentLevel)),
IsUsableUntrained(snapshot.SkillId),
trainedCost,
specializedCost,
raiseCost,
raise10Cost));
}
return result;
}
private static CharacterSkillAdvancementClass AdvancementFromStatus(uint status) => status switch
{
1u => CharacterSkillAdvancementClass.Untrained,
2u => CharacterSkillAdvancementClass.Trained,
3u => CharacterSkillAdvancementClass.Specialized,
_ => CharacterSkillAdvancementClass.Inactive,
};
private static bool IsUsableUntrained(uint skillId) => skillId switch
{
18u or 37u or 38u or 39u or 40u => false,
_ => true,
};
private static long SkillRaiseCost(
DatReaderWriter.DBObjs.ExperienceTable? xp,
CharacterSkillAdvancementClass advancement,
LocalPlayerState.SkillSnapshot skill,
int amount)
{
if (xp is null) return 0L;
uint[] curve = advancement == CharacterSkillAdvancementClass.Specialized
? xp.SpecializedSkills
: xp.TrainedSkills;
return RaiseCostFromXpCurve(curve, skill.Ranks, skill.Xp, amount);
}
/// <summary>Cost to advance <paramref name="amount"/> ranks along a retail
/// cumulative-XP curve: curve[target] xpAlreadySpent, clamped at the
/// curve end (retail GetCostToRaise/GetCostToRaise10 0x0049cb80/0x0049cc70).</summary>
private static long RaiseCostFromXpCurve(uint[]? curve, uint ranks, uint spentXp, int amount)
{
if (curve is null || amount <= 0) return 0L;
long maxIndex = curve.Length - 1L;
if (maxIndex <= ranks) return 0L;
long targetLong = Math.Min((long)ranks + amount, maxIndex);
long targetXp = curve[(int)targetLong];
long cost = targetXp - spentXp;
return cost > 0 ? cost : 0L;
}
private static long ClampToLong(ulong value) =>
value > long.MaxValue ? long.MaxValue : (long)value;
private static string? PkStatusText(int status) => status switch
{
0x2 => "Non-Player Killer",
0x4 => "Player Killer",
0x40 => "Player Killer Lite",
_ => null,
};
private int AttrCurrent(LocalPlayerState.AttributeKind kind) =>
_localPlayer.GetAttribute(kind) is { } attr ? checked((int)Math.Min(int.MaxValue, attr.Current)) : 0;
private int VitalCurrent(LocalPlayerState.VitalKind kind) =>
_localPlayer.Get(kind) is { } vital ? checked((int)Math.Min(int.MaxValue, vital.Current)) : 0;
private int VitalMax(LocalPlayerState.VitalKind kind) =>
_localPlayer.GetMaxApprox(kind) is { } max ? checked((int)Math.Min(int.MaxValue, max)) : 0;
// ── Raise-request flow ─────────────────────────────────────────────────
/// <summary>
/// Send a raise/train action to the server and, when a send delegate
/// fired, optimistically apply the local effect so the sheet stays
/// current during the round trip. The next server snapshot remains
/// authoritative (a rejected raise is corrected by the property echo —
/// pending/rollback ledger tracked as a follow-up issue).
/// </summary>
public void HandleRaiseRequest(CharacterStatController.RaiseRequest request)
{
if (request.Cost <= 0) return;
if (_canSendRaise is not null && !_canSendRaise()) return;
bool sent = false;
switch (request.Kind)
{
case CharacterStatController.RaiseTargetKind.Attribute:
if (_sendRaiseAttribute is not null)
{
_sendRaiseAttribute(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.Vital:
if (_sendRaiseVital is not null)
{
_sendRaiseVital(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.Skill:
if (_sendRaiseSkill is not null)
{
_sendRaiseSkill(request.StatId, (ulong)request.Cost);
sent = true;
}
break;
case CharacterStatController.RaiseTargetKind.TrainSkill:
if (_sendTrainSkill is not null && request.Cost <= uint.MaxValue)
{
_sendTrainSkill(request.StatId, (uint)request.Cost);
sent = true;
}
break;
}
if (sent)
ApplyLocalRaise(request);
}
private void ApplyLocalRaise(CharacterStatController.RaiseRequest request)
{
uint amount = request.Amount <= 0 ? 1u : (uint)request.Amount;
ulong cost = (ulong)request.Cost;
switch (request.Kind)
{
case CharacterStatController.RaiseTargetKind.Attribute:
if (_localPlayer.ApplyAttributeRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.Vital:
if (_localPlayer.ApplyVitalRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.Skill:
if (_localPlayer.ApplySkillRaise(request.StatId, amount, cost))
SpendUnassignedExperience(request.Cost);
break;
case CharacterStatController.RaiseTargetKind.TrainSkill:
if (_localPlayer.ApplySkillTraining(request.StatId))
SpendSkillCredits(request.Cost);
break;
}
}
private void SpendUnassignedExperience(long cost)
{
if (cost <= 0) return;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid) is { } player)
{
long current = player.Properties.GetInt64(UnassignedXpPropertyId);
if (current <= 0) return;
_objects.UpdateInt64Property(guid, UnassignedXpPropertyId,
current > cost ? current - cost : 0L);
return;
}
_localPlayer.DebitInt64Property(UnassignedXpPropertyId, cost);
}
private void SpendSkillCredits(long cost)
{
if (cost <= 0) return;
int debit = cost > int.MaxValue ? int.MaxValue : (int)cost;
uint guid = _playerGuid();
if (guid != 0u && _objects.Get(guid) is { } player)
{
foreach (uint propertyId in SkillCreditPropertyIds)
{
if (!player.Properties.Ints.TryGetValue(propertyId, out int current))
continue;
_objects.UpdateIntProperty(guid, propertyId,
current > debit ? current - debit : 0);
return;
}
return;
}
foreach (uint propertyId in SkillCreditPropertyIds)
{
if (_localPlayer.DebitIntProperty(propertyId, debit))
return;
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -209,6 +209,12 @@ public sealed class ChatWindowController
?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText");
c.Transcript.DatFont = datFont;
c.Transcript.Font = debugFont;
// The imported Type-12 element may inherit HJustify=Center from its dat
// prototype, which makes UiText use the static one-line label path. The
// chat transcript must always use the scrollable multi-line path so it
// caches layout for mouse selection and Ctrl+C.
c.Transcript.Centered = false;
c.Transcript.RightAligned = false;
c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript
c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont);

View file

@ -85,6 +85,9 @@ public static class DatWidgetFactory
_ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers)
};
e.DatElementId = info.Id;
e.SetStateCursors(info.StateCursors);
// Propagate position + size (pixel-exact from the dat).
e.Left = info.X;
e.Top = info.Y;
@ -151,6 +154,7 @@ public static class DatWidgetFactory
{
var m = new UiMeter
{
ElementId = info.Id,
SpriteResolve = resolve,
DatFont = datFont,
};
@ -290,6 +294,7 @@ public static class DatWidgetFactory
var t = new UiText
{
ElementId = info.Id,
BackgroundSprite = bg,
SpriteResolve = resolve,
Centered = centered,

View file

@ -96,6 +96,12 @@ public sealed class ElementInfo
/// </summary>
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
/// <summary>
/// Cursor per state: state name to (RenderSurface file id, hotspot).
/// The "" key represents DirectState, mirroring <see cref="StateMedia"/>.
/// </summary>
public Dictionary<string, UiCursorMedia> StateCursors = new();
/// <summary>
/// The element's initial active state name, taken from <c>ElementDesc.DefaultState.ToString()</c>.
/// Normalized to <c>""</c> when the dat carries Undef/Undefined/0 (no default set).
@ -160,8 +166,8 @@ public static class ElementReader
/// (derived placement, not the base prototype's geometry).
/// </description></item>
/// <item><description>
/// <see cref="ElementInfo.StateMedia"/>: base entries are the default; derived
/// entries override (or add) per state name key.
/// <see cref="ElementInfo.StateMedia"/> and <see cref="ElementInfo.StateCursors"/>:
/// 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.
@ -218,6 +224,9 @@ public static class ElementReader
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
foreach (var kv in derived.StateMedia)
m.StateMedia[kv.Key] = kv.Value;
m.StateCursors = new Dictionary<string, UiCursorMedia>(base_.StateCursors);
foreach (var kv in derived.StateCursors)
m.StateCursors[kv.Key] = kv.Value;
return m;
}
}

View file

@ -22,6 +22,7 @@ public sealed class InventoryController : IItemListDragHandler
public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%")
public const uint BurdenCaptionId = 0x100001D7u; // "Burden"
public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack"
public const uint TitleTextId = 0x100001D3u; // "Inventory of <name>"
public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid)
// Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see
@ -45,6 +46,7 @@ public sealed class InventoryController : IItemListDragHandler
private readonly Func<uint> _playerGuid;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Func<int?> _strength;
private readonly Func<string>? _ownerName;
private readonly UiItemList? _contentsGrid;
private readonly UiItemList? _containerList;
@ -60,6 +62,7 @@ public sealed class InventoryController : IItemListDragHandler
private readonly Action<uint>? _sendUse;
private readonly Action<uint>? _sendNoLongerViewing;
private readonly Action<uint, uint, int>? _sendPutItemInContainer; // (item, container, placement)
private readonly ItemInteractionController? _itemInteraction;
// ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)).
private const uint EncumbranceValProperty = 5u; // total carried burden
@ -71,21 +74,28 @@ public sealed class InventoryController : IItemListDragHandler
Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
Func<string>? ownerName,
UiDatFont? datFont,
uint contentsEmptySprite,
uint sideBagEmptySprite,
uint mainPackEmptySprite,
Action<uint>? sendUse,
Action<uint>? sendNoLongerViewing,
Action<uint, uint, int>? sendPutItemInContainer)
Action<uint, uint, int>? sendPutItemInContainer,
ItemInteractionController? itemInteraction,
Action? onClose)
{
_objects = objects;
_playerGuid = playerGuid;
_iconIds = iconIds;
_strength = strength;
_ownerName = ownerName;
_sendUse = sendUse;
_sendNoLongerViewing = sendNoLongerViewing;
_sendPutItemInContainer = sendPutItemInContainer;
_itemInteraction = itemInteraction;
WindowChromeController.BindCloseButton(layout, onClose);
_contentsGrid = layout.FindElement(ContentsGridId) as UiItemList;
_containerList = layout.FindElement(ContainerListId) as UiItemList;
@ -142,6 +152,7 @@ public sealed class InventoryController : IItemListDragHandler
// Captions: drive each host UiText directly with the known string (the caption
// elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail
// (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.)
AttachCaption(layout.FindElement(TitleTextId), () => "Inventory of " + OwnerName(), datFont);
AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont);
AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont);
AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont);
@ -151,6 +162,8 @@ public sealed class InventoryController : IItemListDragHandler
_objects.ObjectMoved += OnObjectMoved;
_objects.ObjectRemoved += OnObjectChanged;
_objects.ObjectUpdated += OnObjectChanged;
if (_itemInteraction is not null)
_itemInteraction.StateChanged += OnInteractionStateChanged;
Populate();
}
@ -162,19 +175,23 @@ public sealed class InventoryController : IItemListDragHandler
Func<ItemType, uint, uint, uint, uint, uint> iconIds,
Func<int?> strength,
UiDatFont? datFont,
Func<string>? ownerName = null,
uint contentsEmptySprite = 0u,
uint sideBagEmptySprite = 0u,
uint mainPackEmptySprite = 0u,
Action<uint>? sendUse = null,
Action<uint>? sendNoLongerViewing = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont,
Action<uint, uint, int>? sendPutItemInContainer = null,
ItemInteractionController? itemInteraction = null,
Action? onClose = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, strength, ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendNoLongerViewing, sendPutItemInContainer);
sendUse, sendNoLongerViewing, sendPutItemInContainer, itemInteraction, onClose);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectMoved(ClientObject o, uint from, uint to)
{ if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); }
private void OnInteractionStateChanged() => ApplyIndicators();
/// <summary>True if the object is in (or wielded by) the player — i.e. a rebuild is warranted.</summary>
private bool Concerns(ClientObject o)
@ -200,7 +217,7 @@ public sealed class InventoryController : IItemListDragHandler
{
var item = _objects.Get(guid);
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
bool isBag = IsBag(item);
if (isBag) AddCell(_containerList, guid, isContainer: true);
}
@ -210,7 +227,7 @@ public sealed class InventoryController : IItemListDragHandler
{
var item = _objects.Get(guid);
if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue;
bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0;
bool isBag = IsBag(item);
if (!isBag) AddCell(_contentsGrid, guid, isContainer: false);
}
@ -248,7 +265,12 @@ public sealed class InventoryController : IItemListDragHandler
var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve };
main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u));
main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u;
main.Clicked = () => OpenContainer(p);
main.Clicked = () =>
{
if (_itemInteraction?.AcquireSelfTarget() == true) return;
OpenContainer(p);
};
main.DoubleClicked = () => _itemInteraction?.ActivateItem(p);
SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity)
_topContainer.AddItem(main);
}
@ -261,6 +283,11 @@ public sealed class InventoryController : IItemListDragHandler
/// Resolved live (not cached at ctor) so a late-arriving player guid is handled. The default
/// sentinel is 0; once the main pack is explicitly opened, <c>_openContainer</c> holds the player
/// guid instead — both resolve here to the same main-pack container, so the paths are equivalent.</summary>
private static bool IsBag(ClientObject item) =>
item.ContainerTypeHint != 0u
|| item.Type.HasFlag(ItemType.Container)
|| item.ItemsCapacity > 0;
private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid();
/// <summary>Add a populated cell wired to its click role: container cell → open+select,
@ -276,6 +303,7 @@ public sealed class InventoryController : IItemListDragHandler
cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list)
cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed)
cell.DragRejectSprite = 0x060011F8u; // red circle
cell.DoubleClicked = () => _itemInteraction?.ActivateItem(guid);
if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); }
else cell.Clicked = () => SelectItem(guid);
list.AddItem(cell);
@ -405,7 +433,8 @@ public sealed class InventoryController : IItemListDragHandler
{
var cell = list.GetItem(i);
if (cell is null) continue;
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem;
bool pendingTargetSource = _itemInteraction?.IsPendingSource(cell.ItemId) == true;
cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem && !pendingTargetSource;
cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open;
}
}
@ -417,6 +446,12 @@ public sealed class InventoryController : IItemListDragHandler
return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack");
}
private string OwnerName()
{
var name = _ownerName?.Invoke();
return string.IsNullOrWhiteSpace(name) ? "Player" : name;
}
private void AttachCaption(UiElement? host, Func<string> text, UiDatFont? datFont)
{
if (host is null) return;
@ -487,5 +522,7 @@ public sealed class InventoryController : IItemListDragHandler
_objects.ObjectMoved -= OnObjectMoved;
_objects.ObjectRemoved -= OnObjectChanged;
_objects.ObjectUpdated -= OnObjectChanged;
if (_itemInteraction is not null)
_itemInteraction.StateChanged -= OnInteractionStateChanged;
}
}

View file

@ -394,19 +394,27 @@ public static class LayoutImporter
/// <summary>
/// Read the first <see cref="MediaDescImage"/> from <paramref name="sd"/> into
/// <c>info.StateMedia[name]</c> and extract the font DID from property 0x1A
/// <c>info.StateMedia[name]</c>, read any <see cref="MediaDescCursor"/> into
/// <c>info.StateCursors[name]</c>, and extract the font DID from property 0x1A
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>) if not yet set.
/// </summary>
private static void ReadState(StateDesc sd, string name, ElementInfo info)
{
// Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars)
// are intentionally skipped — cursor behavior is Plan 2.
bool imageRead = false;
foreach (var m in sd.Media)
{
if (m is MediaDescImage img && img.File != 0)
if (!imageRead && m is MediaDescImage img && img.File != 0)
{
info.StateMedia[name] = (img.File, (int)img.DrawMode);
break;
imageRead = true;
}
if (m is MediaDescCursor cursor && cursor.File != 0)
{
info.StateCursors[name] = new UiCursorMedia(
cursor.File,
checked((int)cursor.XHotspot),
checked((int)cursor.YHotspot));
}
}

View file

@ -75,6 +75,7 @@ public sealed class PaperdollController : IItemListDragHandler
private readonly Func<uint> _playerGuid;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Action<uint, uint>? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A
private readonly ItemInteractionController? _itemInteraction;
private readonly List<(EquipMask Mask, UiItemList List)> _slots = new();
// ── Slots-toggle state ────────────────────────────────────────────────────────────────────────
@ -85,9 +86,10 @@ public sealed class PaperdollController : IItemListDragHandler
private PaperdollController(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield,
uint emptySlotSprite, UiDatFont? datFont)
uint emptySlotSprite, UiDatFont? datFont, ItemInteractionController? itemInteraction)
{
_objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield;
_itemInteraction = itemInteraction;
for (int i = 0; i < SlotMap.Length; i++)
{
@ -97,6 +99,13 @@ public sealed class PaperdollController : IItemListDragHandler
list.Cell.SourceKind = ItemDragSource.Equipment;
list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield
list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live
list.Cell.UseTargetGuidProvider = _playerGuid;
list.Cell.Clicked = () => { _itemInteraction?.AcquireSelfTarget(); };
list.Cell.DoubleClicked = () =>
{
if (list.Cell.ItemId != 0)
_itemInteraction?.ActivateItem(list.Cell.ItemId);
};
// 3D character (the "figure" — Slice 1/2 correction: NOT a per-slot
// silhouette) is the doll viewport, which arrives in Slice 2 with the Slots toggle.
// Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring
@ -140,8 +149,9 @@ public sealed class PaperdollController : IItemListDragHandler
public static PaperdollController Bind(
ImportedLayout layout, ClientObjectTable objects, Func<uint> playerGuid,
Func<ItemType, uint, uint, uint, uint, uint> iconIds, Action<uint, uint>? sendWield = null,
uint emptySlotSprite = 0u, UiDatFont? datFont = null)
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont);
uint emptySlotSprite = 0u, UiDatFont? datFont = null,
ItemInteractionController? itemInteraction = null)
=> new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont, itemInteraction);
private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); }
private void OnObjectMoved(ClientObject o, uint from, uint to)
@ -209,7 +219,7 @@ public sealed class PaperdollController : IItemListDragHandler
{
var item = _objects.Get(payload.ObjId);
if (item is null) return;
EquipMask wieldMask = item.ValidLocations & MaskFor(targetList);
EquipMask wieldMask = ItemEquipRules.ResolvePaperdollDropWieldMask(item, MaskFor(targetList));
if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected)
_objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask);
_sendWield?.Invoke(payload.ObjId, (uint)wieldMask);

View file

@ -0,0 +1,108 @@
using System;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Mounts an imported retail window's content into the shared 8-piece beveled
/// chrome (<see cref="UiNineSlicePanel"/>) and optionally registers it with
/// the <see cref="UiRoot"/> window manager. This is THE mount recipe every
/// retail window follows: frame sized content + 2×border, content inset by
/// the border with Draggable/Resizable off (the frame owns move/resize).
///
/// <para>The vitals/chat/toolbar/inventory windows still inline this recipe
/// in <c>GameWindow</c> (pre-extraction); new windows must mount through this
/// helper instead of copying the block again — see
/// docs/research/2026-07-02-ui-architecture-review.md (theme T1).</para>
/// </summary>
public static class RetailWindowFrame
{
/// <summary>Per-window placement + behavior knobs. Null-valued knobs keep
/// the widget defaults (<see cref="UiElement"/> / <see cref="UiNineSlicePanel"/>).</summary>
public sealed record Options
{
/// <summary>Initial window position (screen px).</summary>
public float Left { get; init; }
public float Top { get; init; }
/// <summary>Minimum frame size; null = lock to content + 2×border (non-shrinkable).</summary>
public float? MinWidth { get; init; }
public float? MinHeight { get; init; }
/// <summary>Maximum frame height while resizing; null = unbounded.</summary>
public float? MaxHeight { get; init; }
/// <summary>Allow horizontal resize (frame default: true).</summary>
public bool ResizeX { get; init; } = true;
/// <summary>Restrict which edges start a resize; null = all edges.</summary>
public ResizeEdges? ResizableEdges { get; init; }
/// <summary>Window translucency (retail SetDefaultOpacity analog).</summary>
public float Opacity { get; init; } = 1f;
/// <summary>Start shown or hidden (hidden windows toggle via the window manager).</summary>
public bool Visible { get; init; } = true;
/// <summary>How the content tracks the frame when it resizes.</summary>
public AnchorEdges ContentAnchors { get; init; } =
AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom;
/// <summary>Force the content's ClickThrough; null = leave as imported.</summary>
public bool? ContentClickThrough { get; init; }
/// <summary>Register the frame under this name in the UiRoot window
/// manager (<see cref="WindowNames"/>); null = unmanaged window.</summary>
public string? WindowName { get; init; }
}
/// <summary>
/// Wrap <paramref name="content"/> (sized by the importer) in the beveled
/// chrome, add it to <paramref name="root"/>, and register it when
/// <see cref="Options.WindowName"/> is set. Returns the frame.
/// </summary>
public static UiNineSlicePanel Mount(
UiRoot root,
UiElement content,
Func<uint, (uint handle, int w, int h)> resolveChrome,
Options options)
{
ArgumentNullException.ThrowIfNull(root);
ArgumentNullException.ThrowIfNull(content);
ArgumentNullException.ThrowIfNull(resolveChrome);
ArgumentNullException.ThrowIfNull(options);
const int border = RetailChromeSprites.Border;
float contentW = content.Width;
float contentH = content.Height;
var frame = new UiNineSlicePanel(resolveChrome)
{
Left = options.Left,
Top = options.Top,
Width = contentW + 2 * border,
Height = contentH + 2 * border,
MinWidth = options.MinWidth ?? contentW + 2 * border,
MinHeight = options.MinHeight ?? contentH + 2 * border,
ResizeX = options.ResizeX,
Opacity = options.Opacity,
Visible = options.Visible,
};
if (options.MaxHeight is { } maxHeight) frame.MaxHeight = maxHeight;
if (options.ResizableEdges is { } edges) frame.ResizableEdges = edges;
content.Left = border;
content.Top = border;
content.Width = contentW;
content.Height = contentH;
content.Anchors = options.ContentAnchors;
if (options.ContentClickThrough is { } clickThrough) content.ClickThrough = clickThrough;
content.Draggable = false; // the frame owns window move/resize
content.Resizable = false;
frame.AddChild(content);
root.AddChild(frame);
if (options.WindowName is { } name)
root.RegisterWindow(name, frame);
return frame;
}
}

View file

@ -54,8 +54,15 @@ public sealed class ToolbarController : IItemListDragHandler
private static readonly uint[] CombatIndicatorIds =
{ 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u };
private const uint CharacterButtonId = 0x10000199u;
private const uint InventoryButtonId = 0x100001B1u;
private const string ButtonClosedState = "Normal";
private const string ButtonOpenState = "Highlight";
private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length];
private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length];
private readonly UiButton? _characterButton;
private readonly UiButton? _inventoryButton;
private readonly ClientObjectTable _repo;
private readonly Func<IReadOnlyList<PlayerDescriptionParser.ShortcutEntry>> _shortcuts;
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex
@ -64,6 +71,7 @@ public sealed class ToolbarController : IItemListDragHandler
private bool _storeLoaded;
private readonly Action<uint, uint>? _sendAddShortcut; // (index, objectGuid)
private readonly Action<uint>? _sendRemoveShortcut; // (index)
private readonly ItemInteractionController? _itemInteraction;
// Digit sprite DID arrays for slot labels (top row, numbers 1-9).
// Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346.
@ -87,6 +95,7 @@ public sealed class ToolbarController : IItemListDragHandler
uint[]? peaceDigits,
uint[]? warDigits,
uint[]? emptyDigits,
ItemInteractionController? itemInteraction = null,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
@ -97,6 +106,7 @@ public sealed class ToolbarController : IItemListDragHandler
_peaceDigits = peaceDigits;
_warDigits = warDigits;
_emptyDigits = emptyDigits;
_itemInteraction = itemInteraction;
_sendAddShortcut = sendAddShortcut;
_sendRemoveShortcut = sendRemoveShortcut;
@ -120,6 +130,9 @@ public sealed class ToolbarController : IItemListDragHandler
for (int i = 0; i < CombatIndicatorIds.Length; i++)
_combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]);
_characterButton = layout.FindElement(CharacterButtonId) as UiButton;
_inventoryButton = layout.FindElement(InventoryButtonId) as UiButton;
// Hide target-object meters + stack slider (gmToolbarUI::PostInit).
foreach (var id in HiddenIds)
if (layout.FindElement(id) is { } e) e.Visible = false;
@ -201,16 +214,44 @@ public sealed class ToolbarController : IItemListDragHandler
uint[]? peaceDigits = null,
uint[]? warDigits = null,
uint[]? emptyDigits = null,
ItemInteractionController? itemInteraction = null,
Action<uint, uint>? sendAddShortcut = null,
Action<uint>? sendRemoveShortcut = null)
{
var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState,
peaceDigits, warDigits, emptyDigits,
peaceDigits, warDigits, emptyDigits, itemInteraction,
sendAddShortcut, sendRemoveShortcut);
c.Populate();
return c;
}
/// <summary>Wire the retail status-bar launch buttons to top-level window toggles.</summary>
public void BindWindowToggles(Action? toggleInventory, Action? toggleCharacter)
{
if (_inventoryButton is not null)
{
if (_itemInteraction is not null)
_inventoryButton.UseTargetGuidProvider = () => _itemInteraction.PlayerGuid;
_inventoryButton.OnClick = () =>
{
if (_itemInteraction?.AcquireSelfTarget() == true) return;
toggleInventory?.Invoke();
};
}
if (_characterButton is not null)
_characterButton.OnClick = toggleCharacter;
}
public void SetInventoryOpen(bool open) => SetButtonOpen(_inventoryButton, open);
public void SetCharacterOpen(bool open) => SetButtonOpen(_characterButton, open);
private static void SetButtonOpen(UiButton? button, bool open)
{
if (button is not null)
button.ActiveState = open ? ButtonOpenState : ButtonClosedState;
}
/// <summary>
/// Port of <c>gmToolbarUI::UpdateFromPlayerDesc</c>: clear all slots, then bind
/// each shortcut entry that has a resolved item in the repository.
@ -320,7 +361,12 @@ public sealed class ToolbarController : IItemListDragHandler
list.Cell.Clicked = () =>
{
if (list.Cell.ItemId != 0)
_useItem(list.Cell.ItemId);
{
if (_itemInteraction is not null)
_itemInteraction.ActivateItem(list.Cell.ItemId);
else
_useItem(list.Cell.ItemId);
}
};
}

View file

@ -51,6 +51,8 @@ public sealed class UiDatElement : UiElement
/// Falls back to DirectState if the named state is absent.</summary>
public string ActiveState { get; set; } = "";
public override string ActiveCursorStateName => ActiveState;
/// <param name="info">Merged <see cref="ElementInfo"/> for this element.</param>
/// <param name="resolve">Dat file-id → (GL texture handle, native px width, native px height).
/// Returns (0,0,0) when the texture is not yet uploaded.</param>

View file

@ -0,0 +1,54 @@
using System;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Shared bindings for imported retail window chrome. The same max/min button id is
/// reused by several LayoutDesc windows; per-window controllers decide what the click
/// means for their window.
/// </summary>
internal static class WindowChromeController
{
public const uint MaxMinButtonId = 0x1000046Fu;
public const uint InventoryCloseButtonId = 0x100001D2u;
public const uint CharacterCloseButtonId = 0x1000022Au;
public static int BindCloseButton(ImportedLayout layout, Action? onClose)
{
if (onClose is null)
return 0;
return BindCloseButton(layout.Root, onClose);
}
private static int BindCloseButton(UiElement node, Action onClose)
{
int count = TryBindCloseButton(node, onClose) ? 1 : 0;
foreach (var child in node.Children)
count += BindCloseButton(child, onClose);
return count;
}
private static bool TryBindCloseButton(UiElement element, Action onClose)
{
switch (element)
{
case UiButton button when IsCloseButtonId(button.ElementId):
button.OnClick = onClose;
return true;
case UiDatElement datElement when IsCloseButtonId(datElement.ElementId):
datElement.ClickThrough = false;
datElement.CapturesPointerDrag = true;
datElement.OnClick = onClose;
return true;
default:
return false;
}
}
private static bool IsCloseButtonId(uint id) =>
id is MaxMinButtonId or InventoryCloseButtonId or CharacterCloseButtonId;
}