Merge branch 'fix/hud-portraits-merc-summon' into main (Fixes #556)
This commit is contained in:
commit
9e749a21cf
|
|
@ -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 |
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export function decode0x7FPartyMemberState(pkt: Uint8Array): ServerEvent {
|
|||
lifePct,
|
||||
areaId,
|
||||
relation: isPlayer,
|
||||
isPlayer: isPlayer !== 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue