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:
parent
e3fc7ac5ba
commit
b7dc91a053
74 changed files with 6669 additions and 238 deletions
68
src/AcDream.App/UI/Layout/CharacterIdentityText.cs
Normal file
68
src/AcDream.App/UI/Layout/CharacterIdentityText.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
478
src/AcDream.App/UI/Layout/CharacterSheetProvider.cs
Normal file
478
src/AcDream.App/UI/Layout/CharacterSheetProvider.cs
Normal 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 (cur−base)/(cap−base); 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
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
108
src/AcDream.App/UI/Layout/RetailWindowFrame.cs
Normal file
108
src/AcDream.App/UI/Layout/RetailWindowFrame.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
54
src/AcDream.App/UI/Layout/WindowChromeController.cs
Normal file
54
src/AcDream.App/UI/Layout/WindowChromeController.cs
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue