feat(hud): implement authentic 1.13c monster hover lifebar and 2-line subtext tooltip
This commit is contained in:
parent
2a93677e52
commit
b084aaba7f
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue