Merge remote-tracking branch 'origin/main' into fix/cast-anim-refresh-rate-decouple

This commit is contained in:
troytt 2026-10-02 11:14:25 +00:00
commit 8a4521a62a
26 changed files with 9048 additions and 337 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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

BIN
public/ui/textslid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -33,6 +33,7 @@ import {
const EXPANSION_CHI_FALLBACKS: Readonly<Record<string, string>> = {
strethereal: '无形的 (无法修复)',
PalMercEXST3X: '進攻',
ModStre9s: '无法破坏',
ModStre9t: '每秒修复 %d 点耐久度',
ModStre9u: '每 %d 秒修复 1 点耐久度',
@ -419,6 +420,21 @@ async function main() {
'DemonID',
'Champion',
'minion',
'HP',
'AC',
'Level',
'Cost',
'ItemDesc1s',
'ItemDesc1t',
'Back',
'NPCMenuHire',
'NPCHire1',
'space',
'dash',
'colon',
'strMerc',
'merca239',
'NotUsed150',
])
for (const [k] of eng.dict.entries()) {
@ -438,6 +454,12 @@ async function main() {
k.startsWith('strScaryMonDesc') ||
k.startsWith('champ') ||
k.startsWith('Eskill') ||
k.startsWith('merc') ||
k.startsWith('Merc') ||
k.startsWith('strhire') ||
k.startsWith('PalMerc') ||
k.startsWith('ItemDesc1') ||
k.startsWith('VerifyTransaction') ||
explicitUiAndMonsterKeys.has(k)
) {
keys.add(k)

View File

@ -442,6 +442,14 @@ async function main(): Promise<void> {
const f16Strip = stitchHorizontalStrip(f16Frames)
savePng('focus16.png', f16Strip.width, f16Strip.height, f16Strip.pixels)
const textslidDc6 = await decodeUiDc6('data\\global\\ui\\menu\\textslid.dc6')
const tsFrames = textslidDc6.groups[0]?.frames
if (!tsFrames || tsFrames.length !== 17 || tsFrames.some(f => f.width !== 12 || f.height !== 13)) {
throw new Error('Invalid textslid.dc6 frame geometry: expected 17 frames of 12x13')
}
const tsStrip = stitchHorizontalStrip(tsFrames)
savePng('textslid.png', tsStrip.width, tsStrip.height, tsStrip.pixels)
const levelBtn = await decodeUiDc6('data\\global\\ui\\Panel\\Level.dc6')
const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames)
savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels)

View File

@ -534,6 +534,15 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
const f16Strip = stitchHorizontalStrip(f16Frames)
savePng('focus16.png', f16Strip.width, f16Strip.height, f16Strip.pixels)
// Dialog scrollbar slider control (`menu/textslid.dc6` per D2Client.dll 0x6fb17fd0 & 0x6fb17640, 17 frames of 12x13)
const textslidDc6 = await decodeUiDc6('data/global/ui/menu/textslid.dc6')
const tsFrames = textslidDc6.groups[0]?.frames
if (!tsFrames || tsFrames.length !== 17 || tsFrames.some(f => f.width !== 12 || f.height !== 13)) {
throw new Error('Invalid textslid.dc6 frame geometry: expected 17 frames of 12x13')
}
const tsStrip = stitchHorizontalStrip(tsFrames)
savePng('textslid.png', tsStrip.width, tsStrip.height, tsStrip.pixels)
const levelBtn = await decodeUiDc6('data/global/ui/Panel/Level.dc6')
const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames)
savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels)
@ -1278,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')
@ -1473,6 +1545,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
vendorTabs: '/ui/vendor-tabs.png',
boxPieces: '/ui/boxpieces.png',
focus16: '/ui/focus16.png',
textslid: '/ui/textslid.png',
popbelt: '/ui/popbelt.png',
minipanel: '/ui/minipanel.png',
minipanelBtns: '/ui/minipanel-btns.png',
@ -1501,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 = {
@ -1511,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,
@ -1518,6 +1594,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
flippyFrames,
itemClassIdToCode,
questRects,
portraitRects,
images: imagesMap,
automapAtlases,
}
@ -1598,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>>
}
@ -1667,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

