Merge branch 'fix/hud-portraits-merc-summon' into main (Fixes #556)

This commit is contained in:
troytt 2026-10-02 10:42:59 +00:00
commit 9e749a21cf
12 changed files with 1564 additions and 96 deletions

View File

@ -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": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1287,6 +1287,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<string, SpriteRect> = {}
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')
@ -1511,6 +1574,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 = {
@ -1521,6 +1585,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,
@ -1528,6 +1594,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
flippyFrames,
itemClassIdToCode,
questRects,
portraitRects,
images: imagesMap,
automapAtlases,
}
@ -1608,6 +1675,7 @@ export interface BakedUiManifest {
readonly flippyFrames: Record<string, readonly SpriteRect[]>
readonly itemClassIdToCode: Record<number, string>
readonly questRects: Record<string, SpriteRect>
readonly portraitRects?: Record<string, SpriteRect>
readonly images: Record<string, string>
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
}
@ -1677,6 +1745,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,
}

View File

@ -42,6 +42,11 @@ import { D2StatId } from '../world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
import { computeCharacterSheetDerived } from './derived-stats.ts'
import { resolveHirelingCandidates, type HirelingCandidateDesc } from '../../common/units/hireling-table.ts'
import {
resolveMercPortraitIcon,
resolvePlayerClassPortraitIcon,
resolvePetPortraitInfo,
} from '../../common/units/pet-type-table.ts'
export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = {
0: 'ama',
@ -820,10 +825,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
@ -833,11 +840,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<number, typeof activePets>()
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
@ -872,45 +922,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

View File

@ -21,6 +21,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,
@ -464,6 +469,7 @@ export class HudManager {
hireList: any[] = []
mercHireList: any[] = []
partyMembers: any[] = []
summonPortraits: any[] = []
trade: any = null
pendingIdentifyScrollId: number | null = null
@ -486,6 +492,8 @@ export class HudManager {
private images = new Map<string, HTMLImageElement>()
private itemRects: Record<string, { x: number; y: number; w: number; h: number }> = {}
private portraitRects: Record<string, { x: number; y: number; w: number; h: number }> =
(BAKED_UI_MANIFEST as any).portraitRects ?? {}
private decodedDc6Count = 415
private dc6DecodeFailures = 0
private eventsBound = false
@ -783,8 +791,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
}
@ -838,6 +849,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(
@ -2350,49 +2362,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
}
}
}

View File

@ -133,6 +133,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
@ -306,6 +315,7 @@ export class ClientWorld {
}
private _merc: MercenaryState | null = null
private readonly _mercHireList: MercHireEntry[] = []
private readonly _pets = new Map<number, RosterPetState>()
private readonly _chatLog: ChatLogEntry[] = []
private readonly _soundQueue: SoundQueueEntry[] = []
private readonly _portals = new Map<number, PortalState>()
@ -1045,6 +1055,10 @@ export class ClientWorld {
return this._merc
}
get pets(): ReadonlyMap<number, RosterPetState> {
return this._pets
}
get units(): ReadonlyMap<string, ClientUnit> {
return this._units
}
@ -1760,6 +1774,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 =
@ -1795,6 +1816,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)
@ -1949,13 +1971,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
@ -2207,6 +2241,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,
@ -2280,11 +2321,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) {
@ -2454,28 +2513,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<number, number>(),
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<number, number>(),
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
@ -2578,6 +2682,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()

View File

@ -68,6 +68,7 @@ export interface BakedUiManifest {
readonly flippyFrames: Record<string, readonly SpriteRect[]>
readonly itemClassIdToCode: Record<number, string>
readonly questRects: Record<string, SpriteRect>
readonly portraitRects?: Record<string, SpriteRect>
readonly images: Record<string, string>
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
}
@ -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",
@ -293623,7 +293842,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,
}

View File

@ -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<Record<number, string>> = {
0: 'AmazonIcon',
1: 'SorceressIcon',
2: 'NecromancerIcon',
3: 'PaladinIcon',
4: 'BarbarianIcon',
5: 'DruidIcon',
6: 'AssassinIcon',
}
export const PLAYER_CLASS_PORTRAIT_BY_CODE: Readonly<Record<string, string>> = {
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<Record<number, PetTypePortraitMeta>> = {
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<Record<number, number>> = {
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,
}
}

View File

@ -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,
}
}

View File

@ -130,6 +130,7 @@ export function decode0x7FPartyMemberState(pkt: Uint8Array): ServerEvent {
lifePct,
areaId,
relation: isPlayer,
isPlayer: isPlayer !== 0,
}
}

View File

@ -172,6 +172,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
@ -181,6 +182,7 @@ export type ServerEvent =
readonly charName?: string | undefined
readonly charClass?: number | undefined
readonly charLevel?: number | undefined
readonly isPlayer?: boolean | undefined
}
| {
readonly type: 'Trade'
@ -212,6 +214,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'

View File

@ -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)
})
})