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 0000000..55ce6ac Binary files /dev/null and b/public/ui/portraits-atlas.png differ diff --git a/src/baker/packers/pack-ui.ts b/src/baker/packers/pack-ui.ts index bd3d636..38225b2 100644 --- a/src/baker/packers/pack-ui.ts +++ b/src/baker/packers/pack-ui.ts @@ -1278,6 +1278,69 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom savePng('quests-atlas.png', QUEST_ATLAS_W, QUEST_ATLAS_H, stitchFrames(QUEST_ATLAS_W, QUEST_ATLAS_H, questPlacements)) + // --- STAGE 3.25: Top-Left HUD Portraits Atlas (29 icons from data/global/ui/Hireables/*.dc6) --- + console.log('Packing 29 Top-Left HUD Portrait DC6 icons (Player Classes, Hirelings, Summoned Minions)...') + const canonicalPortraits = [ + 'AmazonIcon', + 'SorceressIcon', + 'NecromancerIcon', + 'PaladinIcon', + 'BarbarianIcon', + 'DruidIcon', + 'AssassinIcon', + 'rogueicon', + 'act2hireableicon', + 'act3hireableicon', + 'barbhirable_icon', + 'valkarieicon', + 'earthgolumicon', + 'bloodgolumicon', + 'metalgolumicon', + 'firegolumicon', + 'skeletonicon', + 'skeletonmageicon', + 'revivedicon', + 'raven', + 'wolf', + 'oaksage', + 'heartofwolverine', + 'spiritofbarbs', + 'plaguepoppy', + 'cycleoflife', + 'vines', + 'bear', + 'shadowassassin', + ] + const PORTRAIT_CELL_W = 48 + const PORTRAIT_CELL_H = 42 + const PORTRAIT_COLS = 6 + const PORTRAIT_ROWS = Math.ceil(canonicalPortraits.length / PORTRAIT_COLS) + const PORTRAIT_ATLAS_W = PORTRAIT_COLS * PORTRAIT_CELL_W + const PORTRAIT_ATLAS_H = PORTRAIT_ROWS * PORTRAIT_CELL_H + const portraitRects: Record = {} + 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) + }) +})