feat(hud): implement authentic 1.13c monster hover lifebar and 2-line subtext tooltip

This commit is contained in:
troytt 2026-09-28 04:18:50 +00:00
parent 2a93677e52
commit b084aaba7f
10 changed files with 2683 additions and 114 deletions

View File

@ -24,11 +24,35 @@ export const MONSTATS_DROP_COLUMNS = [
'Id',
'BaseId',
'NameStr',
'DescStr',
'lUndead',
'hUndead',
'demon',
'killable',
'inTown',
'boss',
'noRatio',
'Level',
'Level(N)',
'Level(H)',
'ResDm',
'ResMa',
'ResFi',
'ResLi',
'ResCo',
'ResPo',
'ResDm(N)',
'ResMa(N)',
'ResFi(N)',
'ResLi(N)',
'ResCo(N)',
'ResPo(N)',
'ResDm(H)',
'ResMa(H)',
'ResFi(H)',
'ResLi(H)',
'ResCo(H)',
'ResPo(H)',
'TreasureClass1',
'TreasureClass2',
'TreasureClass3',

View File

@ -161,6 +161,47 @@ async function main() {
}
}
// 7. MonStats.txt (NameStr, DescStr), SuperUniques.txt (Name), and 1.13c monster hover/affix/immunity keys
for (const r of (await readTxt('monstats.txt')).rows) {
addKey(r['NameStr'])
addKey(r['DescStr'])
}
for (const r of (await readTxt('SuperUniques.txt')).rows) {
addKey(r['Name'])
}
for (const k of [
'DemonID',
'UndeadDescriptX',
'Champion',
'minion',
'champghostlyX',
'champfanaticX',
'champpossessedX',
'champberserkX',
'uniquextrastrong',
'uniqueextrafast',
'uniquecursed',
'uniquemagicresistance',
'uniquefireenchanted',
'monsteruniqueprop1',
'monsteruniqueprop2',
'monsteruniqueprop3',
'monsteruniqueprop4',
'monsteruniqueprop5',
'monsteruniqueprop6',
'monsteruniqueprop7',
'monsteruniqueprop8',
'monsteruniqueprop9',
'strScaryMonDesc1',
'strScaryMonDesc2',
'strScaryMonDesc3',
'strScaryMonDesc4',
'strScaryMonDesc5',
'strScaryMonDesc6',
]) {
addKey(k)
}
const sortedKeys = Array.from(keys).sort()
const engOut: Record<string, string> = {}
const chiOut: Record<string, string> = {}
@ -176,6 +217,24 @@ async function main() {
}
}
// Provide canonical alias keys for 3206, 3208, 3210..3218 so both raw .tbl keys and descriptive keys resolve
const monsterModAliases: ReadonlyArray<readonly [string, string]> = [
['uniquextrafast', 'uniqueextrafast'],
['uniquemagicresistant', 'uniquemagicresistance'],
['uniquecoldenchanted', 'monsteruniqueprop1'],
['uniquelightningenchanted', 'monsteruniqueprop2'],
['uniquemanaburn', 'monsteruniqueprop3'],
['uniquespectralhit', 'monsteruniqueprop4'],
['uniqueteleport', 'monsteruniqueprop5'],
['uniquestoneskin', 'monsteruniqueprop6'],
['uniquemultishot', 'monsteruniqueprop7'],
['UniqueAuraEnchanted', 'monsteruniqueprop9'],
]
for (const [alias, canonical] of monsterModAliases) {
if (engOut[canonical] !== undefined) engOut[alias] = engOut[canonical]!
if (chiOut[canonical] !== undefined) chiOut[alias] = chiOut[canonical]!
}
// Also include expansionstring.tbl index 2745 ("Ethereal (Cannot be Repaired)") under canonical key 'strethereal'
const expTbl = eng.tables[1]
if (expTbl && expTbl[2745]) {

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -157,6 +157,20 @@ export interface MonsterStats {
readonly poison?: number
readonly [k: string]: number | undefined
}
/** Canonical `.tbl` key for monster name (`MonStats.txt` `NameStr` or `SuperUniques.txt` `Name`). */
readonly nameKey?: string | undefined
/** Canonical `.tbl` key for monster special ability subtext (`MonStats.txt` `DescStr`). */
readonly descKey?: string | undefined
/** True when `MonStats.txt` `lUndead` or `hUndead` is non-zero (`0x6FD62B50`). */
readonly isUndead?: boolean | undefined
/** True when `MonStats.txt` `demon` is non-zero (`0x6FD62BA0`). */
readonly isDemon?: boolean | undefined
/** True when `MonStats.txt` `boss` is non-zero. */
readonly isBoss?: boolean | undefined
/** True when `MonStats.txt` `killable` is non-zero. */
readonly killable?: boolean | undefined
/** True when `MonStats.txt` `inTown` is non-zero. */
readonly inTown?: boolean | undefined
}
/** One monster's mutable state. */
@ -203,6 +217,8 @@ export interface Monster {
pendingDrop?: any | undefined
/** Deterministic per-unit seed derived from world/level seed and spawn index (D2 1.13c pUnit->dwInitSeed). */
dwInitSeed?: number | undefined
/** Rendered world-space sprite bounding box for mouse hover/click hit-testing (`D2Client.dll` `0x6FB01A80`). */
drawBounds?: { x: number; y: number; w: number; h: number } | undefined
}
export interface MonsterCurseState {

View File

@ -169,6 +169,8 @@ export interface MonsterKind {
* localisation work tracked separately.
*/
readonly nameKey: string
/** `DescStr` — a `string.tbl` key for special monster ability subtext (e.g. `"raises Fallen"`), or `""`. */
readonly descKey?: string
/** `Code` — the two-letter token the art paths are built from, e.g. `FA`. */
readonly code: string
/** `MonType` — family, for immunities and skill counters (`MonType.txt`). */
@ -194,8 +196,13 @@ export interface MonsterKind {
readonly interact: boolean
/** `inTown` — belongs in a town level. */
readonly inTown: boolean
/** `boss` — a boss, excluded from ordinary population. */
/** `lUndead` or `hUndead` — whether this monster is Undead (`0x6FD62B50`). */
readonly isUndead?: boolean
/** `demon` — whether this monster is a Demon (`0x6FD62BA0`). */
readonly isDemon?: boolean
/** `boss` — a boss, excluded from ordinary population (`isBoss` alias included for parity). */
readonly boss: boolean
readonly isBoss?: boolean
/** `noRatio` — summons/pets/traps, excluded from ratio scaling and TC upgrading. */
readonly noRatio?: boolean
/** `killable` — some scenery that merely looks alive. */
@ -333,10 +340,15 @@ export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'norma
return tcForDiff[0] || normalTc[0] || ''
}
const isBoss = flag(table, row, 'boss')
const isUndead = flag(table, row, 'lUndead') || flag(table, row, 'hUndead')
const isDemon = flag(table, row, 'demon')
const kind: MonsterKind = {
id,
baseId: cell(table, row, 'BaseId').trim() || id,
nameKey: cell(table, row, 'NameStr').trim(),
descKey: cell(table, row, 'DescStr').trim(),
code: cell(table, row, 'Code').trim(),
monType: cell(table, row, 'MonType').trim(),
ai: cell(table, row, 'AI').trim(),
@ -348,7 +360,10 @@ export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'norma
npc: flag(table, row, 'npc'),
interact: flag(table, row, 'interact'),
inTown: flag(table, row, 'inTown'),
boss: flag(table, row, 'boss'),
isUndead,
isDemon,
boss: isBoss,
isBoss,
...(flag(table, row, 'noRatio') ? { noRatio: true } : {}),
killable: flag(table, row, 'killable'),
rarity: num(table, row, 'Rarity', 1),
@ -898,26 +913,27 @@ export const MONUMOD_CONSTANTS = {
/** What a monster is: ordinary, or promoted by the level generator. */
export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion' | 'boss'
/** Canonical elite modifiers from `MonUMod.txt`. */
/** Canonical elite modifiers from `MonUMod.txt` with 1.13c `.tbl` keys (`3205..3218`). */
export const CANONICAL_ELITE_MODIFIERS: readonly {
readonly id: number
readonly name: string
readonly tblKey: string
readonly label: string
readonly champion: boolean
}[] = [
{ id: 5, name: 'strong', label: 'Extra Strong', champion: true },
{ id: 6, name: 'fast', label: 'Extra Fast', champion: true },
{ id: 7, name: 'cursed', label: 'Cursed', champion: false },
{ id: 8, name: 'magicresistant', label: 'Magic Resistant', champion: false },
{ id: 9, name: 'fireenchant', label: 'Fire Enchanted', champion: false },
{ id: 17, name: 'lightenchant', label: 'Lightning Enchanted', champion: false },
{ id: 18, name: 'coldenchant', label: 'Cold Enchanted', champion: false },
{ id: 25, name: 'manahit', label: 'Mana Burn', champion: false },
{ id: 26, name: 'teleport', label: 'Teleportation', champion: false },
{ id: 27, name: 'spectralhit', label: 'Spectral Hit', champion: false },
{ id: 28, name: 'stoneskin', label: 'Stone Skin', champion: false },
{ id: 29, name: 'multishot', label: 'Multiple Shots', champion: false },
{ id: 30, name: 'aura', label: 'Aura Enchanted', champion: false },
{ id: 5, name: 'strong', tblKey: 'uniquextrastrong', label: 'Extra Strong', champion: true },
{ id: 6, name: 'fast', tblKey: 'uniqueextrafast', label: 'Extra Fast', champion: true },
{ id: 7, name: 'cursed', tblKey: 'uniquecursed', label: 'Cursed', champion: false },
{ id: 8, name: 'magicresistant', tblKey: 'uniquemagicresistance', label: 'Magic Resistant', champion: false },
{ id: 9, name: 'fireenchant', tblKey: 'uniquefireenchanted', label: 'Fire Enchanted', champion: false },
{ id: 17, name: 'lightenchant', tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted', champion: false },
{ id: 18, name: 'coldenchant', tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted', champion: false },
{ id: 25, name: 'manahit', tblKey: 'monsteruniqueprop3', label: 'Mana Burn', champion: false },
{ id: 26, name: 'teleport', tblKey: 'monsteruniqueprop5', label: 'Teleportation', champion: false },
{ id: 27, name: 'spectralhit', tblKey: 'monsteruniqueprop4', label: 'Spectral Hit', champion: false },
{ id: 28, name: 'stoneskin', tblKey: 'monsteruniqueprop6', label: 'Stone Skin', champion: false },
{ id: 29, name: 'multishot', tblKey: 'monsteruniqueprop7', label: 'Multiple Shots', champion: false },
{ id: 30, name: 'aura', tblKey: 'monsteruniqueprop9', label: 'Aura Enchanted', champion: false },
]
/**
@ -1293,6 +1309,13 @@ export function monsterStatsOf(
return {
id: kind.id,
name: kind.nameKey === '' ? kind.id : kind.nameKey,
nameKey: kind.nameKey === '' ? kind.id : kind.nameKey,
...(kind.descKey !== '' ? { descKey: kind.descKey } : {}),
isUndead: kind.isUndead,
isDemon: kind.isDemon,
isBoss: kind.boss,
killable: kind.killable,
inTown: kind.inTown,
hp: Math.max(1, Math.round((rolled * scale.health) / 100)),
damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)),
cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS,
@ -1638,6 +1661,7 @@ export function planLevelMonsters(
...bossBase,
id: suSpec.monsterId,
name: suSpec.name,
nameKey: suSpec.name,
hp: Math.max(1, Math.round(bossBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
}
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
@ -1696,3 +1720,61 @@ export function planLevelMonsters(
}
}
/**
* Enrich a `MonsterStats` record with canonical `MonStats.txt` and `SuperUniques.txt` metadata
* (`nameKey`, `descKey`, `isUndead`, `isDemon`, `isBoss`, `killable`, `inTown`, `resistances`)
* so pre-baked `scene.json` packs carry full 1.13c hover lifebar and subtext attributes.
*/
export function enrichMonsterStats(
stats: MonsterStats,
kinds: ReadonlyMap<string, MonsterKind>,
superUniques?: ReadonlyMap<string, SuperUnique>,
): MonsterStats {
const kind = kinds.get(stats.id) ?? kinds.get(stats.id.toLowerCase()) ?? kinds.get(stats.id.replace(/\d+$/, '1'))
if (!kind) return stats
const isSuperUniqueLeader = stats.superUniqueId !== undefined && stats.rank === 'unique'
const su = isSuperUniqueLeader && superUniques
? (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 mergedRes = stats.resistances
? { ...kind.resistances, ...stats.resistances }
: kind.resistances
return {
...stats,
nameKey,
...(descKey !== undefined ? { descKey } : {}),
isUndead: stats.isUndead ?? kind.isUndead,
isDemon: stats.isDemon ?? kind.isDemon,
isBoss: stats.isBoss ?? kind.boss,
killable: stats.killable ?? kind.killable,
inTown: stats.inTown ?? kind.inTown,
resistances: mergedRes,
resists: stats.resists ? { ...mergedRes, ...stats.resists } : mergedRes,
}
}
/**
* Enrich an array of `MonsterPack`s with canonical `MonStats.txt` and `SuperUniques.txt` metadata.
*/
export function enrichMonsterPacks(
packs: readonly MonsterPack[],
kinds: ReadonlyMap<string, MonsterKind>,
superUniques?: ReadonlyMap<string, SuperUnique>,
): MonsterPack[] {
return packs.map(pack => ({
...pack,
members: pack.members.map(member =>
enrichMonsterStats(
pack.superUniqueId !== undefined && member.superUniqueId === undefined
? { ...member, superUniqueId: pack.superUniqueId }
: member,
kinds,
superUniques,
),
),
}))
}

View File

@ -37,6 +37,8 @@ import type { StreamingRoomDef } from '../game/monster-streaming.ts'
import {
applyEliteModifiers,
rollEliteModifiers,
enrichMonsterStats,
enrichMonsterPacks,
ELITE_HEALTH_MULTIPLIER,
MONUMOD_CONSTANTS,
type MonsterRank,
@ -104,6 +106,7 @@ import type {
} from '../ui/minimap.ts'
import type { AutomapLayerData } from '../formats/automap-table.ts'
import { HudManager } from '../ui/hud-manager.ts'
import type { MonsterTargetHudInfo } from '../ui/world-panels.ts'
import type { InventoryPanel } from '../ui/inventory.ts'
import { LightGrid } from '../game/engine/light-grid.ts'
import { Environment } from '../game/engine/environment.ts'
@ -1034,6 +1037,31 @@ export interface RebudgetOptions {
readonly seed?: number | undefined
}
export function monsterToTargetHudInfo(monster: Monster): MonsterTargetHudInfo {
const dropTables = getEmbeddedDropTables()
const stats =
monster.stats.isDemon === undefined &&
monster.stats.isUndead === undefined &&
monster.stats.nameKey === undefined
? enrichMonsterStats(monster.stats, dropTables.monsterKinds, dropTables.superUniques)
: monster.stats
const r = stats.rank
return {
name: stats.name || stats.id,
...(stats.nameKey !== undefined ? { nameKey: stats.nameKey } : {}),
hp: monster.hp,
maxHp: stats.hp,
rank: r === 'champion' ? 'champion' : r === 'unique' ? 'unique' : r === 'minion' ? 'minion' : 'normal',
...(stats.isBoss !== undefined ? { isBoss: stats.isBoss } : {}),
...(stats.isUndead !== undefined ? { isUndead: stats.isUndead } : {}),
...(stats.isDemon !== undefined ? { isDemon: stats.isDemon } : {}),
...(stats.descKey !== undefined ? { descKey: stats.descKey } : {}),
...(stats.modifiers !== undefined ? { modifiers: stats.modifiers } : {}),
...(stats.resists !== undefined ? { resists: stats.resists } : {}),
...(stats.resistances !== undefined ? { resistances: stats.resistances } : {}),
}
}
export function rebudgetMonsterPacks(
basePacks: readonly MonsterPack[],
options?: RebudgetOptions,
@ -1046,11 +1074,14 @@ export function rebudgetMonsterPacks(
return []
}
const dropTables = getEmbeddedDropTables()
const enrichedBasePacks = enrichMonsterPacks(basePacks, dropTables.monsterKinds, dropTables.superUniques)
const density = parseDensityMultiplier(options?.densityMultiplier)
const elite = parseEliteMultiplier(options?.eliteMultiplier)
if (density === 1 && elite === 1) {
return basePacks.map(pack => ({
return enrichedBasePacks.map(pack => ({
...pack,
members: pack.members.map(m => ({ ...m })),
}))
@ -1059,8 +1090,8 @@ export function rebudgetMonsterPacks(
const rng = new Rng(options?.seed ?? 0x9e37)
// Separate fixed / landmark packs from regular wild packs
const fixedPacks = basePacks.filter(p => p.superUniqueId !== undefined || p.fixedPosition !== undefined || p.landmarkTile !== undefined)
const regularPacks = basePacks.filter(p => p.superUniqueId === undefined && p.fixedPosition === undefined && p.landmarkTile === undefined)
const fixedPacks = enrichedBasePacks.filter(p => p.superUniqueId !== undefined || p.fixedPosition !== undefined || p.landmarkTile !== undefined)
const regularPacks = enrichedBasePacks.filter(p => p.superUniqueId === undefined && p.fixedPosition === undefined && p.landmarkTile === undefined)
// 1. Density scaling on regular packs
let scaledRegularPacks: MonsterPack[] = []
@ -3957,6 +3988,8 @@ export class SceneMouseController {
isLeftMouseDown = false
isRightMouseDown = false
lastClientPos: { x: number; y: number } | null = null
hoveredMonster: Monster | null = null
lockedMonster: Monster | null = null
hoveredGroundItem: GroundItemEntity | null = null
hoveredWarp: SceneWarp | null = null
hoveredEntrance: SceneEntrance | null = null
@ -3990,6 +4023,140 @@ 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).
*/
findMonsterAtWorld(worldX: number, worldY: number): Monster | null {
const runtime = this.getRuntime?.()
if (runtime && isTownLevel(runtime.levelId)) return null
const monsters = this.engine?.world?.monsters ?? []
let bestMonster: Monster | null = null
let bestDepthY = -Infinity
let bestCenterDist = Infinity
for (const m of monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
if (m.stats?.killable === false || m.stats?.inTown === true) continue
const bounds = m.drawBounds
let minX: number
let maxX: number
let minY: number
let maxY: number
if (bounds !== undefined && bounds.w > 0 && bounds.h > 0) {
minX = bounds.x - 4
maxX = bounds.x + bounds.w + 4
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
}
if (worldX >= minX && worldX <= maxX && worldY >= minY && worldY <= maxY) {
const cx = (minX + maxX) * 0.5
const cy = (minY + maxY) * 0.5
const centerDist = Math.hypot(worldX - cx, worldY - cy)
if (
bestMonster === null ||
m.y > bestDepthY + 1 ||
(Math.abs(m.y - bestDepthY) <= 1 && centerDist < bestCenterDist)
) {
bestMonster = m
bestDepthY = m.y
bestCenterDist = centerDist
}
}
}
return bestMonster
}
/**
* Resolve the currently targeted monster (either target-locked by held left-click
* or hovered under the mouse cursor), or `null` if none / dead.
*/
getActiveTargetMonster(): Monster | null {
if (
this.isLeftMouseDown &&
this.activeClickType === 'monster' &&
this.lockedMonster !== null
) {
if (
this.lockedMonster.state !== 'dead' &&
this.lockedMonster.hp > 0 &&
this.lockedMonster.stats?.killable !== false &&
this.lockedMonster.stats?.inTown !== true
) {
return this.lockedMonster
}
this.lockedMonster = null
}
if (this.hoveredMonster !== null) {
if (
this.hoveredMonster.state !== 'dead' &&
this.hoveredMonster.hp > 0 &&
this.hoveredMonster.stats?.killable !== false &&
this.hoveredMonster.stats?.inTown !== true
) {
return this.hoveredMonster
}
this.hoveredMonster = null
}
return null
}
/**
* Synchronize `hoveredMonster` and `hudManager.targetMonster` with the current
* pointer position and monster states.
*/
syncMonsterTargetHud(
camX = this.engine.world.player.x,
camY = this.engine.world.player.y - 16,
): Monster | null {
let hudIntercepted = false
if (this.lastClientPos !== null) {
if (this.hudManager !== null) {
const logical = this.hudManager.clientToLogical(this.lastClientPos.x, this.lastClientPos.y)
hudIntercepted = this.hudManager.isPointInterceptedByHud(logical.x, logical.y)
}
if (hudIntercepted) {
this.hoveredMonster = null
} else {
const rect = this.canvas.getBoundingClientRect()
const zoom = this.camera?.zoom ?? 1
const worldAtPointer = screenToWorld({
clientX: this.lastClientPos.x,
clientY: this.lastClientPos.y,
rect,
canvas: this.canvas,
camX,
camY,
cameraZoom: zoom,
})
this.hoveredMonster = this.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y)
}
}
if (hudIntercepted && !(this.isLeftMouseDown && this.activeClickType === 'monster')) {
if (this.hudManager !== null) {
this.hudManager.targetMonster = null
}
return null
}
const activeTarget = this.getActiveTargetMonster()
if (this.hudManager !== null) {
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
}
return activeTarget
}
private isHigherPriorityEntityAt(worldX: number, worldY: number): boolean {
for (const npc of this.engine.npcEntities ?? []) {
const distFeet = Math.hypot(npc.x - worldX, npc.y - worldY)
@ -4016,6 +4183,8 @@ export class SceneMouseController {
}
}
if (this.findMonsterAtWorld(worldX, worldY) !== null) return true
for (const m of this.engine.world.monsters ?? []) {
if (m.state === 'dead' || m.hp <= 0) continue
const distFeet = Math.hypot(m.x - worldX, m.y - worldY)
@ -4126,6 +4295,7 @@ export class SceneMouseController {
hudIntercepted = this.hudManager.isPointInterceptedByHud(logical.x, logical.y)
}
if (hudIntercepted) {
this.hoveredMonster = null
this.hoveredGroundItem = null
this.hoveredWarp = null
this.hoveredEntrance = null
@ -4142,6 +4312,7 @@ export class SceneMouseController {
camY,
cameraZoom: zoom,
})
this.hoveredMonster = this.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y)
this.hoveredGroundItem = this.engine.groundItems?.findAtPoint(worldAtPointer.x, worldAtPointer.y, 32) ?? null
this.syncLinkHoverAtWorld(worldAtPointer.x, worldAtPointer.y)
}
@ -4498,9 +4669,13 @@ export class SceneMouseController {
if (this.hudManager !== null) {
const logical = this.hudManager.clientToLogical(e.clientX, e.clientY)
if (this.hudManager.isPointInterceptedByHud(logical.x, logical.y)) {
this.hoveredMonster = null
this.hoveredGroundItem = null
this.hoveredWarp = null
this.hoveredEntrance = null
if (!(this.isLeftMouseDown && this.activeClickType === 'monster')) {
this.hudManager.targetMonster = null
}
if (this.canvas.style) {
this.canvas.style.cursor = ''
}
@ -4512,6 +4687,11 @@ export class SceneMouseController {
}
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
this.hoveredMonster = this.findMonsterAtWorld(worldX, worldY)
const activeTarget = this.getActiveTargetMonster()
if (this.hudManager !== null) {
this.hudManager.targetMonster = activeTarget !== null ? monsterToTargetHudInfo(activeTarget) : null
}
this.hoveredGroundItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32) ?? null
this.syncLinkHoverAtWorld(worldX, worldY)
if (this.labelsContainer) {
@ -4534,9 +4714,14 @@ export class SceneMouseController {
return
}
this.lastClientPos = null
this.hoveredMonster = null
this.lockedMonster = null
this.hoveredGroundItem = null
this.hoveredWarp = null
this.hoveredEntrance = null
if (this.hudManager !== null) {
this.hudManager.targetMonster = null
}
if (this.canvas.style) {
this.canvas.style.cursor = ''
}
@ -4549,13 +4734,16 @@ export class SceneMouseController {
if (e.button === 0) {
this.isLeftMouseDown = false
this.activeClickType = 'none'
this.lockedMonster = null
} else if (e.button === 2) {
this.isRightMouseDown = false
} else {
this.isLeftMouseDown = false
this.isRightMouseDown = false
this.activeClickType = 'none'
this.lockedMonster = null
}
this.syncMonsterTargetHud()
const player = this.engine.world.player
player.infernoChanneling = false
player.infernoManaRemainder256 = 0
@ -4576,22 +4764,29 @@ export class SceneMouseController {
const range = getSkillRange(rightSkillId, this.hudManager)
// 1. Check Monster: living monster within ~36 px
let clickedMonster: Monster | null = null
let bestMonDist = 36
for (const m of this.engine.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
const distFeet = Math.hypot(m.x - worldX, m.y - worldY)
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
const minD = Math.min(distFeet, distBody)
if (minD <= bestMonDist) {
bestMonDist = minD
clickedMonster = m
// 1. Check Monster: sprite bounds first, fallback to living monster within ~36 px
let clickedMonster: Monster | null = this.findMonsterAtWorld(worldX, worldY)
if (clickedMonster === null) {
let bestMonDist = 36
for (const m of this.engine.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
if (m.stats?.killable === false || m.stats?.inTown === true) continue
const distFeet = Math.hypot(m.x - worldX, m.y - worldY)
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
const minD = Math.min(distFeet, distBody)
if (minD <= bestMonDist) {
bestMonDist = minD
clickedMonster = m
}
}
}
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
@ -4694,6 +4889,7 @@ export class SceneMouseController {
handleLeftClickWorld(worldX: number, worldY: number): void {
const player = this.engine.world.player
this.lockedMonster = null
// 1. Check NPC: within ~32 px
let clickedNpc: NpcEntity | null = null
@ -4812,22 +5008,30 @@ export class SceneMouseController {
}
}
// 4. Check Monster: living monster within ~32 px
let clickedMonster: Monster | null = null
let bestMonDist = 36
for (const m of this.engine.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
const distFeet = Math.hypot(m.x - worldX, m.y - worldY)
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
const minD = Math.min(distFeet, distBody)
if (minD <= bestMonDist) {
bestMonDist = minD
clickedMonster = m
// 4. Check Monster: sprite bounds first, fallback to living monster within ~36 px
let clickedMonster: Monster | null = this.findMonsterAtWorld(worldX, worldY)
if (clickedMonster === null) {
let bestMonDist = 36
for (const m of this.engine.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
if (m.stats?.killable === false || m.stats?.inTown === true) continue
const distFeet = Math.hypot(m.x - worldX, m.y - worldY)
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
const minD = Math.min(distFeet, distBody)
if (minD <= bestMonDist) {
bestMonDist = minD
clickedMonster = m
}
}
}
if (clickedMonster !== null) {
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)
@ -4980,9 +5184,14 @@ export class SceneMouseController {
this.isLeftMouseDown = false
this.isRightMouseDown = false
this.lastClientPos = null
this.hoveredMonster = null
this.lockedMonster = null
this.hoveredGroundItem = null
this.hoveredWarp = null
this.hoveredEntrance = null
if (this.hudManager !== null) {
this.hudManager.targetMonster = null
}
if (this.canvas?.style) {
this.canvas.style.cursor = ''
}
@ -9062,9 +9271,17 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
}
})
const activeTargetMonster = mouseController.getActiveTargetMonster()
for (const monster of engine.world.monsters) {
if (monster.state === 'dead' && monster.corpseTicks <= 0) continue
if (monster.state === 'dead' && monster.corpseTicks <= 0) {
monster.drawBounds = undefined
continue
}
if (monster.state === 'dead') {
monster.drawBounds = undefined
}
pushEntity(monster.x, monster.y, () => {
const isTarget = monster === activeTargetMonster && monster.state !== 'dead' && monster.hp > 0
const art = monsterArtMap.get(monster.stats.id.toLowerCase())
if (art !== undefined) {
const animator = monsterAnimators.get(monster.index)
@ -9095,6 +9312,14 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
const drawY = frame.anchorY !== undefined
? monster.y + frame.anchorY
: monster.y - frame.height + FEET_HEIGHT / 2
if (monster.state !== 'dead') {
monster.drawBounds = {
x: drawX,
y: drawY,
w: frame.width,
h: frame.height,
}
}
const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
const isAdditiveFallback = ADDITIVE_MONSTER_TOKENS.has(art.token.toUpperCase())
@ -9107,6 +9332,8 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
? { tint: [0.25, 0.6, 1.0, 1.0] as const, paletteRow: 1 }
: isChilled
? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 }
: isTarget
? { tint: [1.25, 1.25, 1.25, 1.0] as const }
: {}),
})
if (art.glowGroups !== undefined) {
@ -9123,6 +9350,12 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
}
}
if (monster.state !== 'dead') {
monster.drawBounds = {
x: monster.x - MARKER_WIDTH / 2,
y: monster.y - MARKER_HEIGHT,
w: MARKER_WIDTH,
h: MARKER_HEIGHT,
}
const missingId = monster.stats.id.toLowerCase()
if (!state.missingMonsterArt.includes(missingId)) {
state.missingMonsterArt.push(missingId)
@ -9134,6 +9367,8 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
? ([0.25, 0.6, 1.0, 1.0] as const)
: isChilled
? ([0.4, 0.65, 1.0, 1.0] as const)
: isTarget
? ([1.0, 0.35, 0.35, 1.0] as const)
: ([0.9, 0.2, 0.2, 1.0] as const)
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, tint)
}
@ -9300,29 +9535,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
)
renderer.flush()
if (hudManager !== null) {
let nearestMonster: typeof engine.world.monsters[number] | null = null
let bestDistSq = 380 * 380
for (const m of engine.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
const dx = m.x - engine.world.player.x
const dy = m.y - engine.world.player.y
const dSq = dx * dx + dy * dy
if (dSq < bestDistSq) {
bestDistSq = dSq
nearestMonster = m
}
}
if (nearestMonster !== null) {
const r = nearestMonster.stats.rank
hudManager.targetMonster = {
name: nearestMonster.stats.name || nearestMonster.stats.id,
hp: nearestMonster.hp,
maxHp: nearestMonster.stats.hp,
rank: r === 'champion' ? 'champion' : r === 'unique' ? 'unique' : 'normal',
affixes: nearestMonster.stats.modifiers ?? [],
subType: 'Demon · 恶魔',
}
}
mouseController.syncMonsterTargetHud(camX, camY)
hudManager.render(renderStarted, playerMoving)
}

View File

@ -31,7 +31,12 @@ import {
} from './inventory.ts'
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
import { WorldPanelsHud, type LeftDockPanelKind, type VendorTradeState } from './world-panels.ts'
import {
WorldPanelsHud,
type LeftDockPanelKind,
type MonsterTargetHudInfo,
type VendorTradeState,
} from './world-panels.ts'
import {
CursorController,
type BuySellCursorMode,
@ -257,14 +262,7 @@ export class HudManager {
mouseY = 300
/** Optional target monster on top plaque. */
targetMonster: {
name: string
hp: number
maxHp: number
rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'boss'
affixes?: readonly string[]
subType?: string
} | null = null
targetMonster: MonsterTargetHudInfo | null = null
private images = new Map<string, HTMLImageElement>()
private itemRects: Record<string, { x: number; y: number; w: number; h: number }> = {}
@ -1416,8 +1414,12 @@ export class HudManager {
this.font,
)
// 7. Top-Center Monster Target Health Bar & Plaque
this.worldPanels.drawMonsterTargetBar(ctx, this.targetMonster, this.font)
// 7. Top-Center Monster Target Health Bar & Plaque (`D2Client.dll` `0x6FB70850`)
this.worldPanels.drawMonsterTargetBar(ctx, this.targetMonster, this.font, {
leftPanelOpen: this.leftPanel !== 'none',
rightPanelOpen: this.rightPanel !== 'none',
canvasWidth: 800,
})
// 8. Town NPC Stone Interaction Menu (`npcmenu.cpp` + `dialog.cpp` + `boxpieces.dc6`)
if (this.worldPanels.npcMenu) {

View File

@ -43,6 +43,8 @@ import type { VendorTabId } from '../game/vendor-generator.ts'
import { VendorSessionManager } from '../game/vendor-service.ts'
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { isItemRepairable } from '../game/item-cost.ts'
import { CANONICAL_CHI_TBL_DICT, CANONICAL_ENG_TBL_DICT } from '../data/canonical-tbl-strings.ts'
import { CANONICAL_ELITE_MODIFIERS } from '../game/monsters.ts'
export {
GRID_ITEM_BG_BLUE,
@ -2702,55 +2704,67 @@ export class WorldPanelsHud {
}
/**
* Draw Top-Center Monster Target Health Bar & Affix Plaque (`text.cpp` / `panel.cpp`).
* Draw Top-Center Monster Target Health Bar & 2-Line Subtext Plaque
* (`D2Client.dll` `0x6FB70850`, `0x6FACFDD0`, `0x6FB6FCA0`, `0x6FB719B0`, `0x6FB71A20`, `D2Win.dll` `Ordinal_10070`).
*/
drawMonsterTargetBar(
ctx: CanvasRenderingContext2D,
monster: {
name: string
hp: number
maxHp: number
rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'boss'
affixes?: readonly string[]
subType?: string
} | null,
monster: MonsterTargetHudInfo | null,
font: D2FontRenderer,
options?: {
readonly leftPanelOpen?: boolean
readonly rightPanelOpen?: boolean
readonly canvasWidth?: number
readonly locale?: 'en' | 'zh'
},
): void {
if (!monster || monster.hp <= 0) return
const barW = 220
const barH = 22
const x = 400 - barW / 2
const y = 42
const ratio = Math.max(0, Math.min(1, monster.hp / Math.max(1, monster.maxHp)))
const leftPanelOpen = options?.leftPanelOpen ?? false
const rightPanelOpen = options?.rightPanelOpen ?? false
const canvasWidth = options?.canvasWidth ?? 800
const locale = options?.locale ?? 'en'
ctx.fillStyle = 'rgba(28, 8, 8, 0.88)'
ctx.fillRect(x, y, barW, barH)
ctx.fillStyle = 'rgba(156, 20, 20, 0.92)'
ctx.fillRect(x + 1, y + 1, Math.round((barW - 2) * ratio), barH - 2)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(x + 0.5, y + 0.5, barW - 1, barH - 1)
const { paddedTitle, titleColor } = resolveMonsterTitleAndColor(monster, locale)
const textWidth = font.measureText(paddedTitle, 'font16')
const layout = computeMonsterLifebarLayout(
canvasWidth,
leftPanelOpen,
rightPanelOpen,
textWidth,
monster.hp,
monster.maxHp,
)
if (!layout.visible) return
const titleColor: D2ColorCode =
monster.rank === 'unique' || monster.rank === 'superunique' || monster.rank === 'boss'
? 'gold'
: monster.rank === 'champion'
? 'blue'
: 'white'
font.drawText(ctx, monster.name, 400, y + 16, {
font: 'fontexocet10',
// 1. Lifebar Plaque Background (`D2Win.dll` `Ordinal_10070` dark semi-transparent box)
ctx.fillStyle = 'rgba(0, 0, 0, 0.72)'
ctx.fillRect(layout.left, layout.top, layout.barWidth, layout.barHeight)
// 2. Red Health Fill (`paletteIdx = 132` crimson fill, `(barWidth * fillRatio128) >> 7`)
if (layout.fillWidth > 0) {
ctx.fillStyle = 'rgba(156, 20, 20, 0.88)'
ctx.fillRect(layout.left, layout.top, layout.fillWidth, layout.barHeight)
}
// 3. Centered `font16` Title (`" " + Name + " "` at `y = 32`)
font.drawText(ctx, paddedTitle, layout.centerX, layout.y, {
font: 'font16',
color: titleColor,
align: 'center',
})
if (monster.affixes && monster.affixes.length > 0) {
font.drawText(ctx, monster.affixes.join(' · '), 400, y + 36, {
font: 'font8',
color: 'tan',
// 4. 2-Line Subtext (`font6` at `y = 55` and `y = 69` / `55`)
const subtext = buildMonsterHoverSubtext(monster, locale)
if (subtext.line1 !== '') {
font.drawText(ctx, subtext.line1Colored, layout.centerX, subtext.line1Y, {
font: 'font6',
color: 'white',
align: 'center',
})
} else if (monster.subType) {
font.drawText(ctx, monster.subType, 400, y + 36, {
font: 'font8',
}
if (subtext.line2 !== '') {
font.drawText(ctx, subtext.line2Colored, layout.centerX, subtext.line2Y, {
font: 'font6',
color: 'white',
align: 'center',
})
@ -2972,3 +2986,341 @@ export class WorldPanelsHud {
ctx.restore()
}
}
/**
* Target monster HUD info consumed by `drawMonsterTargetBar` (`D2Client.dll` `0x6FB70850`).
*/
export interface MonsterTargetHudInfo {
readonly name: string
readonly nameKey?: string | undefined
readonly hp: number
readonly maxHp: number
readonly rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'minion' | 'boss' | undefined
readonly isBoss?: boolean | undefined
readonly isUndead?: boolean | undefined
readonly isDemon?: boolean | undefined
readonly descStr?: string | undefined
readonly descKey?: string | undefined
readonly modifiers?: readonly string[] | undefined
readonly resists?: {
readonly physical?: number | undefined
readonly magic?: number | undefined
readonly fire?: number | undefined
readonly lightning?: number | undefined
readonly cold?: number | undefined
readonly poison?: number | undefined
} | undefined
readonly resistances?: {
readonly physical?: number | undefined
readonly magic?: number | undefined
readonly fire?: number | undefined
readonly lightning?: number | undefined
readonly cold?: number | undefined
readonly poison?: number | undefined
} | undefined
/** Legacy compatibility fields */
readonly affixes?: readonly string[] | undefined
readonly subType?: string | undefined
}
export interface MonsterLifebarLayout {
readonly visible: boolean
readonly centerX: number
readonly y: number
readonly barWidth: number
readonly barHeight: number
readonly left: number
readonly top: number
readonly right: number
readonly bottom: number
readonly fillRatio128: number
readonly fillWidth: number
}
export interface MonsterHoverSubtextResult {
readonly line1: string
readonly line1Colored: string
readonly line1Y: number
readonly line2: string
readonly line2Colored: string
readonly line2Y: number
}
/**
* Resolve a `.tbl` string key in `CANONICAL_ENG_TBL_DICT` or `CANONICAL_CHI_TBL_DICT`
* (falling back from CHI to ENG when a key is expansion-only).
*/
export function lookupMonsterTblString(key: string, locale: 'en' | 'zh' = 'en'): string | undefined {
if (!key) return undefined
if (locale === 'zh') {
return CANONICAL_CHI_TBL_DICT[key] ?? CANONICAL_ENG_TBL_DICT[key]
}
return CANONICAL_ENG_TBL_DICT[key]
}
const CHAMPION_SUBTYPE_TBL_KEYS: Readonly<Record<string, string>> = {
ghostly: 'champghostlyX',
champghostlyX: 'champghostlyX',
fanatic: 'champfanaticX',
champfanaticX: 'champfanaticX',
possessed: 'champpossessedX',
champpossessedX: 'champpossessedX',
berserk: 'champberserkX',
champberserkX: 'champberserkX',
}
/**
* Resolve localized monster title, 2-space lifebar padding (`" " + Name + " "`),
* and canonical rank color (`white` Normal/Minion, `blue` Champion, `gold` Unique/SuperUnique/Boss)
* per `D2Client.dll` `sub_6FACFDD0` (`0x6FACFDD0`) and `sub_6FB70850` (`0x6FB70850`).
*/
export function resolveMonsterTitleAndColor(
target: MonsterTargetHudInfo,
locale: 'en' | 'zh' = 'en',
): {
readonly displayName: string
readonly paddedTitle: string
readonly titleColor: D2ColorCode
} {
const rawKey = target.nameKey || target.name
const baseName = lookupMonsterTblString(rawKey, locale) ?? target.name
const isGoldRank =
target.rank === 'unique' ||
target.rank === 'superunique' ||
target.rank === 'boss' ||
Boolean(target.isBoss)
if (isGoldRank) {
return {
displayName: baseName,
paddedTitle: ` ${baseName} `,
titleColor: 'gold',
}
}
if (target.rank === 'champion') {
let champTblKey = 'Champion'
if (target.modifiers) {
for (const mod of target.modifiers) {
const mapped = CHAMPION_SUBTYPE_TBL_KEYS[mod]
if (mapped) {
champTblKey = mapped
break
}
}
}
const prefix = lookupMonsterTblString(champTblKey, locale) ?? 'Champion'
const displayName = baseName.startsWith(`${prefix} `) ? baseName : `${prefix} ${baseName}`
return {
displayName,
paddedTitle: ` ${displayName} `,
titleColor: 'blue',
}
}
return {
displayName: baseName,
paddedTitle: ` ${baseName} `,
titleColor: 'white',
}
}
/**
* Compute authentic 1.13c monster lifebar plaque geometry and health fill ratio
* per `D2Client.dll` `sub_6FB70850` (`0x6FB70850`) and `D2Win.dll` `Ordinal_10070` (`0x6F8F18F0`).
*
* - Center X (`sub_6FB70850`):
* - Neither panel open: `canvasWidth / 2` (`400`)
* - Left panel open only: `(3 * canvasWidth) / 4` (`600`)
* - Right panel open only: `canvasWidth / 4` (`200`)
* - Both panels open: hidden (`visible: false`)
* - Vertical position: `y = 32` (`top = 19`, `bottom = 36`, `barHeight = 17`)
* - Bar width: `Math.max(128, textWidth)`
* - Health fill ratio: `clamp(floor((hp * 128) / maxHp), 2, 128)` when `hp > 0`
*/
export function computeMonsterLifebarLayout(
canvasWidth: number,
leftPanelOpen: boolean,
rightPanelOpen: boolean,
textWidth: number,
hp: number,
maxHp: number,
): MonsterLifebarLayout {
const y = 32
const barHeight = 17
const top = y - 13
const bottom = y + 4
const barWidth = Math.max(128, Math.ceil(textWidth))
if (hp <= 0 || (leftPanelOpen && rightPanelOpen)) {
const centerX = Math.floor(canvasWidth / 2)
const left = Math.floor(centerX - barWidth / 2)
return {
visible: false,
centerX,
y,
barWidth,
barHeight,
left,
top,
right: left + barWidth,
bottom,
fillRatio128: 0,
fillWidth: 0,
}
}
let centerX = Math.floor(canvasWidth / 2)
if (leftPanelOpen && !rightPanelOpen) {
centerX = Math.floor((3 * canvasWidth) / 4)
} else if (!leftPanelOpen && rightPanelOpen) {
centerX = Math.floor(canvasWidth / 4)
}
const left = Math.floor(centerX - barWidth / 2)
const right = left + barWidth
const safeMaxHp = maxHp > 0 ? maxHp : 1
const rawRatio = Math.floor((hp * 128) / safeMaxHp)
const fillRatio128 = Math.max(2, Math.min(128, rawRatio))
const fillWidth = Math.floor((barWidth * fillRatio128) / 128)
return {
visible: true,
centerX,
y,
barWidth,
barHeight,
left,
top,
right,
bottom,
fillRatio128,
fillWidth,
}
}
/**
* Canonical 1.13c immunity descriptor table (`.rdata:0x6FB834EC`, iterated in `sub_6FB719B0`).
* Strictly ordered: Physical (gold `ÿc4`), Magic (orange `ÿc8`), Fire (red `ÿc1`),
* Lightning (yellow `ÿc9`), Cold (blue `ÿc3`), Poison (green `ÿc2`).
*/
export const CANONICAL_IMMUNITY_DESCRIPTORS: readonly {
readonly key: 'physical' | 'magic' | 'fire' | 'lightning' | 'cold' | 'poison'
readonly tblKey: string
readonly colorCode: string
readonly fallbackEn: string
}[] = [
{ key: 'physical', tblKey: 'strScaryMonDesc5', colorCode: 'ÿc4', fallbackEn: 'Immune to Physical' },
{ key: 'magic', tblKey: 'strScaryMonDesc6', colorCode: 'ÿc8', fallbackEn: 'Immune to Magic' },
{ key: 'fire', tblKey: 'strScaryMonDesc1', colorCode: 'ÿc1', fallbackEn: 'Immune to Fire' },
{ key: 'lightning', tblKey: 'strScaryMonDesc3', colorCode: 'ÿc9', fallbackEn: 'Immune to Lightning' },
{ key: 'cold', tblKey: 'strScaryMonDesc2', colorCode: 'ÿc3', fallbackEn: 'Immune to Cold' },
{ key: 'poison', tblKey: 'strScaryMonDesc4', colorCode: 'ÿc2', fallbackEn: 'Immune to Poison' },
]
/**
* Build the 2-line monster hover subtext below the lifebar plaque
* per `D2Client.dll` `sub_6FB70850` (`0x6FB70850`), `sub_6FB6FCA0` (`0x6FB6FCA0`),
* `sub_6FB719B0` (`0x6FB719B0`), and `sub_6FB71A20` (`0x6FB71A20`).
*
* - Line 1 (`y = 55`):
* - Race (`Undead` if `isUndead`, `Demon` if `isDemon`, omitted for animals/beasts)
* - Unique/SuperUnique: `" "`-separated modifiers + `DescStr` in `gold` (`ÿc4`)
* - Minion: `Minion` (`3221`) in `white` (`ÿc0`)
* - Normal/Champion/Boss: `DescStr` in rank color (`white` / `blue` / `gold`)
* - Line 2 (`y = 69` when Line 1 is non-empty, `y = 55` when Line 1 is empty):
* - `", "`-separated immunities (`res >= 100`) in exact `.rdata:0x6FB834EC` order.
*/
export function buildMonsterHoverSubtext(
target: MonsterTargetHudInfo,
locale: 'en' | 'zh' = 'en',
): MonsterHoverSubtextResult {
const raceText = target.isUndead
? (lookupMonsterTblString('UndeadDescriptX', locale) ?? 'Undead')
: target.isDemon
? (lookupMonsterTblString('DemonID', locale) ?? 'Demon')
: ''
const rawDesc = target.descKey ?? target.descStr ?? ''
const descText = rawDesc ? (lookupMonsterTblString(rawDesc, locale) ?? rawDesc) : ''
let abilityPlain = ''
let abilityColorPrefix = ''
if (target.rank === 'unique' || target.rank === 'superunique') {
const modParts: string[] = []
const mods = (target.modifiers && target.modifiers.length > 0)
? 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)
: (lookupMonsterTblString(mod, locale) ?? mod)
if (label) modParts.push(label)
}
if (descText) {
modParts.push(descText)
}
abilityPlain = modParts.join(' ')
abilityColorPrefix = 'ÿc4'
} else if (target.rank === 'minion') {
abilityPlain = lookupMonsterTblString('minion', locale) ?? 'Minion'
abilityColorPrefix = ''
} else {
abilityPlain = descText || (target.subType ?? '')
if (target.rank === 'boss' || target.isBoss) {
abilityColorPrefix = 'ÿc4'
} else if (target.rank === 'champion') {
abilityColorPrefix = 'ÿc3'
} else {
abilityColorPrefix = ''
}
}
let line1 = ''
let line1Colored = ''
if (raceText !== '' && abilityPlain !== '') {
line1 = `${raceText} ${abilityPlain}`
line1Colored = abilityColorPrefix
? `ÿc0${raceText} ${abilityColorPrefix}${abilityPlain}`
: `${raceText} ${abilityPlain}`
} else if (raceText !== '') {
line1 = raceText
line1Colored = raceText
} else if (abilityPlain !== '') {
line1 = abilityPlain
line1Colored = abilityColorPrefix ? `${abilityColorPrefix}${abilityPlain}` : abilityPlain
}
const res = target.resists ?? target.resistances
const immPlainParts: string[] = []
const immColoredParts: string[] = []
if (res) {
for (const desc of CANONICAL_IMMUNITY_DESCRIPTORS) {
const val = res[desc.key]
if (typeof val === 'number' && val >= 100) {
const label = lookupMonsterTblString(desc.tblKey, locale) ?? desc.fallbackEn
immPlainParts.push(label)
immColoredParts.push(`${desc.colorCode}${label}`)
}
}
}
const line2 = immPlainParts.join(', ')
const line2Colored = immColoredParts.join('ÿc0, ')
const line1Y = 55
const line2Y = line1 !== '' ? 69 : 55
return {
line1,
line1Colored,
line1Y,
line2,
line2Colored,
line2Y,
}
}

View File

@ -0,0 +1,805 @@
/**
* tests/monster-target-bar.test.ts
*
* Comprehensive unit & integration tests for Diablo II 1.13c Monster Hover Lifebar
* and 2-Line Subtext Tooltip Parity:
* - D2Client.dll `0x6fb739e0` (`UpdateSelectedUnitOnMouseMove`), `0x6fb01a80` (`FindSelectableUnitUnderCursor`)
* - D2Client.dll `0x6fb70850` (`DrawSelectedMonsterLifeBar`), `0x6facfdd0` (`BuildMonsterHoverSubtext`)
* - D2Client.dll `0x6fb6fca0` (`AppendMonsterTypeAndDesc`), `0x6fb719b0` (`AppendMonsterModifiers`), `0x6fb71a20` (`AppendMonsterImmunities`)
* - D2Win.dll `Ordinal_10070` (`DrawMonsterLifeBarBox`)
*/
import { describe, expect, it, vi } from 'vitest'
import {
WorldPanelsHud,
lookupMonsterTblString,
resolveMonsterTitleAndColor,
computeMonsterLifebarLayout,
buildMonsterHoverSubtext,
CANONICAL_IMMUNITY_DESCRIPTORS,
type MonsterTargetHudInfo,
} from '../src/ui/world-panels.ts'
import { D2FontRenderer } from '../src/ui/font.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { ViewportCamera } from '../src/sim/camera.ts'
import { KeyboardInput } from '../src/sim/input.ts'
import { WaypointNetwork } from '../src/game/portal.ts'
import { GameEngine, type GameEngineOptions, type WorldMapProvider } from '../src/game/engine.ts'
import {
SceneMouseController,
monsterToTargetHudInfo,
type MapRuntime,
} from '../src/scene/act-scene.ts'
import {
readMonsterKinds,
readSuperUniques,
monsterStatsOf,
enrichMonsterPacks,
} from '../src/game/monsters.ts'
import { Rng } from '../src/game/rng.ts'
import { parseTable } from '../src/game/acts.ts'
import { RAW_MONSTATS, RAW_SUPERUNIQUES } from '../src/data/canonical-drop-data.ts'
// Headless DOM mocks for SceneMouseController testing
class MockWindow extends EventTarget {
innerWidth = 800
innerHeight = 600
devicePixelRatio = 1
}
class MockCanvas extends EventTarget {
clientWidth = 800
clientHeight = 600
width = 800
height = 600
style = { pointerEvents: 'none' }
getBoundingClientRect() {
return { left: 0, top: 0, width: 800, height: 600 }
}
getContext() {
return null
}
}
class SyntheticPointerEvent extends Event {
readonly clientX: number
readonly clientY: number
readonly button: number
readonly buttons: number
readonly shiftKey: boolean
constructor(
type: string,
init: { clientX: number; clientY: number; button?: number; buttons?: number; shiftKey?: boolean },
) {
super(type, { cancelable: true })
this.clientX = init.clientX
this.clientY = init.clientY
this.button = init.button ?? 0
this.buttons = init.buttons !== undefined ? init.buttons : 1 << (init.button ?? 0)
this.shiftKey = init.shiftKey ?? false
}
}
if (typeof globalThis.window === 'undefined') {
// @ts-ignore
globalThis.window = new MockWindow()
}
if (typeof globalThis.HTMLInputElement === 'undefined') {
// @ts-ignore
globalThis.HTMLInputElement = class HTMLInputElement {}
}
if (typeof globalThis.HTMLSelectElement === 'undefined') {
// @ts-ignore
globalThis.HTMLSelectElement = class HTMLSelectElement {}
}
function createMockFont(charWidth = 8): D2FontRenderer & {
calls: Array<{ text: string; x: number; y: number; opts?: any }>
} {
const calls: Array<{ text: string; x: number; y: number; opts?: any }> = []
return {
calls,
measureText: vi.fn((text: string) => text.length * charWidth),
drawText: vi.fn((_ctx: CanvasRenderingContext2D, text: string, x: number, y: number, opts?: any) => {
calls.push({ text, x, y, opts })
}),
} as unknown as D2FontRenderer & {
calls: Array<{ text: string; x: number; y: number; opts?: any }>
}
}
function createHoverTestScene() {
const canvas = new MockCanvas() as unknown as HTMLCanvasElement
const win = new MockWindow()
const input = new KeyboardInput(win as unknown as Window)
input.attach()
const dummyTerrain: WorldMapProvider = {
widthPx: 2000,
heightPx: 2000,
overlap: () => 0,
}
const dummyOpts: GameEngineOptions = {
spawn: { x: 500, y: 500 },
stats: [
{
id: 'fallen1',
name: 'Fallen',
nameKey: 'Fallen',
descKey: '',
isUndead: false,
isDemon: true,
isBoss: false,
killable: true,
inTown: false,
rank: 'normal',
modifiers: [],
resistances: { physical: 0, magic: 0, fire: 0, lightning: 0, cold: 0, poison: 0 },
hp: 20,
damage: 2,
cooldownTicks: 25,
reach: 24,
aggroRadius: 100,
speed: 0,
xp: 18,
},
{
id: 'zombie1',
name: 'Zombie',
nameKey: 'Zombie',
descKey: '',
isUndead: true,
isDemon: false,
isBoss: false,
killable: true,
inTown: false,
rank: 'unique',
modifiers: ['strong', 'cursed'],
resistances: { physical: 50, magic: 0, fire: 0, lightning: 0, cold: 100, poison: 75 },
hp: 80,
damage: 5,
cooldownTicks: 25,
reach: 24,
aggroRadius: 100,
speed: 0,
xp: 50,
},
],
xpTable: [0, 0, 100, 200],
itemBases: [],
prefixAffixes: [],
suffixAffixes: [],
skills: [],
npcDefs: [],
questDefs: [],
combatOptions: {
playerSpeed: 100,
playerReach: 52,
playerCooldownTicks: 10,
playerDamage: 10,
playerManaPerAttack: 0,
respawnTicks: 100,
},
lootSeed: 42,
talkRadius: 80,
pickupRadius: 50,
inventoryCols: 10,
inventoryRows: 4,
}
const engine = new GameEngine(dummyTerrain, dummyOpts)
// Keep exactly 2 deterministic monsters for hover hit-testing
engine.world.monsters.splice(2)
;(engine.world.monsters[0]! as { stats: unknown }).stats = dummyOpts.stats[0]!
engine.world.monsters[0]!.hp = dummyOpts.stats[0]!.hp
engine.world.monsters[0]!.x = 560
engine.world.monsters[0]!.y = 500
engine.world.monsters[0]!.drawBounds = { x: 544, y: 450, w: 32, h: 54 }
;(engine.world.monsters[1]! as { stats: unknown }).stats = dummyOpts.stats[1]!
engine.world.monsters[1]!.hp = dummyOpts.stats[1]!.hp
engine.world.monsters[1]!.x = 650
engine.world.monsters[1]!.y = 500
engine.world.monsters[1]!.drawBounds = { x: 630, y: 440, w: 40, h: 64 }
const camera = new ViewportCamera(canvas, 2000, 2000)
camera.zoom = 1
const hudManager = new HudManager(canvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
const waypointNetwork = new WaypointNetwork()
const status = { textContent: '' } as HTMLElement
const runtime: MapRuntime = {
base: 'samples/d2-packs',
act: 1,
level: 'Blood Moor',
levelId: 2,
variant: 'default',
variants: ['default'],
quadrant: 'default',
quadrants: ['default'],
cellsX: 100,
cellsY: 100,
widthPx: 2000,
heightPx: 2000,
floors: [],
walls: [],
roofs: [],
objectDrawables: [],
pages: [],
priorityPages: [],
objectPages: [],
grid: {
originX: 0,
originY: 0,
cellsX: 100,
cellsY: 100,
blocked: new Uint8Array(500 * 500),
gridWidth: 500,
},
npcs: [],
entrances: [],
warps: [],
waypoints: [],
animatedDrawables: [],
animSpeed: 1,
frameDurationMs: 40,
palette: null,
charBases: [],
monsterTypes: [],
monsterPacks: [],
entryPath: '',
loadPages: async () => {},
} as unknown as MapRuntime
const controller = new SceneMouseController({
canvas,
engine,
camera,
input,
getRuntime: () => runtime,
hudManager,
waypointNetwork,
status,
})
controller.attach()
return { canvas, engine, camera, hudManager, controller }
}
describe('Diablo II 1.13c Monster Lifebar & 2-Line Subtext Tooltip Parity', () => {
describe('R1: Canonical .tbl Strings & MonStats.txt Metadata Pipeline', () => {
it('packs required MonStats.txt columns and resolves canonical ENG & CHI .tbl strings', () => {
const monstatsTable = parseTable(new TextEncoder().encode(RAW_MONSTATS))
expect(monstatsTable.header).toContain('DescStr')
expect(monstatsTable.header).toContain('lUndead')
expect(monstatsTable.header).toContain('hUndead')
expect(monstatsTable.header).toContain('demon')
expect(monstatsTable.header).toContain('killable')
expect(monstatsTable.header).toContain('inTown')
expect(monstatsTable.header).toContain('ResDm')
expect(monstatsTable.header).toContain('ResCo(H)')
// Base monster names
expect(lookupMonsterTblString('Fallen', 'en')).toBe('Fallen')
expect(lookupMonsterTblString('Fallen', 'zh')).toBe('沈淪魔')
expect(lookupMonsterTblString('Zombie', 'en')).toBe('Zombie')
expect(lookupMonsterTblString('Zombie', 'zh')).toBe('僵屍')
// SuperUniques
expect(lookupMonsterTblString('Bishibosh', 'en')).toBe('Bishibosh')
expect(lookupMonsterTblString('Bishibosh', 'zh')).toBe('畢須博須')
expect(lookupMonsterTblString('Corpsefire', 'en')).toBe('Corpsefire')
expect(lookupMonsterTblString('Corpsefire', 'zh')).toBe('屍體發火')
// Base type & rank affixes
expect(lookupMonsterTblString('DemonID', 'en')).toBe('Demon')
expect(lookupMonsterTblString('DemonID', 'zh')).toBe('惡魔')
expect(lookupMonsterTblString('UndeadDescriptX', 'en')).toBe('Undead')
expect(lookupMonsterTblString('UndeadDescriptX', 'zh')).toBe('不死的')
expect(lookupMonsterTblString('Champion', 'en')).toBe('Champion')
expect(lookupMonsterTblString('Champion', 'zh')).toBe('頭目')
expect(lookupMonsterTblString('minion', 'en')).toBe('Minion')
expect(lookupMonsterTblString('minion', 'zh')).toBe('嘍囉')
// Elite modifiers (3205..3218)
expect(lookupMonsterTblString('uniquextrastrong', 'en')).toBe('Extra Strong')
expect(lookupMonsterTblString('uniquextrastrong', 'zh')).toBe('特別強壯')
expect(lookupMonsterTblString('uniqueextrafast', 'en')).toBe('Extra Fast')
expect(lookupMonsterTblString('uniqueextrafast', 'zh')).toBe('特別快速')
expect(lookupMonsterTblString('uniquecursed', 'en')).toBe('Cursed')
expect(lookupMonsterTblString('uniquecursed', 'zh')).toBe('特別詛咒')
expect(lookupMonsterTblString('monsteruniqueprop6', 'en')).toBe('Stone Skin')
expect(lookupMonsterTblString('monsteruniqueprop6', 'zh')).toBe('皮膚硬化')
// Immunity strings (10990..10995)
expect(lookupMonsterTblString('strScaryMonDesc1', 'en')).toBe('Immune to Fire')
expect(lookupMonsterTblString('strScaryMonDesc1', 'zh')).toBe('火焰系無效')
expect(lookupMonsterTblString('strScaryMonDesc2', 'en')).toBe('Immune to Cold')
expect(lookupMonsterTblString('strScaryMonDesc2', 'zh')).toBe('冰冷系無效')
expect(lookupMonsterTblString('strScaryMonDesc3', 'en')).toBe('Immune to Lightning')
expect(lookupMonsterTblString('strScaryMonDesc3', 'zh')).toBe('閃電系無效')
expect(lookupMonsterTblString('strScaryMonDesc4', 'en')).toBe('Immune to Poison')
expect(lookupMonsterTblString('strScaryMonDesc4', 'zh')).toBe('毒素系無效')
expect(lookupMonsterTblString('strScaryMonDesc5', 'en')).toBe('Immune to Physical')
expect(lookupMonsterTblString('strScaryMonDesc5', 'zh')).toBe('實體攻擊無效')
expect(lookupMonsterTblString('strScaryMonDesc6', 'en')).toBe('Immune to Magic')
expect(lookupMonsterTblString('strScaryMonDesc6', 'zh')).toBe('魔法系無效')
})
it('populates descKey, isUndead, isDemon, isBoss, killable, inTown, and resistances in readMonsterKinds and enrichMonsterPacks', () => {
const kinds = readMonsterKinds(parseTable(new TextEncoder().encode(RAW_MONSTATS)), 'hell')
const superUniquesList = readSuperUniques(parseTable(new TextEncoder().encode(RAW_SUPERUNIQUES)), 'hell')
const superUniques = new Map(superUniquesList.map(s => [s.id.toLowerCase(), s]))
const fallen = kinds.get('fallen1')!
const zombie = kinds.get('zombie1')!
const quillRat = kinds.get('quillrat1')!
const fallenShaman = kinds.get('fallenshaman1')!
const andariel = kinds.get('andariel')!
expect(fallen.isDemon).toBe(true)
expect(fallen.isUndead).toBe(false)
expect(fallen.resistances.fire).toBeGreaterThanOrEqual(100) // Hell Fallen is Fire Immune
expect(zombie.isUndead).toBe(true)
expect(zombie.isDemon).toBe(false)
expect(quillRat.isUndead).toBe(false)
expect(quillRat.isDemon).toBe(false)
expect(fallenShaman.descKey).toBe('raises Fallen')
expect(andariel.isBoss).toBe(true)
const stats = monsterStatsOf(fallen, new Rng(123), 4)
expect(stats.nameKey).toBe('Fallen')
expect(stats.isDemon).toBe(true)
expect(stats.isUndead).toBe(false)
expect(stats.resistances?.fire).toBeGreaterThanOrEqual(100)
// Verify enrichMonsterPacks enriches pre-baked pack stats with MonStats & SuperUniques metadata
const rawPacks = [
{
kindId: 'fallenshaman1',
rank: 'unique' as const,
superUniqueId: 'Bishibosh',
members: [
{
id: 'fallenshaman1',
name: 'Bishibosh',
rank: 'unique' as const,
hp: 120,
damage: 10,
cooldownTicks: 25,
reach: 24,
aggroRadius: 100,
speed: 4,
xp: 90,
},
],
},
]
const enriched = enrichMonsterPacks(rawPacks, kinds, superUniques)
expect(enriched[0]!.members[0]!.nameKey).toBe('Bishibosh')
expect(enriched[0]!.members[0]!.descKey).toBe('raises Fallen')
expect(enriched[0]!.members[0]!.isDemon).toBe(true)
expect(enriched[0]!.members[0]!.isUndead).toBe(false)
})
})
describe('R3: Authentic 1.13c Lifebar Plaque Layout, Title, and 2-Line Subtext', () => {
it('computes 1.13c panel-aware lifebar layout and [2, 128] HP fill clamping (0x6fb70850 & Ordinal_10070)', () => {
// Short name (" Fallen " = 10 chars * 8 = 80px -> clamped to min width 128)
const layoutClosed = computeMonsterLifebarLayout(800, false, false, 80, 100, 100)
expect(layoutClosed.visible).toBe(true)
expect(layoutClosed.centerX).toBe(400)
expect(layoutClosed.barWidth).toBe(128)
expect(layoutClosed.left).toBe(400 - 64)
expect(layoutClosed.y).toBe(32)
expect(layoutClosed.top).toBe(19)
expect(layoutClosed.bottom).toBe(36)
expect(layoutClosed.barHeight).toBe(17) // 16 + 1 inclusive
expect(layoutClosed.fillRatio128).toBe(128)
expect(layoutClosed.fillWidth).toBe(128)
// Long name (textWidth = 264px > 128)
const layoutLong = computeMonsterLifebarLayout(800, false, false, 264, 50, 100)
expect(layoutLong.barWidth).toBe(264)
expect(layoutLong.left).toBe(400 - 132)
expect(layoutLong.fillRatio128).toBe(64)
expect(layoutLong.fillWidth).toBe(132)
// Sliver of HP (1 / 10000) -> fillRatio128 clamped to minimum 2 per 0x6fb7092f
const layoutSliver = computeMonsterLifebarLayout(800, false, false, 80, 1, 10000)
expect(layoutSliver.fillRatio128).toBe(2)
expect(layoutSliver.fillWidth).toBe(2)
// Dead (0 / 100) -> visible = false, fillRatio128 = 0
const layoutDead = computeMonsterLifebarLayout(800, false, false, 80, 0, 100)
expect(layoutDead.visible).toBe(false)
expect(layoutDead.fillRatio128).toBe(0)
expect(layoutDead.fillWidth).toBe(0)
// Left panel open, right closed -> shifts right by +200 to centerX = 600
const layoutLeftOpen = computeMonsterLifebarLayout(800, true, false, 80, 100, 100)
expect(layoutLeftOpen.visible).toBe(true)
expect(layoutLeftOpen.centerX).toBe(600)
// Right panel open, left closed -> shifts left by -200 to centerX = 200
const layoutRightOpen = computeMonsterLifebarLayout(800, false, true, 80, 100, 100)
expect(layoutRightOpen.visible).toBe(true)
expect(layoutRightOpen.centerX).toBe(200)
// Both panels open -> hidden
const layoutBothOpen = computeMonsterLifebarLayout(800, true, true, 80, 100, 100)
expect(layoutBothOpen.visible).toBe(false)
})
it('resolves monster title and color across Normal, Champion, Unique, Minion, and Boss ranks in ENG and CHI', () => {
// Normal
const normalEn = resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 10, maxHp: 10, rank: 'normal' },
'en',
)
expect(normalEn).toEqual({
displayName: 'Fallen',
paddedTitle: ' Fallen ',
titleColor: 'white',
})
const normalZh = resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 10, maxHp: 10, rank: 'normal' },
'zh',
)
expect(normalZh).toEqual({
displayName: '沈淪魔',
paddedTitle: ' 沈淪魔 ',
titleColor: 'white',
})
// Champion
const champEn = resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 20, maxHp: 20, rank: 'champion' },
'en',
)
expect(champEn).toEqual({
displayName: 'Champion Fallen',
paddedTitle: ' Champion Fallen ',
titleColor: 'blue',
})
const champZh = resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 20, maxHp: 20, rank: 'champion' },
'zh',
)
expect(champZh).toEqual({
displayName: '頭目 沈淪魔',
paddedTitle: ' 頭目 沈淪魔 ',
titleColor: 'blue',
})
// SuperUnique / Unique
const uniqueEn = resolveMonsterTitleAndColor(
{ name: 'Bishibosh', nameKey: 'Bishibosh', hp: 100, maxHp: 100, rank: 'unique' },
'en',
)
expect(uniqueEn).toEqual({
displayName: 'Bishibosh',
paddedTitle: ' Bishibosh ',
titleColor: 'gold',
})
const uniqueZh = resolveMonsterTitleAndColor(
{ name: 'Bishibosh', nameKey: 'Bishibosh', hp: 100, maxHp: 100, rank: 'unique' },
'zh',
)
expect(uniqueZh).toEqual({
displayName: '畢須博須',
paddedTitle: ' 畢須博須 ',
titleColor: 'gold',
})
// Minion (title is white base name, subtext line 1 carries Minion / 僕人)
const minionEn = resolveMonsterTitleAndColor(
{ name: 'Fallen', nameKey: 'Fallen', hp: 15, maxHp: 15, rank: 'minion' },
'en',
)
expect(minionEn).toEqual({
displayName: 'Fallen',
paddedTitle: ' Fallen ',
titleColor: 'white',
})
// Act Boss
const bossZh = resolveMonsterTitleAndColor(
{ name: 'Andariel', nameKey: 'Andariel', hp: 500, maxHp: 500, rank: 'boss', isBoss: true },
'zh',
)
expect(bossZh).toEqual({
displayName: '安達利爾',
paddedTitle: ' 安達利爾 ',
titleColor: 'gold',
})
})
it('builds 2-line subtext (0x6facfdd0): Undead/Demon/Animal, DescStr, Unique Modifiers, and 6-element Immunities with line promotion', () => {
// 1. Undead Unique with DescStr ('raises Fallen'), 2 modifiers, and Cold + Poison immunities
const undeadUnique: MonsterTargetHudInfo = {
name: 'Corpsefire',
nameKey: 'Corpsefire',
descKey: 'raises Fallen',
hp: 100,
maxHp: 100,
rank: 'unique',
isUndead: true,
isDemon: false,
modifiers: ['strong', 'spectralhit'],
resistances: {
cold: 100,
fire: 50,
lightning: 0,
poison: 120,
physical: 99,
magic: 0,
},
}
const subEn = buildMonsterHoverSubtext(undeadUnique, 'en')
expect(subEn.line1).toBe('Undead Extra Strong Spectral Hit Raises Fallen')
expect(subEn.line1Y).toBe(55)
expect(subEn.line2).toBe('Immune to Cold, Immune to Poison')
expect(subEn.line2Y).toBe(69)
const subZh = buildMonsterHoverSubtext(undeadUnique, 'zh')
expect(subZh.line1).toBe('不死的 特別強壯 幽靈一擊 復甦的沉淪魔')
expect(subZh.line2).toBe('冰冷系無效, 毒素系無效')
// 2. Animal/Beast (neither Undead nor Demon, no DescStr, normal rank) -> empty subtext when no immunities
const quillRat: MonsterTargetHudInfo = {
name: 'Quill Rat',
nameKey: 'QuillRat',
descKey: '',
hp: 10,
maxHp: 10,
rank: 'normal',
isUndead: false,
isDemon: false,
modifiers: [],
resistances: { cold: 0, fire: 0, lightning: 0, poison: 0, physical: 0, magic: 0 },
}
const emptySub = buildMonsterHoverSubtext(quillRat, 'en')
expect(emptySub.line1).toBe('')
expect(emptySub.line2).toBe('')
// 3. Animal/Beast with Physical + Magic immunity: Line 1 is empty, so Line 2 renders at y = 55 per 0x6fb70850!
const immuneBeast: MonsterTargetHudInfo = {
...quillRat,
resistances: { cold: 0, fire: 0, lightning: 0, poison: 0, physical: 100, magic: 100 },
}
const promotedEn = buildMonsterHoverSubtext(immuneBeast, 'en')
expect(promotedEn.line1).toBe('')
expect(promotedEn.line2).toBe('Immune to Physical, Immune to Magic')
expect(promotedEn.line2Y).toBe(55)
// 4. Verify all 6 immunity descriptors in canonical 1.13c .rdata:0x6FB834EC order
expect(CANONICAL_IMMUNITY_DESCRIPTORS.map(d => d.key)).toEqual([
'physical',
'magic',
'fire',
'lightning',
'cold',
'poison',
])
})
it('renders plaque backdrop at top=19 (h=17), title at y=32, line 1 at y=55, and line 2 at y=69 in WorldPanelsHud.drawMonsterTargetBar', () => {
const hud = new WorldPanelsHud()
const font = createMockFont(8)
const fillRectCalls: Array<{ x: number; y: number; w: number; h: number; fillStyle: string }> = []
const ctx = {
fillStyle: '',
fillRect: vi.fn(function (this: any, x: number, y: number, w: number, h: number) {
fillRectCalls.push({ x, y, w, h, fillStyle: this.fillStyle })
}),
} as unknown as CanvasRenderingContext2D
const target: MonsterTargetHudInfo = {
name: 'Bishibosh',
nameKey: 'Bishibosh',
descKey: 'FallenShamanDesc',
hp: 50,
maxHp: 100,
rank: 'unique',
isDemon: true,
isUndead: false,
modifiers: ['fireenchant', 'magicresistant'],
resistances: { fire: 100, cold: 0, lightning: 0, poison: 0, physical: 0, magic: 75 },
}
hud.drawMonsterTargetBar(ctx, target, font, {
leftPanelOpen: false,
rightPanelOpen: false,
canvasWidth: 800,
locale: 'en',
})
// Two fillRect calls: dark backdrop (w=128, h=17 at top=19) and crimson HP fill (w=64, h=17 at top=19)
expect(fillRectCalls).toHaveLength(2)
expect(fillRectCalls[0]).toEqual({
x: 400 - 64,
y: 19,
w: 128,
h: 17,
fillStyle: 'rgba(0, 0, 0, 0.72)',
})
expect(fillRectCalls[1]).toEqual({
x: 400 - 64,
y: 19,
w: 64,
h: 17,
fillStyle: 'rgba(156, 20, 20, 0.88)',
})
// Three drawText calls: title at y=32, line 1 at y=55, line 2 at y=69
expect(font.calls).toHaveLength(3)
expect(font.calls[0]!.y).toBe(32)
expect(font.calls[0]!.opts?.color).toBe('gold')
expect(font.calls[1]!.y).toBe(55)
expect(font.calls[1]!.opts?.font).toBe('font6')
expect(font.calls[2]!.y).toBe(69)
expect(font.calls[2]!.opts?.font).toBe('font6')
})
})
describe('R2: Authentic Mouse Hover Hit-Testing & Target Clearing in SceneMouseController', () => {
it('does NOT auto-populate targetMonster by proximity when cursor is not hovering over a monster', () => {
const { engine, hudManager, controller } = createHoverTestScene()
// Monster 0 is only 60px away from player (560, 500 vs 500, 500)
expect(
Math.hypot(
engine.world.monsters[0]!.x - engine.world.player.x,
engine.world.monsters[0]!.y - engine.world.player.y,
),
).toBe(60)
// Sync HUD without hovering any monster -> targetMonster MUST be null!
controller.syncMonsterTargetHud(engine.world.player.x, engine.world.player.y - 16)
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
})
it('populates targetMonster on cursor hover over monster drawBounds and clears on move to empty ground, HUD, or pointerleave', () => {
const { canvas, engine, hudManager, controller } = createHoverTestScene()
const camX = engine.world.player.x // 500
const camY = engine.world.player.y - 16 // 484
// Monster 0 drawBounds: x=[544, 576], y=[450, 504]
// World (560, 480) -> screen (400 + (560 - 500) = 460, 300 + (480 - 484) = 296)
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.hoveredMonster).toBe(engine.world.monsters[0])
expect(hudManager.targetMonster).not.toBeNull()
expect(hudManager.targetMonster?.nameKey).toBe('Fallen')
expect(hudManager.targetMonster?.isDemon).toBe(true)
expect(hudManager.targetMonster?.hp).toBe(20)
// Move cursor to empty ground at World (450, 450) -> screen (350, 266)
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 350, clientY: 266, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
// Hover Monster 1 at World (650, 480) -> screen (550, 296)
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 550, clientY: 296, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.hoveredMonster).toBe(engine.world.monsters[1])
expect(hudManager.targetMonster?.nameKey).toBe('Zombie')
expect(hudManager.targetMonster?.isUndead).toBe(true)
expect(hudManager.targetMonster?.modifiers).toEqual(['strong', 'cursed'])
expect(hudManager.targetMonster?.resistances?.cold).toBe(100)
// Move cursor onto bottom HUD control bar (y = 580) -> clears hover
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 400, clientY: 580, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
// Hover Monster 0 again, then trigger pointerleave -> clears hover
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(hudManager.targetMonster).not.toBeNull()
canvas.dispatchEvent(new SyntheticPointerEvent('pointerleave', { clientX: 0, clientY: 0, buttons: 0 }))
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
})
it('selects foreground monster by isometric depth (higher y) when two monsters overlap and locks target during left-click hold', () => {
const { canvas, engine, hudManager, controller } = createHoverTestScene()
const camX = engine.world.player.x // 500
const camY = engine.world.player.y - 16 // 484
// Overlap Monster 0 (y = 490, background) and Monster 1 (y = 505, foreground) at world (560, 480)
engine.world.monsters[0]!.x = 560
engine.world.monsters[0]!.y = 490
engine.world.monsters[0]!.drawBounds = { x: 540, y: 450, w: 40, h: 60 }
engine.world.monsters[1]!.x = 560
engine.world.monsters[1]!.y = 505
engine.world.monsters[1]!.drawBounds = { x: 540, y: 450, w: 40, h: 60 }
const hit = controller.findMonsterAtWorld(560, 480)
expect(hit).toBe(engine.world.monsters[1]) // Foreground monster (higher y) wins
// Left-click hold on Monster 1 at screen (460, 296) -> locks onto Monster 1
canvas.dispatchEvent(
new SyntheticPointerEvent('pointerdown', { clientX: 460, clientY: 296, button: 0, buttons: 1 }),
)
expect(controller.lockedMonster).toBe(engine.world.monsters[1])
// Drag cursor away to empty ground while holding left button -> stays locked on Monster 1
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 300, clientY: 200, button: 0, buttons: 1 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.getActiveTargetMonster()).toBe(engine.world.monsters[1])
expect(hudManager.targetMonster?.nameKey).toBe('Zombie')
// Release left mouse button over empty ground -> clears lock and clears targetMonster
canvas.dispatchEvent(
new SyntheticPointerEvent('pointerup', { clientX: 300, clientY: 200, button: 0, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(controller.lockedMonster).toBeNull()
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
})
it('clears targetMonster immediately when hovered monster dies or becomes unkillable', () => {
const { canvas, engine, hudManager, controller } = createHoverTestScene()
const camX = engine.world.player.x
const camY = engine.world.player.y - 16
// Hover Monster 0
canvas.dispatchEvent(
new SyntheticPointerEvent('pointermove', { clientX: 460, clientY: 296, buttons: 0 }),
)
controller.syncMonsterTargetHud(camX, camY)
expect(hudManager.targetMonster?.nameKey).toBe('Fallen')
// Kill Monster 0
engine.world.monsters[0]!.hp = 0
engine.world.monsters[0]!.state = 'dead'
controller.syncMonsterTargetHud(camX, camY)
expect(controller.hoveredMonster).toBeNull()
expect(hudManager.targetMonster).toBeNull()
// Verify monsterToTargetHudInfo maps all fields cleanly
const hudInfo = monsterToTargetHudInfo(engine.world.monsters[1]!)
expect(hudInfo).toMatchObject({
name: 'Zombie',
nameKey: 'Zombie',
isUndead: true,
isDemon: false,
rank: 'unique',
modifiers: ['strong', 'cursed'],
})
})
})
})