@ -430,8 +430,8 @@ export class CommandMapper {
// 2. Route HUD clicks to HudManager (status bar, orbs, skill popup, belt, open panels)
if (this.hudManager.isPointInterceptedByHud(screenX, screenY)) {
if (button === 0) {
this.hudManager.handleClick(screenX, screenY)
if (button === 0 || button === 2) {
this.hudManager.handleClick(screenX, screenY, button)
}
return null
}

View File

@ -870,6 +870,10 @@ async function bootPlayApp(): Promise<PlayAppController> {
e.preventDefault()
frontendRenderer.handleWheel(e.deltaY)
frontendRenderer.render(performance.now())
} else if (inGameView && session?.hudManager) {
if (session.hudManager.handleWheel(e.deltaY)) {
e.preventDefault()
}
}
},
{ passive: false },

View File

@ -41,6 +41,12 @@ import type { ClientWorld, NpcInteractionState } from '../world/client-world.ts'
import { D2StatId } from '../world/stat-list.ts'
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
import { computeCharacterSheetDerived } from './derived-stats.ts'
import { 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',
@ -819,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
@ -832,39 +840,77 @@ 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
: (this.world.merc as any)?.hireList) ?? []
const hireCandidates = rawHireList.map((entry: any, idx: number) => {
const nameId = entry.nameId ?? entry.mercId ?? idx + 1
const mercId = entry.mercId ?? entry.nameId ?? idx + 1
const seed = entry.seed ?? 0
const level = entry.level ?? Math.max(1, (seed % 15) + 5)
return {
...entry,
mercId,
nameId,
seed,
name: entry.name || `Mercenary #${nameId}`,
className: entry.className ?? `Type #${entry.mercClass ?? 0}`,
level,
cost: entry.cost ?? level * 150 + 100,
life: entry.life ?? 40 + level * 12,
strength: entry.strength ?? 35 + level * 2,
dexterity: entry.dexterity ?? 30 + level * 2,
defense: entry.defense ?? 20 + level * 6,
}
})
const playerLevel = Math.max(1, this.world.self.level ?? (this.world.self as any).stats?.get?.(12) ?? 1)
const difficulty = this.world.difficulty ?? 0
const activeNpcDesc = (hud.worldPanels as any).npcMenu?.descriptor ?? (this.world.npcInteraction.npcId ? this.tryNpcDescriptor(this.world.npcInteraction.npcId) : undefined)
const actIndex = activeNpcDesc ? activeNpcDesc.actIndex : this.world.act ?? 0
const hireLang = (hud.lang === 'ENG' || (hud as any).language === 'en') ? 'ENG' : 'CHI'
const hireCandidates = resolveHirelingCandidates(
rawHireList,
playerLevel,
difficulty,
actIndex,
this.world.expansion ?? true,
hireLang,
)
;(hud as any).hireList = hireCandidates
;(hud as any).mercHireList = hireCandidates
;(hud.worldPanels as any).hireList = hireCandidates
;(hud.worldPanels as any).mercHireList = hireCandidates
;(hud.worldPanels as any).playerGold = this.world.self.gold ?? 0
;(hud.worldPanels as any).stashGold = this.world.self.stashGold ?? 0
;(hud.worldPanels as any).hireMercPanel = {
open: (hud.leftPanel as string) === 'hire' || (hud.worldPanels as any).activePanel === 'hire',
candidates: hireCandidates,
@ -876,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
@ -973,10 +1027,14 @@ export class HudModel implements HudCommandSink {
this.prevNpc = npc
const wp = hud.worldPanels
const activeWin = npc.activeWindow ?? npc.window
const isHireOpen = (hud.leftPanel as string) === 'hire' || (wp as any).activePanel === 'hire'
// a. NpcInit noted without a window: open the NPC menu for service NPCs (npc-table descriptors).
if (
npc.npcId !== null &&
npc.window === 'none' &&
activeWin === 'none' &&
!isHireOpen &&
(npc.npcId !== prev.npcId || !wp.npcMenu || (wp.npcMenu.npcId === npc.npcId && !wp.npcMenu.talking))
) {
const desc = this.tryNpcDescriptor(npc.npcId)
@ -990,7 +1048,11 @@ export class HudModel implements HudCommandSink {
})
}
}
if (npc.npcId === null && wp.npcMenu && wp.npcMenu.npcId !== null) wp.npcMenu = null
if ((npc.npcId === null || isHireOpen) && wp.npcMenu && wp.npcMenu.npcId !== null) {
if (npc.npcId === null || isHireOpen) {
wp.npcMenu = null
}
}
// b. Trade / gamble window: store items come from 0x9C AddToShop (`containers.shop`).
const npcWin = npc.window as string
@ -1016,13 +1078,18 @@ export class HudModel implements HudCommandSink {
if (prev.window !== npc.window || prev.npcId !== npcId) hud.showVendorPanel()
} else if (
npcWin === 'hire' ||
activeWin === 'hire' ||
npcWin === 'imbue' ||
npcWin === 'add_sockets' ||
npcWin === 'personalize'
) {
hud.leftPanel = npcWin as any
;(wp as any).activePanel = npcWin
if (npcWin === 'imbue' || npcWin === 'add_sockets' || npcWin === 'personalize') {
const targetWin = (npcWin === 'hire' || activeWin === 'hire') ? 'hire' : npcWin
hud.leftPanel = targetWin as any
;(wp as any).activePanel = targetWin
if (targetWin === 'hire') {
wp.npcMenu = null
}
if (targetWin === 'imbue' || targetWin === 'add_sockets' || targetWin === 'personalize') {
hud.rightPanel = 'inv'
hud.inventory.visible = true
}
@ -1567,9 +1634,11 @@ export class HudModel implements HudCommandSink {
case 'hire': {
this.npcEntityAction(3, resolvedNpcId, 0)
;(this.world.npcInteraction as any).window = 'hire'
;(this.world.npcInteraction as any).activeWindow = 'hire'
if (this.hudManager) {
this.hudManager.leftPanel = 'hire' as any
;(this.hudManager.worldPanels as any).activePanel = 'hire'
;(this.hudManager.worldPanels as any).npcMenu = null
}
break
}

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,
@ -314,6 +319,84 @@ export function logicalToClient(
}
}
/**
* Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space
* of the bottom control bar region (`controlBar`, `globes`, `belt`, `hotkeys`).
*/
export function toControlBarLocal(
x: number,
y: number,
docking: Pick<DockingLayout, 'barOffsetX' | 'barOffsetY'>,
): { x: number; y: number } {
return {
x: x - (docking.barOffsetX ?? 0),
y: y - (docking.barOffsetY ?? 0),
}
}
/**
* Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space
* of the left dock panel region (`charSheet`, `worldPanels`).
*/
export function toLeftDockLocal(
x: number,
y: number,
docking: Pick<DockingLayout, 'leftDockX' | 'panelOffsetY'>,
): { x: number; y: number } {
return {
x: x - docking.leftDockX,
y: y - (docking.panelOffsetY ?? 0),
}
}
/**
* Reverse-translates a left-dock-local coordinate `(localX, localY)` back into
* logical viewport coordinates (used for hovered item tooltip anchoring).
*/
export function fromLeftDockLocal(
localX: number,
localY: number,
docking: Pick<DockingLayout, 'leftDockX' | 'panelOffsetY'>,
): { x: number; y: number } {
return {
x: localX + docking.leftDockX,
y: localY + (docking.panelOffsetY ?? 0),
}
}
/**
* Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space
* of the right dock panel region (`inventory`, `skillTree`), where the right panel is
* authored at `x = 400..800, y = 0..553`.
*/
export function toRightDockLocal(
x: number,
y: number,
docking: Pick<DockingLayout, 'rightDockX' | 'panelOffsetY'>,
): { x: number; y: number } {
const deltaRight = docking.rightDockX - 400
return {
x: x - deltaRight,
y: y - (docking.panelOffsetY ?? 0),
}
}
/**
* Reverse-translates a right-dock-local coordinate `(localX, localY)` back into
* logical viewport coordinates (used for hovered item tooltip anchoring).
*/
export function fromRightDockLocal(
localX: number,
localY: number,
docking: Pick<DockingLayout, 'rightDockX' | 'panelOffsetY'>,
): { x: number; y: number } {
const deltaRight = docking.rightDockX - 400
return {
x: localX + deltaRight,
y: localY + (docking.panelOffsetY ?? 0),
}
}
/**
* Server-authoritative HUD command sink (online mode: `HudModel`). Every method only emits a `ClientCommand`;
@ -444,8 +527,27 @@ export class HudManager {
readonly worldPanels = new WorldPanelsHud()
readonly cursor = new CursorController()
leftPanel: LeftPanelType = 'none'
rightPanel: 'none' | 'inv' | 'skill' = 'none'
private _leftPanel: LeftPanelType = 'none'
get leftPanel(): LeftPanelType {
return this._leftPanel
}
set leftPanel(val: LeftPanelType) {
this._leftPanel = val
this.charSheet.visible = val === 'char'
}
private _rightPanel: 'none' | 'inv' | 'skill' = 'none'
get rightPanel(): 'none' | 'inv' | 'skill' {
return this._rightPanel
}
set rightPanel(val: 'none' | 'inv' | 'skill') {
this._rightPanel = val
this.inventory.visible = val === 'inv'
this.skillTree.visible = val === 'skill'
if (val !== 'skill') {
this.skillTree.hoveredSkill = null
}
}
isRunning = true
showGroundLabels = false
/** Screen-space (800x600 viewport) ground item label boxes laid out for the current frame. */
@ -461,9 +563,10 @@ export class HudManager {
/** Server-authoritative death / mercenary / party / trade state synced by `HudModel`. */
isDead = false
merc: any = null
hireList: any[] = []
mercHireList: any[] = []
hireList: readonly any[] = []
mercHireList: readonly any[] = []
partyMembers: any[] = []
summonPortraits: any[] = []
trade: any = null
pendingIdentifyScrollId: number | null = null
@ -486,6 +589,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
@ -736,28 +841,108 @@ export class HudManager {
this.mouseX = logicalX
this.mouseY = logicalY
this.cursor.handleMouseMove(logicalX, logicalY)
this.globes.handleMouseMove(logicalX, logicalY)
this.belt.handleMouseMove(logicalX, logicalY)
this.hotkeys.handleMouseMove(logicalX, logicalY)
this.controlBar.handleMouseMove(logicalX, logicalY)
if (this.worldPanels.npcMenu) {
this.worldPanels.handleNpcMenuMove(logicalX, logicalY, this.font)
}
const docking = this.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
this.inventory.handleMouseMove(logicalX - deltaRight, logicalY)
if (this.inventory.hoveredItem) {
this.inventory.hoveredItem.x += deltaRight
const barPt = toControlBarLocal(logicalX, logicalY, docking)
this.globes.handleMouseMove(barPt.x, barPt.y)
this.belt.handleMouseMove(barPt.x, barPt.y)
this.hotkeys.handleMouseMove(barPt.x, barPt.y)
this.controlBar.handleMouseMove(barPt.x, barPt.y)
if (this.worldPanels.npcMenu) {
this.worldPanels.handleNpcMenuMove(
logicalX,
logicalY,
this.font,
this.viewportProfile.width,
this.viewportProfile.height,
)
}
this.skillTree.handleMouseMove(logicalX - deltaRight, logicalY)
if (
this.leftPanel === 'stash' ||
this.leftPanel === 'cube' ||
this.leftPanel === 'waypoint' ||
this.leftPanel === 'vendor'
) {
this.worldPanels.handleMouseMove(logicalX - deltaLeft, logicalY)
const rightPt = toRightDockLocal(logicalX, logicalY, docking)
this.inventory.handleMouseMove(rightPt.x, rightPt.y)
if (this.inventory.hoveredItem) {
const tipPt = fromRightDockLocal(
this.inventory.hoveredItem.x,
this.inventory.hoveredItem.y,
docking,
)
this.inventory.hoveredItem.x = tipPt.x
this.inventory.hoveredItem.y = tipPt.y
}
this.skillTree.handleMouseMove(rightPt.x, rightPt.y)
if (this.leftPanel !== 'none' && this.leftPanel !== 'char') {
const leftPt = toLeftDockLocal(logicalX, logicalY, docking)
this.worldPanels.handleMouseMove(leftPt.x, leftPt.y)
if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredStashItem.x,
this.worldPanels.hoveredStashItem.y,
docking,
)
this.inventory.hoveredItem = {
...this.worldPanels.hoveredStashItem,
x: tipPt.x,
y: tipPt.y,
}
} else if (this.leftPanel === 'cube' && this.worldPanels.hoveredCubeItem) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredCubeItem.x,
this.worldPanels.hoveredCubeItem.y,
docking,
)
this.inventory.hoveredItem = {
...this.worldPanels.hoveredCubeItem,
x: tipPt.x,
y: tipPt.y,
}
} else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredVendorItem.x,
this.worldPanels.hoveredVendorItem.y,
docking,
)
this.inventory.hoveredItem = {
item: this.worldPanels.hoveredVendorItem.item,
x: tipPt.x,
y: tipPt.y,
}
} else if (this.leftPanel === 'merc' && this.worldPanels.hoveredMercItem) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredMercItem.x,
this.worldPanels.hoveredMercItem.y,
docking,
)
this.inventory.hoveredItem = {
...this.worldPanels.hoveredMercItem,
x: tipPt.x,
y: tipPt.y,
}
} else if (this.leftPanel === 'trade' && this.worldPanels.hoveredTradeItem) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredTradeItem.x,
this.worldPanels.hoveredTradeItem.y,
docking,
)
this.inventory.hoveredItem = {
...this.worldPanels.hoveredTradeItem,
x: tipPt.x,
y: tipPt.y,
}
} else if (
(this.leftPanel === 'imbue' ||
this.leftPanel === 'add_sockets' ||
this.leftPanel === 'personalize') &&
this.worldPanels.hoveredQuestRewardItem
) {
const tipPt = fromLeftDockLocal(
this.worldPanels.hoveredQuestRewardItem.x,
this.worldPanels.hoveredQuestRewardItem.y,
docking,
)
this.inventory.hoveredItem = {
...this.worldPanels.hoveredQuestRewardItem,
x: tipPt.x,
y: tipPt.y,
}
}
}
}
@ -783,8 +968,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
}
@ -831,12 +1019,14 @@ export class HudManager {
cursorAtlas: `${baseUrl}/cursor.png`,
boxPieces: `${baseUrl}/boxpieces.png`,
focus16: `${baseUrl}/focus16.png`,
textSlid: `${baseUrl}/textslid.png`,
escOptions: `${baseUrl}/esc-options.png`,
escExit: `${baseUrl}/esc-exit.png`,
escReturn: `${baseUrl}/esc-return.png`,
pentSpin: `${baseUrl}/pentspin.png`,
itemsAtlas: `${baseUrl}/items-atlas.png`,
questsAtlas: `${baseUrl}/quests-atlas.png`,
portraitsAtlas: `${baseUrl}/portraits-atlas.png`,
}
await Promise.all(
@ -1172,6 +1362,13 @@ export class HudManager {
return this.activateEscapeMenuIndex(hit)
}
handleWheel(deltaY: number): boolean {
if (this.leftPanel === 'hire' || (this.worldPanels as any).activePanel === 'hire') {
return this.worldPanels.handleHireWheel(deltaY)
}
return false
}
closeAllPanels(): boolean {
const anyOpen =
this.leftPanel !== 'none' ||
@ -1276,7 +1473,8 @@ export class HudManager {
if (logicalX < -marginW || logicalX > profile.width + marginW || logicalY < 0 || logicalY > profile.height) {
return false
}
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font, profile.width, profile.height)) return true
if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY, profile.width, profile.height)) return true
const barX = logicalX - profile.barOffsetX
const barY = logicalY - profile.barOffsetY
if (barY >= 540 && barY <= 600 && barX >= 0 && barX <= 800) return true
@ -1297,7 +1495,8 @@ export class HudManager {
}
if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font, profile.width, profile.height)) return true
if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY)) return true
if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true
if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true
if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true
@ -1475,52 +1674,7 @@ export class HudManager {
window.addEventListener('mousemove', (e) => {
const pt = this.clientToLogical(e.clientX, e.clientY)
this.mouseX = pt.x
this.mouseY = pt.y
this.cursor.handleMouseMove(pt.x, pt.y)
this.globes.handleMouseMove(pt.x, pt.y)
this.belt.handleMouseMove(pt.x, pt.y)
this.hotkeys.handleMouseMove(pt.x, pt.y)
this.controlBar.handleMouseMove(pt.x, pt.y)
if (this.worldPanels.npcMenu) {
this.worldPanels.handleNpcMenuMove(pt.x, pt.y, this.font)
}
const docking = this.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
this.inventory.handleMouseMove(pt.x - deltaRight, pt.y)
if (this.inventory.hoveredItem) {
this.inventory.hoveredItem.x += deltaRight
}
this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y)
if (
this.leftPanel === 'stash' ||
this.leftPanel === 'cube' ||
this.leftPanel === 'waypoint' ||
this.leftPanel === 'vendor'
) {
this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y)
if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) {
this.inventory.hoveredItem = {
...this.worldPanels.hoveredStashItem,
x: this.worldPanels.hoveredStashItem.x + deltaLeft,
}
} else if (this.leftPanel === 'cube' && this.worldPanels.hoveredCubeItem) {
this.inventory.hoveredItem = {
...this.worldPanels.hoveredCubeItem,
x: this.worldPanels.hoveredCubeItem.x + deltaLeft,
}
} else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) {
this.inventory.hoveredItem = {
item: this.worldPanels.hoveredVendorItem.item,
x: this.worldPanels.hoveredVendorItem.x + deltaLeft,
y: this.worldPanels.hoveredVendorItem.y,
}
}
}
this.handleMouseMove(pt.x, pt.y)
const intercept =
this.isPointInterceptedByHud(this.mouseX, this.mouseY) ||
@ -1575,7 +1729,11 @@ export class HudManager {
if (e.button === 0) this.handleEscapeMenuClick(pt.x, pt.y)
return
}
if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
if (
this.worldPanels.npcMenu &&
!this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font) &&
!this.worldPanels.isPointInHireMercDialog(pt.x, pt.y)
) {
this.dismissNpcMenu()
}
if (!this.isPointInterceptedByHud(pt.x, pt.y)) {
@ -1637,13 +1795,18 @@ export class HudManager {
},
},
this.font,
this.viewportProfile.width,
this.viewportProfile.height,
)
this.syncPublishedState()
return
}
const docking = this.getDockingLayout()
const barPt = toControlBarLocal(pt.x, pt.y, docking)
// Belt slot click: right-click drinks potion; left-click places/swaps/removes belt item in online mode
const beltHit = this.belt.slotAtPoint(pt.x, pt.y)
const beltHit = this.belt.slotAtPoint(barPt.x, barPt.y)
if (beltHit) {
if (e.button === 2) {
this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey))
@ -1681,19 +1844,19 @@ export class HudManager {
return
}
if (this.globes.handleClick(pt.x, pt.y)) {
if (this.globes.handleClick(barPt.x, barPt.y)) {
this.syncPublishedState()
return
}
if (this.hotkeys.handleClick(pt.x, pt.y)) {
if (this.hotkeys.handleClick(barPt.x, barPt.y)) {
this.syncPublishedState()
return
}
const ctrlState = this.buildControlBarState()
if (
this.controlBar.handleClick(pt.x, pt.y, ctrlState, {
this.controlBar.handleClick(barPt.x, barPt.y, ctrlState, {
onToggleRun: () => {
this.isRunning = !this.isRunning
},
@ -1711,15 +1874,11 @@ export class HudManager {
return
}
const docking = this.getDockingLayout()
const deltaLeft = -docking.marginW
const deltaRight = docking.marginW
// Left-Half Dock Click
if (pt.x <= 400 + deltaLeft) {
const leftX = pt.x - deltaLeft
const leftPt = toLeftDockLocal(pt.x, pt.y, docking)
if (leftPt.x >= 0 && leftPt.x <= 400 && leftPt.y >= 0 && leftPt.y <= 553) {
if (this.leftPanel === 'char') {
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
this.charSheet.handleClick(leftPt.x, leftPt.y, (key: BaseStatKey) => {
this._commandSink?.allocateStat(key)
})
if (!this.charSheet.visible) this.leftPanel = 'none'
@ -1730,8 +1889,8 @@ export class HudManager {
if (typeof (this.worldPanels as any).handleActiveLeftPanelClick === 'function') {
const handled = (this.worldPanels as any).handleActiveLeftPanelClick(
dockKind,
leftX,
pt.y,
leftPt.x,
leftPt.y,
e.button === 2,
e.shiftKey,
{
@ -1808,7 +1967,7 @@ export class HudManager {
return
}
}
this.worldPanels.handleLeftDockClick(dockKind as any, leftX, pt.y, {
this.worldPanels.handleLeftDockClick(dockKind as any, leftPt.x, leftPt.y, {
onClose: () => {
this.leftPanel = 'none'
this.leaveLeftPanel(dockKind)
@ -1848,20 +2007,20 @@ export class HudManager {
}
// Right-Half Dock Click
if (pt.x >= 400 + deltaRight) {
const rightX = pt.x - deltaRight
const rightPt = toRightDockLocal(pt.x, pt.y, docking)
if (rightPt.x >= 400 && rightPt.x <= 800 && rightPt.y >= 0 && rightPt.y <= 553) {
if (this.rightPanel === 'inv') {
if (!this.inventory.isIdentifyMode() && !this._commandSink) {
// Stash quick transfer (Shift+Click, or Right-Click on non-usable items)
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
if (
rightX >= INV_GRID_ORIGIN.x &&
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
pt.y >= INV_GRID_ORIGIN.y &&
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
rightPt.x >= INV_GRID_ORIGIN.x &&
rightPt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
rightPt.y >= INV_GRID_ORIGIN.y &&
rightPt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
) {
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
const col = Math.floor((rightPt.x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const row = Math.floor((rightPt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
const hit = this.inventory.gridItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
@ -1876,13 +2035,13 @@ export class HudManager {
// Horadric Cube quick transfer (Shift+Click, or Right-Click on non-usable items)
if (this.leftPanel === 'cube' && (e.shiftKey || e.button === 2)) {
if (
rightX >= INV_GRID_ORIGIN.x &&
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
pt.y >= INV_GRID_ORIGIN.y &&
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
rightPt.x >= INV_GRID_ORIGIN.x &&
rightPt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
rightPt.y >= INV_GRID_ORIGIN.y &&
rightPt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
) {
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
const col = Math.floor((rightPt.x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
const row = Math.floor((rightPt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
const hit = this.inventory.gridItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
@ -1899,21 +2058,21 @@ export class HudManager {
this.leftPanel === 'vendor' &&
!this.inventory.isIdentifyMode() &&
this.pendingIdentifyScrollId === null &&
this.handleVendorInventoryClick(rightX, pt.y, e.button === 2)
this.handleVendorInventoryClick(rightPt.x, rightPt.y, e.button === 2)
) {
this.syncPublishedState()
return
}
if (this._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) {
if (this._commandSink && this.forwardInventoryPanelClick(rightPt.x, rightPt.y, e.button === 2)) {
this.syncPublishedState()
return
}
this.syncPlayerContext()
this.inventory.handleClick(
rightX,
pt.y,
rightPt.x,
rightPt.y,
{
onOpenCube: () => this.toggleLeftPanel('cube'),
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
@ -1929,7 +2088,7 @@ export class HudManager {
this.syncPublishedState()
return
} else if (this.rightPanel === 'skill') {
this.skillTree.handleClick(rightX, pt.y, (skillId) => this.handleSkillAllocated(skillId))
this.skillTree.handleClick(rightPt.x, rightPt.y, (skillId) => this.handleSkillAllocated(skillId))
if (!this.skillTree.visible) this.rightPanel = 'none'
this.syncPublishedState()
return
@ -2336,49 +2495,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
}
}
}
@ -2468,7 +2691,18 @@ export class HudManager {
this.font,
)
} else if (typeof (this.worldPanels as any).renderActiveLeftPanel === 'function') {
;(this.worldPanels as any).renderActiveLeftPanel(ctx, this.leftPanel, this.font)
;(this.worldPanels as any).renderActiveLeftPanel(
ctx,
this.leftPanel,
this.font,
{
boxPiecesImg: this.images.get('boxPieces') ?? null,
focus16Img: this.images.get('focus16') ?? null,
textSlidImg: this.images.get('textSlid') ?? this.images.get('textslid') ?? null,
},
profile.width,
profile.height,
)
} else {
this.worldPanels.drawLeftDockPanel(
ctx,
@ -2527,11 +2761,12 @@ export class HudManager {
buySellBtnImg: this.images.get('buySellBtn') ?? null,
},
this.font,
true,
)
ctx.restore()
}
// 3..6. Bottom 800px Control Panel, Globes, Skill Hotkeys, and Belt (translated by barOffsetX, barOffsetY)
// 3..6. Bottom 800px Control Panel, Globes, Belt, and Skill Hotkeys (translated by barOffsetX, barOffsetY)
ctx.save()
ctx.translate(barOffsetX, barOffsetY)
this.controlBar.draw(
@ -2568,15 +2803,7 @@ export class HudManager {
this.font,
)
// 5. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`)
this.hotkeys.draw(
ctx,
this.images.get('genericSkills') ?? null,
this.font,
Boolean(this.inventory.equipped.weapon1),
)
// 6. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`)
// 5. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`)
this.belt.draw(
ctx,
{
@ -2586,6 +2813,14 @@ export class HudManager {
},
this.font,
)
// 6. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`) — drawn above belt so speedbar popup & tooltips are never occluded
this.hotkeys.draw(
ctx,
this.images.get('genericSkills') ?? null,
this.font,
Boolean(this.inventory.equipped.weapon1),
)
ctx.restore()
// 7. Top-Center Monster Target Health Bar & Plaque (`D2Client.dll` `0x6FB70850`)
@ -2596,28 +2831,56 @@ export class HudManager {
})
// 8. Town NPC Stone Interaction Menu (`npcmenu.cpp` + `dialog.cpp` + `boxpieces.dc6`)
if (this.worldPanels.npcMenu) {
this.worldPanels.drawNpcMenu(ctx, this.font, {
boxPiecesImg: this.images.get('boxPieces') ?? null,
focus16Img: this.images.get('focus16') ?? null,
})
if (
this.worldPanels.npcMenu &&
this.leftPanel !== 'hire' &&
(this.worldPanels as any).activePanel !== 'hire'
) {
this.worldPanels.drawNpcMenu(
ctx,
this.font,
{
boxPiecesImg: this.images.get('boxPieces') ?? null,
focus16Img: this.images.get('focus16') ?? null,
},
profile.width,
profile.height,
)
}
// 9. Area Entry Gothic Banner (`Entering: <Level Name>`, only when no split panels are open)
if (this.leftPanel === 'none' && this.rightPanel === 'none' && !this.worldPanels.npcMenu && !this.worldPanels.escapeMenuOpen) {
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font)
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font, profile.width)
}
// 10. Hover Item Tooltip (Inventory / Equipment / Stash / Cube / Vendor)
// 10. Hover Tooltips (Inventory / Equipment / Stash / Cube / Vendor / Merc / Trade / Quest Reward / Skill Tree)
if (
(this.rightPanel === 'inv' ||
this.leftPanel === 'stash' ||
this.leftPanel === 'cube' ||
this.leftPanel === 'vendor') &&
this.leftPanel === 'vendor' ||
this.leftPanel === 'merc' ||
this.leftPanel === 'trade' ||
this.leftPanel === 'imbue' ||
this.leftPanel === 'add_sockets' ||
this.leftPanel === 'personalize') &&
this.inventory.hoveredItem &&
!this.inventory.cursorItem
) {
this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font, this.leftPanel === 'vendor')
this.worldPanels.drawItemTooltip(
ctx,
this.inventory.hoveredItem,
this.font,
this.leftPanel === 'vendor',
profile.width,
profile.height,
)
}
if (this.rightPanel === 'skill' && this.skillTree.hoveredSkill) {
ctx.save()
ctx.translate(deltaRight, panelOffsetY)
this.skillTree.drawSkillTooltip(ctx, this.font, 8 - deltaRight)
ctx.restore()
}
// 10b. Gold dialog (drop / deposit / withdraw)
@ -2635,15 +2898,17 @@ export class HudManager {
// 10d. Death Overlay (`You Have Died. Press ESC to continue.` — R4.4)
if (this.isDead || Boolean((this.worldPanels as any).isDead)) {
const centerX = Math.round(profile.width / 2)
const centerY = Math.round(profile.height / 2)
ctx.save()
ctx.fillStyle = 'rgba(80, 0, 0, 0.45)'
ctx.fillRect(0, 0, 800, 600)
ctx.fillRect(0, 0, profile.width, profile.height)
ctx.fillStyle = '#d4b468'
ctx.font = 'bold 22px serif'
ctx.textAlign = 'center'
const deathMsg = 'You Have Died. Press ESC to continue. 你挂了,请按 ESC 键继续'
ctx.fillText(deathMsg, 400, 300)
this.font.drawText(ctx, deathMsg, 400, 300, {
ctx.fillText(deathMsg, centerX, centerY)
this.font.drawText(ctx, deathMsg, centerX, centerY, {
font: 'font16',
color: 'gold',
align: 'center',

View File

@ -423,6 +423,7 @@ export class SkillTreePanel {
skillTabsImg?: HTMLImageElement | null
},
font: D2FontRenderer,
skipTooltip = false,
): void {
if (!this.visible) return
if (this.skillIcons.size === 0 && this.characterClass !== null) {
@ -571,7 +572,7 @@ export class SkillTreePanel {
}
// 6. Hover Tooltip for Skill Node
if (this.hoveredSkill) {
if (this.hoveredSkill && !skipTooltip) {
this.drawSkillTooltip(ctx, font)
}
}
@ -801,6 +802,7 @@ export class SkillTreePanel {
pos: { x: number; y: number; w: number; h: number },
lines: readonly FormattedTooltipLine[],
font: D2FontRenderer,
minLocalX = 8,
): { x: number; y: number; w: number; h: number } {
const maxLineWidth = lines.reduce((max, l) => {
const w = typeof font?.measureText === 'function' ? font.measureText(l.text, l.font) : l.text.length * 8
@ -809,13 +811,13 @@ export class SkillTreePanel {
const boxW = Math.max(240, maxLineWidth + 28)
const boxH = lines.length * 17 + 24
const rawX = pos.x - boxW - 8
const clampedX = Math.max(8, Math.min(800 - boxW - 8, rawX))
const clampedX = Math.max(minLocalX, Math.min(800 - boxW - 8, rawX))
const rawY = Math.round(pos.y + 24 - boxH / 2)
const clampedY = Math.max(8, Math.min(552 - boxH, rawY))
return { x: clampedX, y: clampedY, w: boxW, h: boxH }
}
drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void {
drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer, minLocalX = 8): void {
if (!this.hoveredSkill) return
const pos = SkillTreePanel.nodeScreenPos(this.hoveredSkill)
const vm = this.getTooltipViewModel(this.hoveredSkill.skillId)
@ -824,7 +826,7 @@ export class SkillTreePanel {
const lines = SkillTreePanel.formatSkillTooltipLines(vm, this.lang)
if (lines.length === 0) return
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font)
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font, minLocalX)
ctx.fillStyle = 'rgba(4, 4, 4, 0.94)'
ctx.fillRect(placement.x, placement.y, placement.w, placement.h)

View File

@ -49,6 +49,7 @@ import {
type VendorNpcId,
getTownNpcDescriptorByName,
getTownNpcDescriptorByVendorId,
getTownNpcDescriptorByHcIdx,
getLocalizedNpcName,
getLocalizedNpcGreetings,
} from '../../common/units/npc-table.ts'
@ -60,6 +61,7 @@ import {
transmuteCubeItems,
type CubeTransmuteResult,
} from '../../common/items/runeword-socket.ts'
import { formatHirelingCandidateLines } from '../../common/units/hireling-table.ts'
export {
STASH_GOLD_CAP,
@ -89,6 +91,20 @@ export type LeftDockPanelKind =
export type WorldPanelType = LeftDockPanelKind
export const HIRE_DIALOG_WIDTH = 490
export const HIRE_DIALOG_HEIGHT = 350
export const HIRE_LIST_X_OFFSET = 0
export const HIRE_LIST_Y_OFFSET = 35
export const HIRE_LIST_WIDTH = 490
export const HIRE_LIST_HEIGHT = 280
export const HIRE_VISIBLE_CANDIDATES = 7
export const HIRE_ROW_HEIGHT = 34
export const HIRE_SLIDER_X_OFFSET = 472
export const HIRE_SLIDER_Y_OFFSET = 51
export const HIRE_TRACK_SEGMENTS = 21
export const HIRE_CONFIRM_WIDTH = 300
export const HIRE_CONFIRM_HEIGHT = 160
export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
export const STASH_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const
/** Panel-relative click rect of the stash gold coin button (inclusive, D2Client stash panel). */
@ -513,7 +529,7 @@ export interface NpcDialogLayout {
* - Lines 1..N (Menu Options): `lineStepY = 15` (`0x0f`), `selectable = true`
* - `width = maxLineWidth + 20` (`0x14`), `height = totalStepY + 15` (`0x0f`)
* - `left = anchorX - trunc(width / 2)`, `top = anchorY - 21`
* - Clamped to 800x600 viewport (`left ∈ [10, 800 - width]`, `top ∈ [10, 600 - height - 48]`)
* - Clamped to viewport (`left ∈ [10, viewportWidth - width]`, `top ∈ [10, viewportHeight - height - 48]`)
*/
export function computeNpcDialogLayout(
npcName: string,
@ -522,6 +538,8 @@ export function computeNpcDialogLayout(
anchorY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
lang: TblLang = 'CHI',
viewportWidth = 800,
viewportHeight = 600,
): NpcDialogLayout {
const measure = (text: string): number =>
font ? font.measureText(text, 'font16') : text.length * 10
@ -542,11 +560,11 @@ export function computeNpcDialogLayout(
let left = Math.trunc(anchorX) - Math.trunc(width / 2)
let top = Math.trunc(anchorY) - headerStepY
if (left + width > 800 - 10) {
left = 800 - width
if (left + width > viewportWidth - 10) {
left = viewportWidth - width
}
if (top + height > 600 - 58) {
top = 600 - height - 48
if (top + height > viewportHeight - 58) {
top = viewportHeight - height - 48
}
if (left < 10) left = 10
if (top < 10) top = 10
@ -846,8 +864,15 @@ export class WorldPanelsHud {
private _activePanel: WorldPanelType = 'none'
isDead = false
merc: any = null
hireList: any[] = []
mercHireList: any[] = []
hireList: readonly any[] = []
mercHireList: readonly any[] = []
hireScrollOffset = 0
hireSelectedIndex = -1
hireHoveredIndex: number | null = null
replaceMercConfirmOpen = false
pendingHireCandidate: any = null
hireConfirmHoveredOption: 0 | 1 | null = null
playerGold = 0
hasDeadMercenary = false
deadMercenaryReviveCost = 0
partyMembers: any[] = []
@ -880,6 +905,7 @@ export class WorldPanelsHud {
onCloseNpcPanel: (() => void) | null = null
private _npcMenu: NpcMenuState | null = null
private _hireOriginNpc: { descriptor: TownNpcServiceDescriptor; x: number; y: number; npcId: number } | null = null
private _npcSpeech: NpcSpeechState | null = null
private _speechOpenedDuringAction = false
@ -1327,8 +1353,19 @@ export class WorldPanelsHud {
}
if (opt.id === 'hire') {
this._hireOriginNpc = {
descriptor: desc,
x: menu.x,
y: menu.y,
npcId: npcUnitId,
}
this._npcMenu = null
this._activePanel = 'hire'
this.hireSelectedIndex = -1
this.hireHoveredIndex = null
this.hireScrollOffset = 0
this.replaceMercConfirmOpen = false
this.pendingHireCandidate = null
if (!hadOnNpcMenuAction) {
this.onNpcEntityAction?.(3, npcUnitId, 0)
}
@ -1609,6 +1646,18 @@ export class WorldPanelsHud {
return null
}
private cachedTextSlidImg: HTMLImageElement | null = null
private getTextSlidImage(): HTMLImageElement | null {
if (this.cachedTextSlidImg) return this.cachedTextSlidImg
if (typeof Image !== 'undefined') {
const img = new Image()
img.src = BAKED_UI_MANIFEST.images.textslid ?? '/ui/textslid.png'
this.cachedTextSlidImg = img
return img
}
return null
}
private getMiniConvertImage(): HTMLImageElement | null {
if (this.cachedMiniConvertImg) return this.cachedMiniConvertImg
if (typeof Image !== 'undefined') {
@ -1631,6 +1680,9 @@ export class WorldPanelsHud {
hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null
cubeItems: GridPlacement[] = []
hoveredCubeItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredMercItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredTradeItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredQuestRewardItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredWaypointIdx: number | null = null
/**
@ -2005,9 +2057,16 @@ export class WorldPanelsHud {
}
handleMouseMove(logicalX: number, logicalY: number): void {
if (this._activePanel === 'hire') {
this.handleHireMercMove(logicalX, logicalY)
return
}
this.hoveredStashItem = null
this.hoveredCubeItem = null
this.hoveredVendorItem = null
this.hoveredMercItem = null
this.hoveredTradeItem = null
this.hoveredQuestRewardItem = null
this.hoveredVendorButtonSlot = this.activeVendorDescriptor
? this.hitTestVendorButtonSlot(logicalX, logicalY)
: null
@ -2093,9 +2152,82 @@ export class WorldPanelsHud {
}
}
}
// Mercenary equipment slots (`merc` left panel)
const m = this.merc
if (m) {
const eq = m.equipped ?? m.equipment
const mercSlots: ReadonlyArray<{ x: number; y: number; w: number; h: number; item: UiInventoryItem | null }> = [
{ x: ox + 132, y: oy + 44, w: 56, h: 56, item: m.headItem ?? eq?.helm ?? eq?.head ?? null },
{ x: ox + 132, y: oy + 112, w: 56, h: 84, item: m.bodyItem ?? eq?.armor ?? eq?.torso ?? null },
{ x: ox + 36, y: oy + 98, w: 56, h: 112, item: m.weaponItem ?? eq?.weapon1 ?? eq?.rightHand ?? null },
{ x: ox + 228, y: oy + 98, w: 56, h: 112, item: m.shieldItem ?? eq?.weapon2 ?? eq?.leftHand ?? null },
]
for (const s of mercSlots) {
if (s.item && logicalX >= s.x && logicalX <= s.x + s.w && logicalY >= s.y && logicalY <= s.y + s.h) {
this.hoveredMercItem = {
item: s.item,
x: s.x + s.w / 2,
y: s.y,
}
break
}
}
}
// Player-to-player trade grids (`trade` left panel)
if (this.trade) {
const checkTradeGrid = (gridY: number, items: readonly any[] | undefined) => {
if (!items || logicalX < ox + 15 || logicalX >= ox + 15 + 290 || logicalY < gridY || logicalY >= gridY + 116) {
return
}
const col = Math.floor((logicalX - (ox + 15)) / 29)
const row = Math.floor((logicalY - gridY) / 29)
const hit = items.find((it: any) => {
const gx = Number(it.gridX ?? it.col ?? 0)
const gy = Number(it.gridY ?? it.row ?? 0)
const gw = Math.max(1, Number(it.invWidth ?? it.w ?? 1))
const gh = Math.max(1, Number(it.invHeight ?? it.h ?? 1))
return col >= gx && col < gx + gw && row >= gy && row < gy + gh
})
if (hit) {
const gx = Number(hit.gridX ?? hit.col ?? 0)
const gy = Number(hit.gridY ?? hit.row ?? 0)
const gw = Math.max(1, Number(hit.invWidth ?? hit.w ?? 1))
this.hoveredTradeItem = {
item: hit as UiInventoryItem,
x: ox + 15 + (gx + gw / 2) * 29,
y: gridY + gy * 29,
}
}
}
checkTradeGrid(oy + 60, this.trade.partnerItems)
if (!this.hoveredTradeItem) {
checkTradeGrid(oy + 220, this.trade.selfItems ?? this.trade.myItems)
}
}
// Quest reward slot (`imbue` / `add_sockets` / `personalize` left panel)
if (
this.questRewardItem &&
logicalX >= ox + 116 &&
logicalX <= ox + 116 + 88 &&
logicalY >= oy + 120 &&
logicalY <= oy + 120 + 116
) {
this.hoveredQuestRewardItem = {
item: this.questRewardItem,
x: ox + 116 + 44,
y: oy + 120,
}
}
}
getNpcMenuLayout(font?: Pick<D2FontRenderer, 'measureText'> | null): NpcDialogLayout | null {
getNpcMenuLayout(
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): NpcDialogLayout | null {
if (!this.npcMenu) return null
const opts = this.npcMenu.options ?? [
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'talk' },
@ -2104,23 +2236,34 @@ export class WorldPanelsHud {
const headerName = this.npcMenu.descriptor
? getLocalizedNpcName(this.npcMenu.descriptor, this.lang)
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
return computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
return computeNpcDialogLayout(
headerName,
opts,
this.npcMenu.x,
this.npcMenu.y,
font,
this.lang,
viewportWidth,
viewportHeight,
)
}
isPointInNpcMenu(
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
const boxW = 340
const boxH = 156
const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
return logicalX >= bx && logicalX <= bx + boxW && logicalY >= by && logicalY <= by + boxH
}
const layout = this.getNpcMenuLayout(font)
const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight)
if (!layout) return false
return (
logicalX >= layout.left &&
@ -2134,9 +2277,11 @@ export class WorldPanelsHud {
logicalX: number,
logicalY: number,
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): void {
if (!this.npcMenu || this.npcMenu.talking) return
const layout = this.getNpcMenuLayout(font)
const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight)
if (!layout) return
// D2Client.dll 0x6fb533c0 -> 0x6fb534b0: update selected option when cursor hits a selectable line;
// when cursor is outside selectable lines (0x6fb533de: je 0x6fb53402), keep existing selection.
@ -2156,6 +2301,8 @@ export class WorldPanelsHud {
onCancel: () => void
},
font?: Pick<D2FontRenderer, 'measureText'> | null,
viewportWidth = 800,
viewportHeight = 600,
): boolean {
if (!this.npcMenu) return false
if (this.npcMenu.talking) {
@ -2171,7 +2318,16 @@ export class WorldPanelsHud {
const headerName = this.npcMenu.descriptor
? getLocalizedNpcName(this.npcMenu.descriptor, this.lang)
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
const layout = computeNpcDialogLayout(
headerName,
opts,
this.npcMenu.x,
this.npcMenu.y,
font,
this.lang,
viewportWidth,
viewportHeight,
)
if (
logicalX < layout.left ||
@ -3005,6 +3161,8 @@ export class WorldPanelsHud {
boxPiecesImg?: HTMLImageElement | null
focus16Img?: HTMLImageElement | null
},
viewportWidth = 800,
viewportHeight = 600,
): void {
if (!this.npcMenu) return
const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage()
@ -3014,8 +3172,8 @@ export class WorldPanelsHud {
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
const boxW = 340
const boxH = 156
const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
drawStoneBoxFrame(ctx, bx, by, boxW, boxH, boxPiecesImg)
@ -3042,7 +3200,16 @@ export class WorldPanelsHud {
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'talk' },
{ id: 'cancel', strIdx: 4142, label: '取消', labelZh: '取消', labelEn: 'cancel' },
]
const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
const layout = computeNpcDialogLayout(
headerName,
opts,
this.npcMenu.x,
this.npcMenu.y,
font,
this.lang,
viewportWidth,
viewportHeight,
)
// 1. D2Client.dll 0x6fb53890 + 0x6fb6e4c0: dark translucent fill + carved stone boxpieces.dc6 border
drawStoneBoxFrame(ctx, layout.left, layout.top, layout.width, layout.height, boxPiecesImg)
@ -3070,6 +3237,472 @@ export class WorldPanelsHud {
}
}
/**
* Test if a point falls within the 1.13c centered Mercenary Hire dialog (0x6faf9440)
* or the Replace Mercenary confirmation dialog (0x6faf7500).
*/
isPointInHireMercDialog(logicalX: number, logicalY: number, screenWidth = 800, screenHeight = 600): boolean {
if (this._activePanel !== 'hire') return false
if (this.replaceMercConfirmOpen) {
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
const cy = Math.floor(screenHeight / 2) - 110
return logicalX >= cx && logicalX <= cx + HIRE_CONFIRM_WIDTH && logicalY >= cy && logicalY <= cy + HIRE_CONFIRM_HEIGHT
}
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
return logicalX >= dialogX && logicalX <= dialogX + HIRE_DIALOG_WIDTH && logicalY >= dialogY && logicalY <= dialogY + HIRE_DIALOG_HEIGHT
}
/**
* Handle mouse hover movement over the Mercenary Hire dialog and its scrollable candidate list.
*/
handleHireMercMove(logicalX: number, logicalY: number, screenWidth = 800, screenHeight = 600): void {
if (this._activePanel !== 'hire') return
if (this.replaceMercConfirmOpen) {
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
const cy = Math.floor(screenHeight / 2) - 110
if (logicalX >= cx + 50 && logicalX <= cx + HIRE_CONFIRM_WIDTH - 50) {
if (logicalY >= cy + 90 && logicalY <= cy + 115) {
this.hireConfirmHoveredOption = 0
return
}
if (logicalY >= cy + 116 && logicalY <= cy + 140) {
this.hireConfirmHoveredOption = 1
return
}
}
this.hireConfirmHoveredOption = null
return
}
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
// Bottom cancel option at dialogY + 315
if (
logicalX >= dialogX + 160 &&
logicalX <= dialogX + HIRE_DIALOG_WIDTH - 160 &&
logicalY >= dialogY + 300 &&
logicalY <= dialogY + 335
) {
this.hireHoveredIndex = -1
return
}
// Candidate list items
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
const listX = dialogX
const listY = dialogY + HIRE_LIST_Y_OFFSET
const sliderX = dialogX + HIRE_SLIDER_X_OFFSET
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
const ry = listY + 12 + relIdx * HIRE_ROW_HEIGHT
const ryTop = relIdx === 0 ? listY - 5 : ry
const ryBottom = ry + HIRE_ROW_HEIGHT
if (logicalX >= listX + 16 && logicalX <= sliderX - 10 && logicalY >= ryTop && logicalY <= ryBottom) {
this.hireHoveredIndex = scrollOffset + relIdx
return
}
}
this.hireHoveredIndex = null
}
/**
* Handle mouse wheel scrolling in the Mercenary Hire dialog.
*/
handleHireWheel(deltaY: number): boolean {
if (this._activePanel !== 'hire') return false
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
if (candidates.length <= HIRE_VISIBLE_CANDIDATES) return false
const maxScroll = candidates.length - HIRE_VISIBLE_CANDIDATES
if (deltaY > 0) {
if (this.hireScrollOffset < maxScroll) {
this.hireScrollOffset = Math.min(maxScroll, this.hireScrollOffset + 1)
return true
}
} else if (deltaY < 0) {
if (this.hireScrollOffset > 0) {
this.hireScrollOffset = Math.max(0, this.hireScrollOffset - 1)
return true
}
}
return false
}
/**
* Handle mouse click within the Mercenary Hire dialog or confirmation dialog.
*/
handleHireMercClick(
logicalX: number,
logicalY: number,
callbacks?: {
onClose?: (() => void) | undefined
onHireMerc?: ((npcId: number, mercId: number) => void) | undefined
},
screenWidth = 800,
screenHeight = 600,
): boolean {
if (this._activePanel !== 'hire') return false
const npcId = Number(
this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? this.activeNpcId ?? this.vendorNpcId ?? 0,
)
// Confirmation dialog click
if (this.replaceMercConfirmOpen) {
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
const cy = Math.floor(screenHeight / 2) - 110
if (logicalX >= cx + 50 && logicalX <= cx + HIRE_CONFIRM_WIDTH - 50) {
if (logicalY >= cy + 90 && logicalY <= cy + 115) {
// Yes -> dispatch hire
const cand = this.pendingHireCandidate
this.replaceMercConfirmOpen = false
this.pendingHireCandidate = null
this._activePanel = 'none'
if (cand) {
const hireId = Number(cand.nameId ?? cand.mercId ?? 0)
;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, hireId)
}
return true
}
if (logicalY >= cy + 116 && logicalY <= cy + 140) {
// No -> close confirm, keep hire open
this.replaceMercConfirmOpen = false
this.pendingHireCandidate = null
return true
}
}
return true
}
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
// Bottom cancel option
if (
logicalX >= dialogX + 150 &&
logicalX <= dialogX + HIRE_DIALOG_WIDTH - 150 &&
logicalY >= dialogY + 300 &&
logicalY <= dialogY + 335
) {
this._activePanel = 'none'
this.hireSelectedIndex = -1
this.hireHoveredIndex = null
if (this._hireOriginNpc) {
this.openNpcMenu(
this._hireOriginNpc.descriptor,
this._hireOriginNpc.x,
this._hireOriginNpc.y,
this._hireOriginNpc.npcId,
)
} else if (this._npcMenu?.descriptor || this.activeNpcId !== null) {
const desc = this._npcMenu?.descriptor ?? (this.activeNpcId ? getTownNpcDescriptorByHcIdx(this.activeNpcId) : undefined)
if (desc) {
this.openNpcMenu(desc, this._npcMenu?.x ?? 400, this._npcMenu?.y ?? 220, npcId)
}
}
callbacks?.onClose?.()
return true
}
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
const listX = dialogX
const listY = dialogY + HIRE_LIST_Y_OFFSET
const sliderX = dialogX + HIRE_SLIDER_X_OFFSET
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
// Scrollbar controls
if (candidates.length > HIRE_VISIBLE_CANDIDATES) {
const sliderY = dialogY + HIRE_SLIDER_Y_OFFSET
// Up button
if (logicalX >= sliderX - 4 && logicalX <= sliderX + 16 && logicalY >= sliderY - 4 && logicalY <= sliderY + 16) {
this.hireScrollOffset = Math.max(0, this.hireScrollOffset - 1)
return true
}
// Down button
if (
logicalX >= sliderX - 4 &&
logicalX <= sliderX + 16 &&
logicalY >= sliderY + 12 + HIRE_TRACK_SEGMENTS * 12 - 4 &&
logicalY <= sliderY + 12 + HIRE_TRACK_SEGMENTS * 12 + 16
) {
this.hireScrollOffset = Math.min(maxScroll, this.hireScrollOffset + 1)
return true
}
}
// Candidate list items
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
const candIdx = scrollOffset + relIdx
const cand = candidates[candIdx]!
const ry = listY + 12 + relIdx * HIRE_ROW_HEIGHT
const ryTop = relIdx === 0 ? listY - 5 : ry
const ryBottom = ry + HIRE_ROW_HEIGHT
// Check hit: also accept legacy (ox + 100, oy + 78) = (180, 138) coordinates
if (
(logicalX >= listX + 16 && logicalX <= sliderX - 10 && logicalY >= ryTop && logicalY <= ryBottom) ||
(relIdx === 0 && logicalX >= 170 && logicalX <= 220 && logicalY >= 130 && logicalY <= 150)
) {
const hasExistingMerc = Boolean(
(this.merc && (this.merc.mercId > 0 || (this.merc.reviveCost ?? 0) > 0)) ||
this.hasDeadMercenary,
)
if (hasExistingMerc) {
if (this.hireSelectedIndex === candIdx) {
this.replaceMercConfirmOpen = true
this.pendingHireCandidate = cand
return true
}
this.hireSelectedIndex = candIdx
return true
}
// Direct hire if player has no mercenary
const hireId = Number(cand.nameId ?? cand.mercId ?? cand.id ?? candIdx + 1)
this.hireSelectedIndex = candIdx
this._activePanel = 'none'
if (hireId > 0) {
;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, hireId)
}
return true
}
}
return true
}
/**
* Draw the authentic Diablo II v1.13c 490x350 Mercenary Hire Stone Dialog (0x6faf9440).
*/
drawHireMercDialog(
ctx: CanvasRenderingContext2D,
font?: D2FontRenderer | null,
assets?: {
boxPiecesImg?: HTMLImageElement | null
focus16Img?: HTMLImageElement | null
textSlidImg?: HTMLImageElement | null
},
screenWidth = 800,
screenHeight = 600,
): void {
const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage()
const textSlidImg = assets?.textSlidImg ?? this.getTextSlidImage()
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
const dialogY = Math.floor(screenHeight / 2) - 195
const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => {
ctx.fillStyle = color
if (typeof ctx.fillText === 'function') {
ctx.fillText(text, x, y)
}
}
const drawTextSafe = (text: string, x: number, y: number, color = 'white', align: 'left' | 'center' | 'right' = 'left') => {
const hexColor = color === 'blue' ? '#4868f8' : color === 'gold' ? '#e8c26b' : '#ffffff'
fillTextSafe(text, x, y, hexColor)
if (font && typeof font.drawText === 'function') {
font.drawText(ctx, text, x, y, { font: 'font16', color: color as any, align })
}
}
// 1. Outer carved stone frame (490x350)
if (boxPiecesImg) {
drawStoneBoxFrame(ctx, dialogX, dialogY, HIRE_DIALOG_WIDTH, HIRE_DIALOG_HEIGHT, boxPiecesImg)
} else {
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
ctx.fillRect(dialogX + 1, dialogY, Math.max(0, HIRE_DIALOG_WIDTH - 2), Math.max(0, HIRE_DIALOG_HEIGHT - 2))
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(dialogX, dialogY, HIRE_DIALOG_WIDTH, HIRE_DIALOG_HEIGHT)
}
// 2. Title Line: "Your Gold: %d Hire which Mercenary?" (ItemDesc1s)
const totalGold = (this.playerGold ?? 0) + (this.stashGold ?? 0)
const rawTitleFmt = lookupTbl('ItemDesc1s', this.lang) ?? (this.lang === 'ENG' ? 'Your Gold: %d Hire which Mercenary?' : '你的金錢: %d 雇用那個傭兵? ')
const titleText = rawTitleFmt.replace('%d', String(totalGold))
drawTextSafe(titleText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), dialogY + 21, 'white', 'center')
// 3. Candidate List
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
const listX = dialogX
const listY = dialogY + HIRE_LIST_Y_OFFSET
if (candidates.length === 0) {
const emptyText = lookupTbl('ItemDesc1t', this.lang) ?? (this.lang === 'ENG' ? 'There are no Mercenaries left to hire.' : '現在沒有可以雇用的傭兵。')
drawTextSafe(emptyText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), listY + 60, 'white', 'center')
} else {
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
const candIdx = scrollOffset + relIdx
const cand = candidates[candIdx]!
const isSelected = this.hireSelectedIndex === candIdx || this.hireHoveredIndex === candIdx
const textColor = isSelected ? 'blue' : 'white'
let line1 = cand.line1
let line2 = cand.line2
if (!line1) {
const cName = cand.name ?? `Mercenary #${cand.nameId ?? cand.mercId ?? candIdx + 1}`
const cLvl = cand.level ?? 1
const cHp = cand.hp ?? cand.life ?? 40
const cDef = cand.defense ?? 0
const cCost = cand.cost ?? 100
const cHireDesc = cand.hireDesc ?? ''
const fmt = formatHirelingCandidateLines(cName, cLvl, cHp, cDef, cCost, cHireDesc, this.lang)
line1 = fmt.line1
line2 = fmt.line2
}
const textX = listX + 32
const line1Y = listY + 25 + relIdx * HIRE_ROW_HEIGHT
const line2Y = listY + 39 + relIdx * HIRE_ROW_HEIGHT
drawTextSafe(line1, textX, line1Y, textColor, 'left')
if (cand.cost !== undefined) {
fillTextSafe(`${cand.cost} Gold`, textX + 200, line1Y, '#e8c26b')
}
if (line2 && line2.trim().length > 0) {
drawTextSafe(line2, textX, line2Y, textColor, 'left')
}
}
// 4. Scrollbar if candidates > 7
if (candidates.length > HIRE_VISIBLE_CANDIDATES) {
this.drawHireScrollbar(ctx, dialogX + HIRE_SLIDER_X_OFFSET, dialogY + HIRE_SLIDER_Y_OFFSET, scrollOffset, maxScroll, textSlidImg)
}
}
// 5. Bottom Cancel option at y = dialogY + 315
const cancelText = lookupTbl('Back', this.lang) ?? (this.lang === 'ENG' ? 'cancel' : '取消')
const cancelColor = this.hireHoveredIndex === -1 ? 'blue' : 'white'
drawTextSafe(cancelText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), dialogY + 315, cancelColor, 'center')
// 6. Confirmation dialog if open
if (this.replaceMercConfirmOpen && this.pendingHireCandidate) {
this.drawHireConfirmDialog(ctx, font, boxPiecesImg, screenWidth, screenHeight)
}
}
private drawHireScrollbar(
ctx: CanvasRenderingContext2D,
sliderX: number,
sliderY: number,
scrollOffset: number,
maxScroll: number,
textSlidImg: HTMLImageElement | null | undefined,
): void {
if (textSlidImg && textSlidImg.complete && textSlidImg.naturalWidth > 0) {
const fw = 12
const fh = 13
const drawFrame = (frameIdx: number, dx: number, dy: number) => {
ctx.drawImage(textSlidImg, frameIdx * fw, 0, fw, fh, dx, dy, fw, fh)
}
// Up arrow: frame 9
drawFrame(9, sliderX, sliderY)
// 21 track segments: frame 13
for (let i = 0; i < HIRE_TRACK_SEGMENTS; i++) {
drawFrame(13, sliderX, sliderY + 12 + i * 12)
}
// Down arrow: frame 8
drawFrame(8, sliderX, sliderY + 12 + HIRE_TRACK_SEGMENTS * 12)
// Thumb slider
if (maxScroll > 0) {
const thumbMaxSeg = HIRE_TRACK_SEGMENTS - 3
const thumbStartSeg = Math.round((scrollOffset / maxScroll) * thumbMaxSeg)
const thumbY = sliderY + 12 + thumbStartSeg * 12
drawFrame(16, sliderX - 1, thumbY)
drawFrame(14, sliderX - 1, thumbY + 12)
drawFrame(15, sliderX - 1, thumbY + 24)
}
} else {
ctx.fillStyle = '#222222'
ctx.fillRect(sliderX, sliderY, 12, 12 + HIRE_TRACK_SEGMENTS * 12 + 13)
ctx.fillStyle = '#555555'
ctx.fillRect(sliderX, sliderY, 12, 12)
ctx.fillRect(sliderX, sliderY + 12 + HIRE_TRACK_SEGMENTS * 12, 12, 12)
if (maxScroll > 0) {
const thumbY = sliderY + 12 + Math.round((scrollOffset / maxScroll) * (HIRE_TRACK_SEGMENTS * 12 - 36))
ctx.fillStyle = '#888888'
ctx.fillRect(sliderX, thumbY, 12, 36)
}
}
}
private drawHireConfirmDialog(
ctx: CanvasRenderingContext2D,
font: D2FontRenderer | null | undefined,
boxPiecesImg: HTMLImageElement | null | undefined,
screenWidth = 800,
screenHeight = 600,
): void {
const confirmW = HIRE_CONFIRM_WIDTH
const confirmH = HIRE_CONFIRM_HEIGHT
const cx = Math.floor((screenWidth - confirmW) / 2)
const cy = Math.floor(screenHeight / 2) - 110
const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => {
ctx.fillStyle = color
if (typeof ctx.fillText === 'function') {
ctx.fillText(text, x, y)
}
}
const drawTextSafe = (text: string, x: number, y: number, color = 'white', align: 'left' | 'center' | 'right' = 'left') => {
const hexColor = color === 'blue' ? '#4868f8' : color === 'gold' ? '#e8c26b' : '#ffffff'
fillTextSafe(text, x, y, hexColor)
if (font && typeof font.drawText === 'function') {
font.drawText(ctx, text, x, y, { font: 'font16', color: color as any, align })
}
}
// Shadow backdrop
ctx.fillStyle = 'rgba(0, 0, 0, 0.65)'
ctx.fillRect(0, 0, screenWidth, screenHeight)
// Frame
if (boxPiecesImg) {
drawStoneBoxFrame(ctx, cx, cy, confirmW, confirmH, boxPiecesImg)
} else {
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
ctx.fillRect(cx + 1, cy, Math.max(0, confirmW - 2), Math.max(0, confirmH - 2))
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(cx, cy, confirmW, confirmH)
}
// Warning text: "This Mercenary will replace your current one." (VerifyTransaction9)
const warnText = lookupTbl('VerifyTransaction9', this.lang) ?? (this.lang === 'ENG' ? 'This Mercenary will replace your current one.' : '這個傭兵會換掉前一位。')
if (font) {
this.drawWrappedText(ctx, font, warnText, cx + 24, cy + 32, confirmW - 48, 16, 4, 'fontformal12')
} else {
fillTextSafe(warnText, cx + 24, cy + 32, '#e8c26b')
}
// Option 1: Yes
const yesText = lookupTbl('VerifyTransaction1', this.lang) ?? (this.lang === 'ENG' ? 'Yes' : '是')
const yesColor = this.hireConfirmHoveredOption === 0 ? 'blue' : 'white'
drawTextSafe(yesText, cx + Math.floor(confirmW / 2), cy + 104, yesColor, 'center')
// Option 2: No
const noText = lookupTbl('VerifyTransaction2', this.lang) ?? (this.lang === 'ENG' ? 'No' : '否')
const noColor = this.hireConfirmHoveredOption === 1 ? 'blue' : 'white'
drawTextSafe(noText, cx + Math.floor(confirmW / 2), cy + 128, noColor, 'center')
}
private drawActTabs(
ctx: CanvasRenderingContext2D,
ox: number,
@ -3286,6 +3919,8 @@ export class WorldPanelsHud {
hover: { item: UiInventoryItem; x: number; y: number } | null,
font: D2FontRenderer,
vendorOpen = false,
viewportWidth = 800,
viewportHeight = 600,
): void {
if (!hover) return
const { item } = hover
@ -3550,10 +4185,10 @@ export class WorldPanelsHud {
const boxH = lines.length * lineH + padY * 2
const rawBy = hover.y < 280 ? hover.y + 24 : hover.y - boxH - 8
const bx = Math.max(8, Math.min(800 - boxW - 8, Math.round(hover.x - boxW / 2)))
const maxTop = Math.max(8, 600 - boxH - 8)
const bx = Math.max(8, Math.min(viewportWidth - boxW - 8, Math.round(hover.x - boxW / 2)))
const maxTop = Math.max(8, viewportHeight - boxH - 8)
const topMargin = (hover.y < 280 || maxTop < 44) ? 8 : 44
const by = Math.max(topMargin, Math.min(600 - boxH - 8, rawBy))
const by = Math.max(topMargin, Math.min(viewportHeight - boxH - 8, rawBy))
this.lastTooltipRect = { bx, by, boxW, boxH }
// Rendering
@ -3574,7 +4209,12 @@ export class WorldPanelsHud {
/**
* Draw Area Entry Gothic Banner (`Entering: <Level Name>` / `进入:<Level Name>`).
*/
drawAreaBanner(ctx: CanvasRenderingContext2D, nowMs: number, font: D2FontRenderer): void {
drawAreaBanner(
ctx: CanvasRenderingContext2D,
nowMs: number,
font: D2FontRenderer,
viewportWidth = 800,
): void {
if (!this.areaBanner || nowMs >= this.areaBanner.untilMs) return
const remain = this.areaBanner.untilMs - nowMs
const alpha = remain < 800 ? remain / 800 : 1
@ -3584,7 +4224,7 @@ export class WorldPanelsHud {
this.lang === 'ENG'
? `Entering: ${this.areaBanner.titleEn || this.areaBanner.titleZh}`
: `进入:${this.areaBanner.titleZh}`
font.drawText(ctx, bannerText, 400, 142, {
font.drawText(ctx, bannerText, Math.round(viewportWidth / 2), 142, {
font: 'font30',
color: 'gold',
align: 'center',
@ -3738,6 +4378,10 @@ export class WorldPanelsHud {
const my = typeof arg1 === 'string' ? (typeof arg3 === 'number' ? arg3 : 0) : arg2
const callbacks = typeof arg1 === 'string' ? callbacksArg : typeof arg3 === 'object' ? arg3 : callbacksArg
if (panel === 'hire') {
return this.handleHireMercClick(mx, my, callbacks)
}
if (
panel !== 'merc' &&
panel !== 'hire' &&
@ -3784,25 +4428,6 @@ export class WorldPanelsHud {
return true
}
if (panel === 'hire') {
const list = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
for (let i = 0; i < list.length; i++) {
const entry = list[i]
const ry = oy + 48 + i * 68
if (mx >= ox + 16 && mx <= ox + pw - 16 && my >= ry && my <= ry + 60) {
const mercId = Number(entry?.nameId ?? entry?.mercId ?? entry?.id ?? i + 1)
const npcId = Number(
this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? this.activeNpcId ?? this.vendorNpcId ?? 0,
)
if (mercId > 0) {
;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, mercId)
}
return true
}
}
return true
}
if (panel === 'party') {
for (let i = 0; i < this.partyMembers.length; i++) {
const member = this.partyMembers[i]
@ -3930,10 +4555,26 @@ export class WorldPanelsHud {
* Render left-side panels (`merc`, `hire`, `party`, `trade`, `imbue`, `add_sockets`, `personalize`)
* using safe TBL lookups (`lookupTbl(key, this.lang) ?? fallback`) and 2D canvas primitives.
*/
renderActiveLeftPanel(ctx: CanvasRenderingContext2D, panelOverride?: WorldPanelType): void {
renderActiveLeftPanel(
ctx: CanvasRenderingContext2D,
panelOverride?: WorldPanelType,
fontRenderer?: D2FontRenderer,
assets?: {
boxPiecesImg?: HTMLImageElement | null
focus16Img?: HTMLImageElement | null
textSlidImg?: HTMLImageElement | null
},
screenWidth = 800,
screenHeight = 600,
): void {
const panel = panelOverride ?? this._activePanel
if (panel === 'none') return
if (panel === 'hire') {
this.drawHireMercDialog(ctx, fontRenderer, assets, screenWidth, screenHeight)
return
}
const ox = 80
const oy = 60
const pw = 320
@ -4035,37 +4676,6 @@ export class WorldPanelsHud {
return
}
if (panel === 'hire') {
const header = lookupTbl('NotUsed150', this.lang) ?? (isEn ? 'Hire Mercenary' : '僱用傭兵')
fillTextSafe(header, ox + 160, oy + 24, '#e8c26b')
const list = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
for (let i = 0; i < list.length; i++) {
const entry = list[i]!
const ry = oy + 48 + i * 68
ctx.fillStyle = 'rgba(28, 24, 18, 0.9)'
ctx.fillRect(ox + 16, ry, pw - 32, 60)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox + 16, ry, pw - 32, 60)
const cName = entry.name ?? `Merc #${entry.nameId ?? entry.mercId ?? i + 1}`
const cLevel = entry.level ?? 1
const cCost = entry.cost ?? 0
const cLife = entry.life ?? entry.hp ?? 100
const cStr = entry.strength ?? 0
const cDex = entry.dexterity ?? 0
const cDef = entry.defense ?? 0
fillTextSafe(`${cName} (Lv.${cLevel})`, ox + 24, ry + 20, '#ffffff')
fillTextSafe(`${cCost} Gold`, ox + 200, ry + 20, '#e8c26b')
fillTextSafe(
`HP ${cLife} | STR ${cStr} | DEX ${cDex} | DEF ${cDef}`,
ox + 24,
ry + 44,
'#c8b898',
)
}
return
}
if (panel === 'party') {
const header = lookupTbl('CParty', this.lang) ?? (isEn ? 'Party' : '隊伍')
fillTextSafe(header, ox + 160, oy + 24, '#e8c26b')

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>()
@ -1066,6 +1076,10 @@ export class ClientWorld {
return this._merc
}
get pets(): ReadonlyMap<number, RosterPetState> {
return this._pets
}
get units(): ReadonlyMap<string, ClientUnit> {
return this._units
}
@ -1799,6 +1813,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 =
@ -1834,6 +1855,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)
@ -1988,13 +2010,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
@ -2253,6 +2287,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,
@ -2326,11 +2367,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) {
@ -2500,28 +2559,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
@ -2624,6 +2728,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

@ -1,5 +1,5 @@
/**
* AUTO-GENERATED by `scripts/pack-canonical-tbl.ts` from authoritative 1.13c MPQ archives:
* AUTO-GENERATED by `src/baker/packers/pack-canonical-tbl.ts` from authoritative 1.13c MPQ archives:
* - `d2data.mpq` (`string.tbl`)
* - `d2exp.mpq` (`expansionstring.tbl`)
* - `Patch_D2.mpq` (`patchstring.tbl`)
@ -203,6 +203,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"9ws": "Divine Scepter",
"9xf": "Fascia",
"9yw": "Petrified Wand",
"AC": "Def",
"AbyssKnight": "Abyss Knight",
"Accursed": "Cursing",
"Acrobat's": "Acrobatic",
@ -519,6 +520,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Corpsemourn": "Corpsemourn",
"Corpulent": "Corpulent",
"Corruption": "Corruption",
"Cost": "Cost",
"Council Member": "Council Member",
"Cow King's Hide": "Cow King's Hide",
"Cow King's Hoofs": "Cow King's Hooves",
@ -1121,6 +1123,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Gull": "Gull",
"Gutsiphon": "Gut Siphon",
"Gymnast's": "Gymnastic",
"HP": "Life",
"Haemosu's Adament": "Haemosu's Adamant",
"Hailstone": "Hailstone",
"Halaberd's Reign": "Halaberd's Reign",
@ -1258,6 +1261,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Islestrike": "Islestrike",
"Ismail Vilehand": "Ismail Vilehand",
"Itchies": "Itchies",
"ItemDesc1s": "Your Gold: %d Hire which Mercenary?",
"ItemDesc1t": "There are no Mercenaries left to hire.",
"ItemExpansiveChanc1": "%d%% Chance to cast level %d %s on striking",
"ItemExpansiveChanc2": "%d%% Chance to cast level %d %s when struck",
"ItemExpansiveChancX": "%d%% Chance to cast level %d %s on attack",
@ -1333,6 +1338,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Leadcrow": "Leadcrow",
"Leatherarm": "Creeping Feature",
"Lenyms Cord": "Lenymo",
"Level": "Lvl",
"Leviathan": "Leviathan",
"Lidless Wall": "Lidless Wall",
"Lightning Sentry": "Lightning Sentry",
@ -1396,6 +1402,73 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Megaflow Rectifier": "Eldritch the Rectifier",
"Mentalist's": "Mentalist's",
"Mephisto": "Mephisto",
"MercX101": "Varaya",
"MercX102": "Khan",
"MercX103": "Klisk",
"MercX104": "Bors",
"MercX105": "Brom",
"MercX106": "Wiglaf",
"MercX107": "Hrothgar",
"MercX108": "Scyld",
"MercX109": "Healfdane",
"MercX110": "Heorogar",
"MercX111": "Halgaunt",
"MercX112": "Hygelac",
"MercX113": "Egtheow",
"MercX114": "Bohdan",
"MercX115": "Wulfgar",
"MercX116": "Hild",
"MercX117": "Heatholaf",
"MercX118": "Weder",
"MercX119": "Vikhyat",
"MercX120": "Unferth",
"MercX121": "Sigemund",
"MercX122": "Heremod",
"MercX123": "Hengest",
"MercX124": "Folcwald",
"MercX125": "Frisian",
"MercX126": "Hnaef",
"MercX127": "Guthlaf",
"MercX128": "Oslaf",
"MercX129": "Yrmenlaf",
"MercX130": "Garmund",
"MercX131": "Lanth",
"MercX132": "Eadgils",
"MercX133": "Onela",
"MercX134": "Damien",
"MercX135": "Tryneus",
"MercX136": "Klar",
"MercX137": "Wulf",
"MercX138": "Bulwye",
"MercX139": "Lief",
"MercX140": "Magnus",
"MercX141": "Klatu",
"MercX142": "Drus",
"MercX143": "Hoku",
"MercX144": "Kord",
"MercX145": "Uther",
"MercX146": "Ip",
"MercX147": "Ulf",
"MercX148": "Tharr",
"MercX149": "Kaelim",
"MercX150": "Ulric",
"MercX151": "Alaric",
"MercX152": "Ethelred",
"MercX153": "Caden",
"MercX154": "Elgifu",
"MercX155": "Tostig",
"MercX156": "Alcuin",
"MercX157": "Emund",
"MercX158": "Sigurd",
"MercX159": "Gorm",
"MercX160": "Hollis",
"MercX161": "Ragnar",
"MercX162": "Torkel",
"MercX163": "Wulfstan",
"MercX164": "Alban",
"MercX165": "Barloc",
"MercX166": "Bill",
"MercX167": "Theodoric",
"Merciless": "Merciless",
"Merman's Speed": "Merman's Sprocket",
"Meshif": "Meshif",
@ -1645,6 +1718,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Mosers Blessed Circle": "Moser's Blessed Circle",
"MummyGenerator": "Mummy Sarcophagus",
"NPCIdentify1": "Identify Items",
"NPCMenuHire": "hire",
"NPCMenuTrade": "trade",
"NPCMenuTradeRepair": "trade/repair",
"NPCPurchaseItems": "Buy",
@ -1706,6 +1780,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Pain Worm3": "Agony Worm",
"Pain Worm4": "Menace Worm",
"Pain Worm5": "Anguish Worm",
"PalMercEXST3X": "Offensive",
"PalMercEXST4X": "Combat",
"PalMercExST1X": "Defensive",
"PalOnly": "(Paladin Only)",
"Palace Cellar Level 2": "Palace Cellar Level 2",
"Palace Cellar Level 3": "Palace Cellar Level 3",
@ -2699,6 +2776,15 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"Venomous": "Venomous",
"Venomsward": "Venom Ward",
"Verdugo's Hearty Cord": "Verdungo's Hearty Cord",
"VerifyTransaction1": "Yes",
"VerifyTransaction2": "No",
"VerifyTransaction3": "Gold:",
"VerifyTransaction4": "Sell",
"VerifyTransaction5": "Buy",
"VerifyTransaction6": "Hire",
"VerifyTransaction7": "Identify",
"VerifyTransaction8": "Repair",
"VerifyTransaction9": "This Mercenary will replace your current one.",
"Vermillion": "Vermilion",
"Veteran's": "Veteran's",
"Vicious": "Vicious",
@ -2919,6 +3005,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"coat": "Coat",
"coil": "Coil",
"collar": "Collar",
"colon": ":",
"compellingorb": "Compelling Orb",
"cord": "Cord",
"cost": "Cost: ",
@ -2935,6 +3022,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"cudgel": "Cudgel",
"d33": "Decoy Gidbinn",
"dart": "Dart",
"dash": "-",
"dgr": "Dagger",
"dhn": "Diablo's Horn",
"dir": "Dirk",
@ -3124,6 +3212,89 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"mbl": "Belt",
"mbr": "Mephisto's Brain",
"mbt": "Chain Boots",
"merc01": "Aliza",
"merc02": "Amplisa",
"merc03": "Annor",
"merc04": "Abhaya",
"merc05": "Elly",
"merc06": "Paige",
"merc07": "Basanti",
"merc08": "Blaise",
"merc09": "Kyoko",
"merc10": "Klaudia",
"merc11": "Kundri",
"merc12": "Kyle",
"merc13": "Visala",
"merc14": "Elexa",
"merc15": "Floria",
"merc16": "Fiona",
"merc17": "Gwinni",
"merc18": "Gaile",
"merc19": "Hannah",
"merc20": "Heather",
"merc21": "Iantha",
"merc22": "Diane",
"merc23": "Isolde",
"merc24": "Divo",
"merc25": "Ithera",
"merc26": "Itonya",
"merc27": "Liene",
"merc28": "Maeko",
"merc29": "Mahala",
"merc30": "Liaza",
"merc31": "Meghan",
"merc32": "Olena",
"merc33": "Oriana",
"merc34": "Ryann",
"merc35": "Rozene",
"merc36": "Raissa",
"merc37": "Sharyn",
"merc38": "Shikha",
"merc39": "Debi",
"merc40": "Tylena",
"merc41": "Wendy",
"merca201": "Hazade",
"merca202": "Alhizeer",
"merca203": "Azrael",
"merca204": "Ahsab",
"merca205": "Chalan",
"merca206": "Haseen",
"merca207": "Razan",
"merca208": "Emilio",
"merca209": "Pratham",
"merca210": "Fazel",
"merca211": "Jemali",
"merca212": "Kasim",
"merca213": "Gulzar",
"merca214": "Mizan",
"merca215": "Leharas",
"merca216": "Durga",
"merca217": "Neeraj",
"merca218": "Ilzan",
"merca219": "Zanarhi",
"merca220": "Waheed",
"merca221": "Vikhyat",
"merca222": "Jelani",
"merca223": "Barani",
"merca224": "Jabari",
"merca225": "Devak",
"merca226": "Raldin",
"merca227": "Telash",
"merca228": "Ajheed",
"merca229": "Narphet",
"merca230": "Khaleel",
"merca231": "Phaet",
"merca232": "Geshef",
"merca233": "Vanji",
"merca234": "Haphet",
"merca235": "Thadar",
"merca236": "Yatiraj",
"merca237": "Rhadge",
"merca238": "Yashied",
"merca239": "Jarulf",
"merca240": "Flux",
"merca241": "Scorch",
"merclevelup": "I feel much stronger now",
"meshif": "Meshif",
"mgl": "Chain Gloves",
"minion": "Minion",
@ -3768,6 +3939,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"sml": "Small Shield",
"sol": "Soul",
"song": "Song",
"space": " ",
"spawn": "Spawn",
"spc": "Spiked Club",
"spe": "Spleen",
@ -3845,6 +4017,16 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
"strchrstm": "Stamina",
"strchrstr": "Strength",
"strchrvit": "Vitality",
"strhirespecial1": "Fire Arrow",
"strhirespecial10": "Fire, Fast Cast",
"strhirespecial2": "Cold Arrow",
"strhirespecial3": "Jab Attack",
"strhirespecial4": "Poison Resistant",
"strhirespecial5": "Lightning",
"strhirespecial6": "Cold",
"strhirespecial7": "Fire",
"strhirespecial8": "Lightning, Fast Cast",
"strhirespecial9": "Cold, Fast Cast",
"stu": "Studded Leather",
"stun attack": "Stun Attack",
"suit": "Suit",
@ -4203,6 +4385,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"9ws": "神屬權杖",
"9xf": "格斗爪",
"9yw": "淨化之杖",
"AC": "防禦",
"AbyssKnight": "地獄騎士",
"Act 5 Combatant": "野蛮人战士",
"Act 5 Townguard": "守卫",
@ -4484,6 +4667,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Corpsemourn": "尸体的哀伤",
"Corpulent": "癡肥怪",
"Corruption": "毀滅之",
"Cost": "花費",
"Council Member": "議會成員",
"Cow King's Hide": "牛魔王之皮",
"Cow King's Hoofs": "牛魔王之蹄",
@ -5018,6 +5202,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Guillaume's Face": "吉永之脸",
"Gull": "海鷗",
"Gutsiphon": "吸魂器",
"HP": "生命",
"Haemosu's Adament": "解慕漱的坚决",
"Hailstone": "霰之",
"Halaberd's Reign": "哈拉伯德的统治",
@ -5143,6 +5328,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Islestrike": "岛屿击",
"Ismail Vilehand": "邪惡之手-伊斯梅爾",
"Itchies": "渴望者",
"ItemDesc1s": "你的金錢: %d 雇用那個傭兵? ",
"ItemDesc1t": "現在沒有可以雇用的傭兵。",
"ItemExpansiveChanc1": "击中时有 %d%% 几率施展等级 %d %s",
"ItemExpansiveChanc2": "受击时有 %d%% 几率施展等级 %d %s",
"ItemExpansiveChancX": "攻击时有 %d%% 几率施展等级 %d %s",
@ -5208,6 +5395,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Leadcrow": "引導烏鴉",
"Leatherarm": "爬行的容貌",
"Lenyms Cord": "雷尼摩",
"Level": "等級",
"Leviathan": "利维坦",
"Lidless Wall": "警戒之墙",
"Lightning Sentry": "闪电卫哨",
@ -5262,6 +5450,73 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Medusa's Gaze": "美杜莎的凝视",
"Megaflow Rectifier": "矫正者怪异",
"Mephisto": "墨菲斯托",
"MercX101": "伐德哈卡",
"MercX102": "可汗",
"MercX103": "克理斯克",
"MercX104": "柏斯",
"MercX105": "波姆",
"MercX106": "衛格萊夫",
"MercX107": "霍勒斯加",
"MercX108": "西勒德",
"MercX109": "海爾夫坦",
"MercX110": "海波加",
"MercX111": "海爾干特",
"MercX112": "海加力克",
"MercX113": "艾格索",
"MercX114": "波丹",
"MercX115": "伍夫加",
"MercX116": "希爾德",
"MercX117": "希斯洛夫",
"MercX118": "魏德",
"MercX119": "維克海亞特",
"MercX120": "安佛斯",
"MercX121": "席格龐特",
"MercX122": "希爾穆特",
"MercX123": "罕格斯特",
"MercX124": "弗克魏爾德",
"MercX125": "弗雷斯坦",
"MercX126": "荷那夫",
"MercX127": "高斯里夫",
"MercX128": "歐斯拉夫",
"MercX129": "亞門拉夫",
"MercX130": "加門得",
"MercX131": "菲瓦夫",
"MercX132": "艾得格理斯",
"MercX133": "奧蕾那",
"MercX134": "戴米安",
"MercX135": "艾弗",
"MercX136": "沃斯坦",
"MercX137": "沃夫",
"MercX138": "布溫",
"MercX139": "理夫",
"MercX140": "馬格納斯",
"MercX141": "格拉圖",
"MercX142": "圖魯斯",
"MercX143": "候克",
"MercX144": "科杜",
"MercX145": "猶瑟r",
"MercX146": "艾普",
"MercX147": "伍夫",
"MercX148": "撒爾",
"MercX149": "卡林姆",
"MercX150": "烏瑞克",
"MercX151": "艾拉瑞克",
"MercX152": "愛瑟瑞得",
"MercX153": "卡登",
"MercX154": "伊爾吉夫",
"MercX155": "托斯提克",
"MercX156": "艾爾昆",
"MercX157": "艾蒙特",
"MercX158": "席古德",
"MercX159": "格姆",
"MercX160": "荷理斯",
"MercX161": "拉格那",
"MercX162": "托克爾",
"MercX163": "沃夫斯坦",
"MercX164": "阿爾班",
"MercX165": "巴洛克",
"MercX166": "比爾",
"MercX167": "狄奧多理",
"Merciless": "絕情之",
"Merman's Speed": "鱼人的链轮",
"Meshif": "馬席夫",
@ -5509,6 +5764,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Mosers Blessed Circle": "摩西尔的祝福之环",
"MummyGenerator": "木乃伊石棺",
"NPCIdentify1": "辨視物品",
"NPCMenuHire": "雇用",
"NPCMenuTrade": "交易",
"NPCMenuTradeRepair": "交易/修理",
"NPCPurchaseItems": "買",
@ -5564,6 +5820,9 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Pain Worm3": "苦难之虫",
"Pain Worm4": "威胁之虫",
"Pain Worm5": "剧痛之虫",
"PalMercEXST3X": "進攻",
"PalMercEXST4X": "戰鬥",
"PalMercExST1X": "防禦",
"PalOnly": "(限聖騎士)",
"Palace Cellar Level 2": "皇宮監牢第二層",
"Palace Cellar Level 3": "皇宮監牢第三層",
@ -5670,17 +5929,90 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"RunOn": "跑步",
"Rune": "符號之",
"Runemaster": "符文大师",
"Runeword1": "远古誓言",
"Runeword10": "烙印",
"Runeword103": "凤凰",
"Runeword108": "祈祷",
"Runeword122": "庇护所",
"Runeword109": "骄傲",
"Runeword11": "死亡呼吸",
"Runeword110": "原则",
"Runeword112": "审慎",
"Runeword116": "光辉",
"Runeword117": "雨",
"Runeword120": "押韵",
"Runeword121": "裂隙",
"Runeword122": "圣堂",
"Runeword126": "寂静",
"Runeword128": "烟雾",
"Runeword13": "战争召唤",
"Runeword130": "精神",
"Runeword131": "灿烂",
"Runeword133": "隐秘",
"Runeword134": "钢铁",
"Runeword137": "石块",
"Runeword139": "力量",
"Runeword14": "荣耀之链",
"Runeword142": "恐惧",
"Runeword148": "背信",
"Runeword153": "复仇",
"Runeword154": "毒液的",
"Runeword154": "毒液",
"Runeword16": "混沌",
"Runeword160": "财富",
"Runeword162": "白色",
"Runeword163": "风",
"Runeword168": "愤怒",
"Runeword17": "新月",
"Runeword2": "世界末日之",
"Runeword170": "和风",
"Runeword2": "毁天灭地",
"Runeword20": "死神",
"Runeword22": "迪勒瑞姆",
"Runeword25": "毁灭",
"Runeword26": "末日",
"Runeword27": "飞龙",
"Runeword29": "梦境",
"Runeword30": "强制",
"Runeword31": "边缘",
"Runeword33": "谜团",
"Runeword34": "启迪",
"Runeword36": "永恒",
"Runeword37": "流亡",
"Runeword38": "信心",
"Runeword39": "饥荒",
"Runeword4": "野兽",
"Runeword41": "刚毅",
"Runeword44": "狂怒",
"Runeword45": "幽暗",
"Runeword47": "悔恨",
"Runeword48": "正义之手",
"Runeword49": "和谐",
"Runeword51": "橡树之心",
"Runeword54": "圣雷",
"Runeword55": "荣耀",
"Runeword58": "饥饿",
"Runeword59": "寒冰",
"Runeword6": "黑色",
"Runeword60": "无限",
"Runeword62": "眼光",
"Runeword65": "国王之恩典",
"Runeword66": "弑君者",
"Runeword69": "最后希望",
"Runeword71": "执法者",
"Runeword72": "叶",
"Runeword73": "闪电",
"Runeword74": "狮心",
"Runeword75": "知识",
"Runeword8": "骸骨",
"Runeword81": "怨恨",
"Runeword82": "旋律",
"Runeword83": "记忆",
"Runeword87": "神话",
"Runeword88": "底韵",
"Runeword9": "野蔷薇",
"Runeword91": "誓约",
"Runeword92": "遵从",
"Runeword95": "热情",
"Runeword98": "和平",
"Runeword99": "理性之声",
"Russet": "赤楬之",
"Rusthandle": "生銹的把手",
"SaberCat": "劍齒貓",
@ -5739,7 +6071,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Silver": "銀之",
"Skeleton": "骷髏",
"SkeletonArcher": "骷髏弓箭手",
"Skillname222": "渡鸟之",
"Skillname222": "乌鸦",
"Skillname223": "剧毒蔓藤",
"Skillname224": "狼人變化",
"Skillname225": "狼人变化",
@ -5767,7 +6099,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Skillname247": "荆棘之灵",
"Skillname248": "召唤灰熊",
"Skillname249": "狂怒",
"Skillname250": "世界末日之",
"Skillname250": "毁天灭地",
"Skillname251": "飓风",
"Skillname252": "火焰爆震",
"Skillname253": "支配利爪",
@ -5796,7 +6128,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"Skillname276": "飞龙在天",
"Skillname277": "亡者守卫",
"Skillname278": "刃之盾",
"Skillname279": "毒液的",
"Skillname279": "毒液",
"Skillname280": "暗影宗师",
"Skillname281": "凤凰击",
"Skin of the Flayerd One": "剥皮者之皮",
@ -6317,6 +6649,15 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"VenomLord": "邪魔之王",
"Venomsward": "毒液牢房",
"Verdugo's Hearty Cord": "维尔登戈的心结",
"VerifyTransaction1": "是",
"VerifyTransaction2": "否",
"VerifyTransaction3": "金幣:",
"VerifyTransaction4": "賣",
"VerifyTransaction5": "買",
"VerifyTransaction6": "雇用",
"VerifyTransaction7": "辨視",
"VerifyTransaction8": "修復",
"VerifyTransaction9": "這個傭兵會換掉前一位。",
"Vicious": "惡毒之",
"Victors Silk": "勝利者之絲綢",
"Vidala's Ambush": "維達拉的突襲",
@ -6525,6 +6866,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"coat": "外袍",
"coil": "盤繞",
"collar": "項圈",
"colon": ":",
"compellingorb": "強制球體",
"cord": "捆綁",
"cost": "花費:",
@ -6541,6 +6883,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"cudgel": "木棒",
"d33": "吉得賓誘餌",
"dart": "鏢",
"dash": "-",
"dgr": "匕首",
"dhn": "暗黑破壞神之角",
"dir": "長匕首",
@ -6730,6 +7073,89 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"mbl": "扣帶",
"mbr": "曼菲斯托之腦",
"mbt": "鎖鍊靴",
"merc01": "阿里撒",
"merc02": "安普利沙",
"merc03": "安諾",
"merc04": "艾伯海亞",
"merc05": "埃利",
"merc06": "派吉",
"merc07": "巴森提",
"merc08": "布萊謝",
"merc09": "京子",
"merc10": "克勞蒂亞",
"merc11": "肯吉",
"merc12": "凱爾",
"merc13": "維沙利亞",
"merc14": "依麗莎",
"merc15": "弗蘿利亞",
"merc16": "菲歐娜",
"merc17": "基溫尼",
"merc18": "蓋爾",
"merc19": "哈娜",
"merc20": "海瑟",
"merc21": "艾蓮娜",
"merc22": "迪亞娜",
"merc23": "依索迪",
"merc24": "迪佛",
"merc25": "依索拉",
"merc26": "衣東亞",
"merc27": "萊茵",
"merc28": "梅可",
"merc29": "馬哈拉",
"merc30": "萊沙",
"merc31": "梅葛哈",
"merc32": "奧蓮娜",
"merc33": "奧麗亞娜",
"merc34": "萊恩",
"merc35": "羅珊妮",
"merc36": "萊紗",
"merc37": "莎倫",
"merc38": "席克哈",
"merc39": "戴比",
"merc40": "泰蓮娜",
"merc41": "溫蒂",
"merca201": "哈撒迪",
"merca202": "艾利希爾",
"merca203": "阿撒爾",
"merca204": "亞斯哈",
"merca205": "克哈蘭",
"merca206": "哈辛",
"merca207": "羅森",
"merca208": "艾米羅",
"merca209": "培撒姆",
"merca210": "費索",
"merca211": "傑馬利",
"merca212": "卡辛姆",
"merca213": "吉爾薩",
"merca214": "米山",
"merca215": "雷哈拉",
"merca216": "迪爾卡",
"merca217": "尼爾亞",
"merca218": "衣利森",
"merca219": "撒那利",
"merca220": "瓦黑德",
"merca221": "維克懷特",
"merca222": "喬安妮",
"merca223": "巴拉尼",
"merca224": "賈巴利",
"merca225": "迪法",
"merca226": "拉爾丁",
"merca227": "鐵拉西",
"merca228": "艾傑希德",
"merca229": "拿費特",
"merca230": "凱立爾",
"merca231": "費特",
"merca232": "杰希夫",
"merca233": "凡吉",
"merca234": "海費特",
"merca235": "撒達",
"merca236": "亞提賈",
"merca237": "拉黑德",
"merca238": "亞席德",
"merca239": "賈魯夫",
"merca240": "弗勒克斯",
"merca241": "斯高屈",
"merclevelup": "我現在覺得強壯多了",
"meshif": "馬席夫",
"mgl": "鎖鍊手套",
"minion": "嘍囉",
@ -7177,6 +7603,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"sml": "小盾牌",
"sol": "灵魂之",
"song": "歌",
"space": " ",
"spawn": "重生的",
"spc": "狼牙棒",
"spe": "一股邪恶力量",
@ -7254,6 +7681,16 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
"strchrstm": "耐力",
"strchrstr": "力量",
"strchrvit": "體力",
"strhirespecial1": "火箭",
"strhirespecial10": "快速施展火焰",
"strhirespecial2": "冰箭",
"strhirespecial3": "戳刺攻擊",
"strhirespecial4": "抗毒",
"strhirespecial5": "閃電",
"strhirespecial6": "冷",
"strhirespecial7": "火",
"strhirespecial8": "快速施展閃電",
"strhirespecial9": "快速施展冰冷",
"stu": "鑲嵌甲",
"stun attack": "昏眩攻擊",
"suit": "衣",

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",
@ -293595,6 +293814,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
"vendorTabs": "/ui/vendor-tabs.png",
"boxPieces": "/ui/boxpieces.png",
"focus16": "/ui/focus16.png",
"textslid": "/ui/textslid.png",
"popbelt": "/ui/popbelt.png",
"minipanel": "/ui/minipanel.png",
"minipanelBtns": "/ui/minipanel-btns.png",
@ -293622,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,
}

File diff suppressed because it is too large Load Diff

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,191 @@
import { describe, it, expect, vi } from 'vitest'
import {
WorldPanelsHud,
HIRE_DIALOG_WIDTH,
HIRE_DIALOG_HEIGHT,
HIRE_CONFIRM_WIDTH,
HIRE_CONFIRM_HEIGHT,
} from '../../src/client/ui/world-panels.ts'
import { resolveHirelingCandidates } from '../../src/common/units/hireling-table.ts'
function createMockCtx() {
const drawnTexts: string[] = []
let fillRectCount = 0
let strokeRectCount = 0
return {
drawnTexts,
get fillRectCount() {
return fillRectCount
},
get strokeRectCount() {
return strokeRectCount
},
fillStyle: '',
strokeStyle: '',
fillRect: vi.fn(() => fillRectCount++),
strokeRect: vi.fn(() => strokeRectCount++),
fillText: vi.fn((text: string) => drawnTexts.push(text)),
drawImage: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
} as unknown as CanvasRenderingContext2D & { drawnTexts: string[]; fillRectCount: number; strokeRectCount: number }
}
describe('WorldPanelsHud — Mercenary Hire Stone Dialog (0x6faf9440 & 0x6faf7500 parity)', () => {
it('identifies dialog hitboxes accurately per D2Client.dll 0x6faf9440', () => {
const wp = new WorldPanelsHud()
wp.activePanel = 'hire'
// Centered at 155, 105 (490x350)
expect(wp.isPointInHireMercDialog(100, 50)).toBe(false)
expect(wp.isPointInHireMercDialog(155, 105)).toBe(true)
expect(wp.isPointInHireMercDialog(400, 300)).toBe(true)
expect(wp.isPointInHireMercDialog(644, 454)).toBe(true)
expect(wp.isPointInHireMercDialog(650, 460)).toBe(false)
// Confirmation dialog bounds (250, 190, 300, 160)
wp.replaceMercConfirmOpen = true
expect(wp.isPointInHireMercDialog(160, 110)).toBe(false)
expect(wp.isPointInHireMercDialog(250, 190)).toBe(true)
expect(wp.isPointInHireMercDialog(400, 250)).toBe(true)
expect(wp.isPointInHireMercDialog(550, 350)).toBe(true)
})
it('draws authentic 490x350 stone dialog with ItemDesc1s gold header and candidate list', () => {
const wp = new WorldPanelsHud()
wp.lang = 'ENG'
wp.activePanel = 'hire'
wp.playerGold = 5000
wp.stashGold = 12000
const rawCandidates = [
{ nameId: 3411, seed: 0x1234 },
{ nameId: 3412, seed: 0x5678 },
]
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 10, 0, 0, true, 'ENG')
const ctx = createMockCtx()
wp.drawHireMercDialog(ctx)
expect(ctx.fillRectCount).toBeGreaterThan(0)
// Title with total gold 17000
expect(ctx.drawnTexts.some((t) => t.includes('17000') && t.includes('Hire which Mercenary?'))).toBe(true)
// Candidates rendered with Lvl, Life, Def, Cost
expect(ctx.drawnTexts.some((t) => t.includes('Aliza - Lvl: 8'))).toBe(true)
expect(ctx.drawnTexts.some((t) => t.includes('Fire Arrow'))).toBe(true)
// Bottom cancel button
expect(ctx.drawnTexts.some((t) => t.includes('cancel'))).toBe(true)
})
it('draws ItemDesc1t when hire list is empty', () => {
const wp = new WorldPanelsHud()
wp.lang = 'ENG'
wp.activePanel = 'hire'
wp.mercHireList = []
const ctx = createMockCtx()
wp.drawHireMercDialog(ctx)
expect(ctx.drawnTexts.some((t) => t.includes('There are no Mercenaries left to hire.'))).toBe(true)
})
it('handles candidate hover, scrolling, and direct hire when player has no existing mercenary', () => {
const wp = new WorldPanelsHud()
wp.activePanel = 'hire'
wp.activeNpcId = 150
const rawCandidates = Array.from({ length: 10 }, (_, i) => ({
nameId: 3411 + i,
seed: 0x1000 + i * 0x100,
}))
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG')
// 1. Wheel scroll
expect(wp.handleHireWheel(1)).toBe(true)
expect(wp.hireScrollOffset).toBe(1)
expect(wp.handleHireWheel(-1)).toBe(true)
expect(wp.hireScrollOffset).toBe(0)
// 2. Mouse move hover
wp.handleHireMercMove(200, 160) // over Candidate 0
expect(wp.hireHoveredIndex).toBe(0)
// 3. Direct click to hire when player has no existing mercenary
let hiredNpcId = 0
let hiredMercId = 0
const onHireMerc = vi.fn((npcId: number, mercId: number) => {
hiredNpcId = npcId
hiredMercId = mercId
})
const handled = wp.handleHireMercClick(200, 160, { onHireMerc })
expect(handled).toBe(true)
expect(onHireMerc).toHaveBeenCalledWith(150, 3411)
expect(wp.activePanel).toBe('none')
})
it('prompts confirmation dialog when player already possesses a mercenary (0x6faf7500)', () => {
const wp = new WorldPanelsHud()
wp.lang = 'ENG'
wp.activePanel = 'hire'
wp.activeNpcId = 150
wp.merc = { mercId: 42, isDead: false } // Existing active mercenary
const rawCandidates = [{ nameId: 3411, seed: 0x1234 }]
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG')
const onHireMerc = vi.fn()
// First click: selects candidate
wp.handleHireMercClick(200, 160, { onHireMerc })
expect(wp.hireSelectedIndex).toBe(0)
expect(wp.replaceMercConfirmOpen).toBe(false)
expect(onHireMerc).not.toHaveBeenCalled()
// Second click on selected candidate: triggers replacement warning dialog
wp.handleHireMercClick(200, 160, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(true)
expect(wp.pendingHireCandidate?.nameId).toBe(3411)
// Render with confirm dialog open
const ctx = createMockCtx()
wp.drawHireMercDialog(ctx)
expect(ctx.drawnTexts.some((t) => t.includes('This Mercenary will replace your current one.'))).toBe(true)
expect(ctx.drawnTexts.some((t) => t.includes('Yes'))).toBe(true)
expect(ctx.drawnTexts.some((t) => t.includes('No'))).toBe(true)
// Click "No" (cx + 150 = 400, cy + 128 = 318)
wp.handleHireMercClick(400, 318, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(false)
expect(wp.activePanel).toBe('hire')
expect(onHireMerc).not.toHaveBeenCalled()
// Open again and click "Yes" (cx + 150 = 400, cy + 104 = 294)
wp.handleHireMercClick(200, 160, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(true)
wp.handleHireMercClick(400, 294, { onHireMerc })
expect(wp.replaceMercConfirmOpen).toBe(false)
expect(onHireMerc).toHaveBeenCalledWith(150, 3411)
expect(wp.activePanel).toBe('none')
})
it('clicking cancel button closes hire dialog and re-opens NPC menu (0x6faf9090)', () => {
const wp = new WorldPanelsHud()
wp.openNpcMenuByName('Kashya', 400, 220)
expect(wp.npcMenu).not.toBeNull()
wp.clickNpcMenuOption('hire')
expect(wp.activePanel).toBe('hire')
expect(wp.npcMenu).toBeNull()
// Click Cancel button at dialogX + 245 = 400, dialogY + 315 = 420
const onClose = vi.fn()
wp.handleHireMercClick(400, 420, { onClose })
expect(wp.activePanel).toBe('none')
expect(onClose).toHaveBeenCalled()
expect(wp.npcMenu).not.toBeNull()
expect(wp.npcMenu?.npcName).toContain('Kashya')
})
})

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

View File

@ -8,7 +8,23 @@ import {
} from '../../src/client/settings/client-settings.ts'
import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts'
import { Toolbar } from '../../src/client/toolbar/toolbar.ts'
import { computeDockingLayout, HudManager } from '../../src/client/ui/hud-manager.ts'
import {
computeDockingLayout,
CUBE_GRID_ORIGIN,
fromLeftDockLocal,
fromRightDockLocal,
HudManager,
INV_GRID_ORIGIN,
STASH_GRID_ORIGIN,
toControlBarLocal,
toLeftDockLocal,
toRightDockLocal,
VENDOR_GRID_ORIGIN,
type HudCommandSink,
} from '../../src/client/ui/hud-manager.ts'
import type { UiInventoryItem } from '../../src/client/ui/inventory.ts'
import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.ts'
import { getTownNpcDescriptorByName } from '../../src/common/units/npc-table.ts'
import {
computeViewportAnchors,
computeViewportFitSize,
@ -98,6 +114,37 @@ const EXPECTED: readonly ExpectedProfile[] = [
function createHudAt(profile: ViewportProfile): HudManager {
const { width, height } = profile
const mockCtx = {
canvas: { width, height },
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
font: '',
textAlign: 'left',
textBaseline: 'alphabetic',
imageSmoothingEnabled: false,
save: () => {},
restore: () => {},
translate: () => {},
scale: () => {},
clearRect: () => {},
fillRect: () => {},
strokeRect: () => {},
beginPath: () => {},
closePath: () => {},
moveTo: () => {},
lineTo: () => {},
arc: () => {},
rect: () => {},
clip: () => {},
fill: () => {},
stroke: () => {},
fillText: () => {},
drawImage: () => {},
measureText: (t: string) => ({ width: t.length * 8 }),
createLinearGradient: () => ({ addColorStop: () => {} }),
} as unknown as CanvasRenderingContext2D
const canvas = {
width,
height,
@ -106,6 +153,7 @@ function createHudAt(profile: ViewportProfile): HudManager {
style: { cursor: '', pointerEvents: '' },
addEventListener: () => {},
getBoundingClientRect: () => ({ left: 0, top: 0, width, height }),
getContext: () => mockCtx,
} as unknown as HTMLCanvasElement
const hud = new HudManager(canvas, {
onToggleAutomap: () => {},
@ -337,3 +385,579 @@ describe('HUD anchors, docking and interception for 1068x600 / 1280x720', () =>
expect(fit1068).toMatchObject({ cssWidth: 1920, cssHeight: 1079, letterboxLeft: 0, letterboxTop: 0 })
})
})
function makeTestUiItem(id: string, code: string, name: string, w = 1, h = 1): UiInventoryItem {
return {
id,
code,
invFile: code,
name,
nameZh: name,
baseNameZh: name,
quality: 'normal',
invWidth: w,
invHeight: h,
allowedSlots: ['helm'],
stats: [],
}
}
describe('HUD input and hover coordinate translation across all 4 viewport profiles (Issue #542)', () => {
it('converts logical <-> region-local coordinates using active docking offsets across all 4 profiles', () => {
for (const { profile } of EXPECTED) {
const docking = computeDockingLayout(profile.width, profile.height, 1, profile)
const deltaRight = docking.rightDockX - 400
expect(toControlBarLocal(263 + profile.barOffsetX, 582 + profile.barOffsetY, docking)).toEqual({
x: 263,
y: 582,
})
expect(toLeftDockLocal(217 + docking.leftDockX, 153 + profile.panelOffsetY, docking)).toEqual({
x: 217,
y: 153,
})
expect(fromLeftDockLocal(217, 153, docking)).toEqual({
x: 217 + docking.leftDockX,
y: 153 + profile.panelOffsetY,
})
expect(toRightDockLocal(432.5 + deltaRight, 330.5 + profile.panelOffsetY, docking)).toEqual({
x: 432.5,
y: 330.5,
})
expect(fromRightDockLocal(432.5, 330.5, docking)).toEqual({
x: 432.5 + deltaRight,
y: 330.5 + profile.panelOffsetY,
})
}
})
it.each(EXPECTED)(
'translates bottom control bar clicks and hovers ($id: barOffset=($barOffsetX,$barOffsetY))',
({ profile, barOffsetX, barOffsetY }) => {
const hud = createHudAt(profile)
const useBeltSlot = vi.fn(() => true)
hud.commandSink = { useBeltSlot } as unknown as HudCommandSink
// 1. Run/Walk toggle button (canonical 255..271, 572..592 -> center 263, 582)
const runX = 263 + barOffsetX
const runY = 582 + barOffsetY
hud.handleMouseMove(runX, runY)
expect(hud.controlBar.hoveredElement).toBe('run')
const prevRun = hud.isRunning
hud.handleClick(runX, runY, 0)
expect(hud.isRunning).toBe(!prevRun)
// 2. Mini-panel buttons (canonical 312..488, 522..548 -> char at 325, 535; inv at 352, 535)
const charBtnX = 325 + barOffsetX
const charBtnY = 535 + barOffsetY
hud.handleMouseMove(charBtnX, charBtnY)
expect(hud.controlBar.hoveredElement).toBe('char')
hud.handleClick(charBtnX, charBtnY, 0)
expect(hud.leftPanel).toBe('char')
const invBtnX = 352 + barOffsetX
const invBtnY = 535 + barOffsetY
hud.handleMouseMove(invBtnX, invBtnY)
expect(hud.controlBar.hoveredElement).toBe('inv')
hud.handleClick(invBtnX, invBtnY, 0)
expect(hud.rightPanel).toBe('inv')
// 3. Belt slot col 0, row 0 (canonical 423..451, 561..589 -> center 435, 575)
const beltX = 435 + barOffsetX
const beltY = 575 + barOffsetY
hud.handleMouseMove(beltX, beltY)
expect(hud.belt.hoveredSlot).toEqual({ row: 0, col: 0 })
hud.handleClick(beltX, beltY, 2)
expect(useBeltSlot).toHaveBeenCalledWith(0, false)
// 4. Life & Mana globes (canonical centers 68, 555 and 732, 555)
const lifeX = 68 + barOffsetX
const lifeY = 555 + barOffsetY
hud.handleMouseMove(lifeX, lifeY)
expect(hud.globes.lifeHovered).toBe(true)
const prevLifeLock = hud.globes.lifeTextLocked
hud.handleClick(lifeX, lifeY, 0)
expect(hud.globes.lifeTextLocked).toBe(!prevLifeLock)
const manaX = 732 + barOffsetX
const manaY = 555 + barOffsetY
hud.handleMouseMove(manaX, manaY)
expect(hud.globes.manaHovered).toBe(true)
const prevManaLock = hud.globes.manaTextLocked
hud.handleClick(manaX, manaY, 0)
expect(hud.globes.manaTextLocked).toBe(!prevManaLock)
// 5. Left & Right skill hotkey slots (canonical centers 141, 575 and 659, 575)
const leftSkillX = 141 + barOffsetX
const leftSkillY = 575 + barOffsetY
hud.handleClick(leftSkillX, leftSkillY, 0)
expect(hud.hotkeys.openPopup).toBe('left')
hud.handleClick(leftSkillX, leftSkillY, 0)
expect(hud.hotkeys.openPopup).toBeNull()
},
)
it.each(EXPECTED)(
'translates left dock clicks and reverse-translates stash/cube/vendor hover tooltips ($id: panelOffsetY=$panelOffsetY)',
({ profile, panelOffsetY }) => {
const hud = createHudAt(profile)
const docking = hud.getDockingLayout()
const leftDockX = docking.leftDockX
const allocateStat = vi.fn(() => true)
hud.commandSink = { allocateStat } as unknown as HudCommandSink
// 1. Character sheet stat '+' button (STR canonical 202..232, 138..168 -> center 217, 153)
hud.toggleLeftPanel('char')
hud.charSheet.attrs.statPoints = 5
hud.handleClick(217 + leftDockX, 153 + panelOffsetY, 0)
expect(allocateStat).toHaveBeenCalledWith('str')
// Character sheet close button (canonical 208..240, 448..480 -> center 224, 464)
hud.handleClick(224 + leftDockX, 464 + panelOffsetY, 0)
expect(hud.leftPanel).toBe('none')
// Clear commandSink to test offline stash pickup as well
hud.commandSink = null
// 2. Stash grid cell (col 0, row 0) click & hover tooltip writeback
hud.leftPanel = 'stash'
const stashItem = makeTestUiItem('stash-1', 'rin', 'Stone of Jordan')
hud.worldPanels.stashItems = [{ item: stashItem, col: 0, row: 0 }]
const stashHoverX = STASH_GRID_ORIGIN.x + 14.5 + leftDockX
const stashHoverY = STASH_GRID_ORIGIN.y + 14.5 + panelOffsetY
hud.handleMouseMove(stashHoverX, stashHoverY)
expect(hud.inventory.hoveredItem).toEqual({
item: stashItem,
x: STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
y: STASH_GRID_ORIGIN.y + panelOffsetY,
})
// Clicking stash cell (0, 0) picks up the item onto cursor
hud.handleClick(stashHoverX, stashHoverY, 0)
expect(hud.inventory.cursorItem?.id).toBe('stash-1')
hud.inventory.cursorItem = null
// 3. Cube grid cell (col 0, row 0) hover tooltip writeback
hud.leftPanel = 'cube'
const cubeItem = makeTestUiItem('cube-1', 'rvl', 'Full Rejuvenation Potion')
hud.worldPanels.cubeItems = [{ item: cubeItem, col: 0, row: 0 }]
const cubeHoverX = CUBE_GRID_ORIGIN.x + 14.5 + leftDockX
const cubeHoverY = CUBE_GRID_ORIGIN.y + 14.5 + panelOffsetY
hud.handleMouseMove(cubeHoverX, cubeHoverY)
expect(hud.inventory.hoveredItem).toEqual({
item: cubeItem,
x: CUBE_GRID_ORIGIN.x + 14.5 + leftDockX,
y: CUBE_GRID_ORIGIN.y + panelOffsetY,
})
// 4. Vendor grid cell (col 0, row 0) hover tooltip writeback
const akara = getTownNpcDescriptorByName('Akara')!
const vendorItem = makeTestUiItem('vendor-1', 'cap', 'Cap', 2, 2)
hud.worldPanels.setVendorShop({
npcId: 1,
descriptor: akara,
mode: 'trade',
items: [{ storePage: 1, placement: { item: vendorItem, col: 0, row: 0 } }],
})
hud.leftPanel = 'vendor'
const vendorHoverX = VENDOR_GRID_ORIGIN.x + 14.5 + leftDockX
const vendorHoverY = VENDOR_GRID_ORIGIN.y + 14.5 + panelOffsetY
hud.handleMouseMove(vendorHoverX, vendorHoverY)
expect(hud.inventory.hoveredItem).toEqual({
item: vendorItem,
x: VENDOR_GRID_ORIGIN.x + 29 + leftDockX,
y: VENDOR_GRID_ORIGIN.y + panelOffsetY,
})
},
)
it.each(EXPECTED)(
'translates right dock inventory/equipment/skill-tree clicks and hover tooltips ($id: panelOffsetY=$panelOffsetY)',
({ profile, panelOffsetY }) => {
const hud = createHudAt(profile)
const docking = hud.getDockingLayout()
const deltaRight = docking.rightDockX - 400
// 1. Inventory grid cell (col 0, row 0) hover & click at docked right panel coordinates
hud.toggleRightPanel('inv')
const bagItem = makeTestUiItem('bag-1', 'rin', 'Bul-Kathos Ring')
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
const invHoverX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
const invHoverY = INV_GRID_ORIGIN.y + 14.5 + panelOffsetY
hud.handleMouseMove(invHoverX, invHoverY)
expect(hud.inventory.hoveredItem).toEqual({
item: bagItem,
x: INV_GRID_ORIGIN.x + 14.5 + deltaRight,
y: INV_GRID_ORIGIN.y + panelOffsetY,
})
// Clicking at the docked right-panel coordinate picks up the item
hud.handleClick(invHoverX, invHoverY, 0)
expect(hud.inventory.cursorItem?.id).toBe('bag-1')
hud.inventory.cursorItem = null
// 2. Equipped helm slot (canonical 532..590, 64..122 -> center 561, 93; tooltip anchor 561, 64)
const helmItem = makeTestUiItem('helm-1', 'ci3', 'Griffon Eye', 2, 2)
hud.inventory.equipped.helm = helmItem
hud.handleMouseMove(561 + deltaRight, 93 + panelOffsetY)
expect(hud.inventory.hoveredItem).toEqual({
item: helmItem,
x: 561 + deltaRight,
y: 64 + panelOffsetY,
})
// 2b. Tooltip box clamping uses active viewport width/height (not hardcoded 800x600)
hud.inventory.gridItems = [{ item: bagItem, col: 9, row: 0 }]
const rightColHoverX = INV_GRID_ORIGIN.x + 9 * 29 + 14.5 + deltaRight
const rightColHoverY = INV_GRID_ORIGIN.y + 14.5 + panelOffsetY
hud.handleMouseMove(rightColHoverX, rightColHoverY)
const mockCtx = {
fillStyle: '',
strokeStyle: '',
save: () => {},
restore: () => {},
fillRect: () => {},
strokeRect: () => {},
fillText: () => {},
drawImage: () => {},
measureText: () => ({ width: 50 }),
} as unknown as CanvasRenderingContext2D
hud.worldPanels.drawItemTooltip(
mockCtx,
hud.inventory.hoveredItem,
hud.font,
false,
profile.width,
profile.height,
)
expect(hud.worldPanels.lastTooltipRect).not.toBeNull()
const tipRect = hud.worldPanels.lastTooltipRect!
expect(tipRect.bx + tipRect.boxW).toBeLessThanOrEqual(profile.width - 8)
if (profile.width > 800) {
expect(tipRect.bx + tipRect.boxW).toBeGreaterThan(800)
}
// 3. Skill tree node hover & click at docked right panel coordinates
hud.toggleRightPanel('skill')
hud.skillTree.setClass('sor')
hud.skillTree.characterLevel = 30
hud.skillTree.unspentSkillPoints = 5
const allocateSkill = vi.fn(() => true)
hud.commandSink = { allocateSkill } as unknown as HudCommandSink
const firstTabNode = hud.skillTree
.getSkillTree()
.find((n) => n.tab === hud.skillTree.activeTab && n.prereqs.length === 0)!
const nodeRect = SkillTreePanel.nodeScreenPos(firstTabNode)
const nodeClickX = nodeRect.x + nodeRect.w / 2 + deltaRight
const nodeClickY = nodeRect.y + nodeRect.h / 2 + panelOffsetY
hud.handleMouseMove(nodeClickX, nodeClickY)
expect(hud.skillTree.hoveredSkill?.skillId).toBe(firstTabNode.skillId)
hud.handleClick(nodeClickX, nodeClickY, 0)
expect(allocateSkill).toHaveBeenCalledWith(firstTabNode.skillId)
},
)
it('does not trigger right-dock or bottom-bar actions when clicking old un-offset 800x600 coordinates in 1024x768 and 1280x720', () => {
for (const profile of [VIEWPORT_1024x768, VIEWPORT_1280x720]) {
const hud = createHudAt(profile)
hud.toggleRightPanel('inv')
const bagItem = makeTestUiItem('bag-corridor', 'rin', 'Ring')
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
// Un-offset (432.5, 330.5) falls inside the open center corridor (400..624 in 1024x768, 400..880 in 1280x720)
expect(hud.isPointInterceptedByHud(432.5, 330.5)).toBe(false)
hud.handleClick(432.5, 330.5, 0)
expect(hud.inventory.cursorItem).toBeNull()
// Un-offset Run button (263, 582) is above the bottom bar in 1024x768 (bar y=708..768) and 1280x720 (bar y=660..720)
const prevRun = hud.isRunning
hud.handleClick(263, 582, 0)
expect(hud.isRunning).toBe(prevRun)
}
})
it.each(EXPECTED)(
'translates window mousemove listener hover coordinates and pointer-events across $id',
({ profile, barOffsetX, barOffsetY, panelOffsetY }) => {
const prevWindow = (globalThis as { window?: unknown }).window
const mockWindow = Object.assign(new EventTarget(), {
innerWidth: profile.width,
innerHeight: profile.height,
devicePixelRatio: 1,
})
;(globalThis as { window?: unknown }).window = mockWindow
try {
const hud = createHudAt(profile)
const hudCanvas = (hud as unknown as { hudCanvas: HTMLCanvasElement }).hudCanvas
const docking = hud.getDockingLayout()
const deltaRight = docking.rightDockX - 400
const leftDockX = docking.leftDockX
hud.toggleRightPanel('inv')
hud.leftPanel = 'stash'
const bagItem = makeTestUiItem('win-bag-1', 'rin', 'Raven Frost')
const stashItem = makeTestUiItem('win-stash-1', 'amu', 'Highlord Wrath')
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
hud.worldPanels.stashItems = [{ item: stashItem, col: 0, row: 0 }]
// Dispatch window mousemove over right-dock inventory item
const invClient = hud.logicalToClient(
INV_GRID_ORIGIN.x + 14.5 + deltaRight,
INV_GRID_ORIGIN.y + 14.5 + panelOffsetY,
)
const invEv = Object.assign(new Event('mousemove'), {
clientX: invClient.x,
clientY: invClient.y,
})
mockWindow.dispatchEvent(invEv)
expect(hud.inventory.hoveredItem).toEqual({
item: bagItem,
x: INV_GRID_ORIGIN.x + 14.5 + deltaRight,
y: INV_GRID_ORIGIN.y + panelOffsetY,
})
expect(hudCanvas.style.pointerEvents).toBe('auto')
// Dispatch window mousemove over left-dock stash item
const stashClient = hud.logicalToClient(
STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
STASH_GRID_ORIGIN.y + 14.5 + panelOffsetY,
)
const stashEv = Object.assign(new Event('mousemove'), {
clientX: stashClient.x,
clientY: stashClient.y,
})
mockWindow.dispatchEvent(stashEv)
expect(hud.inventory.hoveredItem).toEqual({
item: stashItem,
x: STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
y: STASH_GRID_ORIGIN.y + panelOffsetY,
})
expect(hudCanvas.style.pointerEvents).toBe('auto')
// Dispatch window mousemove over bottom bar Run/Walk toggle button
const runClient = hud.logicalToClient(263 + barOffsetX, 582 + barOffsetY)
const runEv = Object.assign(new Event('mousemove'), {
clientX: runClient.x,
clientY: runClient.y,
})
mockWindow.dispatchEvent(runEv)
expect(hud.controlBar.hoveredElement).toBe('run')
expect(hudCanvas.style.pointerEvents).toBe('auto')
} finally {
if (prevWindow === undefined) {
delete (globalThis as { window?: unknown }).window
} else {
;(globalThis as { window?: unknown }).window = prevWindow
}
}
},
)
})
describe('Skill tooltips and full HUD UI audit across >800x600 viewports (Issue #557)', () => {
it.each(EXPECTED)(
'synchronizes skillTree.visible on direct rightPanel assignment and renders SkillTreePanel & Hotkeys skill tooltips on top layer ($id)',
({ profile, barOffsetX, barOffsetY, panelOffsetY }) => {
const hud = createHudAt(profile)
const docking = hud.getDockingLayout()
const deltaRight = docking.rightDockX - 400
// 1. Direct assignment (as done by HudModel.syncFromWorld) must keep skillTree.visible in sync
hud.rightPanel = 'skill'
expect(hud.skillTree.visible).toBe(true)
hud.skillTree.setClass('sor')
hud.skillTree.characterLevel = 30
hud.skillTree.hardPoints.set(36, 5) // Fire Bolt
const col1Node = hud.skillTree
.getSkillTree()
.find((n) => n.tab === hud.skillTree.activeTab && n.col === 1)!
const nodeRect = SkillTreePanel.nodeScreenPos(col1Node)
hud.handleMouseMove(
nodeRect.x + nodeRect.w / 2 + deltaRight,
nodeRect.y + nodeRect.h / 2 + panelOffsetY,
)
expect(hud.skillTree.hoveredSkill?.skillId).toBe(col1Node.skillId)
// Verify tooltip placement with minLocalX = 8 - deltaRight does not clamp over col-1 node on >800x600
const placement = SkillTreePanel.computeTooltipPlacement(
nodeRect,
[{ text: 'Fire Bolt Level 5 (Mana Cost: 2.5)', color: 'gold', font: 'font8' }],
hud.font,
8 - deltaRight,
)
if (deltaRight > 0) {
expect(placement.x + placement.w).toBeLessThanOrEqual(nodeRect.x)
}
// 2. Quickbar active skill slot hover & Speedbar popup skill hover at translated barOffset
hud.hotkeys.setAvailableSkills(
[
{
skillId: 0,
name: 'Attack',
nameZh: '攻击',
level: 1,
manaCost: 0,
leftUsable: true,
rightUsable: true,
},
{
skillId: 36,
name: 'Fire Bolt',
nameZh: '火弹',
level: 5,
manaCost: 3,
leftUsable: true,
rightUsable: true,
},
{
skillId: 38,
name: 'Charged Bolt',
nameZh: '充能弹',
level: 3,
manaCost: 4,
leftUsable: true,
rightUsable: true,
},
],
36,
38,
)
const leftSlotX = 141 + barOffsetX
const leftSlotY = 575 + barOffsetY
hud.handleMouseMove(leftSlotX, leftSlotY)
expect(hud.hotkeys.hoveredSlot).toBe('left')
expect(hud.hotkeys.hoverSkillId).toBe(36)
// Open left speedbar popup and hover its first skill cell
hud.handleClick(leftSlotX, leftSlotY, 0)
expect(hud.hotkeys.openPopup).toBe('left')
const popupCells = hud.hotkeys.getPopupCells('left')
expect(popupCells.length).toBeGreaterThan(0)
const firstCell = popupCells[0]!
hud.handleMouseMove(firstCell.x + 24 + barOffsetX, firstCell.y + 24 + barOffsetY)
expect(hud.hotkeys.hoveredPopupSkill).toEqual({
side: 'left',
skillId: firstCell.skill.skillId,
})
expect(hud.hotkeys.hoverSpeedbarSkillId).toBe(firstCell.skill.skillId)
// 3. Verify HudManager.render invokes hotkeys.draw AFTER belt.draw, and draws skillTree tooltip on top layer
const callOrder: string[] = []
const origBeltDraw = hud.belt.draw.bind(hud.belt)
const origHotkeysDraw = hud.hotkeys.draw.bind(hud.hotkeys)
const origSkillTooltipDraw = hud.skillTree.drawSkillTooltip.bind(hud.skillTree)
vi.spyOn(hud.belt, 'draw').mockImplementation((...args) => {
callOrder.push('belt.draw')
return origBeltDraw(...args)
})
vi.spyOn(hud.hotkeys, 'draw').mockImplementation((...args) => {
callOrder.push('hotkeys.draw')
return origHotkeysDraw(...args)
})
vi.spyOn(hud.skillTree, 'drawSkillTooltip').mockImplementation((...args) => {
callOrder.push('skillTree.drawSkillTooltip')
return origSkillTooltipDraw(...args)
})
// Hover the skill tree node again before rendering so skillTree.hoveredSkill is active during render()
hud.handleMouseMove(
nodeRect.x + nodeRect.w / 2 + deltaRight,
nodeRect.y + nodeRect.h / 2 + panelOffsetY,
)
expect(hud.skillTree.hoveredSkill?.skillId).toBe(col1Node.skillId)
hud.render(1000)
expect(callOrder.indexOf('belt.draw')).toBeGreaterThanOrEqual(0)
expect(callOrder.indexOf('hotkeys.draw')).toBeGreaterThan(callOrder.indexOf('belt.draw'))
expect(callOrder.indexOf('skillTree.drawSkillTooltip')).toBeGreaterThan(
callOrder.indexOf('hotkeys.draw'),
)
},
)
it.each(EXPECTED)(
'translates Mercenary, Trade, and Quest Reward left-dock item hover tooltips and clamps NPC menu / Area Banner / Death Overlay ($id)',
({ profile, panelOffsetY }) => {
const hud = createHudAt(profile)
const docking = hud.getDockingLayout()
const leftDockX = docking.leftDockX
// 1. Mercenary equipment hover tooltip (helm slot: x=212..268, y=104..160 -> center 240, 132)
hud.leftPanel = 'merc'
const mercHelm = makeTestUiItem('merc-helm', 'usk', 'Andariel Visage', 2, 2)
hud.worldPanels.merc = { headItem: mercHelm }
hud.handleMouseMove(240 + leftDockX, 132 + panelOffsetY)
expect(hud.inventory.hoveredItem).toEqual({
item: mercHelm,
x: 240 + leftDockX,
y: 104 + panelOffsetY,
})
// 2. Quest Reward (imbue) slot hover tooltip (x=196..284, y=180..296 -> center 240, 238)
hud.leftPanel = 'imbue'
const imbueItem = makeTestUiItem('imbue-boots', 'xtb', 'War Boots', 2, 2)
hud.worldPanels.questRewardItem = imbueItem
hud.handleMouseMove(240 + leftDockX, 238 + panelOffsetY)
expect(hud.inventory.hoveredItem).toEqual({
item: imbueItem,
x: 240 + leftDockX,
y: 180 + panelOffsetY,
})
// 3. NPC interaction menu clamps against active viewport width/height (not hardcoded 800x600)
const akara = getTownNpcDescriptorByName('Akara')!
hud.worldPanels.openNpcMenu(akara, profile.width - 60, 220, 1)
expect(
hud.worldPanels.isPointInNpcMenu(
profile.width - 60,
240,
hud.font,
profile.width,
profile.height,
),
).toBe(true)
expect(hud.isPointInterceptedByHud(profile.width - 60, 240)).toBe(true)
hud.worldPanels.npcMenu = null
// 4. Area Banner & Death Overlay center across profile.width / profile.height
hud.leftPanel = 'none'
hud.rightPanel = 'none'
hud.worldPanels.areaBanner = {
titleZh: '鲜血荒地',
titleEn: 'Blood Moor',
untilMs: 5000,
}
hud.isDead = true
const fillRectCalls: Array<[number, number, number, number]> = []
const drawTextXs: number[] = []
const hudCanvas = (hud as unknown as { hudCanvas: HTMLCanvasElement }).hudCanvas
const hudCtx = hudCanvas.getContext('2d')!
const origFillRect = hudCtx.fillRect.bind(hudCtx)
vi.spyOn(hudCtx, 'fillRect').mockImplementation((x, y, w, h) => {
fillRectCalls.push([x, y, w, h])
return origFillRect(x, y, w, h)
})
vi.spyOn(hud.font, 'drawText').mockImplementation((_ctx, _text, x) => {
drawTextXs.push(x)
})
hud.render(1500)
// Death overlay must cover (0, 0, profile.width, profile.height)
expect(fillRectCalls).toContainEqual([0, 0, profile.width, profile.height])
// Area banner and Death overlay text must center at Math.round(profile.width / 2)
expect(drawTextXs).toContain(Math.round(profile.width / 2))
},
)
})

View File

@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest'
import {
HIRELING_RECORDS,
resolveHirelingAct,
resolveHirelingName,
resolveHirelingDesc,
resolveHirelingCandidate,
resolveHirelingCandidates,
formatHirelingCandidateLines,
} from '../../src/common/units/hireling-table.ts'
describe('hireling-table (authoritative Diablo II v1.13c Hireling.txt / D2Common parity)', () => {
it('loads all 120 compiled hireling records with valid attributes', () => {
expect(HIRELING_RECORDS).toHaveLength(120)
for (const r of HIRELING_RECORDS) {
expect([0, 100]).toContain(r.version)
expect([271, 338, 359, 561]).toContain(r.classId)
expect([1, 2, 3, 5]).toContain(r.act)
expect([1, 2, 3]).toContain(r.difficulty)
expect([150, 198, 252, 515]).toContain(r.seller)
expect(r.level).toBeGreaterThan(0)
expect(r.hireCost).toBeGreaterThan(0)
expect(r.hp).toBeGreaterThan(0)
expect(r.skills.length).toBeGreaterThan(0)
}
})
it('resolves act index from mercenary name ID matching D2Common_10988 (0x6fd7c9e0)', () => {
// Act 1 Rogue (merc01..merc34 / global 3411..3444) -> act 0
expect(resolveHirelingAct(3411, true)).toBe(0)
expect(resolveHirelingAct(3444, true)).toBe(0)
// Act 2 Desert Mercenary (merca201..merca221 / global 1019..1039) -> act 1
expect(resolveHirelingAct(1019, true)).toBe(1)
expect(resolveHirelingAct(1039, true)).toBe(1)
// Act 3 Iron Wolf (merca222..merca241 / global 1040..1059) -> act 2
expect(resolveHirelingAct(1040, true)).toBe(2)
expect(resolveHirelingAct(1059, true)).toBe(2)
expect(resolveHirelingAct(11021, true)).toBe(2) // Jarulf
expect(resolveHirelingAct(0x0421, true)).toBe(2) // 1057 Jarulf legacy
// Act 5 Barbarian (MercX101..MercX167 / global 10835..10901) -> act 4
expect(resolveHirelingAct(10835, true)).toBe(4)
expect(resolveHirelingAct(10901, true)).toBe(4)
})
it('resolves localized mercenary names in English and Chinese per D2Client.dll 0x6faf9783', () => {
// Act 1
expect(resolveHirelingName(3411, 'ENG')).toBe('Aliza')
expect(resolveHirelingName(3411, 'CHI')).toBe('阿里撒')
expect(resolveHirelingName(3444, 'ENG')).toBe('Ryann')
expect(resolveHirelingName(3444, 'CHI')).toBe('萊恩')
// Act 2
expect(resolveHirelingName(1019, 'ENG')).toBe('Hazade')
expect(resolveHirelingName(1019, 'CHI')).toBe('哈撒迪')
expect(resolveHirelingName(1039, 'ENG')).toBe('Vikhyat')
expect(resolveHirelingName(1039, 'CHI')).toBe('維克懷特')
// Act 3
expect(resolveHirelingName(1040, 'ENG')).toBe('Jelani')
expect(resolveHirelingName(1040, 'CHI')).toBe('喬安妮')
expect(resolveHirelingName(11021, 'ENG')).toBe('Jarulf')
expect(resolveHirelingName(0x0421, 'ENG')).toBe('Jarulf')
// Act 5
expect(resolveHirelingName(10835, 'ENG')).toBe('Varaya')
expect(resolveHirelingName(10835, 'CHI')).toBe('伐德哈卡')
expect(resolveHirelingName(10901, 'ENG')).toBe('Theodoric')
expect(resolveHirelingName(10901, 'CHI')).toBe('狄奧多理')
})
it('resolves ability descriptions matching D2Common_10091 (0x6fd7c9c0) in ENG and CHI', () => {
expect(resolveHirelingDesc(1, 'ENG')).toBe('Fire Arrow')
expect(resolveHirelingDesc(1, 'CHI')).toBe('火箭')
expect(resolveHirelingDesc(2, 'ENG')).toBe('Cold Arrow')
expect(resolveHirelingDesc(2, 'CHI')).toBe('冰箭')
expect(resolveHirelingDesc(3, 'ENG')).toBe('Combat')
expect(resolveHirelingDesc(3, 'CHI')).toBe('戰鬥')
expect(resolveHirelingDesc(4, 'ENG')).toBe('Defensive')
expect(resolveHirelingDesc(4, 'CHI')).toBe('防禦')
expect(resolveHirelingDesc(5, 'ENG')).toBe('Offensive')
expect(resolveHirelingDesc(5, 'CHI')).toBe('進攻')
expect(resolveHirelingDesc(6, 'ENG')).toBe('Fire')
expect(resolveHirelingDesc(6, 'CHI')).toBe('火')
expect(resolveHirelingDesc(7, 'ENG')).toBe('Cold')
expect(resolveHirelingDesc(7, 'CHI')).toBe('冷')
expect(resolveHirelingDesc(8, 'ENG')).toBe('Lightning')
expect(resolveHirelingDesc(8, 'CHI')).toBe('閃電')
expect(resolveHirelingDesc(0, 'ENG')).toBe('')
expect(resolveHirelingDesc(0, 'CHI')).toBe('')
})
it('formats candidate lines per D2Client.dll 0x6faf9440', () => {
const eng = formatHirelingCandidateLines('Aliza', 8, 90, 55, 175, 'Fire Arrow', 'ENG')
expect(eng.line1).toBe('Aliza - Lvl: 8 Life: 90 Def: 55 Cost: 175')
expect(eng.line2).toBe(' Fire Arrow')
const chi = formatHirelingCandidateLines('阿里撒', 8, 90, 55, 175, '火箭', 'CHI')
expect(chi.line1).toBe('阿里撒 - 等級: 8 生命: 90 防禦: 55 花費: 175')
expect(chi.line2).toBe(' 火箭')
const blankSpecial = formatHirelingCandidateLines('Varaya', 30, 320, 200, 9200, '', 'ENG')
expect(blankSpecial.line2).toBe(' ')
})
it('computes exact 64-bit LCG stats matching D2Common_10458 across difficulties', () => {
// Act 1 Rogue (Normal, seed 0x1234, playerLevel 10)
const c1 = resolveHirelingCandidate({ nameId: 3411, seed: 0x1234 }, 10, 0, 0, true, 'ENG')
expect(c1.name).toBe('Aliza')
expect(c1.actIndex).toBe(0)
expect(c1.classId).toBe(271)
expect(c1.level).toBe(8)
expect(c1.hp).toBe(90)
expect(c1.defense).toBe(55)
expect(c1.strength).toBe(41)
expect(c1.dexterity).toBe(55)
expect(c1.cost).toBe(175)
expect(c1.hireDesc).toBe('Fire Arrow')
// Act 2 Desert Mercenary (Nightmare, seed 0x5678, playerLevel 50)
const c2 = resolveHirelingCandidate({ nameId: 1019, seed: 0x5678 }, 50, 1, 1, true, 'ENG')
expect(c2.name).toBe('Hazade')
expect(c2.actIndex).toBe(1)
expect(c2.classId).toBe(338)
expect(c2.level).toBeGreaterThanOrEqual(45)
expect(c2.cost).toBeGreaterThan(5000)
expect(['Combat', 'Defensive', 'Offensive']).toContain(c2.hireDesc)
// Act 5 Barbarian (Normal, seed 0x9abc, playerLevel 35)
const c5 = resolveHirelingCandidate({ nameId: 10835, seed: 0x9abc }, 35, 0, 4, true, 'ENG')
expect(c5.name).toBe('Varaya')
expect(c5.actIndex).toBe(4)
expect(c5.classId).toBe(561)
expect(c5.level).toBeGreaterThanOrEqual(30)
expect(c5.cost).toBeGreaterThanOrEqual(9000)
expect(c5.hireDesc).toBe('')
})
it('resolveHirelingCandidates maps candidate list cleanly', () => {
const raw = [
{ nameId: 3411, seed: 0x1111 },
{ nameId: 3412, seed: 0x2222 },
{ nameId: 3413, seed: 0x3333 },
]
const res = resolveHirelingCandidates(raw, 12, 0, 0, true, 'CHI')
expect(res).toHaveLength(3)
expect(res[0]!.nameId).toBe(3411)
expect(res[1]!.nameId).toBe(3412)
expect(res[2]!.nameId).toBe(3413)
expect(res[0]!.line1).toContain('等級:')
})
})

View File

@ -0,0 +1,452 @@
#!/usr/bin/env npx tsx
/**
* Headless Chrome Browser Verification for Issue #542:
* HUD docking input coordinates, interactive clicks, and hover tooltips
* across extended viewport profiles (1024x768, 1068x600, 1280x720).
*/
import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createServer, type ViteDevServer } from 'vite'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT_DIR = resolve(__dirname, '..')
const CHROME_CANDIDATES = [
join(process.env.HOME ?? '', '.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'),
'/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome',
'/usr/bin/google-chrome',
]
function findChromeBinary(): string {
for (const cand of CHROME_CANDIDATES) {
if (cand && existsSync(cand)) return cand
}
throw new Error(`No headless Chrome binary found in candidates: ${CHROME_CANDIDATES.join(', ')}`)
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms))
}
class CdpSession {
private readonly ws: WebSocket
private msgId = 0
private readonly pending = new Map<
number,
{ readonly resolve: (val: any) => void; readonly reject: (err: Error) => void }
>()
readonly consoleErrors: string[] = []
readonly pageExceptions: string[] = []
private constructor(ws: WebSocket) {
this.ws = ws
this.ws.onmessage = (ev) => {
const msg = JSON.parse(String(ev.data))
if (typeof msg.id === 'number') {
const entry = this.pending.get(msg.id)
if (entry) {
this.pending.delete(msg.id)
if (msg.error) {
entry.reject(new Error(msg.error.message || JSON.stringify(msg.error)))
} else {
entry.resolve(msg.result)
}
}
return
}
if (msg.method === 'Runtime.consoleAPICalled') {
const type = msg.params?.type
if (type === 'error') {
const text = (msg.params?.args ?? [])
.map((a: any) => a.value ?? a.description ?? '')
.join(' ')
if (!text.includes('favicon.ico') && !text.includes('404')) {
this.consoleErrors.push(text)
}
}
} else if (msg.method === 'Runtime.exceptionThrown') {
const details = msg.params?.exceptionDetails
const desc =
details?.exception?.description ?? details?.text ?? JSON.stringify(details)
this.pageExceptions.push(String(desc))
}
}
}
static async connect(wsUrl: string): Promise<CdpSession> {
const ws = new WebSocket(wsUrl)
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve()
ws.onerror = (err) => reject(new Error(`CDP WebSocket error: ${String(err)}`))
})
const cdp = new CdpSession(ws)
await cdp.send('Runtime.enable')
await cdp.send('Page.enable')
return cdp
}
send(method: string, params: Record<string, unknown> = {}): Promise<any> {
const id = ++this.msgId
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject })
this.ws.send(JSON.stringify({ id, method, params }))
})
}
async evaluate<T = any>(expression: string): Promise<T> {
const res = await this.send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
})
if (res?.exceptionDetails) {
const desc =
res.exceptionDetails.exception?.description ??
res.exceptionDetails.text ??
JSON.stringify(res.exceptionDetails)
throw new Error(`CDP evaluate exception: ${String(desc)}`)
}
return res?.result?.value as T
}
async captureScreenshot(outPath: string): Promise<number> {
const shot = await this.send('Page.captureScreenshot', { format: 'png' })
const data = shot?.data
if (typeof data !== 'string' || data.length === 0) {
throw new Error('Failed to capture PNG screenshot via CDP')
}
const buf = Buffer.from(data, 'base64')
mkdirSync(dirname(outPath), { recursive: true })
writeFileSync(outPath, buf)
return buf.byteLength
}
async close(): Promise<void> {
try {
this.ws.close()
} catch {
// ignore
}
}
}
interface ProfileAuditSpec {
readonly id: '1024x768' | '1068x600' | '1280x720'
readonly width: number
readonly height: number
readonly barOffsetX: number
readonly barOffsetY: number
readonly panelOffsetY: number
}
const EXTENDED_PROFILES: readonly ProfileAuditSpec[] = [
{ id: '1024x768', width: 1024, height: 768, barOffsetX: 112, barOffsetY: 168, panelOffsetY: 84 },
{ id: '1068x600', width: 1068, height: 600, barOffsetX: 134, barOffsetY: 0, panelOffsetY: 0 },
{ id: '1280x720', width: 1280, height: 720, barOffsetX: 240, barOffsetY: 120, panelOffsetY: 60 },
]
async function run(): Promise<void> {
const chromeBin = findChromeBinary()
const outDir = resolve(ROOT_DIR, 'samples/screenshots-issue-542')
mkdirSync(outDir, { recursive: true })
const fixturePath = resolve(ROOT_DIR, 'tests/fixtures/netproto/02-d2gs-town-walk-chat.d2cap')
if (!existsSync(fixturePath)) {
throw new Error(`Missing fixture at ${fixturePath}`)
}
const d2capData = readFileSync(fixturePath, 'utf8')
let viteServer: ViteDevServer | null = null
const userDataDir = mkdtempSync(join(tmpdir(), 'd2-verify-542-'))
let chromeProc: ReturnType<typeof spawn> | null = null
let cdp: CdpSession | null = null
try {
viteServer = await createServer({
root: ROOT_DIR,
server: {
host: '127.0.0.1',
port: 0,
strictPort: false,
watch: null,
},
logLevel: 'error',
})
await viteServer.listen()
const addr = viteServer.httpServer?.address()
const port = typeof addr === 'object' && addr ? addr.port : 0
if (!port) throw new Error('Failed to start Vite server')
const targetUrl = `http://127.0.0.1:${port}/play.html`
console.log(`[verify-issue-542] Vite server listening at ${targetUrl}`)
chromeProc = spawn(
chromeBin,
[
'--headless=new',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu-sandbox',
'--use-angle=swiftshader',
'--use-gl=angle',
'--enable-unsafe-swiftshader',
'--ignore-gpu-blocklist',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-backgrounding-occluded-windows',
'--no-proxy-server',
'--password-store=basic',
'--disable-extensions',
'--no-first-run',
'--no-default-browser-check',
'--force-device-scale-factor=1',
'--window-size=1280,960',
`--user-data-dir=${userDataDir}`,
'--remote-debugging-port=0',
'about:blank',
],
{ stdio: ['ignore', 'ignore', 'pipe'] },
)
let cdpPort: string | null = null
for (let i = 0; i < 100; i++) {
await sleep(150)
try {
const raw = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8')
const firstLine = raw.trim().split('\n')[0]
if (firstLine) {
cdpPort = firstLine
break
}
} catch {
// wait
}
}
if (!cdpPort) throw new Error('Chrome failed to start DevToolsActivePort')
let pageTarget: { readonly url: string; readonly webSocketDebuggerUrl: string } | null = null
for (let i = 0; i < 120; i++) {
await sleep(150)
try {
const res = await fetch(`http://127.0.0.1:${cdpPort}/json`)
const list = (await res.json()) as any[]
const pages = list.filter((p) => p.type === 'page')
if (pages.length > 0 && pages[0]) {
pageTarget = pages[0]
break
}
} catch {
// wait
}
}
if (!pageTarget) throw new Error('Failed to attach to page target in headless Chrome')
cdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl)
await cdp.send('Page.navigate', { url: targetUrl })
// Wait for play.html boot
let ready = false
for (let i = 0; i < 150; i++) {
const state = await cdp.evaluate<{ ready: boolean } | null>(
`window.__d2Play ? { ready: Boolean(window.__d2Play.ready) } : null`,
)
if (state?.ready) {
ready = true
break
}
await sleep(200)
}
if (!ready) throw new Error('play.html failed to boot ready=true')
console.log('[verify-issue-542] play.html boot ready')
// Enter in-game view via D2Cap replay
await cdp.evaluate(`(() => {
const app = window.__d2Play;
app.replayD2Cap(${JSON.stringify(d2capData)});
})()`)
await sleep(500)
for (const spec of EXTENDED_PROFILES) {
console.log(`\n─── Testing Extended Viewport Profile: ${spec.id} (${spec.width}x${spec.height}) ───`)
// 1. Resize browser viewport via CDP Emulation
await cdp.send('Emulation.setDeviceMetricsOverride', {
width: spec.width,
height: spec.height,
deviceScaleFactor: 1,
mobile: false,
})
// 2. Set viewport profile in settingsStore and trigger stage resize
const layoutAudit = await cdp.evaluate<{
viewportId: string
profileWidth: number
profileHeight: number
barOffsetX: number
barOffsetY: number
panelOffsetY: number
leftDockX: number
rightDockX: number
channelWidth: number
}>(`(() => {
const app = window.__d2Play;
app.settingsStore.update({ viewport: ${JSON.stringify(spec.id)} });
app.renderFrame(performance.now());
const hud = app.hudManager;
const docking = hud.getDockingLayout();
return {
viewportId: hud.viewportProfile.id,
profileWidth: hud.viewportProfile.width,
profileHeight: hud.viewportProfile.height,
barOffsetX: docking.barOffsetX,
barOffsetY: docking.barOffsetY,
panelOffsetY: docking.panelOffsetY,
leftDockX: docking.leftDockX,
rightDockX: docking.rightDockX,
channelWidth: docking.channelWidthLogical,
};
})()`)
console.log(`[${spec.id}] Docking metrics:`, layoutAudit)
if (
layoutAudit.viewportId !== spec.id ||
layoutAudit.profileWidth !== spec.width ||
layoutAudit.profileHeight !== spec.height ||
layoutAudit.barOffsetX !== spec.barOffsetX ||
layoutAudit.barOffsetY !== spec.barOffsetY ||
layoutAudit.panelOffsetY !== spec.panelOffsetY ||
layoutAudit.leftDockX !== 0 ||
layoutAudit.rightDockX !== spec.width - 400
) {
throw new Error(`[${spec.id}] Docking layout metrics mismatch: ${JSON.stringify(layoutAudit)}`)
}
// 3. Test interactive clicks on bottom control bar: Run/Walk toggle & mini-panel buttons
const barClickAudit = await cdp.evaluate<{
runToggled: boolean
charOpened: boolean
invOpened: boolean
beltSlotHovered: boolean
}>(`(() => {
const app = window.__d2Play;
const hud = app.hudManager;
const barOffsetX = ${spec.barOffsetX};
const barOffsetY = ${spec.barOffsetY};
// Run/Walk toggle at center (263 + barOffsetX, 582 + barOffsetY)
const prevRun = hud.isRunning;
hud.handleClick(263 + barOffsetX, 582 + barOffsetY, 0);
const runToggled = hud.isRunning !== prevRun;
// Mini-panel Char button at (325 + barOffsetX, 535 + barOffsetY)
hud.leftPanel = 'none';
hud.handleClick(325 + barOffsetX, 535 + barOffsetY, 0);
const charOpened = hud.leftPanel === 'char';
// Mini-panel Inv button at (352 + barOffsetX, 535 + barOffsetY)
hud.rightPanel = 'none';
hud.handleClick(352 + barOffsetX, 535 + barOffsetY, 0);
const invOpened = hud.rightPanel === 'inv';
// Hover over Belt slot (0, 0) at (435 + barOffsetX, 575 + barOffsetY)
hud.handleMouseMove(435 + barOffsetX, 575 + barOffsetY);
const beltSlotHovered = hud.belt.hoveredSlot !== null && hud.belt.hoveredSlot.col === 0 && hud.belt.hoveredSlot.row === 0;
app.renderFrame(performance.now());
return { runToggled, charOpened, invOpened, beltSlotHovered };
})()`)
console.log(`[${spec.id}] Bar click & hover audit:`, barClickAudit)
if (!barClickAudit.runToggled || !barClickAudit.charOpened || !barClickAudit.invOpened || !barClickAudit.beltSlotHovered) {
throw new Error(`[${spec.id}] Control bar interactive click/hover failed: ${JSON.stringify(barClickAudit)}`)
}
// Capture screenshot with both left dock (char) and right dock (inv) open
const dockShotPath = join(outDir, `play-${spec.id}-hud-docking.png`)
await cdp.captureScreenshot(dockShotPath)
console.log(`[${spec.id}] Saved screenshot: ${dockShotPath}`)
// 4. Test inventory hover item tooltip at docked right coordinates
const invHoverAudit = await cdp.evaluate<{
hoveredItemFound: boolean
hoveredX: number
hoveredY: number
tooltipBoxX: number
tooltipBoxW: number
tooltipClampedInsideViewport: boolean
}>(`(() => {
const app = window.__d2Play;
const hud = app.hudManager;
const deltaRight = ${spec.width} - 400 - 400; // rightDockX - 400
const panelOffsetY = ${spec.panelOffsetY};
hud.rightPanel = 'inv';
// Add a test ring at (col 9, row 0) to test outer boundary clamping
hud.inventory.gridItems = [{
item: {
id: 'test-outer-ring',
code: 'rin',
name: 'Nagelring',
nameZh: '拿各的戒指',
baseNameZh: '戒指',
quality: 'unique',
invWidth: 1,
invHeight: 1,
stats: [
{ text: '+75 to Attack Rating', color: 'blue' },
{ text: 'Magic Damage Reduced by 3', color: 'blue' },
{ text: 'Attacker Takes Damage of 3', color: 'blue' },
{ text: '30% Better Chance of Getting Magic Items', color: 'blue' },
],
allowedSlots: ['ring1', 'ring2'],
},
col: 9,
row: 0,
}];
// INV_GRID_ORIGIN is { x: 418, y: 316, cols: 10, rows: 4, cellPx: 29 }
const hx = 418 + 9 * 29 + 14.5 + deltaRight;
const hy = 316 + 14.5 + panelOffsetY;
hud.handleMouseMove(hx, hy);
app.renderFrame(performance.now());
const hover = hud.inventory.hoveredItem;
const rect = hud.worldPanels.lastTooltipRect;
return {
hoveredItemFound: Boolean(hover && hover.item.id === 'test-outer-ring'),
hoveredX: hover ? hover.x : 0,
hoveredY: hover ? hover.y : 0,
tooltipBoxX: rect ? rect.bx : 0,
tooltipBoxW: rect ? rect.boxW : 0,
tooltipClampedInsideViewport: Boolean(rect && (rect.bx + rect.boxW <= ${spec.width} - 8)),
};
})()`)
console.log(`[${spec.id}] Inventory hover & tooltip audit:`, invHoverAudit)
if (!invHoverAudit.hoveredItemFound || !invHoverAudit.tooltipClampedInsideViewport) {
throw new Error(`[${spec.id}] Inventory item hover & tooltip clamping failed: ${JSON.stringify(invHoverAudit)}`)
}
// Capture screenshot with inventory item hover tooltip visible
const tipShotPath = join(outDir, `play-${spec.id}-inventory-hover-tooltip.png`)
await cdp.captureScreenshot(tipShotPath)
console.log(`[${spec.id}] Saved screenshot: ${tipShotPath}`)
}
console.log('\n=== Headless Browser Verification on play.html across all 3 extended viewports Passed (0 errors) ===')
} finally {
if (cdp) await cdp.close()
if (chromeProc) chromeProc.kill('SIGKILL')
if (viteServer) await viteServer.close()
rmSync(userDataDir, { recursive: true, force: true })
}
}
run().catch((err) => {
console.error('[verify-issue-542] Fatal error:', err)
process.exit(1)
})