From be82459dfe6a465b4fabe1e24bf598edf894ac99 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Fri, 2 Oct 2026 10:42:13 +0000 Subject: [PATCH] fix(ui): top-left HUD portraits for party members, mercenaries, and summoned minions (Fixes #556) --- public/ui/manifest.json | 225 +++++++++- public/ui/portraits-atlas.png | Bin 0 -> 26175 bytes src/baker/packers/pack-ui.ts | 69 ++++ src/client/ui-model/hud-model.ts | 136 ++++-- src/client/ui/hud-manager.ts | 130 ++++-- src/client/world/client-world.ts | 153 +++++-- src/common/items/baked-ui-meta.ts | 224 +++++++++- src/common/units/pet-type-table.ts | 389 ++++++++++++++++++ src/netproto/d2gs/s2c/merc.ts | 7 +- src/netproto/d2gs/s2c/party.ts | 1 + src/netproto/domain/server-event.ts | 5 + tests/client/hud-portrait-roster-pets.test.ts | 321 +++++++++++++++ 12 files changed, 1564 insertions(+), 96 deletions(-) create mode 100644 public/ui/portraits-atlas.png create mode 100644 src/common/units/pet-type-table.ts create mode 100644 tests/client/hud-portrait-roster-pets.test.ts diff --git a/public/ui/manifest.json b/public/ui/manifest.json index 2d61ab0..6c48ad5 100644 --- a/public/ui/manifest.json +++ b/public/ui/manifest.json @@ -1,11 +1,13 @@ { "version": "1.13c", - "decodedDc6Count": 695, + "decodedDc6Count": 728, "dc6DecodeFailures": 0, "atlasWidth": 2048, "atlasHeight": 8102, "questsAtlasWidth": 432, "questsAtlasHeight": 774, + "portraitsAtlasWidth": 288, + "portraitsAtlasHeight": 210, "codeToInvFile": { "hax": "invhax", "axe": "invaxe", @@ -268543,6 +268545,224 @@ "h": 86 } }, + "portraitRects": { + "AmazonIcon": { + "x": 0, + "y": 0, + "w": 46, + "h": 41 + }, + "amazonicon": { + "x": 0, + "y": 0, + "w": 46, + "h": 41 + }, + "SorceressIcon": { + "x": 48, + "y": 0, + "w": 46, + "h": 41 + }, + "sorceressicon": { + "x": 48, + "y": 0, + "w": 46, + "h": 41 + }, + "NecromancerIcon": { + "x": 96, + "y": 0, + "w": 46, + "h": 41 + }, + "necromancericon": { + "x": 96, + "y": 0, + "w": 46, + "h": 41 + }, + "PaladinIcon": { + "x": 144, + "y": 0, + "w": 46, + "h": 41 + }, + "paladinicon": { + "x": 144, + "y": 0, + "w": 46, + "h": 41 + }, + "BarbarianIcon": { + "x": 192, + "y": 0, + "w": 46, + "h": 41 + }, + "barbarianicon": { + "x": 192, + "y": 0, + "w": 46, + "h": 41 + }, + "DruidIcon": { + "x": 240, + "y": 0, + "w": 46, + "h": 41 + }, + "druidicon": { + "x": 240, + "y": 0, + "w": 46, + "h": 41 + }, + "AssassinIcon": { + "x": 0, + "y": 42, + "w": 46, + "h": 41 + }, + "assassinicon": { + "x": 0, + "y": 42, + "w": 46, + "h": 41 + }, + "rogueicon": { + "x": 48, + "y": 42, + "w": 46, + "h": 41 + }, + "act2hireableicon": { + "x": 96, + "y": 42, + "w": 46, + "h": 41 + }, + "act3hireableicon": { + "x": 144, + "y": 42, + "w": 46, + "h": 41 + }, + "barbhirable_icon": { + "x": 192, + "y": 42, + "w": 47, + "h": 41 + }, + "valkarieicon": { + "x": 240, + "y": 42, + "w": 46, + "h": 41 + }, + "earthgolumicon": { + "x": 0, + "y": 84, + "w": 46, + "h": 41 + }, + "bloodgolumicon": { + "x": 48, + "y": 84, + "w": 46, + "h": 41 + }, + "metalgolumicon": { + "x": 96, + "y": 84, + "w": 46, + "h": 41 + }, + "firegolumicon": { + "x": 144, + "y": 84, + "w": 46, + "h": 41 + }, + "skeletonicon": { + "x": 192, + "y": 84, + "w": 46, + "h": 41 + }, + "skeletonmageicon": { + "x": 240, + "y": 84, + "w": 46, + "h": 41 + }, + "revivedicon": { + "x": 0, + "y": 126, + "w": 46, + "h": 41 + }, + "raven": { + "x": 48, + "y": 126, + "w": 46, + "h": 41 + }, + "wolf": { + "x": 96, + "y": 126, + "w": 46, + "h": 41 + }, + "oaksage": { + "x": 144, + "y": 126, + "w": 47, + "h": 41 + }, + "heartofwolverine": { + "x": 192, + "y": 126, + "w": 46, + "h": 41 + }, + "spiritofbarbs": { + "x": 240, + "y": 126, + "w": 47, + "h": 41 + }, + "plaguepoppy": { + "x": 0, + "y": 168, + "w": 46, + "h": 41 + }, + "cycleoflife": { + "x": 48, + "y": 168, + "w": 46, + "h": 41 + }, + "vines": { + "x": 96, + "y": 168, + "w": 46, + "h": 41 + }, + "bear": { + "x": 144, + "y": 168, + "w": 46, + "h": 41 + }, + "shadowassassin": { + "x": 192, + "y": 168, + "w": 47, + "h": 41 + } + }, "images": { "ctrlPnl": "/ui/ctrlpnl-800.png", "globeLife": "/ui/globe-life.png", @@ -268591,7 +268811,8 @@ "automapAct4": "/ui/automap-act4.png", "automapAct4S": "/ui/automap-act4s.png", "automapExTn": "/ui/automap-extn.png", - "automapExTnS": "/ui/automap-extns.png" + "automapExTnS": "/ui/automap-extns.png", + "portraitsAtlas": "/ui/portraits-atlas.png" }, "automapAtlases": { "MaxiMap": { diff --git a/public/ui/portraits-atlas.png b/public/ui/portraits-atlas.png new file mode 100644 index 0000000000000000000000000000000000000000..55ce6ac44401d1704dd7e4bd77837470de265342 GIT binary patch literal 26175 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�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 i#s97UX|De-k>y_>*CBDj0Y = {} + const portraitPlacements: BlitPlacement[] = [] + + for (let i = 0; i < canonicalPortraits.length; i++) { + const pName = canonicalPortraits[i]! + const pDc6 = await decodeUiDc6(`data/global/ui/Hireables/${pName}.dc6`) + const frame = pDc6.groups[0]!.frames[0]! + const col = i % PORTRAIT_COLS + const row = Math.floor(i / PORTRAIT_COLS) + const px = col * PORTRAIT_CELL_W + const py = row * PORTRAIT_CELL_H + const rect: SpriteRect = { x: px, y: py, w: frame.width, h: frame.height } + portraitRects[pName] = rect + portraitRects[pName.toLowerCase()] = rect + portraitPlacements.push({ frame, x: px, y: py }) + } + + savePng( + 'portraits-atlas.png', + PORTRAIT_ATLAS_W, + PORTRAIT_ATLAS_H, + stitchFrames(PORTRAIT_ATLAS_W, PORTRAIT_ATLAS_H, portraitPlacements), + ) + // --- STAGE 3.5: Automap DC6 Sprite Sheets (8 sheets: MaxiMap(S), Act2Map(S), Act4Map(S), ExTnMap(S)) --- console.log('Packing 8 Automap DC6 sprite sheets (MaxiMap, Act2Map, Act4Map, ExTnMap + S variants)...') const automapOutDir = join(process.cwd(), 'public', 'assets', 'ui', 'automap') @@ -1501,6 +1564,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom automapAct4S: '/ui/automap-act4s.png', automapExTn: '/ui/automap-extn.png', automapExTnS: '/ui/automap-extns.png', + portraitsAtlas: '/ui/portraits-atlas.png', } const manifest = { @@ -1511,6 +1575,8 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom atlasHeight: atlasH, questsAtlasWidth: QUEST_ATLAS_W, questsAtlasHeight: QUEST_ATLAS_H, + portraitsAtlasWidth: PORTRAIT_ATLAS_W, + portraitsAtlasHeight: PORTRAIT_ATLAS_H, codeToInvFile, codeToFlippyFile, itemRects, @@ -1518,6 +1584,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom flippyFrames, itemClassIdToCode, questRects, + portraitRects, images: imagesMap, automapAtlases, } @@ -1598,6 +1665,7 @@ export interface BakedUiManifest { readonly flippyFrames: Record readonly itemClassIdToCode: Record readonly questRects: Record + readonly portraitRects?: Record readonly images: Record readonly automapAtlases?: Readonly> } @@ -1667,6 +1735,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = { flippyFrames: ${JSON.stringify(flippyFrames, null, 2)}, itemClassIdToCode: ${JSON.stringify(itemClassIdToCode, null, 2)}, questRects: ${JSON.stringify(questRects, null, 2)}, + portraitRects: ${JSON.stringify(portraitRects, null, 2)}, images: ${JSON.stringify(imagesMap, null, 2)}, automapAtlases: AUTOMAP_UI_MANIFEST.atlases, } diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 11d0123..f93a512 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -41,6 +41,11 @@ import type { ClientWorld, NpcInteractionState } from '../world/client-world.ts' import { D2StatId } from '../world/stat-list.ts' import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts' import { computeCharacterSheetDerived } from './derived-stats.ts' +import { + resolveMercPortraitIcon, + resolvePlayerClassPortraitIcon, + resolvePetPortraitInfo, +} from '../../common/units/pet-type-table.ts' export const CLASS_ID_TO_CODE: Readonly> = { 0: 'ama', @@ -819,10 +824,12 @@ export class HudModel implements HudCommandSink { equipment: mercEquipment, equipped: mercEquipped, } + const mercIconKey = resolveMercPortraitIcon(this.world.merc?.classId ?? mercRaw?.classId) const syncedMerc = this.world.merc ? { ...this.world.merc, unitId: mercRaw.unitId ?? this.world.merc.mercId, + iconKey: mercIconKey, ...mercCommonFields, } : this.world.containers.merc.size > 0 @@ -832,11 +839,54 @@ export class HudModel implements HudCommandSink { nameId: 0, isDead: false, reviveCost: 0, + iconKey: mercIconKey, ...mercCommonFields, } : null ;(hud as any).merc = syncedMerc ;(hud.worldPanels as any).merc = syncedMerc + + // 5c2. Summoned Minions (PetType.txt & Hireables.cpp) + const summonPortraits: Array<{ + readonly petType: number + readonly iconKey: string + readonly label: string + readonly count: number + readonly lifePct: number + readonly drawHp: boolean + readonly iconType: number + }> = [] + + if (this.world.pets && this.world.pets.size > 0) { + const activePets = [...this.world.pets.values()].filter( + p => !p.isDead && (p.ownerId === this.world.selfId || this.world.selfId === 0), + ) + const byType = new Map() + for (const p of activePets) { + if (p.petType === 7) continue // Mercenary handled separately + const arr = byType.get(p.petType) ?? [] + arr.push(p) + byType.set(p.petType, arr) + } + for (const [petTypeIdx, pets] of byType.entries()) { + const first = pets[0]! + const portInfo = resolvePetPortraitInfo(petTypeIdx, first.classId) + if (!portInfo) continue + const count = pets.length + const avgLifePct = Math.round(pets.reduce((sum, p) => sum + (p.lifePct ?? 100), 0) / count) + summonPortraits.push({ + petType: petTypeIdx, + iconKey: portInfo.iconKey, + label: ((hud as any).lang === 'CHI' || (hud as any)._lang === 'CHI') ? portInfo.nameZh : portInfo.nameEn, + count, + lifePct: avgLifePct, + drawHp: portInfo.meta.drawHp, + iconType: portInfo.meta.iconType, + }) + } + } + ;(hud as any).summonPortraits = summonPortraits + const rawHireList = (this.world.mercHireList && this.world.mercHireList.length > 0 ? this.world.mercHireList @@ -876,45 +926,53 @@ export class HudModel implements HudCommandSink { ;(hud.worldPanels as any).hasUnidentifiedItems = this.hasUnidentifiedItems() // 5d. Party state - const partyList = [...this.world.party.values()].map((m: any) => { - const pid = m.unitId ?? m.playerId ?? 0 - const resolvedName = m.name || m.charName || `Player #${pid}` - const resolvedLevel = m.level ?? m.charLevel ?? 1 - const resolvedPartyState = - m.partyState ?? - (typeof m.relationship === 'string' && m.relationship.length > 0 - ? m.relationship - : m.hostile - ? 'hostile' - : m.partyId !== undefined && m.partyId !== 0xffff - ? 'in_party' - : 'none') - const resolvedRelationship = - typeof m.relationship === 'string' && m.relationship.length > 0 - ? m.relationship - : resolvedPartyState - const lootFlag = Boolean(m.lootPermit ?? m.lootAllowed) - return { - unitId: pid, - playerId: pid, - name: resolvedName, - charName: resolvedName, - charClass: m.charClass ?? 0, - level: resolvedLevel, - charLevel: resolvedLevel, - partyId: m.partyId ?? 0xffff, - relationship: resolvedRelationship, - partyState: resolvedPartyState, - hostile: Boolean(m.hostile), - lootPermit: lootFlag, - lootAllowed: lootFlag, - lifePct: m.lifePct ?? 100, - areaId: m.levelId ?? m.areaId ?? 0, - levelId: m.levelId ?? m.areaId ?? 0, - x: m.x ?? 0, - y: m.y ?? 0, - } - }) + const partyList = [...this.world.party.values()] + .filter((m: any) => { + const pid = m.unitId ?? m.playerId ?? 0 + if (pid === 0) return false + if (this.world.selfId !== 0 && pid === this.world.selfId) return false + return true + }) + .map((m: any) => { + const pid = m.unitId ?? m.playerId ?? 0 + const resolvedName = m.name || m.charName || `Player #${pid}` + const resolvedLevel = m.level ?? m.charLevel ?? 1 + const resolvedPartyState = + m.partyState ?? + (typeof m.relationship === 'string' && m.relationship.length > 0 + ? m.relationship + : m.hostile + ? 'hostile' + : m.partyId !== undefined && m.partyId !== 0xffff + ? 'in_party' + : 'none') + const resolvedRelationship = + typeof m.relationship === 'string' && m.relationship.length > 0 + ? m.relationship + : resolvedPartyState + const lootFlag = Boolean(m.lootPermit ?? m.lootAllowed) + return { + unitId: pid, + playerId: pid, + name: resolvedName, + charName: resolvedName, + charClass: m.charClass ?? 0, + portraitIcon: resolvePlayerClassPortraitIcon(m.charClass), + level: resolvedLevel, + charLevel: resolvedLevel, + partyId: m.partyId ?? 0xffff, + relationship: resolvedRelationship, + partyState: resolvedPartyState, + hostile: Boolean(m.hostile), + lootPermit: lootFlag, + lootAllowed: lootFlag, + lifePct: m.lifePct ?? 100, + areaId: m.levelId ?? m.areaId ?? 0, + levelId: m.levelId ?? m.areaId ?? 0, + x: m.x ?? 0, + y: m.y ?? 0, + } + }) ;(hud as any).partyMembers = partyList ;(hud.worldPanels as any).partyMembers = partyList diff --git a/src/client/ui/hud-manager.ts b/src/client/ui/hud-manager.ts index f1b3271..8f805d5 100644 --- a/src/client/ui/hud-manager.ts +++ b/src/client/ui/hud-manager.ts @@ -20,6 +20,11 @@ import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from import { ControlBarHud, type MiniPanelAction } from './control-bar.ts' import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts' import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts' +import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts' +import { + resolveMercPortraitIcon, + resolvePlayerClassPortraitIcon, +} from '../../common/units/pet-type-table.ts' import { InventoryPanel, type EquipSlotId, @@ -436,6 +441,7 @@ export class HudManager { hireList: any[] = [] mercHireList: any[] = [] partyMembers: any[] = [] + summonPortraits: any[] = [] trade: any = null pendingIdentifyScrollId: number | null = null @@ -458,6 +464,8 @@ export class HudManager { private images = new Map() private itemRects: Record = {} + private portraitRects: Record = + (BAKED_UI_MANIFEST as any).portraitRects ?? {} private decodedDc6Count = 415 private dc6DecodeFailures = 0 private eventsBound = false @@ -755,8 +763,11 @@ export class HudManager { try { const mRes = await fetch(`${baseUrl}/manifest.json`) if (mRes.ok) { - const manifest = (await mRes.json()) as UiManifest + const manifest = (await mRes.json()) as any this.itemRects = manifest.itemRects + if (manifest.portraitRects) { + this.portraitRects = manifest.portraitRects + } this.decodedDc6Count = manifest.decodedDc6Count this.dc6DecodeFailures = manifest.dc6DecodeFailures } @@ -809,6 +820,7 @@ export class HudManager { pentSpin: `${baseUrl}/pentspin.png`, itemsAtlas: `${baseUrl}/items-atlas.png`, questsAtlas: `${baseUrl}/quests-atlas.png`, + portraitsAtlas: `${baseUrl}/portraits-atlas.png`, } await Promise.all( @@ -2308,49 +2320,113 @@ export class HudManager { ctx.translate(Math.round(offsetX * dpr), Math.round(offsetY * dpr)) ctx.scale(uiScale * dpr, uiScale * dpr) - // 0b. Top-Left Mercenary Portrait & Life Bar (R3.3) + Party Mini-Portraits (R5.1) + // 0b. Top-Left Mercenary Portrait & Life Bar (R3.3) + Summoned Minions + Party Mini-Portraits (R5.1) if (this.leftPanel === 'none') { + const atlasImg = this.images.get('portraitsAtlas') + let slotX = 8 + + // 1. Mercenary (slot 0) if (this.merc && !this.merc.isDead) { const hpRatio = Math.max(0, Math.min(1, Number(this.merc.hp ?? 0) / Math.max(1, Number(this.merc.maxHp ?? 1)))) ctx.fillStyle = 'rgba(16, 16, 20, 0.85)' - ctx.fillRect(8, 8, 48, 48) + ctx.fillRect(slotX, 8, 48, 48) ctx.strokeStyle = '#8b7344' - ctx.strokeRect(8.5, 8.5, 47, 47) + ctx.strokeRect(slotX + 0.5, 8.5, 47, 47) + + const iconKey = String(this.merc.iconKey || resolveMercPortraitIcon(this.merc.classId)) + const pRect = this.portraitRects[iconKey] || this.portraitRects[iconKey.toLowerCase()] + if (pRect && atlasImg) { + ctx.drawImage(atlasImg, pRect.x, pRect.y, pRect.w, pRect.h, slotX + 1, 9, 46, 41) + } + ctx.fillStyle = '#181818' - ctx.fillRect(8, 58, 48, 6) + ctx.fillRect(slotX, 58, 48, 6) ctx.fillStyle = hpRatio > 0.5 ? '#20a020' : hpRatio > 0.25 ? '#d0a020' : '#c02020' - ctx.fillRect(8, 58, Math.round(48 * hpRatio), 6) + ctx.fillRect(slotX, 58, Math.round(48 * hpRatio), 6) + const mercLabel = String(this.merc.name || 'Merc') - ctx.fillStyle = '#d4b468' - ctx.fillText(mercLabel, 32, 22) - this.font.drawText(ctx, mercLabel, 32, 22, { + this.font.drawText(ctx, mercLabel, slotX + 24, 22, { font: 'font6', color: 'gold', align: 'center', }) + slotX += 56 } - if (this.partyMembers.length > 0) { - for (let idx = 0; idx < this.partyMembers.length; idx++) { - const member = this.partyMembers[idx] - if (!member) continue - const py = 72 + idx * 52 - const lifeRatio = Math.max(0, Math.min(1, Number(member.lifePct ?? 100) / 100)) - ctx.fillStyle = 'rgba(16, 16, 20, 0.82)' - ctx.fillRect(8, py, 44, 44) - ctx.strokeStyle = member.hostile ? '#c02020' : '#7a6840' - ctx.strokeRect(8.5, py + 0.5, 43, 43) - ctx.fillStyle = '#181818' - ctx.fillRect(8, py + 45, 44, 5) - ctx.fillStyle = '#20a020' - ctx.fillRect(8, py + 45, Math.round(44 * lifeRatio), 5) - const memberName = String(member.name || `P${member.unitId ?? idx + 1}`) - ctx.fillStyle = '#ffffff' - ctx.fillText(memberName, 30, py + 18) - this.font.drawText(ctx, memberName, 30, py + 18, { + + // 2. Summoned Minions (PetType.txt & Hireables.cpp) + if (this.summonPortraits && this.summonPortraits.length > 0) { + for (let sIdx = 0; sIdx < this.summonPortraits.length; sIdx++) { + const summon = this.summonPortraits[sIdx] + if (!summon) continue + ctx.fillStyle = 'rgba(16, 16, 20, 0.85)' + ctx.fillRect(slotX, 8, 48, 48) + ctx.strokeStyle = '#6c5838' + ctx.strokeRect(slotX + 0.5, 8.5, 47, 47) + + const sRect = this.portraitRects[summon.iconKey] || this.portraitRects[String(summon.iconKey).toLowerCase()] + if (sRect && atlasImg) { + ctx.drawImage(atlasImg, sRect.x, sRect.y, sRect.w, sRect.h, slotX + 1, 9, 46, 41) + } + + if (summon.drawHp) { + const sLifeRatio = Math.max(0, Math.min(1, Number(summon.lifePct ?? 100) / 100)) + ctx.fillStyle = '#181818' + ctx.fillRect(slotX, 58, 48, 6) + ctx.fillStyle = sLifeRatio > 0.5 ? '#20a020' : sLifeRatio > 0.25 ? '#d0a020' : '#c02020' + ctx.fillRect(slotX, 58, Math.round(48 * sLifeRatio), 6) + } + + if (summon.iconType === 2 || summon.count > 1) { + this.font.drawText(ctx, String(summon.count), slotX + 4, 20, { + font: 'font6', + color: 'white', + align: 'left', + }) + } + + const sLabel = String(summon.label || '') + this.font.drawText(ctx, sLabel, slotX + 24, 22, { font: 'font6', color: 'white', align: 'center', }) + slotX += 56 + } + } + + // 3. Allied Party Members (Only in same party, excluding self and unpartied players) + if (this.partyMembers.length > 0) { + for (let idx = 0; idx < this.partyMembers.length; idx++) { + const member = this.partyMembers[idx] + if (!member) continue + if (member.isSelf === true) continue + const isExplicitUnpartied = member.partyId === 0xffff && (member.partyState === 'none' || member.partyState === 0) + if (isExplicitUnpartied) continue + + const lifeRatio = Math.max(0, Math.min(1, Number(member.lifePct ?? 100) / 100)) + ctx.fillStyle = 'rgba(16, 16, 20, 0.82)' + ctx.fillRect(slotX, 8, 48, 48) + ctx.strokeStyle = member.hostile ? '#c02020' : '#7a6840' + ctx.strokeRect(slotX + 0.5, 8.5, 47, 47) + + const classIcon = member.portraitIcon || resolvePlayerClassPortraitIcon(member.charClass) + const mRect = this.portraitRects[classIcon] || this.portraitRects[String(classIcon).toLowerCase()] + if (mRect && atlasImg) { + ctx.drawImage(atlasImg, mRect.x, mRect.y, mRect.w, mRect.h, slotX + 1, 9, 46, 41) + } + + ctx.fillStyle = '#181818' + ctx.fillRect(slotX, 58, 48, 6) + ctx.fillStyle = member.hostile ? '#c02020' : '#20a020' + ctx.fillRect(slotX, 58, Math.round(48 * lifeRatio), 6) + + const memberName = String(member.name || `P${member.unitId ?? idx + 1}`) + this.font.drawText(ctx, memberName, slotX + 24, 22, { + font: 'font6', + color: 'white', + align: 'center', + }) + slotX += 56 } } } diff --git a/src/client/world/client-world.ts b/src/client/world/client-world.ts index 534ca6d..bb63392 100644 --- a/src/client/world/client-world.ts +++ b/src/client/world/client-world.ts @@ -75,6 +75,15 @@ export interface MercenaryState { readonly hireList: readonly MercHireEntry[] } +export interface RosterPetState { + readonly unitId: number + readonly ownerId: number + readonly petType: number + readonly classId: number + lifePct: number + isDead: boolean +} + export interface ChatLogEntry { readonly kind: 'say' | 'whisper' | 'overhead' | 'system' | 'event' readonly from?: string | undefined @@ -183,6 +192,7 @@ export class ClientWorld { } private _merc: MercenaryState | null = null private readonly _mercHireList: MercHireEntry[] = [] + private readonly _pets = new Map() private readonly _chatLog: ChatLogEntry[] = [] private readonly _soundQueue: SoundQueueEntry[] = [] private readonly _portals = new Map() @@ -369,6 +379,10 @@ export class ClientWorld { return this._merc } + get pets(): ReadonlyMap { + return this._pets + } + get units(): ReadonlyMap { return this._units } @@ -683,6 +697,13 @@ export class ClientWorld { this._merc.isDead = true } } + const pet = this._pets.get(unit.id) + if (pet) { + pet.lifePct = unit.lifePct + if (unit.lifePct <= 0 || unit.mode === 12) { + pet.isDead = true + } + } } const isSelfPlayer = @@ -718,6 +739,7 @@ export class ClientWorld { } if (typeName === 'monster') { this._monsters.delete(uId) + this._pets.delete(uId) } const key = makeUnitKey(ev.unitType, uId) this._units.delete(key) @@ -857,13 +879,25 @@ export class ClientWorld { this._self.isDead = true } } - if (unit.unitType === 'monster' && this._merc && unit.id === this._merc.mercId) { - if (ev.lifePct !== undefined) { - this._merc.lifePct = ev.lifePct + if (unit.unitType === 'monster') { + if (this._merc && unit.id === this._merc.mercId) { + if (ev.lifePct !== undefined) { + this._merc.lifePct = ev.lifePct + } + if (ev.mode === 0 || ev.mode === 12 || (ev.lifePct !== undefined && ev.lifePct <= 0)) { + this._merc.isDead = true + this._merc.lifePct = 0 + } } - if (ev.mode === 0 || ev.mode === 12 || (ev.lifePct !== undefined && ev.lifePct <= 0)) { - this._merc.isDead = true - this._merc.lifePct = 0 + const pet = this._pets.get(unit.id) + if (pet) { + if (ev.lifePct !== undefined) { + pet.lifePct = ev.lifePct + } + if (ev.mode === 0 || ev.mode === 12 || (ev.lifePct !== undefined && ev.lifePct <= 0)) { + pet.isDead = true + pet.lifePct = 0 + } } } break @@ -1068,6 +1102,13 @@ export class ClientWorld { this._merc.isDead = false } } + const pet = this._pets.get(targetId) + if (pet) { + pet.lifePct = healPct + if (healPct > 0) { + pet.isDead = false + } + } } else if (ev.kind === 'info') { this._npcInteraction = { ...this._npcInteraction, @@ -1141,11 +1182,29 @@ export class ClientWorld { } } + // 0x7F PartyMemberState for pets / hirelings (isPlayer === 0) + if (ev.kind === 'memberState' && (ev.isPlayer === false || ev.relation === 0)) { + const targetId = ev.unitId ?? 0 + if (this._merc && this._merc.mercId === targetId) { + this._merc.lifePct = ev.lifePct ?? this._merc.lifePct + if (this._merc.lifePct > 0) this._merc.isDead = false + } + const pet = this._pets.get(targetId) + if (pet) { + pet.lifePct = ev.lifePct ?? pet.lifePct + if (pet.lifePct > 0) pet.isDead = false + } + break + } + const id = this.selfId !== 0 && rawUnitId === this.selfId && rawOtherId !== undefined ? rawOtherId : (rawUnitId ?? rawOtherId ?? 0) if (id !== 0) { + if (this.selfId !== 0 && id === this.selfId && !ev.charName && !this._party.has(id)) { + break + } // Only delete on 0x5C PlayerLeftGame (partyId === 0xffff with no charName or empty charName). // 0x5B PlayerInGame sends partyId === 0xffff when the player is unpartied. if (ev.kind === 'roster' && ev.partyId === 0xffff && !ev.charName) { @@ -1315,28 +1374,73 @@ export class ClientWorld { case 'Merc': { if (ev.kind === 'assign' && ev.mercId !== undefined) { - if (ev.ownerId === undefined || ev.ownerId === this.selfId || this.selfId === 0) { - this._self.mercId = ev.mercId === 0 ? null : ev.mercId - const mercUnit = ev.mercId !== 0 ? this.getUnit('monster', ev.mercId) : undefined - this._merc = - ev.mercId === 0 - ? null - : { - mercId: ev.mercId, + const isSummonAction = (ev as any).isSummonAction === true || (ev as any).summonAction !== undefined + const petType = (ev as any).petType ?? ((ev as any).stat !== undefined ? ev.value : undefined) ?? 0 + const isSummonPet = isSummonAction && petType !== 7 + + if (isSummonPet) { + const action = (ev as any).summonAction ?? ev.stat ?? 1 + if (action === 0) { + this._pets.delete(ev.mercId) + } else { + const petUnit = this.getUnit('monster', ev.mercId) + this._pets.set(ev.mercId, { + unitId: ev.mercId, + ownerId: ev.ownerId ?? this.selfId, + petType, + classId: ev.classId ?? 0, + lifePct: petUnit?.lifePct ?? 128, + isDead: false, + }) + } + } else { + if (ev.ownerId === undefined || ev.ownerId === this.selfId || this.selfId === 0) { + if (isSummonAction && (ev as any).summonAction === 0) { + if (this._merc && this._merc.mercId === ev.mercId) { + this._merc.isDead = true + this._merc.lifePct = 0 + } + this._pets.delete(ev.mercId) + } else { + this._self.mercId = ev.mercId === 0 ? null : ev.mercId + const mercUnit = ev.mercId !== 0 ? this.getUnit('monster', ev.mercId) : undefined + this._merc = + ev.mercId === 0 + ? null + : { + mercId: ev.mercId, + ownerId: ev.ownerId ?? this.selfId, + classId: ev.classId ?? this._merc?.classId ?? 0, + ...(ev.nameId !== undefined + ? { nameId: ev.nameId } + : this._merc?.nameId !== undefined + ? { nameId: this._merc.nameId } + : {}), + exp: this._merc?.exp, + lifePct: mercUnit?.lifePct ?? 128, + isDead: false, + reviveCost: 0, + stats: this._merc?.stats ?? new Map(), + hireList: this._mercHireList, + } + if (ev.mercId !== 0) { + this._pets.set(ev.mercId, { + unitId: ev.mercId, ownerId: ev.ownerId ?? this.selfId, - classId: ev.classId ?? this._merc?.classId ?? 0, - ...(ev.nameId !== undefined - ? { nameId: ev.nameId } - : this._merc?.nameId !== undefined - ? { nameId: this._merc.nameId } - : {}), - exp: this._merc?.exp, + petType: 7, + classId: ev.classId ?? 0, lifePct: mercUnit?.lifePct ?? 128, isDead: false, - reviveCost: 0, - stats: this._merc?.stats ?? new Map(), - hireList: this._mercHireList, + }) + } else { + for (const [id, p] of this._pets.entries()) { + if (p.petType === 7 && p.ownerId === (ev.ownerId ?? this.selfId)) { + this._pets.delete(id) + } } + } + } + } } } else if (ev.kind === 'exp' && ev.value !== undefined && this._merc) { this._merc.exp = ev.value @@ -1439,6 +1543,7 @@ export class ClientWorld { this._self.reset() this._merc = null this._mercHireList.length = 0 + this._pets.clear() this._units.clear() this._monsters.clear() this._containers.clear() diff --git a/src/common/items/baked-ui-meta.ts b/src/common/items/baked-ui-meta.ts index bfcf300..269e5d8 100644 --- a/src/common/items/baked-ui-meta.ts +++ b/src/common/items/baked-ui-meta.ts @@ -68,6 +68,7 @@ export interface BakedUiManifest { readonly flippyFrames: Record readonly itemClassIdToCode: Record readonly questRects: Record + readonly portraitRects?: Record readonly images: Record readonly automapAtlases?: Readonly> } @@ -25031,7 +25032,7 @@ export const AUTOMAP_UI_MANIFEST: AutomapUiManifest = { export const BAKED_UI_MANIFEST: BakedUiManifest = { version: '1.13c', - decodedDc6Count: 695, + decodedDc6Count: 728, dc6DecodeFailures: 0, atlasWidth: 2048, atlasHeight: 8102, @@ -293573,6 +293574,224 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = { "w": 72, "h": 86 } +}, + portraitRects: { + "AmazonIcon": { + "x": 0, + "y": 0, + "w": 46, + "h": 41 + }, + "amazonicon": { + "x": 0, + "y": 0, + "w": 46, + "h": 41 + }, + "SorceressIcon": { + "x": 48, + "y": 0, + "w": 46, + "h": 41 + }, + "sorceressicon": { + "x": 48, + "y": 0, + "w": 46, + "h": 41 + }, + "NecromancerIcon": { + "x": 96, + "y": 0, + "w": 46, + "h": 41 + }, + "necromancericon": { + "x": 96, + "y": 0, + "w": 46, + "h": 41 + }, + "PaladinIcon": { + "x": 144, + "y": 0, + "w": 46, + "h": 41 + }, + "paladinicon": { + "x": 144, + "y": 0, + "w": 46, + "h": 41 + }, + "BarbarianIcon": { + "x": 192, + "y": 0, + "w": 46, + "h": 41 + }, + "barbarianicon": { + "x": 192, + "y": 0, + "w": 46, + "h": 41 + }, + "DruidIcon": { + "x": 240, + "y": 0, + "w": 46, + "h": 41 + }, + "druidicon": { + "x": 240, + "y": 0, + "w": 46, + "h": 41 + }, + "AssassinIcon": { + "x": 0, + "y": 42, + "w": 46, + "h": 41 + }, + "assassinicon": { + "x": 0, + "y": 42, + "w": 46, + "h": 41 + }, + "rogueicon": { + "x": 48, + "y": 42, + "w": 46, + "h": 41 + }, + "act2hireableicon": { + "x": 96, + "y": 42, + "w": 46, + "h": 41 + }, + "act3hireableicon": { + "x": 144, + "y": 42, + "w": 46, + "h": 41 + }, + "barbhirable_icon": { + "x": 192, + "y": 42, + "w": 47, + "h": 41 + }, + "valkarieicon": { + "x": 240, + "y": 42, + "w": 46, + "h": 41 + }, + "earthgolumicon": { + "x": 0, + "y": 84, + "w": 46, + "h": 41 + }, + "bloodgolumicon": { + "x": 48, + "y": 84, + "w": 46, + "h": 41 + }, + "metalgolumicon": { + "x": 96, + "y": 84, + "w": 46, + "h": 41 + }, + "firegolumicon": { + "x": 144, + "y": 84, + "w": 46, + "h": 41 + }, + "skeletonicon": { + "x": 192, + "y": 84, + "w": 46, + "h": 41 + }, + "skeletonmageicon": { + "x": 240, + "y": 84, + "w": 46, + "h": 41 + }, + "revivedicon": { + "x": 0, + "y": 126, + "w": 46, + "h": 41 + }, + "raven": { + "x": 48, + "y": 126, + "w": 46, + "h": 41 + }, + "wolf": { + "x": 96, + "y": 126, + "w": 46, + "h": 41 + }, + "oaksage": { + "x": 144, + "y": 126, + "w": 47, + "h": 41 + }, + "heartofwolverine": { + "x": 192, + "y": 126, + "w": 46, + "h": 41 + }, + "spiritofbarbs": { + "x": 240, + "y": 126, + "w": 47, + "h": 41 + }, + "plaguepoppy": { + "x": 0, + "y": 168, + "w": 46, + "h": 41 + }, + "cycleoflife": { + "x": 48, + "y": 168, + "w": 46, + "h": 41 + }, + "vines": { + "x": 96, + "y": 168, + "w": 46, + "h": 41 + }, + "bear": { + "x": 144, + "y": 168, + "w": 46, + "h": 41 + }, + "shadowassassin": { + "x": 192, + "y": 168, + "w": 47, + "h": 41 + } }, images: { "ctrlPnl": "/ui/ctrlpnl-800.png", @@ -293622,7 +293841,8 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = { "automapAct4": "/ui/automap-act4.png", "automapAct4S": "/ui/automap-act4s.png", "automapExTn": "/ui/automap-extn.png", - "automapExTnS": "/ui/automap-extns.png" + "automapExTnS": "/ui/automap-extns.png", + "portraitsAtlas": "/ui/portraits-atlas.png" }, automapAtlases: AUTOMAP_UI_MANIFEST.atlases, } diff --git a/src/common/units/pet-type-table.ts b/src/common/units/pet-type-table.ts new file mode 100644 index 0000000..869a139 --- /dev/null +++ b/src/common/units/pet-type-table.ts @@ -0,0 +1,389 @@ +/** + * Diablo II v1.13c Top-Left HUD Portrait & `PetType.txt` Metadata (`Hireables.cpp` & `RosterPets.cpp`). + * + * Ground Truth: + * - `D2Client.dll` `Hireables.cpp` (`0x6fb0b120..0x6fb0c980`): + * - 7 player class icons (`0x6fb0b260`): `AmazonIcon`, `SorceressIcon`, `NecromancerIcon`, + * `PaladinIcon`, `BarbarianIcon`, `DruidIcon`, `AssassinIcon` (`classId 0..6`). + * - 4 hireling icons (`PetType.txt` row `7` `hireable`): `rogueicon` (Act 1 Rogue, `hcIdx 271`), + * `act2hireableicon` (Act 2 Desert Merc, `hcIdx 338`), `act3hireableicon` (Act 3 Iron Wolf, `hcIdx 359`), + * `barbhirable_icon` (Act 5 Barbarian, `hcIdx 560/561`). + * - 18 summoned minion icons (`PetType.txt` rows `2..6, 10..16`, `icontype 1..3`). + */ + +export type PortraitIconType = 0 | 1 | 2 | 3 + +export interface PetSubClassMeta { + readonly classId: number + readonly icon: string + readonly nameEn: string + readonly nameZh: string +} + +export interface PetTypePortraitMeta { + readonly idx: number + readonly petType: string + readonly group: number + readonly drawHp: boolean + readonly iconType: PortraitIconType + readonly baseIcon: string + readonly nameKey: string + readonly nameEn: string + readonly nameZh: string + readonly subClasses: readonly PetSubClassMeta[] +} + +/** + * All 29 portrait DC6 asset stems in `data/global/ui/Hireables/*.dc6`. + */ +export const CANONICAL_PORTRAIT_DC6_NAMES: readonly string[] = [ + // 7 Player Classes (`0x6fb0b260`) + 'AmazonIcon', + 'SorceressIcon', + 'NecromancerIcon', + 'PaladinIcon', + 'BarbarianIcon', + 'DruidIcon', + 'AssassinIcon', + // 4 Mercenary / Hireling Classes (`PetType.txt` idx=7) + 'rogueicon', + 'act2hireableicon', + 'act3hireableicon', + 'barbhirable_icon', + // 18 Summon / Minion Icons (`PetType.txt` idx=2..6, 10..16) + 'valkarieicon', + 'earthgolumicon', + 'bloodgolumicon', + 'metalgolumicon', + 'firegolumicon', + 'skeletonicon', + 'skeletonmageicon', + 'revivedicon', + 'raven', + 'wolf', + 'oaksage', + 'heartofwolverine', + 'spiritofbarbs', + 'plaguepoppy', + 'cycleoflife', + 'vines', + 'bear', + 'shadowassassin', +] as const + +/** + * Player class portrait icon key by `classId` (`0..6`) or class code (`ama..ass`). + */ +export const PLAYER_CLASS_PORTRAIT_BY_ID: Readonly> = { + 0: 'AmazonIcon', + 1: 'SorceressIcon', + 2: 'NecromancerIcon', + 3: 'PaladinIcon', + 4: 'BarbarianIcon', + 5: 'DruidIcon', + 6: 'AssassinIcon', +} + +export const PLAYER_CLASS_PORTRAIT_BY_CODE: Readonly> = { + ama: 'AmazonIcon', + sor: 'SorceressIcon', + nec: 'NecromancerIcon', + pal: 'PaladinIcon', + bar: 'BarbarianIcon', + dru: 'DruidIcon', + ass: 'AssassinIcon', +} + +/** + * Authoritative 1.13c `PetType.txt` table (`idx 1..19`). + */ +export const PET_TYPE_PORTRAIT_TABLE: Readonly> = { + 2: { + idx: 2, + petType: 'valkyrie', + group: 0, + drawHp: true, + iconType: 1, + baseIcon: 'valkarieicon', + nameKey: 'Valkyrie', + nameEn: 'Valkyrie', + nameZh: '女武神', + subClasses: [{ classId: 357, icon: 'valkarieicon', nameEn: 'Valkyrie', nameZh: '女武神' }], + }, + 3: { + idx: 3, + petType: 'golem', + group: 0, + drawHp: true, + iconType: 3, + baseIcon: 'earthgolumicon', + nameKey: 'Golem', + nameEn: 'Clay Golem', + nameZh: '黏土石魔', + subClasses: [ + { classId: 289, icon: 'earthgolumicon', nameEn: 'Clay Golem', nameZh: '黏土石魔' }, + { classId: 290, icon: 'bloodgolumicon', nameEn: 'Blood Golem', nameZh: '鮮血石魔' }, + { classId: 291, icon: 'metalgolumicon', nameEn: 'Iron Golem', nameZh: '鋼鐵石魔' }, + { classId: 292, icon: 'firegolumicon', nameEn: 'Fire Golem', nameZh: '火焰石魔' }, + ], + }, + 4: { + idx: 4, + petType: 'skeleton', + group: 0, + drawHp: true, + iconType: 2, + baseIcon: 'skeletonicon', + nameKey: 'Skeleton', + nameEn: 'Skeleton', + nameZh: '骷髏', + subClasses: [{ classId: 363, icon: 'skeletonicon', nameEn: 'Skeleton', nameZh: '骷髏' }], + }, + 5: { + idx: 5, + petType: 'skeletonmage', + group: 0, + drawHp: true, + iconType: 2, + baseIcon: 'skeletonmageicon', + nameKey: 'SkeletonMage', + nameEn: 'Skeletal Mage', + nameZh: '骷髏法師', + subClasses: [{ classId: 364, icon: 'skeletonmageicon', nameEn: 'Skeletal Mage', nameZh: '骷髏法師' }], + }, + 6: { + idx: 6, + petType: 'revive', + group: 0, + drawHp: true, + iconType: 2, + baseIcon: 'revivedicon', + nameKey: 'Revive', + nameEn: 'Revive', + nameZh: '復生怪物', + subClasses: [], + }, + 7: { + idx: 7, + petType: 'hireable', + group: 0, + drawHp: true, + iconType: 1, + baseIcon: 'rogueicon', + nameKey: 'Merc', + nameEn: 'Mercenary', + nameZh: '傭兵', + subClasses: [ + { classId: 271, icon: 'rogueicon', nameEn: 'Rogue Scout', nameZh: '蘿格斥候' }, + { classId: 338, icon: 'act2hireableicon', nameEn: 'Desert Mercenary', nameZh: '沙漠傭兵' }, + { classId: 359, icon: 'act3hireableicon', nameEn: 'Iron Wolf', nameZh: '鐵狼衛士' }, + { classId: 560, icon: 'barbhirable_icon', nameEn: 'Barbarian', nameZh: '野蠻人傭兵' }, + { classId: 561, icon: 'barbhirable_icon', nameEn: 'Barbarian', nameZh: '野蠻人傭兵' }, + ], + }, + 10: { + idx: 10, + petType: 'raven', + group: 0, + drawHp: false, + iconType: 2, + baseIcon: 'raven', + nameKey: 'DruidHawk', + nameEn: 'Raven', + nameZh: '烏鴉', + subClasses: [{ classId: 419, icon: 'raven', nameEn: 'Raven', nameZh: '烏鴉' }], + }, + 11: { + idx: 11, + petType: 'spiritwolf', + group: 1, + drawHp: true, + iconType: 2, + baseIcon: 'wolf', + nameKey: 'SpiritWolf', + nameEn: 'Spirit Wolf', + nameZh: '鬼狼', + subClasses: [{ classId: 420, icon: 'wolf', nameEn: 'Spirit Wolf', nameZh: '鬼狼' }], + }, + 12: { + idx: 12, + petType: 'fenris', + group: 1, + drawHp: true, + iconType: 2, + baseIcon: 'wolf', + nameKey: 'Fenris', + nameEn: 'Dire Wolf', + nameZh: '狂狼', + subClasses: [{ classId: 421, icon: 'wolf', nameEn: 'Dire Wolf', nameZh: '狂狼' }], + }, + 13: { + idx: 13, + petType: 'totem', + group: 2, + drawHp: true, + iconType: 3, + baseIcon: 'oaksage', + nameKey: 'Totem', + nameEn: 'Oak Sage', + nameZh: '橡木智者', + subClasses: [ + { classId: 424, icon: 'oaksage', nameEn: 'Oak Sage', nameZh: '橡木智者' }, + { classId: 423, icon: 'heartofwolverine', nameEn: 'Heart of Wolverine', nameZh: '狼獾之心' }, + { classId: 422, icon: 'spiritofbarbs', nameEn: 'Spirit of Barbs', nameZh: '棘靈' }, + ], + }, + 14: { + idx: 14, + petType: 'vine', + group: 3, + drawHp: true, + iconType: 3, + baseIcon: 'plaguepoppy', + nameKey: 'Vine', + nameEn: 'Poison Creeper', + nameZh: '劇毒藤蔓', + subClasses: [ + { classId: 425, icon: 'plaguepoppy', nameEn: 'Poison Creeper', nameZh: '劇毒藤蔓' }, + { classId: 426, icon: 'cycleoflife', nameEn: 'Carrion Vine', nameZh: '食屍藤蔓' }, + { classId: 427, icon: 'vines', nameEn: 'Solar Creeper', nameZh: '太陽藤蔓' }, + ], + }, + 15: { + idx: 15, + petType: 'grizzly', + group: 1, + drawHp: true, + iconType: 1, + baseIcon: 'bear', + nameKey: 'DruidBear', + nameEn: 'Grizzly', + nameZh: '灰熊', + subClasses: [{ classId: 428, icon: 'bear', nameEn: 'Grizzly', nameZh: '灰熊' }], + }, + 16: { + idx: 16, + petType: 'shadowwarrior', + group: 4, + drawHp: true, + iconType: 1, + baseIcon: 'shadowassassin', + nameKey: 'ShadowWarrior', + nameEn: 'Shadow Warrior', + nameZh: '影子戰士', + subClasses: [ + { classId: 417, icon: 'shadowassassin', nameEn: 'Shadow Warrior', nameZh: '影子戰士' }, + { classId: 418, icon: 'shadowassassin', nameEn: 'Shadow Master', nameZh: '影子大師' }, + ], + }, +} + +/** + * Reverse lookup from monster `hcIdx` (`classId`) to `PetType.txt` row index (`idx`). + */ +export const MONSTER_CLASS_TO_PET_TYPE_IDX: Readonly> = { + 357: 2, // Valkyrie + 289: 3, // Clay Golem + 290: 3, // Blood Golem + 291: 3, // Iron Golem + 292: 3, // Fire Golem + 363: 4, // Necro Skeleton + 364: 5, // Necro Skeletal Mage + 271: 7, // Act 1 Rogue Hireling + 338: 7, // Act 2 Desert Mercenary + 359: 7, // Act 3 Iron Wolf + 560: 7, // Act 5 Barbarian Hireling + 561: 7, // Act 5 Barbarian Hireling + 419: 10, // Raven + 420: 11, // Spirit Wolf + 421: 12, // Dire Wolf (Fenris) + 422: 13, // Spirit of Barbs + 423: 13, // Heart of Wolverine + 424: 13, // Oak Sage + 425: 14, // Poison Creeper (Plague Poppy) + 426: 14, // Carrion Vine (Cycle of Life) + 427: 14, // Solar Creeper (Vine Creature) + 428: 15, // Druid Grizzly Bear + 417: 16, // Shadow Warrior + 418: 16, // Shadow Master +} + +/** + * Resolve the authentic 1.13c mercenary portrait icon key from `classId` (`Hireables.cpp` `0x6fb0b2a0`). + */ +export function resolveMercPortraitIcon(classId: number | undefined): string { + switch (classId) { + case 338: + return 'act2hireableicon' + case 359: + return 'act3hireableicon' + case 560: + case 561: + return 'barbhirable_icon' + case 271: + default: + return 'rogueicon' + } +} + +/** + * Resolve the authentic 1.13c player class portrait icon key from `charClass` (`0..6` or `'ama'..'ass'`). + */ +export function resolvePlayerClassPortraitIcon(charClass: number | string | undefined): string { + if (typeof charClass === 'number') { + return PLAYER_CLASS_PORTRAIT_BY_ID[charClass] ?? 'AmazonIcon' + } + if (typeof charClass === 'string') { + const num = Number(charClass) + if (Number.isFinite(num) && PLAYER_CLASS_PORTRAIT_BY_ID[num]) { + return PLAYER_CLASS_PORTRAIT_BY_ID[num]! + } + const lower = charClass.toLowerCase().slice(0, 3) + if (PLAYER_CLASS_PORTRAIT_BY_CODE[lower]) { + return PLAYER_CLASS_PORTRAIT_BY_CODE[lower]! + } + } + return 'AmazonIcon' +} + +/** + * Resolve `PetTypePortraitMeta` and specific sub-class icon/label for a summoned pet (`Hireables.cpp` `0x6fb0b170`). + */ +export function resolvePetPortraitInfo( + petTypeOrIdx: number | string | undefined, + classId?: number | undefined, +): { + readonly meta: PetTypePortraitMeta + readonly iconKey: string + readonly nameEn: string + readonly nameZh: string +} | null { + let idx: number | undefined + if (typeof petTypeOrIdx === 'number' && PET_TYPE_PORTRAIT_TABLE[petTypeOrIdx]) { + idx = petTypeOrIdx + } else if (typeof petTypeOrIdx === 'string') { + const asNum = Number(petTypeOrIdx) + if (Number.isFinite(asNum) && PET_TYPE_PORTRAIT_TABLE[asNum]) { + idx = asNum + } else { + const lower = petTypeOrIdx.toLowerCase() + const found = Object.values(PET_TYPE_PORTRAIT_TABLE).find(p => p.petType === lower) + if (found) idx = found.idx + } + } + if (idx === undefined && classId !== undefined) { + idx = MONSTER_CLASS_TO_PET_TYPE_IDX[classId] + } + if (idx === undefined) return null + + const meta = PET_TYPE_PORTRAIT_TABLE[idx] + if (!meta || meta.iconType === 0) return null + + const sub = classId !== undefined ? meta.subClasses.find(s => s.classId === classId) : undefined + return { + meta, + iconKey: sub?.icon ?? meta.baseIcon, + nameEn: sub?.nameEn ?? meta.nameEn, + nameZh: sub?.nameZh ?? meta.nameZh, + } +} diff --git a/src/netproto/d2gs/s2c/merc.ts b/src/netproto/d2gs/s2c/merc.ts index ae2b684..cb2bddd 100644 --- a/src/netproto/d2gs/s2c/merc.ts +++ b/src/netproto/d2gs/s2c/merc.ts @@ -39,7 +39,7 @@ export function decode0x7ASummonAction(pkt: Uint8Array): ServerEvent { const r = new ByteReader(pkt) r.u8() // 0x7A const action = r.u8() - const skillTree = r.u8() + const petType = r.u8() const classId = r.u16LE() const ownerId = r.u32LE() const mercId = r.u32LE() @@ -50,7 +50,10 @@ export function decode0x7ASummonAction(pkt: Uint8Array): ServerEvent { mercId, classId, stat: action, - value: skillTree, + value: petType, + isSummonAction: true, + summonAction: action, + petType, } } diff --git a/src/netproto/d2gs/s2c/party.ts b/src/netproto/d2gs/s2c/party.ts index bbd5700..196a369 100644 --- a/src/netproto/d2gs/s2c/party.ts +++ b/src/netproto/d2gs/s2c/party.ts @@ -130,6 +130,7 @@ export function decode0x7FPartyMemberState(pkt: Uint8Array): ServerEvent { lifePct, areaId, relation: isPlayer, + isPlayer: isPlayer !== 0, } } diff --git a/src/netproto/domain/server-event.ts b/src/netproto/domain/server-event.ts index 5e20476..b649034 100644 --- a/src/netproto/domain/server-event.ts +++ b/src/netproto/domain/server-event.ts @@ -170,6 +170,7 @@ export type ServerEvent = readonly type: 'Party' readonly kind: 'roster' | 'relation' | 'memberState' | 'memberPos' readonly unitId?: number | undefined + readonly playerId?: number | undefined readonly otherId?: number | undefined readonly partyId?: number | undefined readonly relation?: number | undefined @@ -179,6 +180,7 @@ export type ServerEvent = readonly charName?: string | undefined readonly charClass?: number | undefined readonly charLevel?: number | undefined + readonly isPlayer?: boolean | undefined } | { readonly type: 'Trade' @@ -210,6 +212,9 @@ export type ServerEvent = readonly nameId?: number | undefined readonly stat?: number | undefined readonly value?: number | undefined + readonly isSummonAction?: boolean | undefined + readonly summonAction?: number | undefined + readonly petType?: number | undefined } | { readonly type: 'Portal' diff --git a/tests/client/hud-portrait-roster-pets.test.ts b/tests/client/hud-portrait-roster-pets.test.ts new file mode 100644 index 0000000..dbf9a91 --- /dev/null +++ b/tests/client/hud-portrait-roster-pets.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, it } from 'vitest' +import { ClientWorld } from '../../src/client/world/client-world.ts' +import { HudModel } from '../../src/client/ui-model/hud-model.ts' +import { HudManager } from '../../src/client/ui/hud-manager.ts' +import { decode0x7ASummonAction } from '../../src/netproto/d2gs/s2c/merc.ts' +import { decode0x7FPartyMemberState } from '../../src/netproto/d2gs/s2c/party.ts' +import { + CANONICAL_PORTRAIT_DC6_NAMES, + resolveMercPortraitIcon, + resolvePlayerClassPortraitIcon, + resolvePetPortraitInfo, +} from '../../src/common/units/pet-type-table.ts' +import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts' + +function createMockContext() { + const drawnTexts: Array<{ text: string; x: number; y: number }> = [] + const drawnRects: Array<{ x: number; y: number; w: number; h: number }> = [] + const drawnImages: Array<{ sx: number; sy: number; sw: number; sh: number; dx: number; dy: number; dw: number; dh: number }> = [] + + const ctx = { + canvas: { width: 800, height: 600 }, + save: () => {}, + restore: () => {}, + scale: () => {}, + translate: () => {}, + clearRect: () => {}, + beginPath: () => {}, + closePath: () => {}, + moveTo: () => {}, + lineTo: () => {}, + stroke: () => {}, + fill: () => {}, + fillRect: (x: number, y: number, w: number, h: number) => { + drawnRects.push({ x, y, w, h }) + }, + strokeRect: (x: number, y: number, w: number, h: number) => { + drawnRects.push({ x, y, w, h }) + }, + fillText: (text: string, x: number, y: number) => { + drawnTexts.push({ text: String(text), x, y }) + }, + measureText: (text: string) => ({ width: String(text).length * 6 }), + drawImage: (_img: any, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number) => { + drawnImages.push({ sx, sy, sw, sh, dx, dy, dw, dh }) + }, + imageSmoothingEnabled: false, + fillStyle: '#000000', + strokeStyle: '#000000', + font: '10px sans-serif', + textBaseline: 'alphabetic', + drawnTexts, + drawnRects, + drawnImages, + } as unknown as CanvasRenderingContext2D & { + drawnTexts: Array<{ text: string; x: number; y: number }> + drawnRects: Array<{ x: number; y: number; w: number; h: number }> + drawnImages: Array<{ sx: number; sy: number; sw: number; sh: number; dx: number; dy: number; dw: number; dh: number }> + } + return ctx +} + +describe('Top-Left HUD Portraits & 1.13c RosterPets Parity (Hireables.cpp)', () => { + it('P1: BAKED_UI_MANIFEST bundles all 29 authentic 1.13c DC6 portrait frames with valid non-zero dimensions', () => { + expect(BAKED_UI_MANIFEST.portraitRects).toBeDefined() + const rects = BAKED_UI_MANIFEST.portraitRects! + + for (const name of CANONICAL_PORTRAIT_DC6_NAMES) { + const rect = rects[name] ?? rects[name.toLowerCase()] + expect(rect, `Expected portrait rect for ${name}`).toBeDefined() + expect(rect.w).toBeGreaterThanOrEqual(44) + expect(rect.h).toBeGreaterThanOrEqual(40) + } + }) + + it('P2: solo unpartied player never renders a phantom "Player #1" portrait or box in top-left HUD', () => { + const world = new ClientWorld() + world.selfId = 1 + const mockCtx = createMockContext() + const hud = new HudManager() + hud.ctx = mockCtx + const model = new HudModel({ world, hudManager: hud }) + + // Player joins solo: 0x5B PlayerInGame sends selfId with partyId 0xffff + world.apply({ + type: 'Party', + kind: 'roster', + unitId: 1, + playerId: 1, + charClass: 0, + charLevel: 20, + partyId: 0xffff, + charName: 'AmazonHero', + }) + // 0x8B PlayerPartyRelation sends state 0 (unpartied) + world.apply({ + type: 'Party', + kind: 'relation', + unitId: 1, + relation: 0, + }) + model.syncFromWorld() + + expect(hud.partyMembers).toHaveLength(0) + + hud.render() + expect(mockCtx.drawnTexts.some((t) => t.text.includes('Player #1'))).toBe(false) + expect(mockCtx.drawnTexts.some((t) => t.text.includes('AmazonHero'))).toBe(false) + expect(mockCtx.drawnRects.filter((r) => r.y <= 64)).toHaveLength(0) + }) + + it('P3: 0x7F PartyMemberState with isPlayer === 0 updates pet/hireling life without creating phantom party member', () => { + const world = new ClientWorld() + world.selfId = 1 + const mockCtx = createMockContext() + const hud = new HudManager() + hud.ctx = mockCtx + const model = new HudModel({ world, hudManager: hud }) + + // Assign hireling + world.apply({ + type: 'Merc', + kind: 'assign', + ownerId: 1, + mercId: 88, + classId: 338, // Act 2 Desert Mercenary + }) + expect(world.merc).not.toBeNull() + + // 0x7F packet: [0x7f, isPlayer=0, lifePct=96 (u16), unitId=88 (u32), areaId=1 (u16)] + const pkt7f = new Uint8Array([0x7f, 0, 96, 0, 88, 0, 0, 0, 1, 0]) + const ev7f = decode0x7FPartyMemberState(pkt7f) + expect(ev7f.type).toBe('Party') + expect((ev7f as any).isPlayer).toBe(false) + + world.apply(ev7f) + expect(world.merc!.lifePct).toBe(96) + expect(world.party.has(88)).toBe(false) + + model.syncFromWorld() + expect(hud.partyMembers).toHaveLength(0) + expect((hud as any).merc.lifePct).toBe(96) + }) + + it('P4: renders authentic mercenary portrait (act2hireableicon) without duplicate overlapping fillText', () => { + const world = new ClientWorld() + world.selfId = 1 + const mockCtx = createMockContext() + const hud = new HudManager() + hud.ctx = mockCtx + const model = new HudModel({ world, hudManager: hud }) + + world.apply({ + type: 'Merc', + kind: 'assign', + ownerId: 1, + mercId: 99, + classId: 338, // Desert Mercenary + nameId: 14, + }) + model.syncFromWorld() + + expect((hud as any).merc.iconKey).toBe('act2hireableicon') + + hud.render() + // The portrait frame and health bar are drawn + expect(mockCtx.drawnRects.some((r) => r.x <= 24 && r.y <= 24 && r.w >= 32)).toBe(true) + + // In headless unit tests where DC6 font atlases are unloaded, D2FontRenderer.drawText + // falls back to ctx.fillText with 3 drop shadow passes + 1 foreground pass (4 passes). + // The previous buggy code called an extra manual ctx.fillText beforehand (5 passes with stacked browser text). + const mercTexts = mockCtx.drawnTexts.filter((t) => t.text.includes('Merc')) + expect(mercTexts).toHaveLength(4) + }) + + it('P5: 0x7A SummonAction maintains active pets in world.pets without clobbering world.merc', () => { + const world = new ClientWorld() + world.selfId = 1 + const mockCtx = createMockContext() + const hud = new HudManager() + hud.ctx = mockCtx + const model = new HudModel({ world, hudManager: hud }) + + // Player has an active mercenary + world.apply({ + type: 'Merc', + kind: 'assign', + ownerId: 1, + mercId: 55, + classId: 271, // Rogue + nameId: 3, + }) + expect(world.merc).not.toBeNull() + + // 0x7A SummonAction: [0x7a, action=1, petType=4 (skeleton), classId=363 (necroskeleton), ownerId=1, mercId=101] + const pkt7a = new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 101, 0, 0, 0]) + const ev7a = decode0x7ASummonAction(pkt7a) + expect((ev7a as any).isSummonAction).toBe(true) + expect((ev7a as any).petType).toBe(4) + + world.apply(ev7a) + // Mercenary remains completely intact! + expect(world.merc).not.toBeNull() + expect(world.merc!.mercId).toBe(55) + + // Pet is tracked in world.pets + expect(world.pets.has(101)).toBe(true) + const pet = world.pets.get(101)! + expect(pet.petType).toBe(4) + expect(pet.classId).toBe(363) + expect(pet.ownerId).toBe(1) + + // Add 2 more skeletons + world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 102, 0, 0, 0]))) + world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 103, 0, 0, 0]))) + expect(world.pets.size).toBe(4) // 3 skeletons + 1 merc mirrored + + model.syncFromWorld() + const summons = (hud as any).summonPortraits + expect(summons).toHaveLength(1) + expect(summons[0].iconKey).toBe('skeletonicon') + expect(summons[0].count).toBe(3) + expect(summons[0].iconType).toBe(2) + + // Render frame and verify count badge "3" is drawn in top-left + hud.render() + expect(mockCtx.drawnTexts.some((t) => t.text === '3')).toBe(true) + + // Unsummon skeleton 102 (action=0) + world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 0, 4, 0x6b, 0x01, 1, 0, 0, 0, 102, 0, 0, 0]))) + expect(world.pets.has(102)).toBe(false) + model.syncFromWorld() + expect((hud as any).summonPortraits[0].count).toBe(2) + }) + + it('P6: icontype=3 sub-type summons (Golems, Spirits, Vines) resolve exact sub-icon via classId', () => { + const clay = resolvePetPortraitInfo(3, 289) + expect(clay?.iconKey).toBe('earthgolumicon') + + const blood = resolvePetPortraitInfo(3, 290) + expect(blood?.iconKey).toBe('bloodgolumicon') + + const iron = resolvePetPortraitInfo(3, 291) + expect(iron?.iconKey).toBe('metalgolumicon') + + const fire = resolvePetPortraitInfo(3, 292) + expect(fire?.iconKey).toBe('firegolumicon') + + const oak = resolvePetPortraitInfo(13, 424) + expect(oak?.iconKey).toBe('oaksage') + + const wolverine = resolvePetPortraitInfo(13, 423) + expect(wolverine?.iconKey).toBe('heartofwolverine') + + const poison = resolvePetPortraitInfo(14, 425) + expect(poison?.iconKey).toBe('plaguepoppy') + }) + + it('P7: allied party members render their authentic class portraits on the top-left HUD', () => { + const world = new ClientWorld() + world.selfId = 1 + const mockCtx = createMockContext() + const hud = new HudManager() + hud.ctx = mockCtx + const model = new HudModel({ world, hudManager: hud }) + + // Local player + world.apply({ + type: 'Party', + kind: 'roster', + unitId: 1, + charClass: 0, // Amazon + charName: 'SelfAmazon', + partyId: 1, + }) + // Allied Paladin in same party + world.apply({ + type: 'Party', + kind: 'roster', + unitId: 2, + charClass: 3, // Paladin + charName: 'PaladinBro', + partyId: 1, + lifePct: 90, + }) + world.apply({ + type: 'Party', + kind: 'relation', + unitId: 2, + relation: 1, // in party + }) + // Unpartied Sorceress in different party / unpartied + world.apply({ + type: 'Party', + kind: 'roster', + unitId: 3, + charClass: 1, // Sorceress + charName: 'SoloSorc', + partyId: 0xffff, + }) + world.apply({ + type: 'Party', + kind: 'relation', + unitId: 3, + relation: 0, // none + }) + + model.syncFromWorld() + // Party list in hud model excludes selfId + expect(hud.partyMembers).toHaveLength(2) + const paladinEntry = hud.partyMembers.find((m) => m.unitId === 2) + expect(paladinEntry?.portraitIcon).toBe('PaladinIcon') + + hud.render() + // Allied Paladin is drawn + expect(mockCtx.drawnTexts.some((t) => t.text.includes('PaladinBro'))).toBe(true) + // Unpartied Sorceress is NOT drawn on the top-left HUD + expect(mockCtx.drawnTexts.some((t) => t.text.includes('SoloSorc'))).toBe(false) + // Self is NOT drawn on the top-left HUD + expect(mockCtx.drawnTexts.some((t) => t.text.includes('SelfAmazon'))).toBe(false) + }) +})