From 3a4cb77d0614abead76738f7a401499ca687b2da Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 28 Sep 2026 05:04:38 +0000 Subject: [PATCH] fix(hud): resolve monster lifebar review findings, dummy DescStr filter, thief umod, and target lock --- src/game/monsters.ts | 13 ++- src/scene/act-scene.ts | 105 ++++++++++++++--- src/ui/world-panels.ts | 135 +++++++++++++++++++--- tests/monster-target-bar.test.ts | 189 +++++++++++++++++++++++++++++++ tests/monsters.test.ts | 34 ++++++ 5 files changed, 444 insertions(+), 32 deletions(-) diff --git a/src/game/monsters.ts b/src/game/monsters.ts index d5a12eb..0b03841 100644 --- a/src/game/monsters.ts +++ b/src/game/monsters.ts @@ -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() + const modNameById = new Map([ + [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 diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index e35e557..aece955 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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) | 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) | 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) { diff --git a/src/ui/world-panels.ts b/src/ui/world-panels.ts index 626eddf..13188dd 100644 --- a/src/ui/world-panels.ts +++ b/src/ui/world-panels.ts @@ -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> = { + '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') { diff --git a/tests/monster-target-bar.test.ts b/tests/monster-target-bar.test.ts index 2bf673b..b01ba56 100644 --- a/tests/monster-target-bar.test.ts +++ b/tests/monster-target-bar.test.ts @@ -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() + }) }) }) diff --git a/tests/monsters.test.ts b/tests/monsters.test.ts index 826668e..503e641 100644 --- a/tests/monsters.test.ts +++ b/tests/monsters.test.ts @@ -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)