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',
|
'Id',
|
||||||
'BaseId',
|
'BaseId',
|
||||||
'NameStr',
|
'NameStr',
|
||||||
|
'DescStr',
|
||||||
|
'lUndead',
|
||||||
|
'hUndead',
|
||||||
|
'demon',
|
||||||
|
'killable',
|
||||||
|
'inTown',
|
||||||
'boss',
|
'boss',
|
||||||
'noRatio',
|
'noRatio',
|
||||||
'Level',
|
'Level',
|
||||||
'Level(N)',
|
'Level(N)',
|
||||||
'Level(H)',
|
'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',
|
'TreasureClass1',
|
||||||
'TreasureClass2',
|
'TreasureClass2',
|
||||||
'TreasureClass3',
|
'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 sortedKeys = Array.from(keys).sort()
|
||||||
const engOut: Record<string, string> = {}
|
const engOut: Record<string, string> = {}
|
||||||
const chiOut: 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'
|
// Also include expansionstring.tbl index 2745 ("Ethereal (Cannot be Repaired)") under canonical key 'strethereal'
|
||||||
const expTbl = eng.tables[1]
|
const expTbl = eng.tables[1]
|
||||||
if (expTbl && expTbl[2745]) {
|
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 poison?: number
|
||||||
readonly [k: string]: number | undefined
|
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. */
|
/** One monster's mutable state. */
|
||||||
|
|
@ -203,6 +217,8 @@ export interface Monster {
|
||||||
pendingDrop?: any | undefined
|
pendingDrop?: any | undefined
|
||||||
/** Deterministic per-unit seed derived from world/level seed and spawn index (D2 1.13c pUnit->dwInitSeed). */
|
/** Deterministic per-unit seed derived from world/level seed and spawn index (D2 1.13c pUnit->dwInitSeed). */
|
||||||
dwInitSeed?: number | undefined
|
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 {
|
export interface MonsterCurseState {
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,8 @@ export interface MonsterKind {
|
||||||
* localisation work tracked separately.
|
* localisation work tracked separately.
|
||||||
*/
|
*/
|
||||||
readonly nameKey: string
|
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`. */
|
/** `Code` — the two-letter token the art paths are built from, e.g. `FA`. */
|
||||||
readonly code: string
|
readonly code: string
|
||||||
/** `MonType` — family, for immunities and skill counters (`MonType.txt`). */
|
/** `MonType` — family, for immunities and skill counters (`MonType.txt`). */
|
||||||
|
|
@ -194,8 +196,13 @@ export interface MonsterKind {
|
||||||
readonly interact: boolean
|
readonly interact: boolean
|
||||||
/** `inTown` — belongs in a town level. */
|
/** `inTown` — belongs in a town level. */
|
||||||
readonly inTown: boolean
|
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 boss: boolean
|
||||||
|
readonly isBoss?: boolean
|
||||||
/** `noRatio` — summons/pets/traps, excluded from ratio scaling and TC upgrading. */
|
/** `noRatio` — summons/pets/traps, excluded from ratio scaling and TC upgrading. */
|
||||||
readonly noRatio?: boolean
|
readonly noRatio?: boolean
|
||||||
/** `killable` — some scenery that merely looks alive. */
|
/** `killable` — some scenery that merely looks alive. */
|
||||||
|
|
@ -333,10 +340,15 @@ export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'norma
|
||||||
return tcForDiff[0] || normalTc[0] || ''
|
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 = {
|
const kind: MonsterKind = {
|
||||||
id,
|
id,
|
||||||
baseId: cell(table, row, 'BaseId').trim() || id,
|
baseId: cell(table, row, 'BaseId').trim() || id,
|
||||||
nameKey: cell(table, row, 'NameStr').trim(),
|
nameKey: cell(table, row, 'NameStr').trim(),
|
||||||
|
descKey: cell(table, row, 'DescStr').trim(),
|
||||||
code: cell(table, row, 'Code').trim(),
|
code: cell(table, row, 'Code').trim(),
|
||||||
monType: cell(table, row, 'MonType').trim(),
|
monType: cell(table, row, 'MonType').trim(),
|
||||||
ai: cell(table, row, 'AI').trim(),
|
ai: cell(table, row, 'AI').trim(),
|
||||||
|
|
@ -348,7 +360,10 @@ export function readMonsterKinds(table: D2Table, difficulty: Difficulty = 'norma
|
||||||
npc: flag(table, row, 'npc'),
|
npc: flag(table, row, 'npc'),
|
||||||
interact: flag(table, row, 'interact'),
|
interact: flag(table, row, 'interact'),
|
||||||
inTown: flag(table, row, 'inTown'),
|
inTown: flag(table, row, 'inTown'),
|
||||||
boss: flag(table, row, 'boss'),
|
isUndead,
|
||||||
|
isDemon,
|
||||||
|
boss: isBoss,
|
||||||
|
isBoss,
|
||||||
...(flag(table, row, 'noRatio') ? { noRatio: true } : {}),
|
...(flag(table, row, 'noRatio') ? { noRatio: true } : {}),
|
||||||
killable: flag(table, row, 'killable'),
|
killable: flag(table, row, 'killable'),
|
||||||
rarity: num(table, row, 'Rarity', 1),
|
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. */
|
/** What a monster is: ordinary, or promoted by the level generator. */
|
||||||
export type MonsterRank = 'normal' | 'champion' | 'unique' | 'minion' | 'boss'
|
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 {
|
export const CANONICAL_ELITE_MODIFIERS: readonly {
|
||||||
readonly id: number
|
readonly id: number
|
||||||
readonly name: string
|
readonly name: string
|
||||||
|
readonly tblKey: string
|
||||||
readonly label: string
|
readonly label: string
|
||||||
readonly champion: boolean
|
readonly champion: boolean
|
||||||
}[] = [
|
}[] = [
|
||||||
{ id: 5, name: 'strong', label: 'Extra Strong', champion: true },
|
{ id: 5, name: 'strong', tblKey: 'uniquextrastrong', label: 'Extra Strong', champion: true },
|
||||||
{ id: 6, name: 'fast', label: 'Extra Fast', champion: true },
|
{ id: 6, name: 'fast', tblKey: 'uniqueextrafast', label: 'Extra Fast', champion: true },
|
||||||
{ id: 7, name: 'cursed', label: 'Cursed', champion: false },
|
{ id: 7, name: 'cursed', tblKey: 'uniquecursed', label: 'Cursed', champion: false },
|
||||||
{ id: 8, name: 'magicresistant', label: 'Magic Resistant', champion: false },
|
{ id: 8, name: 'magicresistant', tblKey: 'uniquemagicresistance', label: 'Magic Resistant', champion: false },
|
||||||
{ id: 9, name: 'fireenchant', label: 'Fire Enchanted', champion: false },
|
{ id: 9, name: 'fireenchant', tblKey: 'uniquefireenchanted', label: 'Fire Enchanted', champion: false },
|
||||||
{ id: 17, name: 'lightenchant', label: 'Lightning Enchanted', champion: false },
|
{ id: 17, name: 'lightenchant', tblKey: 'monsteruniqueprop2', label: 'Lightning Enchanted', champion: false },
|
||||||
{ id: 18, name: 'coldenchant', label: 'Cold Enchanted', champion: false },
|
{ id: 18, name: 'coldenchant', tblKey: 'monsteruniqueprop1', label: 'Cold Enchanted', champion: false },
|
||||||
{ id: 25, name: 'manahit', label: 'Mana Burn', champion: false },
|
{ id: 25, name: 'manahit', tblKey: 'monsteruniqueprop3', label: 'Mana Burn', champion: false },
|
||||||
{ id: 26, name: 'teleport', label: 'Teleportation', champion: false },
|
{ id: 26, name: 'teleport', tblKey: 'monsteruniqueprop5', label: 'Teleportation', champion: false },
|
||||||
{ id: 27, name: 'spectralhit', label: 'Spectral Hit', champion: false },
|
{ id: 27, name: 'spectralhit', tblKey: 'monsteruniqueprop4', label: 'Spectral Hit', champion: false },
|
||||||
{ id: 28, name: 'stoneskin', label: 'Stone Skin', champion: false },
|
{ id: 28, name: 'stoneskin', tblKey: 'monsteruniqueprop6', label: 'Stone Skin', champion: false },
|
||||||
{ id: 29, name: 'multishot', label: 'Multiple Shots', champion: false },
|
{ id: 29, name: 'multishot', tblKey: 'monsteruniqueprop7', label: 'Multiple Shots', champion: false },
|
||||||
{ id: 30, name: 'aura', label: 'Aura Enchanted', champion: false },
|
{ id: 30, name: 'aura', tblKey: 'monsteruniqueprop9', label: 'Aura Enchanted', champion: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1293,6 +1309,13 @@ export function monsterStatsOf(
|
||||||
return {
|
return {
|
||||||
id: kind.id,
|
id: kind.id,
|
||||||
name: kind.nameKey === '' ? kind.id : kind.nameKey,
|
name: kind.nameKey === '' ? kind.id : kind.nameKey,
|
||||||
|
nameKey: kind.nameKey === '' ? kind.id : kind.nameKey,
|
||||||
|
...(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)),
|
hp: Math.max(1, Math.round((rolled * scale.health) / 100)),
|
||||||
damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)),
|
damage: Math.max(1, Math.round((averageDamage * scale.damage) / 100)),
|
||||||
cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS,
|
cooldownTicks: DEFAULT_ATTACK_COOLDOWN_TICKS,
|
||||||
|
|
@ -1638,6 +1661,7 @@ export function planLevelMonsters(
|
||||||
...bossBase,
|
...bossBase,
|
||||||
id: suSpec.monsterId,
|
id: suSpec.monsterId,
|
||||||
name: suSpec.name,
|
name: suSpec.name,
|
||||||
|
nameKey: suSpec.name,
|
||||||
hp: Math.max(1, Math.round(bossBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
|
hp: Math.max(1, Math.round(bossBase.hp * ELITE_HEALTH_MULTIPLIER.unique)),
|
||||||
}
|
}
|
||||||
const bossStats = applyEliteModifiers(bossScaled, 'unique', suSpec.modifiers, suSpec.id)
|
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 {
|
import {
|
||||||
applyEliteModifiers,
|
applyEliteModifiers,
|
||||||
rollEliteModifiers,
|
rollEliteModifiers,
|
||||||
|
enrichMonsterStats,
|
||||||
|
enrichMonsterPacks,
|
||||||
ELITE_HEALTH_MULTIPLIER,
|
ELITE_HEALTH_MULTIPLIER,
|
||||||
MONUMOD_CONSTANTS,
|
MONUMOD_CONSTANTS,
|
||||||
type MonsterRank,
|
type MonsterRank,
|
||||||
|
|
@ -104,6 +106,7 @@ import type {
|
||||||
} from '../ui/minimap.ts'
|
} from '../ui/minimap.ts'
|
||||||
import type { AutomapLayerData } from '../formats/automap-table.ts'
|
import type { AutomapLayerData } from '../formats/automap-table.ts'
|
||||||
import { HudManager } from '../ui/hud-manager.ts'
|
import { HudManager } from '../ui/hud-manager.ts'
|
||||||
|
import type { MonsterTargetHudInfo } from '../ui/world-panels.ts'
|
||||||
import type { InventoryPanel } from '../ui/inventory.ts'
|
import type { InventoryPanel } from '../ui/inventory.ts'
|
||||||
import { LightGrid } from '../game/engine/light-grid.ts'
|
import { LightGrid } from '../game/engine/light-grid.ts'
|
||||||
import { Environment } from '../game/engine/environment.ts'
|
import { Environment } from '../game/engine/environment.ts'
|
||||||
|
|
@ -1034,6 +1037,31 @@ export interface RebudgetOptions {
|
||||||
readonly seed?: number | undefined
|
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(
|
export function rebudgetMonsterPacks(
|
||||||
basePacks: readonly MonsterPack[],
|
basePacks: readonly MonsterPack[],
|
||||||
options?: RebudgetOptions,
|
options?: RebudgetOptions,
|
||||||
|
|
@ -1046,11 +1074,14 @@ export function rebudgetMonsterPacks(
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dropTables = getEmbeddedDropTables()
|
||||||
|
const enrichedBasePacks = enrichMonsterPacks(basePacks, dropTables.monsterKinds, dropTables.superUniques)
|
||||||
|
|
||||||
const density = parseDensityMultiplier(options?.densityMultiplier)
|
const density = parseDensityMultiplier(options?.densityMultiplier)
|
||||||
const elite = parseEliteMultiplier(options?.eliteMultiplier)
|
const elite = parseEliteMultiplier(options?.eliteMultiplier)
|
||||||
|
|
||||||
if (density === 1 && elite === 1) {
|
if (density === 1 && elite === 1) {
|
||||||
return basePacks.map(pack => ({
|
return enrichedBasePacks.map(pack => ({
|
||||||
...pack,
|
...pack,
|
||||||
members: pack.members.map(m => ({ ...m })),
|
members: pack.members.map(m => ({ ...m })),
|
||||||
}))
|
}))
|
||||||
|
|
@ -1059,8 +1090,8 @@ export function rebudgetMonsterPacks(
|
||||||
const rng = new Rng(options?.seed ?? 0x9e37)
|
const rng = new Rng(options?.seed ?? 0x9e37)
|
||||||
|
|
||||||
// Separate fixed / landmark packs from regular wild packs
|
// Separate fixed / landmark packs from regular wild packs
|
||||||
const fixedPacks = 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 = basePacks.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
|
// 1. Density scaling on regular packs
|
||||||
let scaledRegularPacks: MonsterPack[] = []
|
let scaledRegularPacks: MonsterPack[] = []
|
||||||
|
|
@ -3957,6 +3988,8 @@ export class SceneMouseController {
|
||||||
isLeftMouseDown = false
|
isLeftMouseDown = false
|
||||||
isRightMouseDown = false
|
isRightMouseDown = false
|
||||||
lastClientPos: { x: number; y: number } | null = null
|
lastClientPos: { x: number; y: number } | null = null
|
||||||
|
hoveredMonster: Monster | null = null
|
||||||
|
lockedMonster: Monster | null = null
|
||||||
hoveredGroundItem: GroundItemEntity | null = null
|
hoveredGroundItem: GroundItemEntity | null = null
|
||||||
hoveredWarp: SceneWarp | null = null
|
hoveredWarp: SceneWarp | null = null
|
||||||
hoveredEntrance: SceneEntrance | 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 {
|
private isHigherPriorityEntityAt(worldX: number, worldY: number): boolean {
|
||||||
for (const npc of this.engine.npcEntities ?? []) {
|
for (const npc of this.engine.npcEntities ?? []) {
|
||||||
const distFeet = Math.hypot(npc.x - worldX, npc.y - worldY)
|
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 ?? []) {
|
for (const m of this.engine.world.monsters ?? []) {
|
||||||
if (m.state === 'dead' || m.hp <= 0) continue
|
if (m.state === 'dead' || m.hp <= 0) continue
|
||||||
const distFeet = Math.hypot(m.x - worldX, m.y - worldY)
|
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)
|
hudIntercepted = this.hudManager.isPointInterceptedByHud(logical.x, logical.y)
|
||||||
}
|
}
|
||||||
if (hudIntercepted) {
|
if (hudIntercepted) {
|
||||||
|
this.hoveredMonster = null
|
||||||
this.hoveredGroundItem = null
|
this.hoveredGroundItem = null
|
||||||
this.hoveredWarp = null
|
this.hoveredWarp = null
|
||||||
this.hoveredEntrance = null
|
this.hoveredEntrance = null
|
||||||
|
|
@ -4142,6 +4312,7 @@ export class SceneMouseController {
|
||||||
camY,
|
camY,
|
||||||
cameraZoom: zoom,
|
cameraZoom: zoom,
|
||||||
})
|
})
|
||||||
|
this.hoveredMonster = this.findMonsterAtWorld(worldAtPointer.x, worldAtPointer.y)
|
||||||
this.hoveredGroundItem = this.engine.groundItems?.findAtPoint(worldAtPointer.x, worldAtPointer.y, 32) ?? null
|
this.hoveredGroundItem = this.engine.groundItems?.findAtPoint(worldAtPointer.x, worldAtPointer.y, 32) ?? null
|
||||||
this.syncLinkHoverAtWorld(worldAtPointer.x, worldAtPointer.y)
|
this.syncLinkHoverAtWorld(worldAtPointer.x, worldAtPointer.y)
|
||||||
}
|
}
|
||||||
|
|
@ -4498,9 +4669,13 @@ export class SceneMouseController {
|
||||||
if (this.hudManager !== null) {
|
if (this.hudManager !== null) {
|
||||||
const logical = this.hudManager.clientToLogical(e.clientX, e.clientY)
|
const logical = this.hudManager.clientToLogical(e.clientX, e.clientY)
|
||||||
if (this.hudManager.isPointInterceptedByHud(logical.x, logical.y)) {
|
if (this.hudManager.isPointInterceptedByHud(logical.x, logical.y)) {
|
||||||
|
this.hoveredMonster = null
|
||||||
this.hoveredGroundItem = null
|
this.hoveredGroundItem = null
|
||||||
this.hoveredWarp = null
|
this.hoveredWarp = null
|
||||||
this.hoveredEntrance = null
|
this.hoveredEntrance = null
|
||||||
|
if (!(this.isLeftMouseDown && this.activeClickType === 'monster')) {
|
||||||
|
this.hudManager.targetMonster = null
|
||||||
|
}
|
||||||
if (this.canvas.style) {
|
if (this.canvas.style) {
|
||||||
this.canvas.style.cursor = ''
|
this.canvas.style.cursor = ''
|
||||||
}
|
}
|
||||||
|
|
@ -4512,6 +4687,11 @@ export class SceneMouseController {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
|
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.hoveredGroundItem = this.engine.groundItems.findAtPoint(worldX, worldY, 32) ?? null
|
||||||
this.syncLinkHoverAtWorld(worldX, worldY)
|
this.syncLinkHoverAtWorld(worldX, worldY)
|
||||||
if (this.labelsContainer) {
|
if (this.labelsContainer) {
|
||||||
|
|
@ -4534,9 +4714,14 @@ export class SceneMouseController {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.lastClientPos = null
|
this.lastClientPos = null
|
||||||
|
this.hoveredMonster = null
|
||||||
|
this.lockedMonster = null
|
||||||
this.hoveredGroundItem = null
|
this.hoveredGroundItem = null
|
||||||
this.hoveredWarp = null
|
this.hoveredWarp = null
|
||||||
this.hoveredEntrance = null
|
this.hoveredEntrance = null
|
||||||
|
if (this.hudManager !== null) {
|
||||||
|
this.hudManager.targetMonster = null
|
||||||
|
}
|
||||||
if (this.canvas.style) {
|
if (this.canvas.style) {
|
||||||
this.canvas.style.cursor = ''
|
this.canvas.style.cursor = ''
|
||||||
}
|
}
|
||||||
|
|
@ -4549,13 +4734,16 @@ export class SceneMouseController {
|
||||||
if (e.button === 0) {
|
if (e.button === 0) {
|
||||||
this.isLeftMouseDown = false
|
this.isLeftMouseDown = false
|
||||||
this.activeClickType = 'none'
|
this.activeClickType = 'none'
|
||||||
|
this.lockedMonster = null
|
||||||
} else if (e.button === 2) {
|
} else if (e.button === 2) {
|
||||||
this.isRightMouseDown = false
|
this.isRightMouseDown = false
|
||||||
} else {
|
} else {
|
||||||
this.isLeftMouseDown = false
|
this.isLeftMouseDown = false
|
||||||
this.isRightMouseDown = false
|
this.isRightMouseDown = false
|
||||||
this.activeClickType = 'none'
|
this.activeClickType = 'none'
|
||||||
|
this.lockedMonster = null
|
||||||
}
|
}
|
||||||
|
this.syncMonsterTargetHud()
|
||||||
const player = this.engine.world.player
|
const player = this.engine.world.player
|
||||||
player.infernoChanneling = false
|
player.infernoChanneling = false
|
||||||
player.infernoManaRemainder256 = 0
|
player.infernoManaRemainder256 = 0
|
||||||
|
|
@ -4576,11 +4764,13 @@ export class SceneMouseController {
|
||||||
|
|
||||||
const range = getSkillRange(rightSkillId, this.hudManager)
|
const range = getSkillRange(rightSkillId, this.hudManager)
|
||||||
|
|
||||||
// 1. Check Monster: living monster within ~36 px
|
// 1. Check Monster: sprite bounds first, fallback to living monster within ~36 px
|
||||||
let clickedMonster: Monster | null = null
|
let clickedMonster: Monster | null = this.findMonsterAtWorld(worldX, worldY)
|
||||||
|
if (clickedMonster === null) {
|
||||||
let bestMonDist = 36
|
let bestMonDist = 36
|
||||||
for (const m of this.engine.world.monsters) {
|
for (const m of this.engine.world.monsters) {
|
||||||
if (m.state === 'dead' || m.hp <= 0) continue
|
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 distFeet = Math.hypot(m.x - worldX, m.y - worldY)
|
||||||
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
|
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
|
||||||
const minD = Math.min(distFeet, distBody)
|
const minD = Math.min(distFeet, distBody)
|
||||||
|
|
@ -4589,9 +4779,14 @@ export class SceneMouseController {
|
||||||
clickedMonster = m
|
clickedMonster = m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (clickedMonster !== null) {
|
if (clickedMonster !== null) {
|
||||||
this.activeClickType = 'monster'
|
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)
|
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
|
||||||
if (distToPlayer <= range) {
|
if (distToPlayer <= range) {
|
||||||
this.navTarget = null
|
this.navTarget = null
|
||||||
|
|
@ -4694,6 +4889,7 @@ export class SceneMouseController {
|
||||||
|
|
||||||
handleLeftClickWorld(worldX: number, worldY: number): void {
|
handleLeftClickWorld(worldX: number, worldY: number): void {
|
||||||
const player = this.engine.world.player
|
const player = this.engine.world.player
|
||||||
|
this.lockedMonster = null
|
||||||
|
|
||||||
// 1. Check NPC: within ~32 px
|
// 1. Check NPC: within ~32 px
|
||||||
let clickedNpc: NpcEntity | null = null
|
let clickedNpc: NpcEntity | null = null
|
||||||
|
|
@ -4812,11 +5008,13 @@ export class SceneMouseController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Check Monster: living monster within ~32 px
|
// 4. Check Monster: sprite bounds first, fallback to living monster within ~36 px
|
||||||
let clickedMonster: Monster | null = null
|
let clickedMonster: Monster | null = this.findMonsterAtWorld(worldX, worldY)
|
||||||
|
if (clickedMonster === null) {
|
||||||
let bestMonDist = 36
|
let bestMonDist = 36
|
||||||
for (const m of this.engine.world.monsters) {
|
for (const m of this.engine.world.monsters) {
|
||||||
if (m.state === 'dead' || m.hp <= 0) continue
|
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 distFeet = Math.hypot(m.x - worldX, m.y - worldY)
|
||||||
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
|
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
|
||||||
const minD = Math.min(distFeet, distBody)
|
const minD = Math.min(distFeet, distBody)
|
||||||
|
|
@ -4825,9 +5023,15 @@ export class SceneMouseController {
|
||||||
clickedMonster = m
|
clickedMonster = m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (clickedMonster !== null) {
|
if (clickedMonster !== null) {
|
||||||
this.activeClickType = 'monster'
|
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 leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
|
||||||
const range = getSkillRange(leftSkillId, this.hudManager)
|
const range = getSkillRange(leftSkillId, this.hudManager)
|
||||||
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
|
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
|
||||||
|
|
@ -4980,9 +5184,14 @@ export class SceneMouseController {
|
||||||
this.isLeftMouseDown = false
|
this.isLeftMouseDown = false
|
||||||
this.isRightMouseDown = false
|
this.isRightMouseDown = false
|
||||||
this.lastClientPos = null
|
this.lastClientPos = null
|
||||||
|
this.hoveredMonster = null
|
||||||
|
this.lockedMonster = null
|
||||||
this.hoveredGroundItem = null
|
this.hoveredGroundItem = null
|
||||||
this.hoveredWarp = null
|
this.hoveredWarp = null
|
||||||
this.hoveredEntrance = null
|
this.hoveredEntrance = null
|
||||||
|
if (this.hudManager !== null) {
|
||||||
|
this.hudManager.targetMonster = null
|
||||||
|
}
|
||||||
if (this.canvas?.style) {
|
if (this.canvas?.style) {
|
||||||
this.canvas.style.cursor = ''
|
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) {
|
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, () => {
|
pushEntity(monster.x, monster.y, () => {
|
||||||
|
const isTarget = monster === activeTargetMonster && monster.state !== 'dead' && monster.hp > 0
|
||||||
const art = monsterArtMap.get(monster.stats.id.toLowerCase())
|
const art = monsterArtMap.get(monster.stats.id.toLowerCase())
|
||||||
if (art !== undefined) {
|
if (art !== undefined) {
|
||||||
const animator = monsterAnimators.get(monster.index)
|
const animator = monsterAnimators.get(monster.index)
|
||||||
|
|
@ -9095,6 +9312,14 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
const drawY = frame.anchorY !== undefined
|
const drawY = frame.anchorY !== undefined
|
||||||
? monster.y + frame.anchorY
|
? monster.y + frame.anchorY
|
||||||
: monster.y - frame.height + FEET_HEIGHT / 2
|
: 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 isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0
|
||||||
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
|
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
|
||||||
const isAdditiveFallback = ADDITIVE_MONSTER_TOKENS.has(art.token.toUpperCase())
|
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 }
|
? { tint: [0.25, 0.6, 1.0, 1.0] as const, paletteRow: 1 }
|
||||||
: isChilled
|
: isChilled
|
||||||
? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 }
|
? { 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) {
|
if (art.glowGroups !== undefined) {
|
||||||
|
|
@ -9123,6 +9350,12 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (monster.state !== 'dead') {
|
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()
|
const missingId = monster.stats.id.toLowerCase()
|
||||||
if (!state.missingMonsterArt.includes(missingId)) {
|
if (!state.missingMonsterArt.includes(missingId)) {
|
||||||
state.missingMonsterArt.push(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)
|
? ([0.25, 0.6, 1.0, 1.0] as const)
|
||||||
: isChilled
|
: isChilled
|
||||||
? ([0.4, 0.65, 1.0, 1.0] as const)
|
? ([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)
|
: ([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)
|
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()
|
renderer.flush()
|
||||||
if (hudManager !== null) {
|
if (hudManager !== null) {
|
||||||
let nearestMonster: typeof engine.world.monsters[number] | null = null
|
mouseController.syncMonsterTargetHud(camX, camY)
|
||||||
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 · 恶魔',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
hudManager.render(renderStarted, playerMoving)
|
hudManager.render(renderStarted, playerMoving)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,12 @@ import {
|
||||||
} from './inventory.ts'
|
} from './inventory.ts'
|
||||||
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
||||||
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.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 {
|
import {
|
||||||
CursorController,
|
CursorController,
|
||||||
type BuySellCursorMode,
|
type BuySellCursorMode,
|
||||||
|
|
@ -257,14 +262,7 @@ export class HudManager {
|
||||||
mouseY = 300
|
mouseY = 300
|
||||||
|
|
||||||
/** Optional target monster on top plaque. */
|
/** Optional target monster on top plaque. */
|
||||||
targetMonster: {
|
targetMonster: MonsterTargetHudInfo | null = null
|
||||||
name: string
|
|
||||||
hp: number
|
|
||||||
maxHp: number
|
|
||||||
rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'boss'
|
|
||||||
affixes?: readonly string[]
|
|
||||||
subType?: string
|
|
||||||
} | null = null
|
|
||||||
|
|
||||||
private images = new Map<string, HTMLImageElement>()
|
private images = new Map<string, HTMLImageElement>()
|
||||||
private itemRects: Record<string, { x: number; y: number; w: number; h: number }> = {}
|
private itemRects: Record<string, { x: number; y: number; w: number; h: number }> = {}
|
||||||
|
|
@ -1416,8 +1414,12 @@ export class HudManager {
|
||||||
this.font,
|
this.font,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 7. Top-Center Monster Target Health Bar & Plaque
|
// 7. Top-Center Monster Target Health Bar & Plaque (`D2Client.dll` `0x6FB70850`)
|
||||||
this.worldPanels.drawMonsterTargetBar(ctx, this.targetMonster, this.font)
|
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`)
|
// 8. Town NPC Stone Interaction Menu (`npcmenu.cpp` + `dialog.cpp` + `boxpieces.dc6`)
|
||||||
if (this.worldPanels.npcMenu) {
|
if (this.worldPanels.npcMenu) {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ import type { VendorTabId } from '../game/vendor-generator.ts'
|
||||||
import { VendorSessionManager } from '../game/vendor-service.ts'
|
import { VendorSessionManager } from '../game/vendor-service.ts'
|
||||||
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
|
import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
|
||||||
import { isItemRepairable } from '../game/item-cost.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 {
|
export {
|
||||||
GRID_ITEM_BG_BLUE,
|
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(
|
drawMonsterTargetBar(
|
||||||
ctx: CanvasRenderingContext2D,
|
ctx: CanvasRenderingContext2D,
|
||||||
monster: {
|
monster: MonsterTargetHudInfo | null,
|
||||||
name: string
|
|
||||||
hp: number
|
|
||||||
maxHp: number
|
|
||||||
rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'boss'
|
|
||||||
affixes?: readonly string[]
|
|
||||||
subType?: string
|
|
||||||
} | null,
|
|
||||||
font: D2FontRenderer,
|
font: D2FontRenderer,
|
||||||
|
options?: {
|
||||||
|
readonly leftPanelOpen?: boolean
|
||||||
|
readonly rightPanelOpen?: boolean
|
||||||
|
readonly canvasWidth?: number
|
||||||
|
readonly locale?: 'en' | 'zh'
|
||||||
|
},
|
||||||
): void {
|
): void {
|
||||||
if (!monster || monster.hp <= 0) return
|
if (!monster || monster.hp <= 0) return
|
||||||
const barW = 220
|
const leftPanelOpen = options?.leftPanelOpen ?? false
|
||||||
const barH = 22
|
const rightPanelOpen = options?.rightPanelOpen ?? false
|
||||||
const x = 400 - barW / 2
|
const canvasWidth = options?.canvasWidth ?? 800
|
||||||
const y = 42
|
const locale = options?.locale ?? 'en'
|
||||||
const ratio = Math.max(0, Math.min(1, monster.hp / Math.max(1, monster.maxHp)))
|
|
||||||
|
|
||||||
ctx.fillStyle = 'rgba(28, 8, 8, 0.88)'
|
const { paddedTitle, titleColor } = resolveMonsterTitleAndColor(monster, locale)
|
||||||
ctx.fillRect(x, y, barW, barH)
|
const textWidth = font.measureText(paddedTitle, 'font16')
|
||||||
ctx.fillStyle = 'rgba(156, 20, 20, 0.92)'
|
const layout = computeMonsterLifebarLayout(
|
||||||
ctx.fillRect(x + 1, y + 1, Math.round((barW - 2) * ratio), barH - 2)
|
canvasWidth,
|
||||||
ctx.strokeStyle = '#6c5838'
|
leftPanelOpen,
|
||||||
ctx.strokeRect(x + 0.5, y + 0.5, barW - 1, barH - 1)
|
rightPanelOpen,
|
||||||
|
textWidth,
|
||||||
|
monster.hp,
|
||||||
|
monster.maxHp,
|
||||||
|
)
|
||||||
|
if (!layout.visible) return
|
||||||
|
|
||||||
const titleColor: D2ColorCode =
|
// 1. Lifebar Plaque Background (`D2Win.dll` `Ordinal_10070` dark semi-transparent box)
|
||||||
monster.rank === 'unique' || monster.rank === 'superunique' || monster.rank === 'boss'
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.72)'
|
||||||
? 'gold'
|
ctx.fillRect(layout.left, layout.top, layout.barWidth, layout.barHeight)
|
||||||
: monster.rank === 'champion'
|
|
||||||
? 'blue'
|
// 2. Red Health Fill (`paletteIdx = 132` crimson fill, `(barWidth * fillRatio128) >> 7`)
|
||||||
: 'white'
|
if (layout.fillWidth > 0) {
|
||||||
font.drawText(ctx, monster.name, 400, y + 16, {
|
ctx.fillStyle = 'rgba(156, 20, 20, 0.88)'
|
||||||
font: 'fontexocet10',
|
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,
|
color: titleColor,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (monster.affixes && monster.affixes.length > 0) {
|
// 4. 2-Line Subtext (`font6` at `y = 55` and `y = 69` / `55`)
|
||||||
font.drawText(ctx, monster.affixes.join(' · '), 400, y + 36, {
|
const subtext = buildMonsterHoverSubtext(monster, locale)
|
||||||
font: 'font8',
|
if (subtext.line1 !== '') {
|
||||||
color: 'tan',
|
font.drawText(ctx, subtext.line1Colored, layout.centerX, subtext.line1Y, {
|
||||||
|
font: 'font6',
|
||||||
|
color: 'white',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
} else if (monster.subType) {
|
}
|
||||||
font.drawText(ctx, monster.subType, 400, y + 36, {
|
if (subtext.line2 !== '') {
|
||||||
font: 'font8',
|
font.drawText(ctx, subtext.line2Colored, layout.centerX, subtext.line2Y, {
|
||||||
|
font: 'font6',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
})
|
})
|
||||||
|
|
@ -2972,3 +2986,341 @@ export class WorldPanelsHud {
|
||||||
ctx.restore()
|
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