fix(hud): resolve monster lifebar review findings, dummy DescStr filter, thief umod, and target lock

This commit is contained in:
troytt 2026-09-28 05:04:38 +00:00
parent b084aaba7f
commit 3a4cb77d06
5 changed files with 444 additions and 32 deletions

View File

@ -343,12 +343,14 @@ export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'norma
const isBoss = flag(table, row, 'boss') const isBoss = flag(table, row, 'boss')
const isUndead = flag(table, row, 'lUndead') || flag(table, row, 'hUndead') const isUndead = flag(table, row, 'lUndead') || flag(table, row, 'hUndead')
const isDemon = flag(table, row, 'demon') const isDemon = flag(table, row, 'demon')
const rawDescStr = cell(table, row, 'DescStr').trim()
const descKey = rawDescStr.toLowerCase() === 'dummy' ? '' : rawDescStr
const kind: MonsterKind = { const kind: MonsterKind = {
id, id,
baseId: cell(table, row, 'BaseId').trim() || id, baseId: cell(table, row, 'BaseId').trim() || id,
nameKey: cell(table, row, 'NameStr').trim(), nameKey: cell(table, row, 'NameStr').trim(),
descKey: cell(table, row, 'DescStr').trim(), descKey,
code: cell(table, row, 'Code').trim(), code: cell(table, row, 'Code').trim(),
monType: cell(table, row, 'MonType').trim(), monType: cell(table, row, 'MonType').trim(),
ai: cell(table, row, 'AI').trim(), ai: cell(table, row, 'AI').trim(),
@ -1310,7 +1312,7 @@ export function monsterStatsOf(
id: kind.id, id: kind.id,
name: kind.nameKey === '' ? kind.id : kind.nameKey, name: kind.nameKey === '' ? kind.id : kind.nameKey,
nameKey: kind.nameKey === '' ? kind.id : kind.nameKey, nameKey: kind.nameKey === '' ? kind.id : kind.nameKey,
...(kind.descKey !== '' ? { descKey: kind.descKey } : {}), ...(kind.descKey !== undefined && kind.descKey !== '' && kind.descKey.toLowerCase() !== 'dummy' ? { descKey: kind.descKey } : {}),
isUndead: kind.isUndead, isUndead: kind.isUndead,
isDemon: kind.isDemon, isDemon: kind.isDemon,
isBoss: kind.boss, isBoss: kind.boss,
@ -1464,7 +1466,9 @@ export function buildSuperUniqueLandmarkSpecs(
} }
} }
const modNameById = new Map<number, string>() const modNameById = new Map<number, string>([
[24, 'thief'],
])
for (const m of CANONICAL_ELITE_MODIFIERS) { for (const m of CANONICAL_ELITE_MODIFIERS) {
modNameById.set(m.id, m.name) modNameById.set(m.id, m.name)
} }
@ -1737,7 +1741,8 @@ export function enrichMonsterStats(
? (superUniques.get(stats.superUniqueId!.toLowerCase()) ?? superUniques.get(stats.superUniqueId!)) ? (superUniques.get(stats.superUniqueId!.toLowerCase()) ?? superUniques.get(stats.superUniqueId!))
: undefined : undefined
const nameKey = stats.nameKey ?? (isSuperUniqueLeader ? (su?.nameKey || stats.name || stats.superUniqueId) : (kind.nameKey || stats.name)) const nameKey = stats.nameKey ?? (isSuperUniqueLeader ? (su?.nameKey || stats.name || stats.superUniqueId) : (kind.nameKey || stats.name))
const descKey = stats.descKey ?? (kind.descKey !== '' ? kind.descKey : undefined) const rawDesc = stats.descKey ?? (kind.descKey !== '' ? kind.descKey : undefined)
const descKey = rawDesc !== undefined && rawDesc !== '' && rawDesc.toLowerCase() !== 'dummy' ? rawDesc : undefined
const mergedRes = stats.resistances const mergedRes = stats.resistances
? { ...kind.resistances, ...stats.resistances } ? { ...kind.resistances, ...stats.resistances }
: kind.resistances : kind.resistances

View File

@ -1051,7 +1051,18 @@ export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo {
...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}), ...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}),
hp: monster.hp, hp: monster.hp,
maxHp: stats.hp, maxHp: stats.hp,
rank: r === 'champion' ? 'champion' : r === 'unique' ? 'unique' : r === 'minion' ? 'minion' : 'normal', rank:
r === 'champion'
? 'champion'
: r === 'unique'
? 'unique'
: (r as string) === 'superunique'
? 'superunique'
: r === 'minion'
? 'minion'
: r === 'boss' || stats.isBoss === true
? 'boss'
: 'normal',
...(stats.isBoss !== undefined ? { isBoss: stats.isBoss } : {}), ...(stats.isBoss !== undefined ? { isBoss: stats.isBoss } : {}),
...(stats.isUndead !== undefined ? { isUndead: stats.isUndead } : {}), ...(stats.isUndead !== undefined ? { isUndead: stats.isUndead } : {}),
...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}), ...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}),
@ -3876,6 +3887,7 @@ export interface SceneMouseControllerOptions {
readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined
readonly isTravelling?: (() => boolean) | undefined readonly isTravelling?: (() => boolean) | undefined
readonly labelsContainer?: HTMLElement | null | undefined readonly labelsContainer?: HTMLElement | null | undefined
readonly getMonsterArtMap?: (() => ReadonlyMap<string, LoadedMonsterArt>) | undefined
} }
/** /**
@ -3981,6 +3993,7 @@ export class SceneMouseController {
readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined
readonly isTravelling?: (() => boolean) | undefined readonly isTravelling?: (() => boolean) | undefined
readonly labelsContainer?: HTMLElement | null | undefined readonly labelsContainer?: HTMLElement | null | undefined
readonly getMonsterArtMap?: (() => ReadonlyMap<string, LoadedMonsterArt>) | undefined
navTarget: { x: number; y: number } | null = null navTarget: { x: number; y: number } | null = null
pendingInteraction: PendingInteraction | null = null pendingInteraction: PendingInteraction | null = null
@ -4017,6 +4030,7 @@ export class SceneMouseController {
this.onEnterEntrance = options.onEnterEntrance this.onEnterEntrance = options.onEnterEntrance
this.isTravelling = options.isTravelling this.isTravelling = options.isTravelling
this.labelsContainer = options.labelsContainer ?? null this.labelsContainer = options.labelsContainer ?? null
this.getMonsterArtMap = options.getMonsterArtMap
;(this.engine as any).dialog = null ;(this.engine as any).dialog = null
if (this.labelsContainer) { if (this.labelsContainer) {
this.updateOverheadLabels() this.updateOverheadLabels()
@ -4025,14 +4039,16 @@ export class SceneMouseController {
/** /**
* Hit-test world coordinates against living, killable, non-town monsters using * Hit-test world coordinates against living, killable, non-town monsters using
* their rendered sprite bounding box (`m.drawBounds` or fallback footprint * their rendered sprite bounding box (`m.drawBounds`, loaded sprite frame 0 from
* `[m.x - 24, m.x + 24] × [m.y - 64, m.y + 8]`), selecting the frontmost * `monsterArtMap` before first render, or rank/boss-aware fallback footprint),
* monster in isometric depth (`m.y` descending, tie-breaking by center distance). * selecting the frontmost monster in isometric depth (`m.y` descending,
* tie-breaking by center distance).
*/ */
findMonsterAtWorld(worldX: number, worldY: number): Monster | null { findMonsterAtWorld(worldX: number, worldY: number): Monster | null {
const runtime = this.getRuntime?.() const runtime = this.getRuntime?.()
if (runtime && isTownLevel(runtime.levelId)) return null if (runtime && isTownLevel(runtime.levelId)) return null
const monsters = this.engine?.world?.monsters ?? [] const monsters = this.engine?.world?.monsters ?? []
const artMap = this.getMonsterArtMap?.()
let bestMonster: Monster | null = null let bestMonster: Monster | null = null
let bestDepthY = -Infinity let bestDepthY = -Infinity
let bestCenterDist = Infinity let bestCenterDist = Infinity
@ -4052,10 +4068,27 @@ export class SceneMouseController {
minY = bounds.y - 4 minY = bounds.y - 4
maxY = bounds.y + bounds.h + 4 maxY = bounds.y + bounds.h + 4
} else { } else {
minX = m.x - 24 const art = artMap?.get(m.stats.id.toLowerCase())
maxX = m.x + 24 const standGroup = art !== undefined ? (art.groups[art.standOffset] ?? art.groups[0]) : undefined
minY = m.y - 64 const frame = standGroup?.[0]
maxY = m.y + 8 if (frame !== undefined && frame.width > 0 && frame.height > 0) {
const drawX = frame.anchorX !== undefined ? m.x + frame.anchorX : m.x - frame.width / 2
const drawY = frame.anchorY !== undefined ? m.y + frame.anchorY : m.y - frame.height + FEET_HEIGHT / 2
minX = drawX - 4
maxX = drawX + frame.width + 4
minY = drawY - 4
maxY = drawY + frame.height + 4
} else if (m.stats?.isBoss === true || m.stats?.rank === 'boss') {
minX = m.x - 36
maxX = m.x + 36
minY = m.y - 96
maxY = m.y + 8
} else {
minX = m.x - 24
maxX = m.x + 24
minY = m.y - 64
maxY = m.y + 8
}
} }
if (worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY) { if (worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY) {
@ -4643,6 +4676,10 @@ export class SceneMouseController {
player.facing = facingOf(worldX - player.x, worldY - player.y) player.facing = facingOf(worldX - player.x, worldY - player.y)
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
this.castSkill(leftSkillId, worldX, worldY) this.castSkill(leftSkillId, worldX, worldY)
if (this.hudManager !== null) {
const activeTarget = this.getActiveTargetMonster()
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
}
} else { } else {
// R3: Left-click click-to-move & entity interaction // R3: Left-click click-to-move & entity interaction
this.handleLeftClickWorld(worldX, worldY) this.handleLeftClickWorld(worldX, worldY)
@ -4657,6 +4694,10 @@ export class SceneMouseController {
player.facing = facingOf(worldX - player.x, worldY - player.y) player.facing = facingOf(worldX - player.x, worldY - player.y)
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64 const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
this.castSkill(rightSkillId, worldX, worldY) this.castSkill(rightSkillId, worldX, worldY)
if (this.hudManager !== null) {
const activeTarget = this.getActiveTargetMonster()
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
}
} else { } else {
this.handleRightClickWorld(worldX, worldY) this.handleRightClickWorld(worldX, worldY)
} }
@ -4784,9 +4825,6 @@ export class SceneMouseController {
if (clickedMonster !== null) { if (clickedMonster !== null) {
this.activeClickType = 'monster' this.activeClickType = 'monster'
this.hoveredMonster = clickedMonster this.hoveredMonster = clickedMonster
if (this.hudManager !== null) {
this.hudManager.targetMonster = monsterToTargetHudInfo(clickedMonster)
}
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y) const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
if (distToPlayer <= range) { if (distToPlayer <= range) {
this.navTarget = null this.navTarget = null
@ -4800,6 +4838,10 @@ export class SceneMouseController {
this.stuckTicks = 0 this.stuckTicks = 0
this.lastPlayerPos = { x: player.x, y: player.y } this.lastPlayerPos = { x: player.x, y: player.y }
} }
if (this.hudManager !== null) {
const activeTarget = this.getActiveTargetMonster()
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
}
return return
} }
@ -5029,9 +5071,6 @@ export class SceneMouseController {
this.activeClickType = 'monster' this.activeClickType = 'monster'
this.lockedMonster = clickedMonster this.lockedMonster = clickedMonster
this.hoveredMonster = clickedMonster this.hoveredMonster = clickedMonster
if (this.hudManager !== null) {
this.hudManager.targetMonster = monsterToTargetHudInfo(clickedMonster)
}
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
const range = getSkillRange(leftSkillId, this.hudManager) const range = getSkillRange(leftSkillId, this.hudManager)
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y) const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
@ -5047,6 +5086,10 @@ export class SceneMouseController {
this.stuckTicks = 0 this.stuckTicks = 0
this.lastPlayerPos = { x: player.x, y: player.y } this.lastPlayerPos = { x: player.x, y: player.y }
} }
if (this.hudManager !== null) {
const activeTarget = this.getActiveTargetMonster()
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
}
return return
} }
@ -5255,7 +5298,34 @@ export class SceneMouseController {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
this.navTarget = { x: worldX, y: worldY } this.navTarget = { x: worldX, y: worldY }
} else if (this.activeClickType === 'monster') { } else if (this.activeClickType === 'monster') {
if (this.pendingInteraction === null && player.cooldown === 0) { const locked = this.lockedMonster
if (locked !== null) {
if (locked.state === 'dead' || locked.hp <= 0 || locked.stats?.killable === false) {
this.lockedMonster = null
this.pendingInteraction = null
this.navTarget = null
} else if (this.pendingInteraction === null) {
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
const range = getSkillRange(leftSkillId, this.hudManager)
const distToPlayer = Math.hypot(locked.x - player.x, locked.y - player.y)
if (distToPlayer <= range) {
if (player.cooldown === 0) {
player.facing = facingOf(locked.x - player.x, locked.y - player.y)
this.castSkill(leftSkillId, locked.x, locked.y)
}
} else {
this.pendingInteraction = { kind: 'monster', monster: locked, skillId: leftSkillId }
this.navTarget = { x: locked.x, y: locked.y }
this.lastDist = distToPlayer
this.stuckTicks = 0
this.lastPlayerPos = { x: player.x, y: player.y }
}
}
if (this.hudManager !== null) {
const activeTarget = this.getActiveTargetMonster()
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
}
} else if (this.pendingInteraction === null && player.cooldown === 0) {
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y) const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47 const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
this.castSkill(leftSkillId, worldX, worldY) this.castSkill(leftSkillId, worldX, worldY)
@ -5394,6 +5464,10 @@ export class SceneMouseController {
this.navTarget = { x: monster.x, y: monster.y } this.navTarget = { x: monster.x, y: monster.y }
} }
} }
if (this.hudManager !== null) {
const activeTarget = this.getActiveTargetMonster()
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
}
} else if (this.pendingInteraction.kind === 'ground-item') { } else if (this.pendingInteraction.kind === 'ground-item') {
const item = this.pendingInteraction.item const item = this.pendingInteraction.item
const maxEdgeReach = Math.max(64, (this.engine.opts as any)?.pickupRadius ?? 64) const maxEdgeReach = Math.max(64, (this.engine.opts as any)?.pickupRadius ?? 64)
@ -8292,6 +8366,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
}, },
isTravelling: () => travelling, isTravelling: () => travelling,
labelsContainer, labelsContainer,
getMonsterArtMap: () => monsterArtMap,
}) })
mouseController.attach() mouseController.attach()
if (groundLabelOverlay !== null) { if (groundLabelOverlay !== null) {

View File

@ -2755,19 +2755,64 @@ export class WorldPanelsHud {
// 4. 2-Line Subtext (`font6` at `y = 55` and `y = 69` / `55`) // 4. 2-Line Subtext (`font6` at `y = 55` and `y = 69` / `55`)
const subtext = buildMonsterHoverSubtext(monster, locale) const subtext = buildMonsterHoverSubtext(monster, locale)
const singleSidePanelOpen = (leftPanelOpen && !rightPanelOpen) || (rightPanelOpen && !leftPanelOpen)
const activeViewportWidth = singleSidePanelOpen ? Math.max(200, canvasWidth - 400) : canvasWidth
// D2Client.dll 0x6facff8d caps modifier accumulation at 0x1e0 (480px), further bounded by open viewport width
const maxLine1Width = Math.min(480, Math.max(120, activeViewportWidth - 8))
const maxLine2Width = Math.max(120, activeViewportWidth - 8)
if (subtext.line1 !== '') { if (subtext.line1 !== '') {
font.drawText(ctx, subtext.line1Colored, layout.centerX, subtext.line1Y, { let line1ToDraw = subtext.line1Colored
if (font.measureText(subtext.line1, 'font6') > maxLine1Width && line1ToDraw.includes(' ')) {
const plainTokens = subtext.line1.split(' ')
const coloredTokens = subtext.line1Colored.split(' ')
let count = plainTokens.length
while (count > 1 && font.measureText(plainTokens.slice(0, count).join(' '), 'font6') > maxLine1Width) {
count -= 1
}
line1ToDraw = coloredTokens.slice(0, count).join(' ')
}
font.drawText(ctx, line1ToDraw, layout.centerX, subtext.line1Y, {
font: 'font6', font: 'font6',
color: 'white', color: 'white',
align: 'center', align: 'center',
}) })
} }
if (subtext.line2 !== '') { if (subtext.line2 !== '') {
font.drawText(ctx, subtext.line2Colored, layout.centerX, subtext.line2Y, { if (font.measureText(subtext.line2, 'font6') > maxLine2Width && subtext.line2Colored.includes('ÿc0, ')) {
font: 'font6', const plainParts = subtext.line2.split(', ')
color: 'white', const coloredParts = subtext.line2Colored.split('ÿc0, ')
align: 'center', const wrappedColoredLines: string[] = []
}) let curPlain = ''
let curColored = ''
for (let i = 0; i < coloredParts.length; i += 1) {
const pPart = plainParts[i] ?? ''
const cPart = coloredParts[i] ?? ''
const nextPlain = curPlain ? `${curPlain}, ${pPart}` : pPart
if (curPlain && font.measureText(nextPlain, 'font6') > maxLine2Width) {
wrappedColoredLines.push(curColored)
curPlain = pPart
curColored = cPart
} else {
curPlain = nextPlain
curColored = curColored ? `${curColored}ÿc0, ${cPart}` : cPart
}
}
if (curColored) wrappedColoredLines.push(curColored)
for (let i = 0; i < wrappedColoredLines.length; i += 1) {
font.drawText(ctx, wrappedColoredLines[i]!, layout.centerX, subtext.line2Y + i * 14, {
font: 'font6',
color: 'white',
align: 'center',
})
}
} else {
font.drawText(ctx, subtext.line2Colored, layout.centerX, subtext.line2Y, {
font: 'font6',
color: 'white',
align: 'center',
})
}
} }
} }
@ -3220,6 +3265,64 @@ export const CANONICAL_IMMUNITY_DESCRIPTORS: readonly {
{ key: 'poison', tblKey: 'strScaryMonDesc4', colorCode: 'ÿc2', fallbackEn: 'Immune to Poison' }, { key: 'poison', tblKey: 'strScaryMonDesc4', colorCode: 'ÿc2', fallbackEn: 'Immune to Poison' },
] ]
/**
* Complete `wMonUModStrTable` (`D2Client.dll` `.data:0x6FBA9F88`) lookup map
* covering all 14 displayed `MonUMod.txt` IDs (`5..9, 17, 18, 24..30`),
* canonical names, `.tbl` keys (`3205..3218`), and `MonUMod.txt` short names.
*/
const MONSTER_UMOD_TBL_KEYS: Readonly<Record<string, { readonly tblKey: string; readonly label: string }>> = {
'5': { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
strong: { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
uniquextrastrong: { tblKey: 'uniquextrastrong', label: 'Extra Strong' },
'6': { tblKey: 'uniqueextrafast', label: 'Extra Fast' },
fast: { tblKey: 'uniqueextrafast', label: 'Extra Fast' },
uniqueextrafast: { tblKey: 'uniqueextrafast', label: 'Extra Fast' },
'7': { tblKey: 'uniquecursed', label: 'Cursed' },
cursed: { tblKey: 'uniquecursed', label: 'Cursed' },
curse: { tblKey: 'uniquecursed', label: 'Cursed' },
uniquecursed: { tblKey: 'uniquecursed', label: 'Cursed' },
'8': { tblKey: 'uniquemagicresistance', label: 'Magic Resistant' },
magicresistant: { tblKey: 'uniquemagicresistance', label: 'Magic Resistant' },
resist: { tblKey: 'uniquemagicresistance', label: 'Magic Resistant' },
uniquemagicresistance: { tblKey: 'uniquemagicresistance', label: 'Magic Resistant' },
'9': { tblKey: 'uniquefireenchanted', label: 'Fire Enchanted' },
fireenchant: { tblKey: 'uniquefireenchanted', label: 'Fire Enchanted' },
fire: { tblKey: 'uniquefireenchanted', label: 'Fire Enchanted' },
uniquefireenchanted: { tblKey: 'uniquefireenchanted', label: 'Fire Enchanted' },
'17': { tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted' },
lightenchant: { tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted' },
lightning: { tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted' },
monsteruniqueprop2: { tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted' },
'18': { tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted' },
coldenchant: { tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted' },
cold: { tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted' },
monsteruniqueprop1: { tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted' },
'24': { tblKey: 'monsteruniqueprop8', label: 'Thief' },
thief: { tblKey: 'monsteruniqueprop8', label: 'Thief' },
monsteruniqueprop8: { tblKey: 'monsteruniqueprop8', label: 'Thief' },
'25': { tblKey: 'monsteruniqueprop3', label: 'Mana Burn' },
manahit: { tblKey: 'monsteruniqueprop3', label: 'Mana Burn' },
mana: { tblKey: 'monsteruniqueprop3', label: 'Mana Burn' },
monsteruniqueprop3: { tblKey: 'monsteruniqueprop3', label: 'Mana Burn' },
'26': { tblKey: 'monsteruniqueprop5', label: 'Teleportation' },
teleport: { tblKey: 'monsteruniqueprop5', label: 'Teleportation' },
monsteruniqueprop5: { tblKey: 'monsteruniqueprop5', label: 'Teleportation' },
'27': { tblKey: 'monsteruniqueprop4', label: 'Spectral Hit' },
spectralhit: { tblKey: 'monsteruniqueprop4', label: 'Spectral Hit' },
spectral: { tblKey: 'monsteruniqueprop4', label: 'Spectral Hit' },
monsteruniqueprop4: { tblKey: 'monsteruniqueprop4', label: 'Spectral Hit' },
'28': { tblKey: 'monsteruniqueprop6', label: 'Stone Skin' },
stoneskin: { tblKey: 'monsteruniqueprop6', label: 'Stone Skin' },
monsteruniqueprop6: { tblKey: 'monsteruniqueprop6', label: 'Stone Skin' },
'29': { tblKey: 'monsteruniqueprop7', label: 'Multiple Shots' },
multishot: { tblKey: 'monsteruniqueprop7', label: 'Multiple Shots' },
multi: { tblKey: 'monsteruniqueprop7', label: 'Multiple Shots' },
monsteruniqueprop7: { tblKey: 'monsteruniqueprop7', label: 'Multiple Shots' },
'30': { tblKey: 'monsteruniqueprop9', label: 'Aura Enchanted' },
aura: { tblKey: 'monsteruniqueprop9', label: 'Aura Enchanted' },
monsteruniqueprop9: { tblKey: 'monsteruniqueprop9', label: 'Aura Enchanted' },
}
/** /**
* Build the 2-line monster hover subtext below the lifebar plaque * Build the 2-line monster hover subtext below the lifebar plaque
* per `D2Client.dll` `sub_6FB70850` (`0x6FB70850`), `sub_6FB6FCA0` (`0x6FB6FCA0`), * per `D2Client.dll` `sub_6FB70850` (`0x6FB70850`), `sub_6FB6FCA0` (`0x6FB6FCA0`),
@ -3243,8 +3346,10 @@ export function buildMonsterHoverSubtext(
? (lookupMonsterTblString('DemonID', locale) ?? 'Demon') ? (lookupMonsterTblString('DemonID', locale) ?? 'Demon')
: '' : ''
const rawDesc = target.descKey ?? target.descStr ?? '' const rawDesc = (target.descKey ?? target.descStr ?? '').trim()
const descText = rawDesc ? (lookupMonsterTblString(rawDesc, locale) ?? rawDesc) : '' const isDummyDesc = rawDesc.toLowerCase() === 'dummy'
const resolvedDesc = rawDesc && !isDummyDesc ? (lookupMonsterTblString(rawDesc, locale) ?? rawDesc) : ''
const descText = resolvedDesc.toLowerCase() === 'dummy' ? '' : resolvedDesc
let abilityPlain = '' let abilityPlain = ''
let abilityColorPrefix = '' let abilityColorPrefix = ''
@ -3255,11 +3360,13 @@ export function buildMonsterHoverSubtext(
? target.modifiers ? target.modifiers
: (target.affixes ?? []) : (target.affixes ?? [])
for (const mod of mods) { for (const mod of mods) {
const entry = CANONICAL_ELITE_MODIFIERS.find(m => m.name === mod || m.tblKey === mod) const normalizedMod = String(mod).trim().toLowerCase()
const label = entry const umodEntry = MONSTER_UMOD_TBL_KEYS[normalizedMod]
? (lookupMonsterTblString(entry.tblKey, locale) ?? entry.label) ?? CANONICAL_ELITE_MODIFIERS.find(m => m.name === normalizedMod || m.tblKey === normalizedMod)
const label = umodEntry
? (lookupMonsterTblString(umodEntry.tblKey, locale) ?? umodEntry.label)
: (lookupMonsterTblString(mod, locale) ?? mod) : (lookupMonsterTblString(mod, locale) ?? mod)
if (label) modParts.push(label) if (label && label.toLowerCase() !== 'dummy') modParts.push(label)
} }
if (descText) { if (descText) {
modParts.push(descText) modParts.push(descText)
@ -3270,7 +3377,9 @@ export function buildMonsterHoverSubtext(
abilityPlain = lookupMonsterTblString('minion', locale) ?? 'Minion' abilityPlain = lookupMonsterTblString('minion', locale) ?? 'Minion'
abilityColorPrefix = '' abilityColorPrefix = ''
} else { } else {
abilityPlain = descText || (target.subType ?? '') const rawSubType = (target.subType ?? '').trim()
const cleanSubType = rawSubType.toLowerCase() === 'dummy' ? '' : rawSubType
abilityPlain = descText || cleanSubType
if (target.rank === 'boss' || target.isBoss) { if (target.rank === 'boss' || target.isBoss) {
abilityColorPrefix = 'ÿc4' abilityColorPrefix = 'ÿc4'
} else if (target.rank === 'champion') { } else if (target.rank === 'champion') {

View File

@ -801,5 +801,194 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () =
modifiers: ['strong', 'cursed'], modifiers: ['strong', 'cursed'],
}) })
}) })
it('filters "dummy" DescStr (0x6fb70c0b) and resolves wMonUModStrTable[24] ("thief") plus short/numeric MonUMod keys', () => {
const dummyMonster: MonsterTargetHudInfo = {
name: 'Skeleton',
nameKey: 'Skeleton',
descKey: 'dummy',
subType: 'Dummy',
hp: 30,
maxHp: 30,
rank: 'normal',
isUndead: true,
isDemon: false,
}
const dummySub = buildMonsterHoverSubtext(dummyMonster, 'en')
expect(dummySub.line1).toBe('Undead')
expect(dummySub.line1Colored).toBe('Undead')
const thiefUnique: MonsterTargetHudInfo = {
name: 'Bone Ash',
nameKey: 'Bone Ash',
descKey: 'dummy',
hp: 150,
maxHp: 150,
rank: 'unique',
isUndead: true,
modifiers: ['thief', 'curse', 'resist', '18'],
}
const thiefEn = buildMonsterHoverSubtext(thiefUnique, 'en')
expect(thiefEn.line1).toBe('Undead Thief Cursed Magic Resistant Cold Enchanted')
const thiefZh = buildMonsterHoverSubtext(thiefUnique, 'zh')
expect(thiefZh.line1).toBe('不死的 小偷 特別詛咒 魔法抵抗 冰冷強化')
})
it('wraps multi-immunity Line 2 and clamps long modifier Line 1 within the open half-viewport when a side panel is open', () => {
const hud = new WorldPanelsHud()
const font = createMockFont(8)
const ctx = {
fillStyle: '',
fillRect: vi.fn(),
} as unknown as CanvasRenderingContext2D
const sixImmuneBoss: MonsterTargetHudInfo = {
name: 'Uber Boss',
nameKey: 'Diablo',
hp: 500,
maxHp: 500,
rank: 'unique',
isDemon: true,
modifiers: ['strong', 'fast', 'cursed', 'magicresistant', 'fireenchant', 'lightenchant', 'coldenchant', 'stoneskin'],
resistances: {
physical: 100,
magic: 100,
fire: 100,
lightning: 100,
cold: 100,
poison: 100,
},
}
hud.drawMonsterTargetBar(ctx, sixImmuneBoss, font, {
leftPanelOpen: true,
rightPanelOpen: false,
canvasWidth: 800,
locale: 'en',
})
// Active viewport is [400, 800] (width 400, maxSubtextWidth = 392)
// Title at y=32, clamped Line 1 at y=55, wrapped Line 2 across y=69, 83, 97
const line1Call = font.calls.find(c => c.y === 55)
expect(line1Call).toBeDefined()
const plainLine1 = line1Call!.text.replace(/ÿc[0-9a-z]/g, '')
expect(font.measureText(plainLine1, 'font6')).toBeLessThanOrEqual(392)
const immunityCalls = font.calls.filter(c => c.y >= 69)
expect(immunityCalls.length).toBeGreaterThanOrEqual(2)
expect(immunityCalls[0]!.y).toBe(69)
expect(immunityCalls[1]!.y).toBe(83)
for (const call of immunityCalls) {
const plain = call.text.replace(/ÿc[0-9a-z]/g, '')
expect(font.measureText(plain, 'font6')).toBeLessThanOrEqual(392)
}
})
it('preserves boss and superunique ranks in monsterToTargetHudInfo and uses getMonsterArtMap or boss fallback bounds before first render', () => {
const { engine, canvas, camera, hudManager, controller } = createHoverTestScene()
// Boss monster without drawBounds (before first render)
const bossMonster = engine.world.monsters[0]!
bossMonster.drawBounds = undefined
;(bossMonster as any).stats = {
...bossMonster.stats,
id: 'andariel',
name: 'Andariel',
nameKey: 'Andariel',
rank: 'boss',
isBoss: true,
isDemon: true,
}
const bossHud = monsterToTargetHudInfo(bossMonster)
expect(bossHud.rank).toBe('boss')
expect(bossHud.isBoss).toBe(true)
// 1. Without monsterArtMap, boss fallback bounds extend up to y - 96 (normal only extends to y - 64)
const controllerNoArt = new SceneMouseController({
canvas,
engine,
camera,
input: controller.input,
getRuntime: controller.getRuntime,
hudManager,
waypointNetwork: { activate: () => false } as any,
status: { textContent: '', hidden: false } as unknown as HTMLElement,
})
expect(controllerNoArt.findMonsterAtWorld(bossMonster.x, bossMonster.y - 85)).toBe(bossMonster)
// 2. With getMonsterArtMap, pre-render hit-testing uses frame 0 dimensions from monsterArtMap
const fakeArtMap = new Map([
[
'andariel',
{
token: 'AN',
handle: {} as any,
groups: [[{ x: 0, y: 0, width: 120, height: 150, anchorX: -60, anchorY: -140 }]],
directions: 8,
walkOffset: 0,
standOffset: 0,
walkFrames: 1,
standFrames: 1,
layerFailures: [],
},
],
])
const controllerWithArt = new SceneMouseController({
canvas,
engine,
camera,
input: controller.input,
getRuntime: controller.getRuntime,
hudManager,
waypointNetwork: { activate: () => false } as any,
status: { textContent: '', hidden: false } as unknown as HTMLElement,
getMonsterArtMap: () => fakeArtMap as any,
})
expect(controllerWithArt.findMonsterAtWorld(bossMonster.x - 55, bossMonster.y - 130)).toBe(bossMonster)
})
it('keeps attacking lockedMonster in tick() when cursor moves away during left-click hold and updates hudManager.targetMonster HP immediately after castSkill', () => {
const { canvas, engine, hudManager, controller } = createHoverTestScene()
const monster0 = engine.world.monsters[0]!
// Place Monster 0 within melee reach (distance 30 <= reach)
monster0.x = 530
monster0.y = 500
monster0.drawBounds = { x: 510, y: 450, w: 40, h: 60 }
const castSpy = vi.spyOn(controller, 'castSkill').mockImplementation((_skillId, tx, ty) => {
if (tx === monster0.x && ty === monster0.y) {
monster0.hp = Math.max(0, monster0.hp - 7)
if (monster0.hp === 0) monster0.state = 'dead'
}
return true
})
// Left-click Monster 0 at screen (430, 296) -> world (530, 480)
canvas.dispatchEvent(
new SyntheticPointerEvent('pointerdown', { clientX: 430, clientY: 296, button: 0, buttons: 1 }),
)
expect(controller.lockedMonster).toBe(monster0)
// HP on HUD must immediately reflect post-hit HP (20 - 7 = 13), not stale 20!
expect(hudManager.targetMonster?.hp).toBe(13)
// Drag cursor far away to screen (100, 100) while holding left button
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 100, clientY: 100, button: 0, buttons: 1 }),
)
engine.world.player.cooldown = 0
controller.tick()
// tick() must attack lockedMonster at (530, 500), NOT cursor at (200, 184)!
expect(castSpy).toHaveBeenLastCalledWith(expect.any(Number), 530, 500)
expect(hudManager.targetMonster?.hp).toBe(6)
// Third swing kills Monster 0 -> lockedMonster and hudManager.targetMonster clear immediately
engine.world.player.cooldown = 0
controller.tick()
expect(monster0.hp).toBe(0)
expect(controller.lockedMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
})
}) })
}) })

View File

@ -208,6 +208,7 @@ describe('readMonsterKinds', () => {
id: 'fallen1', id: 'fallen1',
baseId: 'fallen1', baseId: 'fallen1',
nameKey: 'Fallen', nameKey: 'Fallen',
descKey: '',
code: 'FA', code: 'FA',
monType: 'fallen', monType: 'fallen',
ai: 'Fallen', ai: 'Fallen',
@ -218,7 +219,10 @@ describe('readMonsterKinds', () => {
npc: false, npc: false,
interact: false, interact: false,
inTown: false, inTown: false,
isUndead: false,
isDemon: false,
boss: false, boss: false,
isBoss: false,
killable: true, killable: true,
rarity: 2, rarity: 2,
minGroup: 2, minGroup: 2,
@ -242,6 +246,36 @@ describe('readMonsterKinds', () => {
}) })
}) })
it('parses DescStr (filtering out "dummy" sentinel per 0x6fb70c0b) and race/boss flags (lUndead, hUndead, demon, boss)', () => {
const customTable = table(
['Id', 'BaseId', 'NameStr', 'DescStr', 'lUndead', 'hUndead', 'demon', 'boss', 'enabled', 'killable'],
[
['fallenshaman1', 'fallenshaman1', 'Fallen Shaman', 'FallenShamanDesc', '', '', '1', '', '1', '1'],
['skeleton1', 'skeleton1', 'Skeleton', 'dummy', '1', '', '', '', '1', '1'],
['lich1', 'lich1', 'Lich', 'Dummy', '', '1', '', '1', '1', '1'],
],
)
const parsed = readMonsterKinds(customTable)
expect(parsed.get('fallenshaman1')).toMatchObject({
descKey: 'FallenShamanDesc',
isDemon: true,
isUndead: false,
isBoss: false,
})
expect(parsed.get('skeleton1')).toMatchObject({
descKey: '',
isUndead: true,
isDemon: false,
isBoss: false,
})
expect(parsed.get('lich1')).toMatchObject({
descKey: '',
isUndead: true,
isDemon: false,
isBoss: true,
})
})
it('treats a blank flag as false and a "1" as true', () => { it('treats a blank flag as false and a "1" as true', () => {
expect(kinds.get('quillrat1')?.isMelee).toBe(false) expect(kinds.get('quillrat1')?.isMelee).toBe(false)
expect(kinds.get('quillrat1')?.ranged).toBe(true) expect(kinds.get('quillrat1')?.ranged).toBe(true)