fix(hud): resolve monster lifebar review findings, dummy DescStr filter, thief umod, and target lock
This commit is contained in:
parent
b084aaba7f
commit
3a4cb77d06
|
|
@ -343,12 +343,14 @@ export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'norma
|
|||
const isBoss = flag(table, row, 'boss')
|
||||
const isUndead = flag(table, row, 'lUndead') || flag(table, row, 'hUndead')
|
||||
const isDemon = flag(table, row, 'demon')
|
||||
const rawDescStr = cell(table, row, 'DescStr').trim()
|
||||
const descKey = rawDescStr.toLowerCase() === 'dummy' ? '' : rawDescStr
|
||||
|
||||
const kind: MonsterKind = {
|
||||
id,
|
||||
baseId: cell(table, row, 'BaseId').trim() || id,
|
||||
nameKey: cell(table, row, 'NameStr').trim(),
|
||||
descKey: cell(table, row, 'DescStr').trim(),
|
||||
descKey,
|
||||
code: cell(table, row, 'Code').trim(),
|
||||
monType: cell(table, row, 'MonType').trim(),
|
||||
ai: cell(table, row, 'AI').trim(),
|
||||
|
|
@ -1310,7 +1312,7 @@ export function monsterStatsOf(
|
|||
id: kind.id,
|
||||
name: 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,
|
||||
isDemon: kind.isDemon,
|
||||
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) {
|
||||
modNameById.set(m.id, m.name)
|
||||
}
|
||||
|
|
@ -1737,7 +1741,8 @@ export function enrichMonsterStats(
|
|||
? (superUniques.get(stats.superUniqueId!.toLowerCase()) ?? superUniques.get(stats.superUniqueId!))
|
||||
: undefined
|
||||
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
|
||||
? { ...kind.resistances, ...stats.resistances }
|
||||
: kind.resistances
|
||||
|
|
|
|||
|
|
@ -1051,7 +1051,18 @@ export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo {
|
|||
...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}),
|
||||
hp: monster.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.isUndead !== undefined ? { isUndead: stats.isUndead } : {}),
|
||||
...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}),
|
||||
|
|
@ -3876,6 +3887,7 @@ export interface SceneMouseControllerOptions {
|
|||
readonly onEnterEntrance?: ((entrance: SceneEntrance) => void) | undefined
|
||||
readonly isTravelling?: (() => boolean) | 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 isTravelling?: (() => boolean) | undefined
|
||||
readonly labelsContainer?: HTMLElement | null | undefined
|
||||
readonly getMonsterArtMap?: (() => ReadonlyMap<string, LoadedMonsterArt>) | undefined
|
||||
|
||||
navTarget: { x: number; y: number } | null = null
|
||||
pendingInteraction: PendingInteraction | null = null
|
||||
|
|
@ -4017,6 +4030,7 @@ export class SceneMouseController {
|
|||
this.onEnterEntrance = options.onEnterEntrance
|
||||
this.isTravelling = options.isTravelling
|
||||
this.labelsContainer = options.labelsContainer ?? null
|
||||
this.getMonsterArtMap = options.getMonsterArtMap
|
||||
;(this.engine as any).dialog = null
|
||||
if (this.labelsContainer) {
|
||||
this.updateOverheadLabels()
|
||||
|
|
@ -4025,14 +4039,16 @@ export class SceneMouseController {
|
|||
|
||||
/**
|
||||
* Hit-test world coordinates against living, killable, non-town monsters using
|
||||
* their rendered sprite bounding box (`m.drawBounds` or fallback footprint
|
||||
* `[m.x - 24, m.x + 24] × [m.y - 64, m.y + 8]`), selecting the frontmost
|
||||
* monster in isometric depth (`m.y` descending, tie-breaking by center distance).
|
||||
* their rendered sprite bounding box (`m.drawBounds`, loaded sprite frame 0 from
|
||||
* `monsterArtMap` before first render, or rank/boss-aware fallback footprint),
|
||||
* selecting the frontmost monster in isometric depth (`m.y` descending,
|
||||
* tie-breaking by center distance).
|
||||
*/
|
||||
findMonsterAtWorld(worldX: number, worldY: number): Monster | null {
|
||||
const runtime = this.getRuntime?.()
|
||||
if (runtime && isTownLevel(runtime.levelId)) return null
|
||||
const monsters = this.engine?.world?.monsters ?? []
|
||||
const artMap = this.getMonsterArtMap?.()
|
||||
let bestMonster: Monster | null = null
|
||||
let bestDepthY = -Infinity
|
||||
let bestCenterDist = Infinity
|
||||
|
|
@ -4052,10 +4068,27 @@ export class SceneMouseController {
|
|||
minY = bounds.y - 4
|
||||
maxY = bounds.y + bounds.h + 4
|
||||
} else {
|
||||
minX = m.x - 24
|
||||
maxX = m.x + 24
|
||||
minY = m.y - 64
|
||||
maxY = m.y + 8
|
||||
const art = artMap?.get(m.stats.id.toLowerCase())
|
||||
const standGroup = art !== undefined ? (art.groups[art.standOffset] ?? art.groups[0]) : undefined
|
||||
const frame = standGroup?.[0]
|
||||
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) {
|
||||
|
|
@ -4643,6 +4676,10 @@ export class SceneMouseController {
|
|||
player.facing = facingOf(worldX - player.x, worldY - player.y)
|
||||
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
|
||||
this.castSkill(leftSkillId, worldX, worldY)
|
||||
if (this.hudManager !== null) {
|
||||
const activeTarget = this.getActiveTargetMonster()
|
||||
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
|
||||
}
|
||||
} else {
|
||||
// R3: Left-click click-to-move & entity interaction
|
||||
this.handleLeftClickWorld(worldX, worldY)
|
||||
|
|
@ -4657,6 +4694,10 @@ export class SceneMouseController {
|
|||
player.facing = facingOf(worldX - player.x, worldY - player.y)
|
||||
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
|
||||
this.castSkill(rightSkillId, worldX, worldY)
|
||||
if (this.hudManager !== null) {
|
||||
const activeTarget = this.getActiveTargetMonster()
|
||||
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
|
||||
}
|
||||
} else {
|
||||
this.handleRightClickWorld(worldX, worldY)
|
||||
}
|
||||
|
|
@ -4784,9 +4825,6 @@ export class SceneMouseController {
|
|||
if (clickedMonster !== null) {
|
||||
this.activeClickType = 'monster'
|
||||
this.hoveredMonster = clickedMonster
|
||||
if (this.hudManager !== null) {
|
||||
this.hudManager.targetMonster = monsterToTargetHudInfo(clickedMonster)
|
||||
}
|
||||
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
|
||||
if (distToPlayer <= range) {
|
||||
this.navTarget = null
|
||||
|
|
@ -4800,6 +4838,10 @@ export class SceneMouseController {
|
|||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -5029,9 +5071,6 @@ export class SceneMouseController {
|
|||
this.activeClickType = 'monster'
|
||||
this.lockedMonster = clickedMonster
|
||||
this.hoveredMonster = clickedMonster
|
||||
if (this.hudManager !== null) {
|
||||
this.hudManager.targetMonster = monsterToTargetHudInfo(clickedMonster)
|
||||
}
|
||||
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
|
||||
const range = getSkillRange(leftSkillId, this.hudManager)
|
||||
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
|
||||
|
|
@ -5047,6 +5086,10 @@ export class SceneMouseController {
|
|||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -5255,7 +5298,34 @@ export class SceneMouseController {
|
|||
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
|
||||
this.navTarget = { x: worldX, y: worldY }
|
||||
} 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 leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
|
||||
this.castSkill(leftSkillId, worldX, worldY)
|
||||
|
|
@ -5394,6 +5464,10 @@ export class SceneMouseController {
|
|||
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') {
|
||||
const item = this.pendingInteraction.item
|
||||
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,
|
||||
labelsContainer,
|
||||
getMonsterArtMap: () => monsterArtMap,
|
||||
})
|
||||
mouseController.attach()
|
||||
if (groundLabelOverlay !== null) {
|
||||
|
|
|
|||
|
|
@ -2755,19 +2755,64 @@ export class WorldPanelsHud {
|
|||
|
||||
// 4. 2-Line Subtext (`font6` at `y = 55` and `y = 69` / `55`)
|
||||
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 !== '') {
|
||||
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',
|
||||
color: 'white',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
if (subtext.line2 !== '') {
|
||||
font.drawText(ctx, subtext.line2Colored, layout.centerX, subtext.line2Y, {
|
||||
font: 'font6',
|
||||
color: 'white',
|
||||
align: 'center',
|
||||
})
|
||||
if (font.measureText(subtext.line2, 'font6') > maxLine2Width && subtext.line2Colored.includes('ÿc0, ')) {
|
||||
const plainParts = subtext.line2.split(', ')
|
||||
const coloredParts = subtext.line2Colored.split('ÿc0, ')
|
||||
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' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 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
|
||||
* per `D2Client.dll` `sub_6FB70850` (`0x6FB70850`), `sub_6FB6FCA0` (`0x6FB6FCA0`),
|
||||
|
|
@ -3243,8 +3346,10 @@ export function buildMonsterHoverSubtext(
|
|||
? (lookupMonsterTblString('DemonID', locale) ?? 'Demon')
|
||||
: ''
|
||||
|
||||
const rawDesc = target.descKey ?? target.descStr ?? ''
|
||||
const descText = rawDesc ? (lookupMonsterTblString(rawDesc, locale) ?? rawDesc) : ''
|
||||
const rawDesc = (target.descKey ?? target.descStr ?? '').trim()
|
||||
const isDummyDesc = rawDesc.toLowerCase() === 'dummy'
|
||||
const resolvedDesc = rawDesc && !isDummyDesc ? (lookupMonsterTblString(rawDesc, locale) ?? rawDesc) : ''
|
||||
const descText = resolvedDesc.toLowerCase() === 'dummy' ? '' : resolvedDesc
|
||||
|
||||
let abilityPlain = ''
|
||||
let abilityColorPrefix = ''
|
||||
|
|
@ -3255,11 +3360,13 @@ export function buildMonsterHoverSubtext(
|
|||
? target.modifiers
|
||||
: (target.affixes ?? [])
|
||||
for (const mod of mods) {
|
||||
const entry = CANONICAL_ELITE_MODIFIERS.find(m => m.name === mod || m.tblKey === mod)
|
||||
const label = entry
|
||||
? (lookupMonsterTblString(entry.tblKey, locale) ?? entry.label)
|
||||
const normalizedMod = String(mod).trim().toLowerCase()
|
||||
const umodEntry = MONSTER_UMOD_TBL_KEYS[normalizedMod]
|
||||
?? CANONICAL_ELITE_MODIFIERS.find(m => m.name === normalizedMod || m.tblKey === normalizedMod)
|
||||
const label = umodEntry
|
||||
? (lookupMonsterTblString(umodEntry.tblKey, locale) ?? umodEntry.label)
|
||||
: (lookupMonsterTblString(mod, locale) ?? mod)
|
||||
if (label) modParts.push(label)
|
||||
if (label && label.toLowerCase() !== 'dummy') modParts.push(label)
|
||||
}
|
||||
if (descText) {
|
||||
modParts.push(descText)
|
||||
|
|
@ -3270,7 +3377,9 @@ export function buildMonsterHoverSubtext(
|
|||
abilityPlain = lookupMonsterTblString('minion', locale) ?? 'Minion'
|
||||
abilityColorPrefix = ''
|
||||
} 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) {
|
||||
abilityColorPrefix = 'ÿc4'
|
||||
} else if (target.rank === 'champion') {
|
||||
|
|
|
|||
|
|
@ -801,5 +801,194 @@ describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () =
|
|||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -208,6 +208,7 @@ describe('readMonsterKinds', () => {
|
|||
id: 'fallen1',
|
||||
baseId: 'fallen1',
|
||||
nameKey: 'Fallen',
|
||||
descKey: '',
|
||||
code: 'FA',
|
||||
monType: 'fallen',
|
||||
ai: 'Fallen',
|
||||
|
|
@ -218,7 +219,10 @@ describe('readMonsterKinds', () => {
|
|||
npc: false,
|
||||
interact: false,
|
||||
inTown: false,
|
||||
isUndead: false,
|
||||
isDemon: false,
|
||||
boss: false,
|
||||
isBoss: false,
|
||||
killable: true,
|
||||
rarity: 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', () => {
|
||||
expect(kinds.get('quillrat1')?.isMelee).toBe(false)
|
||||
expect(kinds.get('quillrat1')?.ranged).toBe(true)
|
||||
|
|
|
|||
Loading…
Reference in New Issue