Merge remote-tracking branch 'origin/main' into fix/cast-anim-refresh-rate-decouple
This commit is contained in:
commit
8a4521a62a
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,11 +1,13 @@
|
|||
{
|
||||
"version": "1.13c",
|
||||
"decodedDc6Count": 695,
|
||||
"decodedDc6Count": 728,
|
||||
"dc6DecodeFailures": 0,
|
||||
"atlasWidth": 2048,
|
||||
"atlasHeight": 8102,
|
||||
"questsAtlasWidth": 432,
|
||||
"questsAtlasHeight": 774,
|
||||
"portraitsAtlasWidth": 288,
|
||||
"portraitsAtlasHeight": 210,
|
||||
"codeToInvFile": {
|
||||
"hax": "invhax",
|
||||
"axe": "invaxe",
|
||||
|
|
@ -268543,6 +268545,224 @@
|
|||
"h": 86
|
||||
}
|
||||
},
|
||||
"portraitRects": {
|
||||
"AmazonIcon": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"amazonicon": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"SorceressIcon": {
|
||||
"x": 48,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"sorceressicon": {
|
||||
"x": 48,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"NecromancerIcon": {
|
||||
"x": 96,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"necromancericon": {
|
||||
"x": 96,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"PaladinIcon": {
|
||||
"x": 144,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"paladinicon": {
|
||||
"x": 144,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"BarbarianIcon": {
|
||||
"x": 192,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"barbarianicon": {
|
||||
"x": 192,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"DruidIcon": {
|
||||
"x": 240,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"druidicon": {
|
||||
"x": 240,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"AssassinIcon": {
|
||||
"x": 0,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"assassinicon": {
|
||||
"x": 0,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"rogueicon": {
|
||||
"x": 48,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"act2hireableicon": {
|
||||
"x": 96,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"act3hireableicon": {
|
||||
"x": 144,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"barbhirable_icon": {
|
||||
"x": 192,
|
||||
"y": 42,
|
||||
"w": 47,
|
||||
"h": 41
|
||||
},
|
||||
"valkarieicon": {
|
||||
"x": 240,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"earthgolumicon": {
|
||||
"x": 0,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"bloodgolumicon": {
|
||||
"x": 48,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"metalgolumicon": {
|
||||
"x": 96,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"firegolumicon": {
|
||||
"x": 144,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"skeletonicon": {
|
||||
"x": 192,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"skeletonmageicon": {
|
||||
"x": 240,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"revivedicon": {
|
||||
"x": 0,
|
||||
"y": 126,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"raven": {
|
||||
"x": 48,
|
||||
"y": 126,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"wolf": {
|
||||
"x": 96,
|
||||
"y": 126,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"oaksage": {
|
||||
"x": 144,
|
||||
"y": 126,
|
||||
"w": 47,
|
||||
"h": 41
|
||||
},
|
||||
"heartofwolverine": {
|
||||
"x": 192,
|
||||
"y": 126,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"spiritofbarbs": {
|
||||
"x": 240,
|
||||
"y": 126,
|
||||
"w": 47,
|
||||
"h": 41
|
||||
},
|
||||
"plaguepoppy": {
|
||||
"x": 0,
|
||||
"y": 168,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"cycleoflife": {
|
||||
"x": 48,
|
||||
"y": 168,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"vines": {
|
||||
"x": 96,
|
||||
"y": 168,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"bear": {
|
||||
"x": 144,
|
||||
"y": 168,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"shadowassassin": {
|
||||
"x": 192,
|
||||
"y": 168,
|
||||
"w": 47,
|
||||
"h": 41
|
||||
}
|
||||
},
|
||||
"images": {
|
||||
"ctrlPnl": "/ui/ctrlpnl-800.png",
|
||||
"globeLife": "/ui/globe-life.png",
|
||||
|
|
@ -268591,7 +268811,8 @@
|
|||
"automapAct4": "/ui/automap-act4.png",
|
||||
"automapAct4S": "/ui/automap-act4s.png",
|
||||
"automapExTn": "/ui/automap-extn.png",
|
||||
"automapExTnS": "/ui/automap-extns.png"
|
||||
"automapExTnS": "/ui/automap-extns.png",
|
||||
"portraitsAtlas": "/ui/portraits-atlas.png"
|
||||
},
|
||||
"automapAtlases": {
|
||||
"MaxiMap": {
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -33,6 +33,7 @@ import {
|
|||
|
||||
const EXPANSION_CHI_FALLBACKS: Readonly<Record<string, string>> = {
|
||||
strethereal: '无形的 (无法修复)',
|
||||
PalMercEXST3X: '進攻',
|
||||
ModStre9s: '无法破坏',
|
||||
ModStre9t: '每秒修复 %d 点耐久度',
|
||||
ModStre9u: '每 %d 秒修复 1 点耐久度',
|
||||
|
|
@ -419,6 +420,21 @@ async function main() {
|
|||
'DemonID',
|
||||
'Champion',
|
||||
'minion',
|
||||
'HP',
|
||||
'AC',
|
||||
'Level',
|
||||
'Cost',
|
||||
'ItemDesc1s',
|
||||
'ItemDesc1t',
|
||||
'Back',
|
||||
'NPCMenuHire',
|
||||
'NPCHire1',
|
||||
'space',
|
||||
'dash',
|
||||
'colon',
|
||||
'strMerc',
|
||||
'merca239',
|
||||
'NotUsed150',
|
||||
])
|
||||
|
||||
for (const [k] of eng.dict.entries()) {
|
||||
|
|
@ -438,6 +454,12 @@ async function main() {
|
|||
k.startsWith('strScaryMonDesc') ||
|
||||
k.startsWith('champ') ||
|
||||
k.startsWith('Eskill') ||
|
||||
k.startsWith('merc') ||
|
||||
k.startsWith('Merc') ||
|
||||
k.startsWith('strhire') ||
|
||||
k.startsWith('PalMerc') ||
|
||||
k.startsWith('ItemDesc1') ||
|
||||
k.startsWith('VerifyTransaction') ||
|
||||
explicitUiAndMonsterKeys.has(k)
|
||||
) {
|
||||
keys.add(k)
|
||||
|
|
|
|||
|
|
@ -442,6 +442,14 @@ async function main(): Promise<void> {
|
|||
const f16Strip = stitchHorizontalStrip(f16Frames)
|
||||
savePng('focus16.png', f16Strip.width, f16Strip.height, f16Strip.pixels)
|
||||
|
||||
const textslidDc6 = await decodeUiDc6('data\\global\\ui\\menu\\textslid.dc6')
|
||||
const tsFrames = textslidDc6.groups[0]?.frames
|
||||
if (!tsFrames || tsFrames.length !== 17 || tsFrames.some(f => f.width !== 12 || f.height !== 13)) {
|
||||
throw new Error('Invalid textslid.dc6 frame geometry: expected 17 frames of 12x13')
|
||||
}
|
||||
const tsStrip = stitchHorizontalStrip(tsFrames)
|
||||
savePng('textslid.png', tsStrip.width, tsStrip.height, tsStrip.pixels)
|
||||
|
||||
const levelBtn = await decodeUiDc6('data\\global\\ui\\Panel\\Level.dc6')
|
||||
const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames)
|
||||
savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels)
|
||||
|
|
|
|||
|
|
@ -534,6 +534,15 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
|
|||
const f16Strip = stitchHorizontalStrip(f16Frames)
|
||||
savePng('focus16.png', f16Strip.width, f16Strip.height, f16Strip.pixels)
|
||||
|
||||
// Dialog scrollbar slider control (`menu/textslid.dc6` per D2Client.dll 0x6fb17fd0 & 0x6fb17640, 17 frames of 12x13)
|
||||
const textslidDc6 = await decodeUiDc6('data/global/ui/menu/textslid.dc6')
|
||||
const tsFrames = textslidDc6.groups[0]?.frames
|
||||
if (!tsFrames || tsFrames.length !== 17 || tsFrames.some(f => f.width !== 12 || f.height !== 13)) {
|
||||
throw new Error('Invalid textslid.dc6 frame geometry: expected 17 frames of 12x13')
|
||||
}
|
||||
const tsStrip = stitchHorizontalStrip(tsFrames)
|
||||
savePng('textslid.png', tsStrip.width, tsStrip.height, tsStrip.pixels)
|
||||
|
||||
const levelBtn = await decodeUiDc6('data/global/ui/Panel/Level.dc6')
|
||||
const lvlStrip = stitchHorizontalStrip(levelBtn.groups[0]!.frames)
|
||||
savePng('level-btn.png', lvlStrip.width, lvlStrip.height, lvlStrip.pixels)
|
||||
|
|
@ -1278,6 +1287,69 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
|
|||
|
||||
savePng('quests-atlas.png', QUEST_ATLAS_W, QUEST_ATLAS_H, stitchFrames(QUEST_ATLAS_W, QUEST_ATLAS_H, questPlacements))
|
||||
|
||||
// --- STAGE 3.25: Top-Left HUD Portraits Atlas (29 icons from data/global/ui/Hireables/*.dc6) ---
|
||||
console.log('Packing 29 Top-Left HUD Portrait DC6 icons (Player Classes, Hirelings, Summoned Minions)...')
|
||||
const canonicalPortraits = [
|
||||
'AmazonIcon',
|
||||
'SorceressIcon',
|
||||
'NecromancerIcon',
|
||||
'PaladinIcon',
|
||||
'BarbarianIcon',
|
||||
'DruidIcon',
|
||||
'AssassinIcon',
|
||||
'rogueicon',
|
||||
'act2hireableicon',
|
||||
'act3hireableicon',
|
||||
'barbhirable_icon',
|
||||
'valkarieicon',
|
||||
'earthgolumicon',
|
||||
'bloodgolumicon',
|
||||
'metalgolumicon',
|
||||
'firegolumicon',
|
||||
'skeletonicon',
|
||||
'skeletonmageicon',
|
||||
'revivedicon',
|
||||
'raven',
|
||||
'wolf',
|
||||
'oaksage',
|
||||
'heartofwolverine',
|
||||
'spiritofbarbs',
|
||||
'plaguepoppy',
|
||||
'cycleoflife',
|
||||
'vines',
|
||||
'bear',
|
||||
'shadowassassin',
|
||||
]
|
||||
const PORTRAIT_CELL_W = 48
|
||||
const PORTRAIT_CELL_H = 42
|
||||
const PORTRAIT_COLS = 6
|
||||
const PORTRAIT_ROWS = Math.ceil(canonicalPortraits.length / PORTRAIT_COLS)
|
||||
const PORTRAIT_ATLAS_W = PORTRAIT_COLS * PORTRAIT_CELL_W
|
||||
const PORTRAIT_ATLAS_H = PORTRAIT_ROWS * PORTRAIT_CELL_H
|
||||
const portraitRects: Record<string, SpriteRect> = {}
|
||||
const portraitPlacements: BlitPlacement[] = []
|
||||
|
||||
for (let i = 0; i < canonicalPortraits.length; i++) {
|
||||
const pName = canonicalPortraits[i]!
|
||||
const pDc6 = await decodeUiDc6(`data/global/ui/Hireables/${pName}.dc6`)
|
||||
const frame = pDc6.groups[0]!.frames[0]!
|
||||
const col = i % PORTRAIT_COLS
|
||||
const row = Math.floor(i / PORTRAIT_COLS)
|
||||
const px = col * PORTRAIT_CELL_W
|
||||
const py = row * PORTRAIT_CELL_H
|
||||
const rect: SpriteRect = { x: px, y: py, w: frame.width, h: frame.height }
|
||||
portraitRects[pName] = rect
|
||||
portraitRects[pName.toLowerCase()] = rect
|
||||
portraitPlacements.push({ frame, x: px, y: py })
|
||||
}
|
||||
|
||||
savePng(
|
||||
'portraits-atlas.png',
|
||||
PORTRAIT_ATLAS_W,
|
||||
PORTRAIT_ATLAS_H,
|
||||
stitchFrames(PORTRAIT_ATLAS_W, PORTRAIT_ATLAS_H, portraitPlacements),
|
||||
)
|
||||
|
||||
// --- STAGE 3.5: Automap DC6 Sprite Sheets (8 sheets: MaxiMap(S), Act2Map(S), Act4Map(S), ExTnMap(S)) ---
|
||||
console.log('Packing 8 Automap DC6 sprite sheets (MaxiMap, Act2Map, Act4Map, ExTnMap + S variants)...')
|
||||
const automapOutDir = join(process.cwd(), 'public', 'assets', 'ui', 'automap')
|
||||
|
|
@ -1473,6 +1545,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
|
|||
vendorTabs: '/ui/vendor-tabs.png',
|
||||
boxPieces: '/ui/boxpieces.png',
|
||||
focus16: '/ui/focus16.png',
|
||||
textslid: '/ui/textslid.png',
|
||||
popbelt: '/ui/popbelt.png',
|
||||
minipanel: '/ui/minipanel.png',
|
||||
minipanelBtns: '/ui/minipanel-btns.png',
|
||||
|
|
@ -1501,6 +1574,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
|
|||
automapAct4S: '/ui/automap-act4s.png',
|
||||
automapExTn: '/ui/automap-extn.png',
|
||||
automapExTnS: '/ui/automap-extns.png',
|
||||
portraitsAtlas: '/ui/portraits-atlas.png',
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
|
|
@ -1511,6 +1585,8 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
|
|||
atlasHeight: atlasH,
|
||||
questsAtlasWidth: QUEST_ATLAS_W,
|
||||
questsAtlasHeight: QUEST_ATLAS_H,
|
||||
portraitsAtlasWidth: PORTRAIT_ATLAS_W,
|
||||
portraitsAtlasHeight: PORTRAIT_ATLAS_H,
|
||||
codeToInvFile,
|
||||
codeToFlippyFile,
|
||||
itemRects,
|
||||
|
|
@ -1518,6 +1594,7 @@ export async function bakeUi(mpqDir = 'samples/d2', outBaseDir = 'public'): Prom
|
|||
flippyFrames,
|
||||
itemClassIdToCode,
|
||||
questRects,
|
||||
portraitRects,
|
||||
images: imagesMap,
|
||||
automapAtlases,
|
||||
}
|
||||
|
|
@ -1598,6 +1675,7 @@ export interface BakedUiManifest {
|
|||
readonly flippyFrames: Record<string, readonly SpriteRect[]>
|
||||
readonly itemClassIdToCode: Record<number, string>
|
||||
readonly questRects: Record<string, SpriteRect>
|
||||
readonly portraitRects?: Record<string, SpriteRect>
|
||||
readonly images: Record<string, string>
|
||||
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
|
||||
}
|
||||
|
|
@ -1667,6 +1745,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
|||
flippyFrames: ${JSON.stringify(flippyFrames, null, 2)},
|
||||
itemClassIdToCode: ${JSON.stringify(itemClassIdToCode, null, 2)},
|
||||
questRects: ${JSON.stringify(questRects, null, 2)},
|
||||
portraitRects: ${JSON.stringify(portraitRects, null, 2)},
|
||||
images: ${JSON.stringify(imagesMap, null, 2)},
|
||||
automapAtlases: AUTOMAP_UI_MANIFEST.atlases,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -430,8 +430,8 @@ export class CommandMapper {
|
|||
|
||||
// 2. Route HUD clicks to HudManager (status bar, orbs, skill popup, belt, open panels)
|
||||
if (this.hudManager.isPointInterceptedByHud(screenX, screenY)) {
|
||||
if (button === 0) {
|
||||
this.hudManager.handleClick(screenX, screenY)
|
||||
if (button === 0 || button === 2) {
|
||||
this.hudManager.handleClick(screenX, screenY, button)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -870,6 +870,10 @@ async function bootPlayApp(): Promise<PlayAppController> {
|
|||
e.preventDefault()
|
||||
frontendRenderer.handleWheel(e.deltaY)
|
||||
frontendRenderer.render(performance.now())
|
||||
} else if (inGameView && session?.hudManager) {
|
||||
if (session.hudManager.handleWheel(e.deltaY)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
},
|
||||
{ passive: false },
|
||||
|
|
|
|||
|
|
@ -41,6 +41,12 @@ import type { ClientWorld, NpcInteractionState } from '../world/client-world.ts'
|
|||
import { D2StatId } from '../world/stat-list.ts'
|
||||
import { D2_EXPERIENCE_TABLE_99 } from '../../common/skills/formulas.ts'
|
||||
import { computeCharacterSheetDerived } from './derived-stats.ts'
|
||||
import { resolveHirelingCandidates, type HirelingCandidateDesc } from '../../common/units/hireling-table.ts'
|
||||
import {
|
||||
resolveMercPortraitIcon,
|
||||
resolvePlayerClassPortraitIcon,
|
||||
resolvePetPortraitInfo,
|
||||
} from '../../common/units/pet-type-table.ts'
|
||||
|
||||
export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = {
|
||||
0: 'ama',
|
||||
|
|
@ -819,10 +825,12 @@ export class HudModel implements HudCommandSink {
|
|||
equipment: mercEquipment,
|
||||
equipped: mercEquipped,
|
||||
}
|
||||
const mercIconKey = resolveMercPortraitIcon(this.world.merc?.classId ?? mercRaw?.classId)
|
||||
const syncedMerc = this.world.merc
|
||||
? {
|
||||
...this.world.merc,
|
||||
unitId: mercRaw.unitId ?? this.world.merc.mercId,
|
||||
iconKey: mercIconKey,
|
||||
...mercCommonFields,
|
||||
}
|
||||
: this.world.containers.merc.size > 0
|
||||
|
|
@ -832,39 +840,77 @@ export class HudModel implements HudCommandSink {
|
|||
nameId: 0,
|
||||
isDead: false,
|
||||
reviveCost: 0,
|
||||
iconKey: mercIconKey,
|
||||
...mercCommonFields,
|
||||
}
|
||||
: null
|
||||
;(hud as any).merc = syncedMerc
|
||||
;(hud.worldPanels as any).merc = syncedMerc
|
||||
|
||||
// 5c2. Summoned Minions (PetType.txt & Hireables.cpp)
|
||||
const summonPortraits: Array<{
|
||||
readonly petType: number
|
||||
readonly iconKey: string
|
||||
readonly label: string
|
||||
readonly count: number
|
||||
readonly lifePct: number
|
||||
readonly drawHp: boolean
|
||||
readonly iconType: number
|
||||
}> = []
|
||||
|
||||
if (this.world.pets && this.world.pets.size > 0) {
|
||||
const activePets = [...this.world.pets.values()].filter(
|
||||
p => !p.isDead && (p.ownerId === this.world.selfId || this.world.selfId === 0),
|
||||
)
|
||||
const byType = new Map<number, typeof activePets>()
|
||||
for (const p of activePets) {
|
||||
if (p.petType === 7) continue // Mercenary handled separately
|
||||
const arr = byType.get(p.petType) ?? []
|
||||
arr.push(p)
|
||||
byType.set(p.petType, arr)
|
||||
}
|
||||
for (const [petTypeIdx, pets] of byType.entries()) {
|
||||
const first = pets[0]!
|
||||
const portInfo = resolvePetPortraitInfo(petTypeIdx, first.classId)
|
||||
if (!portInfo) continue
|
||||
const count = pets.length
|
||||
const avgLifePct = Math.round(pets.reduce((sum, p) => sum + (p.lifePct ?? 100), 0) / count)
|
||||
summonPortraits.push({
|
||||
petType: petTypeIdx,
|
||||
iconKey: portInfo.iconKey,
|
||||
label: ((hud as any).lang === 'CHI' || (hud as any)._lang === 'CHI') ? portInfo.nameZh : portInfo.nameEn,
|
||||
count,
|
||||
lifePct: avgLifePct,
|
||||
drawHp: portInfo.meta.drawHp,
|
||||
iconType: portInfo.meta.iconType,
|
||||
})
|
||||
}
|
||||
}
|
||||
;(hud as any).summonPortraits = summonPortraits
|
||||
|
||||
const rawHireList =
|
||||
(this.world.mercHireList && this.world.mercHireList.length > 0
|
||||
? this.world.mercHireList
|
||||
: (this.world.merc as any)?.hireList) ?? []
|
||||
const hireCandidates = rawHireList.map((entry: any, idx: number) => {
|
||||
const nameId = entry.nameId ?? entry.mercId ?? idx + 1
|
||||
const mercId = entry.mercId ?? entry.nameId ?? idx + 1
|
||||
const seed = entry.seed ?? 0
|
||||
const level = entry.level ?? Math.max(1, (seed % 15) + 5)
|
||||
return {
|
||||
...entry,
|
||||
mercId,
|
||||
nameId,
|
||||
seed,
|
||||
name: entry.name || `Mercenary #${nameId}`,
|
||||
className: entry.className ?? `Type #${entry.mercClass ?? 0}`,
|
||||
level,
|
||||
cost: entry.cost ?? level * 150 + 100,
|
||||
life: entry.life ?? 40 + level * 12,
|
||||
strength: entry.strength ?? 35 + level * 2,
|
||||
dexterity: entry.dexterity ?? 30 + level * 2,
|
||||
defense: entry.defense ?? 20 + level * 6,
|
||||
}
|
||||
})
|
||||
const playerLevel = Math.max(1, this.world.self.level ?? (this.world.self as any).stats?.get?.(12) ?? 1)
|
||||
const difficulty = this.world.difficulty ?? 0
|
||||
const activeNpcDesc = (hud.worldPanels as any).npcMenu?.descriptor ?? (this.world.npcInteraction.npcId ? this.tryNpcDescriptor(this.world.npcInteraction.npcId) : undefined)
|
||||
const actIndex = activeNpcDesc ? activeNpcDesc.actIndex : this.world.act ?? 0
|
||||
const hireLang = (hud.lang === 'ENG' || (hud as any).language === 'en') ? 'ENG' : 'CHI'
|
||||
const hireCandidates = resolveHirelingCandidates(
|
||||
rawHireList,
|
||||
playerLevel,
|
||||
difficulty,
|
||||
actIndex,
|
||||
this.world.expansion ?? true,
|
||||
hireLang,
|
||||
)
|
||||
;(hud as any).hireList = hireCandidates
|
||||
;(hud as any).mercHireList = hireCandidates
|
||||
;(hud.worldPanels as any).hireList = hireCandidates
|
||||
;(hud.worldPanels as any).mercHireList = hireCandidates
|
||||
;(hud.worldPanels as any).playerGold = this.world.self.gold ?? 0
|
||||
;(hud.worldPanels as any).stashGold = this.world.self.stashGold ?? 0
|
||||
;(hud.worldPanels as any).hireMercPanel = {
|
||||
open: (hud.leftPanel as string) === 'hire' || (hud.worldPanels as any).activePanel === 'hire',
|
||||
candidates: hireCandidates,
|
||||
|
|
@ -876,7 +922,14 @@ export class HudModel implements HudCommandSink {
|
|||
;(hud.worldPanels as any).hasUnidentifiedItems = this.hasUnidentifiedItems()
|
||||
|
||||
// 5d. Party state
|
||||
const partyList = [...this.world.party.values()].map((m: any) => {
|
||||
const partyList = [...this.world.party.values()]
|
||||
.filter((m: any) => {
|
||||
const pid = m.unitId ?? m.playerId ?? 0
|
||||
if (pid === 0) return false
|
||||
if (this.world.selfId !== 0 && pid === this.world.selfId) return false
|
||||
return true
|
||||
})
|
||||
.map((m: any) => {
|
||||
const pid = m.unitId ?? m.playerId ?? 0
|
||||
const resolvedName = m.name || m.charName || `Player #${pid}`
|
||||
const resolvedLevel = m.level ?? m.charLevel ?? 1
|
||||
|
|
@ -900,6 +953,7 @@ export class HudModel implements HudCommandSink {
|
|||
name: resolvedName,
|
||||
charName: resolvedName,
|
||||
charClass: m.charClass ?? 0,
|
||||
portraitIcon: resolvePlayerClassPortraitIcon(m.charClass),
|
||||
level: resolvedLevel,
|
||||
charLevel: resolvedLevel,
|
||||
partyId: m.partyId ?? 0xffff,
|
||||
|
|
@ -973,10 +1027,14 @@ export class HudModel implements HudCommandSink {
|
|||
this.prevNpc = npc
|
||||
const wp = hud.worldPanels
|
||||
|
||||
const activeWin = npc.activeWindow ?? npc.window
|
||||
const isHireOpen = (hud.leftPanel as string) === 'hire' || (wp as any).activePanel === 'hire'
|
||||
|
||||
// a. NpcInit noted without a window: open the NPC menu for service NPCs (npc-table descriptors).
|
||||
if (
|
||||
npc.npcId !== null &&
|
||||
npc.window === 'none' &&
|
||||
activeWin === 'none' &&
|
||||
!isHireOpen &&
|
||||
(npc.npcId !== prev.npcId || !wp.npcMenu || (wp.npcMenu.npcId === npc.npcId && !wp.npcMenu.talking))
|
||||
) {
|
||||
const desc = this.tryNpcDescriptor(npc.npcId)
|
||||
|
|
@ -990,7 +1048,11 @@ export class HudModel implements HudCommandSink {
|
|||
})
|
||||
}
|
||||
}
|
||||
if (npc.npcId === null && wp.npcMenu && wp.npcMenu.npcId !== null) wp.npcMenu = null
|
||||
if ((npc.npcId === null || isHireOpen) && wp.npcMenu && wp.npcMenu.npcId !== null) {
|
||||
if (npc.npcId === null || isHireOpen) {
|
||||
wp.npcMenu = null
|
||||
}
|
||||
}
|
||||
|
||||
// b. Trade / gamble window: store items come from 0x9C AddToShop (`containers.shop`).
|
||||
const npcWin = npc.window as string
|
||||
|
|
@ -1016,13 +1078,18 @@ export class HudModel implements HudCommandSink {
|
|||
if (prev.window !== npc.window || prev.npcId !== npcId) hud.showVendorPanel()
|
||||
} else if (
|
||||
npcWin === 'hire' ||
|
||||
activeWin === 'hire' ||
|
||||
npcWin === 'imbue' ||
|
||||
npcWin === 'add_sockets' ||
|
||||
npcWin === 'personalize'
|
||||
) {
|
||||
hud.leftPanel = npcWin as any
|
||||
;(wp as any).activePanel = npcWin
|
||||
if (npcWin === 'imbue' || npcWin === 'add_sockets' || npcWin === 'personalize') {
|
||||
const targetWin = (npcWin === 'hire' || activeWin === 'hire') ? 'hire' : npcWin
|
||||
hud.leftPanel = targetWin as any
|
||||
;(wp as any).activePanel = targetWin
|
||||
if (targetWin === 'hire') {
|
||||
wp.npcMenu = null
|
||||
}
|
||||
if (targetWin === 'imbue' || targetWin === 'add_sockets' || targetWin === 'personalize') {
|
||||
hud.rightPanel = 'inv'
|
||||
hud.inventory.visible = true
|
||||
}
|
||||
|
|
@ -1567,9 +1634,11 @@ export class HudModel implements HudCommandSink {
|
|||
case 'hire': {
|
||||
this.npcEntityAction(3, resolvedNpcId, 0)
|
||||
;(this.world.npcInteraction as any).window = 'hire'
|
||||
;(this.world.npcInteraction as any).activeWindow = 'hire'
|
||||
if (this.hudManager) {
|
||||
this.hudManager.leftPanel = 'hire' as any
|
||||
;(this.hudManager.worldPanels as any).activePanel = 'hire'
|
||||
;(this.hudManager.worldPanels as any).npcMenu = null
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,11 @@ import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from
|
|||
import { ControlBarHud, type MiniPanelAction } from './control-bar.ts'
|
||||
import { SKILLS_BY_ID } from '../../common/data/skills-catalog.ts'
|
||||
import { calculateManaCost } from '../../common/skills/skill-calc-engine.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../../common/items/baked-ui-meta.ts'
|
||||
import {
|
||||
resolveMercPortraitIcon,
|
||||
resolvePlayerClassPortraitIcon,
|
||||
} from '../../common/units/pet-type-table.ts'
|
||||
import {
|
||||
InventoryPanel,
|
||||
type EquipSlotId,
|
||||
|
|
@ -314,6 +319,84 @@ export function logicalToClient(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space
|
||||
* of the bottom control bar region (`controlBar`, `globes`, `belt`, `hotkeys`).
|
||||
*/
|
||||
export function toControlBarLocal(
|
||||
x: number,
|
||||
y: number,
|
||||
docking: Pick<DockingLayout, 'barOffsetX' | 'barOffsetY'>,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: x - (docking.barOffsetX ?? 0),
|
||||
y: y - (docking.barOffsetY ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space
|
||||
* of the left dock panel region (`charSheet`, `worldPanels`).
|
||||
*/
|
||||
export function toLeftDockLocal(
|
||||
x: number,
|
||||
y: number,
|
||||
docking: Pick<DockingLayout, 'leftDockX' | 'panelOffsetY'>,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: x - docking.leftDockX,
|
||||
y: y - (docking.panelOffsetY ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-translates a left-dock-local coordinate `(localX, localY)` back into
|
||||
* logical viewport coordinates (used for hovered item tooltip anchoring).
|
||||
*/
|
||||
export function fromLeftDockLocal(
|
||||
localX: number,
|
||||
localY: number,
|
||||
docking: Pick<DockingLayout, 'leftDockX' | 'panelOffsetY'>,
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: localX + docking.leftDockX,
|
||||
y: localY + (docking.panelOffsetY ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a logical viewport coordinate `(x, y)` into the 800×600-local coordinate space
|
||||
* of the right dock panel region (`inventory`, `skillTree`), where the right panel is
|
||||
* authored at `x = 400..800, y = 0..553`.
|
||||
*/
|
||||
export function toRightDockLocal(
|
||||
x: number,
|
||||
y: number,
|
||||
docking: Pick<DockingLayout, 'rightDockX' | 'panelOffsetY'>,
|
||||
): { x: number; y: number } {
|
||||
const deltaRight = docking.rightDockX - 400
|
||||
return {
|
||||
x: x - deltaRight,
|
||||
y: y - (docking.panelOffsetY ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse-translates a right-dock-local coordinate `(localX, localY)` back into
|
||||
* logical viewport coordinates (used for hovered item tooltip anchoring).
|
||||
*/
|
||||
export function fromRightDockLocal(
|
||||
localX: number,
|
||||
localY: number,
|
||||
docking: Pick<DockingLayout, 'rightDockX' | 'panelOffsetY'>,
|
||||
): { x: number; y: number } {
|
||||
const deltaRight = docking.rightDockX - 400
|
||||
return {
|
||||
x: localX + deltaRight,
|
||||
y: localY + (docking.panelOffsetY ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Server-authoritative HUD command sink (online mode: `HudModel`). Every method only emits a `ClientCommand`;
|
||||
|
|
@ -444,8 +527,27 @@ export class HudManager {
|
|||
readonly worldPanels = new WorldPanelsHud()
|
||||
readonly cursor = new CursorController()
|
||||
|
||||
leftPanel: LeftPanelType = 'none'
|
||||
rightPanel: 'none' | 'inv' | 'skill' = 'none'
|
||||
private _leftPanel: LeftPanelType = 'none'
|
||||
get leftPanel(): LeftPanelType {
|
||||
return this._leftPanel
|
||||
}
|
||||
set leftPanel(val: LeftPanelType) {
|
||||
this._leftPanel = val
|
||||
this.charSheet.visible = val === 'char'
|
||||
}
|
||||
|
||||
private _rightPanel: 'none' | 'inv' | 'skill' = 'none'
|
||||
get rightPanel(): 'none' | 'inv' | 'skill' {
|
||||
return this._rightPanel
|
||||
}
|
||||
set rightPanel(val: 'none' | 'inv' | 'skill') {
|
||||
this._rightPanel = val
|
||||
this.inventory.visible = val === 'inv'
|
||||
this.skillTree.visible = val === 'skill'
|
||||
if (val !== 'skill') {
|
||||
this.skillTree.hoveredSkill = null
|
||||
}
|
||||
}
|
||||
isRunning = true
|
||||
showGroundLabels = false
|
||||
/** Screen-space (800x600 viewport) ground item label boxes laid out for the current frame. */
|
||||
|
|
@ -461,9 +563,10 @@ export class HudManager {
|
|||
/** Server-authoritative death / mercenary / party / trade state synced by `HudModel`. */
|
||||
isDead = false
|
||||
merc: any = null
|
||||
hireList: any[] = []
|
||||
mercHireList: any[] = []
|
||||
hireList: readonly any[] = []
|
||||
mercHireList: readonly any[] = []
|
||||
partyMembers: any[] = []
|
||||
summonPortraits: any[] = []
|
||||
trade: any = null
|
||||
pendingIdentifyScrollId: number | null = null
|
||||
|
||||
|
|
@ -486,6 +589,8 @@ export class HudManager {
|
|||
|
||||
private images = new Map<string, HTMLImageElement>()
|
||||
private itemRects: Record<string, { x: number; y: number; w: number; h: number }> = {}
|
||||
private portraitRects: Record<string, { x: number; y: number; w: number; h: number }> =
|
||||
(BAKED_UI_MANIFEST as any).portraitRects ?? {}
|
||||
private decodedDc6Count = 415
|
||||
private dc6DecodeFailures = 0
|
||||
private eventsBound = false
|
||||
|
|
@ -736,28 +841,108 @@ export class HudManager {
|
|||
this.mouseX = logicalX
|
||||
this.mouseY = logicalY
|
||||
this.cursor.handleMouseMove(logicalX, logicalY)
|
||||
this.globes.handleMouseMove(logicalX, logicalY)
|
||||
this.belt.handleMouseMove(logicalX, logicalY)
|
||||
this.hotkeys.handleMouseMove(logicalX, logicalY)
|
||||
this.controlBar.handleMouseMove(logicalX, logicalY)
|
||||
if (this.worldPanels.npcMenu) {
|
||||
this.worldPanels.handleNpcMenuMove(logicalX, logicalY, this.font)
|
||||
}
|
||||
const docking = this.getDockingLayout()
|
||||
const deltaLeft = -docking.marginW
|
||||
const deltaRight = docking.marginW
|
||||
this.inventory.handleMouseMove(logicalX - deltaRight, logicalY)
|
||||
if (this.inventory.hoveredItem) {
|
||||
this.inventory.hoveredItem.x += deltaRight
|
||||
const barPt = toControlBarLocal(logicalX, logicalY, docking)
|
||||
this.globes.handleMouseMove(barPt.x, barPt.y)
|
||||
this.belt.handleMouseMove(barPt.x, barPt.y)
|
||||
this.hotkeys.handleMouseMove(barPt.x, barPt.y)
|
||||
this.controlBar.handleMouseMove(barPt.x, barPt.y)
|
||||
if (this.worldPanels.npcMenu) {
|
||||
this.worldPanels.handleNpcMenuMove(
|
||||
logicalX,
|
||||
logicalY,
|
||||
this.font,
|
||||
this.viewportProfile.width,
|
||||
this.viewportProfile.height,
|
||||
)
|
||||
}
|
||||
this.skillTree.handleMouseMove(logicalX - deltaRight, logicalY)
|
||||
if (
|
||||
this.leftPanel === 'stash' ||
|
||||
this.leftPanel === 'cube' ||
|
||||
this.leftPanel === 'waypoint' ||
|
||||
this.leftPanel === 'vendor'
|
||||
const rightPt = toRightDockLocal(logicalX, logicalY, docking)
|
||||
this.inventory.handleMouseMove(rightPt.x, rightPt.y)
|
||||
if (this.inventory.hoveredItem) {
|
||||
const tipPt = fromRightDockLocal(
|
||||
this.inventory.hoveredItem.x,
|
||||
this.inventory.hoveredItem.y,
|
||||
docking,
|
||||
)
|
||||
this.inventory.hoveredItem.x = tipPt.x
|
||||
this.inventory.hoveredItem.y = tipPt.y
|
||||
}
|
||||
this.skillTree.handleMouseMove(rightPt.x, rightPt.y)
|
||||
if (this.leftPanel !== 'none' && this.leftPanel !== 'char') {
|
||||
const leftPt = toLeftDockLocal(logicalX, logicalY, docking)
|
||||
this.worldPanels.handleMouseMove(leftPt.x, leftPt.y)
|
||||
if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) {
|
||||
const tipPt = fromLeftDockLocal(
|
||||
this.worldPanels.hoveredStashItem.x,
|
||||
this.worldPanels.hoveredStashItem.y,
|
||||
docking,
|
||||
)
|
||||
this.inventory.hoveredItem = {
|
||||
...this.worldPanels.hoveredStashItem,
|
||||
x: tipPt.x,
|
||||
y: tipPt.y,
|
||||
}
|
||||
} else if (this.leftPanel === 'cube' && this.worldPanels.hoveredCubeItem) {
|
||||
const tipPt = fromLeftDockLocal(
|
||||
this.worldPanels.hoveredCubeItem.x,
|
||||
this.worldPanels.hoveredCubeItem.y,
|
||||
docking,
|
||||
)
|
||||
this.inventory.hoveredItem = {
|
||||
...this.worldPanels.hoveredCubeItem,
|
||||
x: tipPt.x,
|
||||
y: tipPt.y,
|
||||
}
|
||||
} else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) {
|
||||
const tipPt = fromLeftDockLocal(
|
||||
this.worldPanels.hoveredVendorItem.x,
|
||||
this.worldPanels.hoveredVendorItem.y,
|
||||
docking,
|
||||
)
|
||||
this.inventory.hoveredItem = {
|
||||
item: this.worldPanels.hoveredVendorItem.item,
|
||||
x: tipPt.x,
|
||||
y: tipPt.y,
|
||||
}
|
||||
} else if (this.leftPanel === 'merc' && this.worldPanels.hoveredMercItem) {
|
||||
const tipPt = fromLeftDockLocal(
|
||||
this.worldPanels.hoveredMercItem.x,
|
||||
this.worldPanels.hoveredMercItem.y,
|
||||
docking,
|
||||
)
|
||||
this.inventory.hoveredItem = {
|
||||
...this.worldPanels.hoveredMercItem,
|
||||
x: tipPt.x,
|
||||
y: tipPt.y,
|
||||
}
|
||||
} else if (this.leftPanel === 'trade' && this.worldPanels.hoveredTradeItem) {
|
||||
const tipPt = fromLeftDockLocal(
|
||||
this.worldPanels.hoveredTradeItem.x,
|
||||
this.worldPanels.hoveredTradeItem.y,
|
||||
docking,
|
||||
)
|
||||
this.inventory.hoveredItem = {
|
||||
...this.worldPanels.hoveredTradeItem,
|
||||
x: tipPt.x,
|
||||
y: tipPt.y,
|
||||
}
|
||||
} else if (
|
||||
(this.leftPanel === 'imbue' ||
|
||||
this.leftPanel === 'add_sockets' ||
|
||||
this.leftPanel === 'personalize') &&
|
||||
this.worldPanels.hoveredQuestRewardItem
|
||||
) {
|
||||
this.worldPanels.handleMouseMove(logicalX - deltaLeft, logicalY)
|
||||
const tipPt = fromLeftDockLocal(
|
||||
this.worldPanels.hoveredQuestRewardItem.x,
|
||||
this.worldPanels.hoveredQuestRewardItem.y,
|
||||
docking,
|
||||
)
|
||||
this.inventory.hoveredItem = {
|
||||
...this.worldPanels.hoveredQuestRewardItem,
|
||||
x: tipPt.x,
|
||||
y: tipPt.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -783,8 +968,11 @@ export class HudManager {
|
|||
try {
|
||||
const mRes = await fetch(`${baseUrl}/manifest.json`)
|
||||
if (mRes.ok) {
|
||||
const manifest = (await mRes.json()) as UiManifest
|
||||
const manifest = (await mRes.json()) as any
|
||||
this.itemRects = manifest.itemRects
|
||||
if (manifest.portraitRects) {
|
||||
this.portraitRects = manifest.portraitRects
|
||||
}
|
||||
this.decodedDc6Count = manifest.decodedDc6Count
|
||||
this.dc6DecodeFailures = manifest.dc6DecodeFailures
|
||||
}
|
||||
|
|
@ -831,12 +1019,14 @@ export class HudManager {
|
|||
cursorAtlas: `${baseUrl}/cursor.png`,
|
||||
boxPieces: `${baseUrl}/boxpieces.png`,
|
||||
focus16: `${baseUrl}/focus16.png`,
|
||||
textSlid: `${baseUrl}/textslid.png`,
|
||||
escOptions: `${baseUrl}/esc-options.png`,
|
||||
escExit: `${baseUrl}/esc-exit.png`,
|
||||
escReturn: `${baseUrl}/esc-return.png`,
|
||||
pentSpin: `${baseUrl}/pentspin.png`,
|
||||
itemsAtlas: `${baseUrl}/items-atlas.png`,
|
||||
questsAtlas: `${baseUrl}/quests-atlas.png`,
|
||||
portraitsAtlas: `${baseUrl}/portraits-atlas.png`,
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
|
|
@ -1172,6 +1362,13 @@ export class HudManager {
|
|||
return this.activateEscapeMenuIndex(hit)
|
||||
}
|
||||
|
||||
handleWheel(deltaY: number): boolean {
|
||||
if (this.leftPanel === 'hire' || (this.worldPanels as any).activePanel === 'hire') {
|
||||
return this.worldPanels.handleHireWheel(deltaY)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
closeAllPanels(): boolean {
|
||||
const anyOpen =
|
||||
this.leftPanel !== 'none' ||
|
||||
|
|
@ -1276,7 +1473,8 @@ export class HudManager {
|
|||
if (logicalX < -marginW || logicalX > profile.width + marginW || logicalY < 0 || logicalY > profile.height) {
|
||||
return false
|
||||
}
|
||||
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true
|
||||
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font, profile.width, profile.height)) return true
|
||||
if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY, profile.width, profile.height)) return true
|
||||
const barX = logicalX - profile.barOffsetX
|
||||
const barY = logicalY - profile.barOffsetY
|
||||
if (barY >= 540 && barY <= 600 && barX >= 0 && barX <= 800) return true
|
||||
|
|
@ -1297,7 +1495,8 @@ export class HudManager {
|
|||
}
|
||||
|
||||
if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false
|
||||
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) return true
|
||||
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font, profile.width, profile.height)) return true
|
||||
if (this.worldPanels.isPointInHireMercDialog(logicalX, logicalY)) return true
|
||||
if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true
|
||||
if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true
|
||||
if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true
|
||||
|
|
@ -1475,52 +1674,7 @@ export class HudManager {
|
|||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
const pt = this.clientToLogical(e.clientX, e.clientY)
|
||||
this.mouseX = pt.x
|
||||
this.mouseY = pt.y
|
||||
this.cursor.handleMouseMove(pt.x, pt.y)
|
||||
this.globes.handleMouseMove(pt.x, pt.y)
|
||||
this.belt.handleMouseMove(pt.x, pt.y)
|
||||
this.hotkeys.handleMouseMove(pt.x, pt.y)
|
||||
this.controlBar.handleMouseMove(pt.x, pt.y)
|
||||
|
||||
if (this.worldPanels.npcMenu) {
|
||||
this.worldPanels.handleNpcMenuMove(pt.x, pt.y, this.font)
|
||||
}
|
||||
|
||||
const docking = this.getDockingLayout()
|
||||
const deltaLeft = -docking.marginW
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
this.inventory.handleMouseMove(pt.x - deltaRight, pt.y)
|
||||
if (this.inventory.hoveredItem) {
|
||||
this.inventory.hoveredItem.x += deltaRight
|
||||
}
|
||||
this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y)
|
||||
if (
|
||||
this.leftPanel === 'stash' ||
|
||||
this.leftPanel === 'cube' ||
|
||||
this.leftPanel === 'waypoint' ||
|
||||
this.leftPanel === 'vendor'
|
||||
) {
|
||||
this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y)
|
||||
if (this.leftPanel === 'stash' && this.worldPanels.hoveredStashItem) {
|
||||
this.inventory.hoveredItem = {
|
||||
...this.worldPanels.hoveredStashItem,
|
||||
x: this.worldPanels.hoveredStashItem.x + deltaLeft,
|
||||
}
|
||||
} else if (this.leftPanel === 'cube' && this.worldPanels.hoveredCubeItem) {
|
||||
this.inventory.hoveredItem = {
|
||||
...this.worldPanels.hoveredCubeItem,
|
||||
x: this.worldPanels.hoveredCubeItem.x + deltaLeft,
|
||||
}
|
||||
} else if (this.leftPanel === 'vendor' && this.worldPanels.hoveredVendorItem) {
|
||||
this.inventory.hoveredItem = {
|
||||
item: this.worldPanels.hoveredVendorItem.item,
|
||||
x: this.worldPanels.hoveredVendorItem.x + deltaLeft,
|
||||
y: this.worldPanels.hoveredVendorItem.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
this.handleMouseMove(pt.x, pt.y)
|
||||
|
||||
const intercept =
|
||||
this.isPointInterceptedByHud(this.mouseX, this.mouseY) ||
|
||||
|
|
@ -1575,7 +1729,11 @@ export class HudManager {
|
|||
if (e.button === 0) this.handleEscapeMenuClick(pt.x, pt.y)
|
||||
return
|
||||
}
|
||||
if (this.worldPanels.npcMenu && !this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font)) {
|
||||
if (
|
||||
this.worldPanels.npcMenu &&
|
||||
!this.worldPanels.isPointInNpcMenu(pt.x, pt.y, this.font) &&
|
||||
!this.worldPanels.isPointInHireMercDialog(pt.x, pt.y)
|
||||
) {
|
||||
this.dismissNpcMenu()
|
||||
}
|
||||
if (!this.isPointInterceptedByHud(pt.x, pt.y)) {
|
||||
|
|
@ -1637,13 +1795,18 @@ export class HudManager {
|
|||
},
|
||||
},
|
||||
this.font,
|
||||
this.viewportProfile.width,
|
||||
this.viewportProfile.height,
|
||||
)
|
||||
this.syncPublishedState()
|
||||
return
|
||||
}
|
||||
|
||||
const docking = this.getDockingLayout()
|
||||
const barPt = toControlBarLocal(pt.x, pt.y, docking)
|
||||
|
||||
// Belt slot click: right-click drinks potion; left-click places/swaps/removes belt item in online mode
|
||||
const beltHit = this.belt.slotAtPoint(pt.x, pt.y)
|
||||
const beltHit = this.belt.slotAtPoint(barPt.x, barPt.y)
|
||||
if (beltHit) {
|
||||
if (e.button === 2) {
|
||||
this.drinkBeltSlot(beltHit.col, Boolean(e.shiftKey))
|
||||
|
|
@ -1681,19 +1844,19 @@ export class HudManager {
|
|||
return
|
||||
}
|
||||
|
||||
if (this.globes.handleClick(pt.x, pt.y)) {
|
||||
if (this.globes.handleClick(barPt.x, barPt.y)) {
|
||||
this.syncPublishedState()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.hotkeys.handleClick(pt.x, pt.y)) {
|
||||
if (this.hotkeys.handleClick(barPt.x, barPt.y)) {
|
||||
this.syncPublishedState()
|
||||
return
|
||||
}
|
||||
|
||||
const ctrlState = this.buildControlBarState()
|
||||
if (
|
||||
this.controlBar.handleClick(pt.x, pt.y, ctrlState, {
|
||||
this.controlBar.handleClick(barPt.x, barPt.y, ctrlState, {
|
||||
onToggleRun: () => {
|
||||
this.isRunning = !this.isRunning
|
||||
},
|
||||
|
|
@ -1711,15 +1874,11 @@ export class HudManager {
|
|||
return
|
||||
}
|
||||
|
||||
const docking = this.getDockingLayout()
|
||||
const deltaLeft = -docking.marginW
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
// Left-Half Dock Click
|
||||
if (pt.x <= 400 + deltaLeft) {
|
||||
const leftX = pt.x - deltaLeft
|
||||
const leftPt = toLeftDockLocal(pt.x, pt.y, docking)
|
||||
if (leftPt.x >= 0 && leftPt.x <= 400 && leftPt.y >= 0 && leftPt.y <= 553) {
|
||||
if (this.leftPanel === 'char') {
|
||||
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
|
||||
this.charSheet.handleClick(leftPt.x, leftPt.y, (key: BaseStatKey) => {
|
||||
this._commandSink?.allocateStat(key)
|
||||
})
|
||||
if (!this.charSheet.visible) this.leftPanel = 'none'
|
||||
|
|
@ -1730,8 +1889,8 @@ export class HudManager {
|
|||
if (typeof (this.worldPanels as any).handleActiveLeftPanelClick === 'function') {
|
||||
const handled = (this.worldPanels as any).handleActiveLeftPanelClick(
|
||||
dockKind,
|
||||
leftX,
|
||||
pt.y,
|
||||
leftPt.x,
|
||||
leftPt.y,
|
||||
e.button === 2,
|
||||
e.shiftKey,
|
||||
{
|
||||
|
|
@ -1808,7 +1967,7 @@ export class HudManager {
|
|||
return
|
||||
}
|
||||
}
|
||||
this.worldPanels.handleLeftDockClick(dockKind as any, leftX, pt.y, {
|
||||
this.worldPanels.handleLeftDockClick(dockKind as any, leftPt.x, leftPt.y, {
|
||||
onClose: () => {
|
||||
this.leftPanel = 'none'
|
||||
this.leaveLeftPanel(dockKind)
|
||||
|
|
@ -1848,20 +2007,20 @@ export class HudManager {
|
|||
}
|
||||
|
||||
// Right-Half Dock Click
|
||||
if (pt.x >= 400 + deltaRight) {
|
||||
const rightX = pt.x - deltaRight
|
||||
const rightPt = toRightDockLocal(pt.x, pt.y, docking)
|
||||
if (rightPt.x >= 400 && rightPt.x <= 800 && rightPt.y >= 0 && rightPt.y <= 553) {
|
||||
if (this.rightPanel === 'inv') {
|
||||
if (!this.inventory.isIdentifyMode() && !this._commandSink) {
|
||||
// Stash quick transfer (Shift+Click, or Right-Click on non-usable items)
|
||||
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
||||
if (
|
||||
rightX >= INV_GRID_ORIGIN.x &&
|
||||
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||
pt.y >= INV_GRID_ORIGIN.y &&
|
||||
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
||||
rightPt.x >= INV_GRID_ORIGIN.x &&
|
||||
rightPt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||
rightPt.y >= INV_GRID_ORIGIN.y &&
|
||||
rightPt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
||||
) {
|
||||
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||
const col = Math.floor((rightPt.x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||
const row = Math.floor((rightPt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||
const hit = this.inventory.gridItems.find(
|
||||
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||
)
|
||||
|
|
@ -1876,13 +2035,13 @@ export class HudManager {
|
|||
// Horadric Cube quick transfer (Shift+Click, or Right-Click on non-usable items)
|
||||
if (this.leftPanel === 'cube' && (e.shiftKey || e.button === 2)) {
|
||||
if (
|
||||
rightX >= INV_GRID_ORIGIN.x &&
|
||||
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||
pt.y >= INV_GRID_ORIGIN.y &&
|
||||
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
||||
rightPt.x >= INV_GRID_ORIGIN.x &&
|
||||
rightPt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||
rightPt.y >= INV_GRID_ORIGIN.y &&
|
||||
rightPt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
||||
) {
|
||||
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||
const col = Math.floor((rightPt.x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||
const row = Math.floor((rightPt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||
const hit = this.inventory.gridItems.find(
|
||||
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||
)
|
||||
|
|
@ -1899,21 +2058,21 @@ export class HudManager {
|
|||
this.leftPanel === 'vendor' &&
|
||||
!this.inventory.isIdentifyMode() &&
|
||||
this.pendingIdentifyScrollId === null &&
|
||||
this.handleVendorInventoryClick(rightX, pt.y, e.button === 2)
|
||||
this.handleVendorInventoryClick(rightPt.x, rightPt.y, e.button === 2)
|
||||
) {
|
||||
this.syncPublishedState()
|
||||
return
|
||||
}
|
||||
|
||||
if (this._commandSink && this.forwardInventoryPanelClick(rightX, pt.y, e.button === 2)) {
|
||||
if (this._commandSink && this.forwardInventoryPanelClick(rightPt.x, rightPt.y, e.button === 2)) {
|
||||
this.syncPublishedState()
|
||||
return
|
||||
}
|
||||
|
||||
this.syncPlayerContext()
|
||||
this.inventory.handleClick(
|
||||
rightX,
|
||||
pt.y,
|
||||
rightPt.x,
|
||||
rightPt.y,
|
||||
{
|
||||
onOpenCube: () => this.toggleLeftPanel('cube'),
|
||||
onCastTownPortal: (source, item) => this.callbacks.onCastTownPortal?.(source, item),
|
||||
|
|
@ -1929,7 +2088,7 @@ export class HudManager {
|
|||
this.syncPublishedState()
|
||||
return
|
||||
} else if (this.rightPanel === 'skill') {
|
||||
this.skillTree.handleClick(rightX, pt.y, (skillId) => this.handleSkillAllocated(skillId))
|
||||
this.skillTree.handleClick(rightPt.x, rightPt.y, (skillId) => this.handleSkillAllocated(skillId))
|
||||
if (!this.skillTree.visible) this.rightPanel = 'none'
|
||||
this.syncPublishedState()
|
||||
return
|
||||
|
|
@ -2336,49 +2495,113 @@ export class HudManager {
|
|||
ctx.translate(Math.round(offsetX * dpr), Math.round(offsetY * dpr))
|
||||
ctx.scale(uiScale * dpr, uiScale * dpr)
|
||||
|
||||
// 0b. Top-Left Mercenary Portrait & Life Bar (R3.3) + Party Mini-Portraits (R5.1)
|
||||
// 0b. Top-Left Mercenary Portrait & Life Bar (R3.3) + Summoned Minions + Party Mini-Portraits (R5.1)
|
||||
if (this.leftPanel === 'none') {
|
||||
const atlasImg = this.images.get('portraitsAtlas')
|
||||
let slotX = 8
|
||||
|
||||
// 1. Mercenary (slot 0)
|
||||
if (this.merc && !this.merc.isDead) {
|
||||
const hpRatio = Math.max(0, Math.min(1, Number(this.merc.hp ?? 0) / Math.max(1, Number(this.merc.maxHp ?? 1))))
|
||||
ctx.fillStyle = 'rgba(16, 16, 20, 0.85)'
|
||||
ctx.fillRect(8, 8, 48, 48)
|
||||
ctx.fillRect(slotX, 8, 48, 48)
|
||||
ctx.strokeStyle = '#8b7344'
|
||||
ctx.strokeRect(8.5, 8.5, 47, 47)
|
||||
ctx.strokeRect(slotX + 0.5, 8.5, 47, 47)
|
||||
|
||||
const iconKey = String(this.merc.iconKey || resolveMercPortraitIcon(this.merc.classId))
|
||||
const pRect = this.portraitRects[iconKey] || this.portraitRects[iconKey.toLowerCase()]
|
||||
if (pRect && atlasImg) {
|
||||
ctx.drawImage(atlasImg, pRect.x, pRect.y, pRect.w, pRect.h, slotX + 1, 9, 46, 41)
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#181818'
|
||||
ctx.fillRect(8, 58, 48, 6)
|
||||
ctx.fillRect(slotX, 58, 48, 6)
|
||||
ctx.fillStyle = hpRatio > 0.5 ? '#20a020' : hpRatio > 0.25 ? '#d0a020' : '#c02020'
|
||||
ctx.fillRect(8, 58, Math.round(48 * hpRatio), 6)
|
||||
ctx.fillRect(slotX, 58, Math.round(48 * hpRatio), 6)
|
||||
|
||||
const mercLabel = String(this.merc.name || 'Merc')
|
||||
ctx.fillStyle = '#d4b468'
|
||||
ctx.fillText(mercLabel, 32, 22)
|
||||
this.font.drawText(ctx, mercLabel, 32, 22, {
|
||||
this.font.drawText(ctx, mercLabel, slotX + 24, 22, {
|
||||
font: 'font6',
|
||||
color: 'gold',
|
||||
align: 'center',
|
||||
})
|
||||
slotX += 56
|
||||
}
|
||||
if (this.partyMembers.length > 0) {
|
||||
for (let idx = 0; idx < this.partyMembers.length; idx++) {
|
||||
const member = this.partyMembers[idx]
|
||||
if (!member) continue
|
||||
const py = 72 + idx * 52
|
||||
const lifeRatio = Math.max(0, Math.min(1, Number(member.lifePct ?? 100) / 100))
|
||||
ctx.fillStyle = 'rgba(16, 16, 20, 0.82)'
|
||||
ctx.fillRect(8, py, 44, 44)
|
||||
ctx.strokeStyle = member.hostile ? '#c02020' : '#7a6840'
|
||||
ctx.strokeRect(8.5, py + 0.5, 43, 43)
|
||||
|
||||
// 2. Summoned Minions (PetType.txt & Hireables.cpp)
|
||||
if (this.summonPortraits && this.summonPortraits.length > 0) {
|
||||
for (let sIdx = 0; sIdx < this.summonPortraits.length; sIdx++) {
|
||||
const summon = this.summonPortraits[sIdx]
|
||||
if (!summon) continue
|
||||
ctx.fillStyle = 'rgba(16, 16, 20, 0.85)'
|
||||
ctx.fillRect(slotX, 8, 48, 48)
|
||||
ctx.strokeStyle = '#6c5838'
|
||||
ctx.strokeRect(slotX + 0.5, 8.5, 47, 47)
|
||||
|
||||
const sRect = this.portraitRects[summon.iconKey] || this.portraitRects[String(summon.iconKey).toLowerCase()]
|
||||
if (sRect && atlasImg) {
|
||||
ctx.drawImage(atlasImg, sRect.x, sRect.y, sRect.w, sRect.h, slotX + 1, 9, 46, 41)
|
||||
}
|
||||
|
||||
if (summon.drawHp) {
|
||||
const sLifeRatio = Math.max(0, Math.min(1, Number(summon.lifePct ?? 100) / 100))
|
||||
ctx.fillStyle = '#181818'
|
||||
ctx.fillRect(8, py + 45, 44, 5)
|
||||
ctx.fillStyle = '#20a020'
|
||||
ctx.fillRect(8, py + 45, Math.round(44 * lifeRatio), 5)
|
||||
const memberName = String(member.name || `P${member.unitId ?? idx + 1}`)
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fillText(memberName, 30, py + 18)
|
||||
this.font.drawText(ctx, memberName, 30, py + 18, {
|
||||
ctx.fillRect(slotX, 58, 48, 6)
|
||||
ctx.fillStyle = sLifeRatio > 0.5 ? '#20a020' : sLifeRatio > 0.25 ? '#d0a020' : '#c02020'
|
||||
ctx.fillRect(slotX, 58, Math.round(48 * sLifeRatio), 6)
|
||||
}
|
||||
|
||||
if (summon.iconType === 2 || summon.count > 1) {
|
||||
this.font.drawText(ctx, String(summon.count), slotX + 4, 20, {
|
||||
font: 'font6',
|
||||
color: 'white',
|
||||
align: 'left',
|
||||
})
|
||||
}
|
||||
|
||||
const sLabel = String(summon.label || '')
|
||||
this.font.drawText(ctx, sLabel, slotX + 24, 22, {
|
||||
font: 'font6',
|
||||
color: 'white',
|
||||
align: 'center',
|
||||
})
|
||||
slotX += 56
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Allied Party Members (Only in same party, excluding self and unpartied players)
|
||||
if (this.partyMembers.length > 0) {
|
||||
for (let idx = 0; idx < this.partyMembers.length; idx++) {
|
||||
const member = this.partyMembers[idx]
|
||||
if (!member) continue
|
||||
if (member.isSelf === true) continue
|
||||
const isExplicitUnpartied = member.partyId === 0xffff && (member.partyState === 'none' || member.partyState === 0)
|
||||
if (isExplicitUnpartied) continue
|
||||
|
||||
const lifeRatio = Math.max(0, Math.min(1, Number(member.lifePct ?? 100) / 100))
|
||||
ctx.fillStyle = 'rgba(16, 16, 20, 0.82)'
|
||||
ctx.fillRect(slotX, 8, 48, 48)
|
||||
ctx.strokeStyle = member.hostile ? '#c02020' : '#7a6840'
|
||||
ctx.strokeRect(slotX + 0.5, 8.5, 47, 47)
|
||||
|
||||
const classIcon = member.portraitIcon || resolvePlayerClassPortraitIcon(member.charClass)
|
||||
const mRect = this.portraitRects[classIcon] || this.portraitRects[String(classIcon).toLowerCase()]
|
||||
if (mRect && atlasImg) {
|
||||
ctx.drawImage(atlasImg, mRect.x, mRect.y, mRect.w, mRect.h, slotX + 1, 9, 46, 41)
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#181818'
|
||||
ctx.fillRect(slotX, 58, 48, 6)
|
||||
ctx.fillStyle = member.hostile ? '#c02020' : '#20a020'
|
||||
ctx.fillRect(slotX, 58, Math.round(48 * lifeRatio), 6)
|
||||
|
||||
const memberName = String(member.name || `P${member.unitId ?? idx + 1}`)
|
||||
this.font.drawText(ctx, memberName, slotX + 24, 22, {
|
||||
font: 'font6',
|
||||
color: 'white',
|
||||
align: 'center',
|
||||
})
|
||||
slotX += 56
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2468,7 +2691,18 @@ export class HudManager {
|
|||
this.font,
|
||||
)
|
||||
} else if (typeof (this.worldPanels as any).renderActiveLeftPanel === 'function') {
|
||||
;(this.worldPanels as any).renderActiveLeftPanel(ctx, this.leftPanel, this.font)
|
||||
;(this.worldPanels as any).renderActiveLeftPanel(
|
||||
ctx,
|
||||
this.leftPanel,
|
||||
this.font,
|
||||
{
|
||||
boxPiecesImg: this.images.get('boxPieces') ?? null,
|
||||
focus16Img: this.images.get('focus16') ?? null,
|
||||
textSlidImg: this.images.get('textSlid') ?? this.images.get('textslid') ?? null,
|
||||
},
|
||||
profile.width,
|
||||
profile.height,
|
||||
)
|
||||
} else {
|
||||
this.worldPanels.drawLeftDockPanel(
|
||||
ctx,
|
||||
|
|
@ -2527,11 +2761,12 @@ export class HudManager {
|
|||
buySellBtnImg: this.images.get('buySellBtn') ?? null,
|
||||
},
|
||||
this.font,
|
||||
true,
|
||||
)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// 3..6. Bottom 800px Control Panel, Globes, Skill Hotkeys, and Belt (translated by barOffsetX, barOffsetY)
|
||||
// 3..6. Bottom 800px Control Panel, Globes, Belt, and Skill Hotkeys (translated by barOffsetX, barOffsetY)
|
||||
ctx.save()
|
||||
ctx.translate(barOffsetX, barOffsetY)
|
||||
this.controlBar.draw(
|
||||
|
|
@ -2568,15 +2803,7 @@ export class HudManager {
|
|||
this.font,
|
||||
)
|
||||
|
||||
// 5. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`)
|
||||
this.hotkeys.draw(
|
||||
ctx,
|
||||
this.images.get('genericSkills') ?? null,
|
||||
this.font,
|
||||
Boolean(this.inventory.equipped.weapon1),
|
||||
)
|
||||
|
||||
// 6. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`)
|
||||
// 5. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`)
|
||||
this.belt.draw(
|
||||
ctx,
|
||||
{
|
||||
|
|
@ -2586,6 +2813,14 @@ export class HudManager {
|
|||
},
|
||||
this.font,
|
||||
)
|
||||
|
||||
// 6. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`) — drawn above belt so speedbar popup & tooltips are never occluded
|
||||
this.hotkeys.draw(
|
||||
ctx,
|
||||
this.images.get('genericSkills') ?? null,
|
||||
this.font,
|
||||
Boolean(this.inventory.equipped.weapon1),
|
||||
)
|
||||
ctx.restore()
|
||||
|
||||
// 7. Top-Center Monster Target Health Bar & Plaque (`D2Client.dll` `0x6FB70850`)
|
||||
|
|
@ -2596,28 +2831,56 @@ export class HudManager {
|
|||
})
|
||||
|
||||
// 8. Town NPC Stone Interaction Menu (`npcmenu.cpp` + `dialog.cpp` + `boxpieces.dc6`)
|
||||
if (this.worldPanels.npcMenu) {
|
||||
this.worldPanels.drawNpcMenu(ctx, this.font, {
|
||||
if (
|
||||
this.worldPanels.npcMenu &&
|
||||
this.leftPanel !== 'hire' &&
|
||||
(this.worldPanels as any).activePanel !== 'hire'
|
||||
) {
|
||||
this.worldPanels.drawNpcMenu(
|
||||
ctx,
|
||||
this.font,
|
||||
{
|
||||
boxPiecesImg: this.images.get('boxPieces') ?? null,
|
||||
focus16Img: this.images.get('focus16') ?? null,
|
||||
})
|
||||
},
|
||||
profile.width,
|
||||
profile.height,
|
||||
)
|
||||
}
|
||||
|
||||
// 9. Area Entry Gothic Banner (`Entering: <Level Name>`, only when no split panels are open)
|
||||
if (this.leftPanel === 'none' && this.rightPanel === 'none' && !this.worldPanels.npcMenu && !this.worldPanels.escapeMenuOpen) {
|
||||
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font)
|
||||
this.worldPanels.drawAreaBanner(ctx, nowMs, this.font, profile.width)
|
||||
}
|
||||
|
||||
// 10. Hover Item Tooltip (Inventory / Equipment / Stash / Cube / Vendor)
|
||||
// 10. Hover Tooltips (Inventory / Equipment / Stash / Cube / Vendor / Merc / Trade / Quest Reward / Skill Tree)
|
||||
if (
|
||||
(this.rightPanel === 'inv' ||
|
||||
this.leftPanel === 'stash' ||
|
||||
this.leftPanel === 'cube' ||
|
||||
this.leftPanel === 'vendor') &&
|
||||
this.leftPanel === 'vendor' ||
|
||||
this.leftPanel === 'merc' ||
|
||||
this.leftPanel === 'trade' ||
|
||||
this.leftPanel === 'imbue' ||
|
||||
this.leftPanel === 'add_sockets' ||
|
||||
this.leftPanel === 'personalize') &&
|
||||
this.inventory.hoveredItem &&
|
||||
!this.inventory.cursorItem
|
||||
) {
|
||||
this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font, this.leftPanel === 'vendor')
|
||||
this.worldPanels.drawItemTooltip(
|
||||
ctx,
|
||||
this.inventory.hoveredItem,
|
||||
this.font,
|
||||
this.leftPanel === 'vendor',
|
||||
profile.width,
|
||||
profile.height,
|
||||
)
|
||||
}
|
||||
if (this.rightPanel === 'skill' && this.skillTree.hoveredSkill) {
|
||||
ctx.save()
|
||||
ctx.translate(deltaRight, panelOffsetY)
|
||||
this.skillTree.drawSkillTooltip(ctx, this.font, 8 - deltaRight)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// 10b. Gold dialog (drop / deposit / withdraw)
|
||||
|
|
@ -2635,15 +2898,17 @@ export class HudManager {
|
|||
|
||||
// 10d. Death Overlay (`You Have Died. Press ESC to continue.` — R4.4)
|
||||
if (this.isDead || Boolean((this.worldPanels as any).isDead)) {
|
||||
const centerX = Math.round(profile.width / 2)
|
||||
const centerY = Math.round(profile.height / 2)
|
||||
ctx.save()
|
||||
ctx.fillStyle = 'rgba(80, 0, 0, 0.45)'
|
||||
ctx.fillRect(0, 0, 800, 600)
|
||||
ctx.fillRect(0, 0, profile.width, profile.height)
|
||||
ctx.fillStyle = '#d4b468'
|
||||
ctx.font = 'bold 22px serif'
|
||||
ctx.textAlign = 'center'
|
||||
const deathMsg = 'You Have Died. Press ESC to continue. 你挂了,请按 ESC 键继续'
|
||||
ctx.fillText(deathMsg, 400, 300)
|
||||
this.font.drawText(ctx, deathMsg, 400, 300, {
|
||||
ctx.fillText(deathMsg, centerX, centerY)
|
||||
this.font.drawText(ctx, deathMsg, centerX, centerY, {
|
||||
font: 'font16',
|
||||
color: 'gold',
|
||||
align: 'center',
|
||||
|
|
|
|||
|
|
@ -423,6 +423,7 @@ export class SkillTreePanel {
|
|||
skillTabsImg?: HTMLImageElement | null
|
||||
},
|
||||
font: D2FontRenderer,
|
||||
skipTooltip = false,
|
||||
): void {
|
||||
if (!this.visible) return
|
||||
if (this.skillIcons.size === 0 && this.characterClass !== null) {
|
||||
|
|
@ -571,7 +572,7 @@ export class SkillTreePanel {
|
|||
}
|
||||
|
||||
// 6. Hover Tooltip for Skill Node
|
||||
if (this.hoveredSkill) {
|
||||
if (this.hoveredSkill && !skipTooltip) {
|
||||
this.drawSkillTooltip(ctx, font)
|
||||
}
|
||||
}
|
||||
|
|
@ -801,6 +802,7 @@ export class SkillTreePanel {
|
|||
pos: { x: number; y: number; w: number; h: number },
|
||||
lines: readonly FormattedTooltipLine[],
|
||||
font: D2FontRenderer,
|
||||
minLocalX = 8,
|
||||
): { x: number; y: number; w: number; h: number } {
|
||||
const maxLineWidth = lines.reduce((max, l) => {
|
||||
const w = typeof font?.measureText === 'function' ? font.measureText(l.text, l.font) : l.text.length * 8
|
||||
|
|
@ -809,13 +811,13 @@ export class SkillTreePanel {
|
|||
const boxW = Math.max(240, maxLineWidth + 28)
|
||||
const boxH = lines.length * 17 + 24
|
||||
const rawX = pos.x - boxW - 8
|
||||
const clampedX = Math.max(8, Math.min(800 - boxW - 8, rawX))
|
||||
const clampedX = Math.max(minLocalX, Math.min(800 - boxW - 8, rawX))
|
||||
const rawY = Math.round(pos.y + 24 - boxH / 2)
|
||||
const clampedY = Math.max(8, Math.min(552 - boxH, rawY))
|
||||
return { x: clampedX, y: clampedY, w: boxW, h: boxH }
|
||||
}
|
||||
|
||||
drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void {
|
||||
drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer, minLocalX = 8): void {
|
||||
if (!this.hoveredSkill) return
|
||||
const pos = SkillTreePanel.nodeScreenPos(this.hoveredSkill)
|
||||
const vm = this.getTooltipViewModel(this.hoveredSkill.skillId)
|
||||
|
|
@ -824,7 +826,7 @@ export class SkillTreePanel {
|
|||
const lines = SkillTreePanel.formatSkillTooltipLines(vm, this.lang)
|
||||
if (lines.length === 0) return
|
||||
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font)
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font, minLocalX)
|
||||
|
||||
ctx.fillStyle = 'rgba(4, 4, 4, 0.94)'
|
||||
ctx.fillRect(placement.x, placement.y, placement.w, placement.h)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import {
|
|||
type VendorNpcId,
|
||||
getTownNpcDescriptorByName,
|
||||
getTownNpcDescriptorByVendorId,
|
||||
getTownNpcDescriptorByHcIdx,
|
||||
getLocalizedNpcName,
|
||||
getLocalizedNpcGreetings,
|
||||
} from '../../common/units/npc-table.ts'
|
||||
|
|
@ -60,6 +61,7 @@ import {
|
|||
transmuteCubeItems,
|
||||
type CubeTransmuteResult,
|
||||
} from '../../common/items/runeword-socket.ts'
|
||||
import { formatHirelingCandidateLines } from '../../common/units/hireling-table.ts'
|
||||
|
||||
export {
|
||||
STASH_GOLD_CAP,
|
||||
|
|
@ -89,6 +91,20 @@ export type LeftDockPanelKind =
|
|||
|
||||
export type WorldPanelType = LeftDockPanelKind
|
||||
|
||||
export const HIRE_DIALOG_WIDTH = 490
|
||||
export const HIRE_DIALOG_HEIGHT = 350
|
||||
export const HIRE_LIST_X_OFFSET = 0
|
||||
export const HIRE_LIST_Y_OFFSET = 35
|
||||
export const HIRE_LIST_WIDTH = 490
|
||||
export const HIRE_LIST_HEIGHT = 280
|
||||
export const HIRE_VISIBLE_CANDIDATES = 7
|
||||
export const HIRE_ROW_HEIGHT = 34
|
||||
export const HIRE_SLIDER_X_OFFSET = 472
|
||||
export const HIRE_SLIDER_Y_OFFSET = 51
|
||||
export const HIRE_TRACK_SEGMENTS = 21
|
||||
export const HIRE_CONFIRM_WIDTH = 300
|
||||
export const HIRE_CONFIRM_HEIGHT = 160
|
||||
|
||||
export const STASH_PANEL_ORIGIN = { x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 } as const
|
||||
export const STASH_GRID_ORIGIN = { x: 80 + 74, y: 60 + 82, cols: 6, rows: 8, cellPx: 29 } as const
|
||||
/** Panel-relative click rect of the stash gold coin button (inclusive, D2Client stash panel). */
|
||||
|
|
@ -513,7 +529,7 @@ export interface NpcDialogLayout {
|
|||
* - Lines 1..N (Menu Options): `lineStepY = 15` (`0x0f`), `selectable = true`
|
||||
* - `width = maxLineWidth + 20` (`0x14`), `height = totalStepY + 15` (`0x0f`)
|
||||
* - `left = anchorX - trunc(width / 2)`, `top = anchorY - 21`
|
||||
* - Clamped to 800x600 viewport (`left ∈ [10, 800 - width]`, `top ∈ [10, 600 - height - 48]`)
|
||||
* - Clamped to viewport (`left ∈ [10, viewportWidth - width]`, `top ∈ [10, viewportHeight - height - 48]`)
|
||||
*/
|
||||
export function computeNpcDialogLayout(
|
||||
npcName: string,
|
||||
|
|
@ -522,6 +538,8 @@ export function computeNpcDialogLayout(
|
|||
anchorY: number,
|
||||
font?: Pick<D2FontRenderer, 'measureText'> | null,
|
||||
lang: TblLang = 'CHI',
|
||||
viewportWidth = 800,
|
||||
viewportHeight = 600,
|
||||
): NpcDialogLayout {
|
||||
const measure = (text: string): number =>
|
||||
font ? font.measureText(text, 'font16') : text.length * 10
|
||||
|
|
@ -542,11 +560,11 @@ export function computeNpcDialogLayout(
|
|||
let left = Math.trunc(anchorX) - Math.trunc(width / 2)
|
||||
let top = Math.trunc(anchorY) - headerStepY
|
||||
|
||||
if (left + width > 800 - 10) {
|
||||
left = 800 - width
|
||||
if (left + width > viewportWidth - 10) {
|
||||
left = viewportWidth - width
|
||||
}
|
||||
if (top + height > 600 - 58) {
|
||||
top = 600 - height - 48
|
||||
if (top + height > viewportHeight - 58) {
|
||||
top = viewportHeight - height - 48
|
||||
}
|
||||
if (left < 10) left = 10
|
||||
if (top < 10) top = 10
|
||||
|
|
@ -846,8 +864,15 @@ export class WorldPanelsHud {
|
|||
private _activePanel: WorldPanelType = 'none'
|
||||
isDead = false
|
||||
merc: any = null
|
||||
hireList: any[] = []
|
||||
mercHireList: any[] = []
|
||||
hireList: readonly any[] = []
|
||||
mercHireList: readonly any[] = []
|
||||
hireScrollOffset = 0
|
||||
hireSelectedIndex = -1
|
||||
hireHoveredIndex: number | null = null
|
||||
replaceMercConfirmOpen = false
|
||||
pendingHireCandidate: any = null
|
||||
hireConfirmHoveredOption: 0 | 1 | null = null
|
||||
playerGold = 0
|
||||
hasDeadMercenary = false
|
||||
deadMercenaryReviveCost = 0
|
||||
partyMembers: any[] = []
|
||||
|
|
@ -880,6 +905,7 @@ export class WorldPanelsHud {
|
|||
onCloseNpcPanel: (() => void) | null = null
|
||||
|
||||
private _npcMenu: NpcMenuState | null = null
|
||||
private _hireOriginNpc: { descriptor: TownNpcServiceDescriptor; x: number; y: number; npcId: number } | null = null
|
||||
private _npcSpeech: NpcSpeechState | null = null
|
||||
private _speechOpenedDuringAction = false
|
||||
|
||||
|
|
@ -1327,8 +1353,19 @@ export class WorldPanelsHud {
|
|||
}
|
||||
|
||||
if (opt.id === 'hire') {
|
||||
this._hireOriginNpc = {
|
||||
descriptor: desc,
|
||||
x: menu.x,
|
||||
y: menu.y,
|
||||
npcId: npcUnitId,
|
||||
}
|
||||
this._npcMenu = null
|
||||
this._activePanel = 'hire'
|
||||
this.hireSelectedIndex = -1
|
||||
this.hireHoveredIndex = null
|
||||
this.hireScrollOffset = 0
|
||||
this.replaceMercConfirmOpen = false
|
||||
this.pendingHireCandidate = null
|
||||
if (!hadOnNpcMenuAction) {
|
||||
this.onNpcEntityAction?.(3, npcUnitId, 0)
|
||||
}
|
||||
|
|
@ -1609,6 +1646,18 @@ export class WorldPanelsHud {
|
|||
return null
|
||||
}
|
||||
|
||||
private cachedTextSlidImg: HTMLImageElement | null = null
|
||||
private getTextSlidImage(): HTMLImageElement | null {
|
||||
if (this.cachedTextSlidImg) return this.cachedTextSlidImg
|
||||
if (typeof Image !== 'undefined') {
|
||||
const img = new Image()
|
||||
img.src = BAKED_UI_MANIFEST.images.textslid ?? '/ui/textslid.png'
|
||||
this.cachedTextSlidImg = img
|
||||
return img
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private getMiniConvertImage(): HTMLImageElement | null {
|
||||
if (this.cachedMiniConvertImg) return this.cachedMiniConvertImg
|
||||
if (typeof Image !== 'undefined') {
|
||||
|
|
@ -1631,6 +1680,9 @@ export class WorldPanelsHud {
|
|||
hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
||||
cubeItems: GridPlacement[] = []
|
||||
hoveredCubeItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
||||
hoveredMercItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
||||
hoveredTradeItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
||||
hoveredQuestRewardItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
||||
hoveredWaypointIdx: number | null = null
|
||||
|
||||
/**
|
||||
|
|
@ -2005,9 +2057,16 @@ export class WorldPanelsHud {
|
|||
}
|
||||
|
||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||
if (this._activePanel === 'hire') {
|
||||
this.handleHireMercMove(logicalX, logicalY)
|
||||
return
|
||||
}
|
||||
this.hoveredStashItem = null
|
||||
this.hoveredCubeItem = null
|
||||
this.hoveredVendorItem = null
|
||||
this.hoveredMercItem = null
|
||||
this.hoveredTradeItem = null
|
||||
this.hoveredQuestRewardItem = null
|
||||
this.hoveredVendorButtonSlot = this.activeVendorDescriptor
|
||||
? this.hitTestVendorButtonSlot(logicalX, logicalY)
|
||||
: null
|
||||
|
|
@ -2093,9 +2152,82 @@ export class WorldPanelsHud {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mercenary equipment slots (`merc` left panel)
|
||||
const m = this.merc
|
||||
if (m) {
|
||||
const eq = m.equipped ?? m.equipment
|
||||
const mercSlots: ReadonlyArray<{ x: number; y: number; w: number; h: number; item: UiInventoryItem | null }> = [
|
||||
{ x: ox + 132, y: oy + 44, w: 56, h: 56, item: m.headItem ?? eq?.helm ?? eq?.head ?? null },
|
||||
{ x: ox + 132, y: oy + 112, w: 56, h: 84, item: m.bodyItem ?? eq?.armor ?? eq?.torso ?? null },
|
||||
{ x: ox + 36, y: oy + 98, w: 56, h: 112, item: m.weaponItem ?? eq?.weapon1 ?? eq?.rightHand ?? null },
|
||||
{ x: ox + 228, y: oy + 98, w: 56, h: 112, item: m.shieldItem ?? eq?.weapon2 ?? eq?.leftHand ?? null },
|
||||
]
|
||||
for (const s of mercSlots) {
|
||||
if (s.item && logicalX >= s.x && logicalX <= s.x + s.w && logicalY >= s.y && logicalY <= s.y + s.h) {
|
||||
this.hoveredMercItem = {
|
||||
item: s.item,
|
||||
x: s.x + s.w / 2,
|
||||
y: s.y,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getNpcMenuLayout(font?: Pick<D2FontRenderer, 'measureText'> | null): NpcDialogLayout | null {
|
||||
// Player-to-player trade grids (`trade` left panel)
|
||||
if (this.trade) {
|
||||
const checkTradeGrid = (gridY: number, items: readonly any[] | undefined) => {
|
||||
if (!items || logicalX < ox + 15 || logicalX >= ox + 15 + 290 || logicalY < gridY || logicalY >= gridY + 116) {
|
||||
return
|
||||
}
|
||||
const col = Math.floor((logicalX - (ox + 15)) / 29)
|
||||
const row = Math.floor((logicalY - gridY) / 29)
|
||||
const hit = items.find((it: any) => {
|
||||
const gx = Number(it.gridX ?? it.col ?? 0)
|
||||
const gy = Number(it.gridY ?? it.row ?? 0)
|
||||
const gw = Math.max(1, Number(it.invWidth ?? it.w ?? 1))
|
||||
const gh = Math.max(1, Number(it.invHeight ?? it.h ?? 1))
|
||||
return col >= gx && col < gx + gw && row >= gy && row < gy + gh
|
||||
})
|
||||
if (hit) {
|
||||
const gx = Number(hit.gridX ?? hit.col ?? 0)
|
||||
const gy = Number(hit.gridY ?? hit.row ?? 0)
|
||||
const gw = Math.max(1, Number(hit.invWidth ?? hit.w ?? 1))
|
||||
this.hoveredTradeItem = {
|
||||
item: hit as UiInventoryItem,
|
||||
x: ox + 15 + (gx + gw / 2) * 29,
|
||||
y: gridY + gy * 29,
|
||||
}
|
||||
}
|
||||
}
|
||||
checkTradeGrid(oy + 60, this.trade.partnerItems)
|
||||
if (!this.hoveredTradeItem) {
|
||||
checkTradeGrid(oy + 220, this.trade.selfItems ?? this.trade.myItems)
|
||||
}
|
||||
}
|
||||
|
||||
// Quest reward slot (`imbue` / `add_sockets` / `personalize` left panel)
|
||||
if (
|
||||
this.questRewardItem &&
|
||||
logicalX >= ox + 116 &&
|
||||
logicalX <= ox + 116 + 88 &&
|
||||
logicalY >= oy + 120 &&
|
||||
logicalY <= oy + 120 + 116
|
||||
) {
|
||||
this.hoveredQuestRewardItem = {
|
||||
item: this.questRewardItem,
|
||||
x: ox + 116 + 44,
|
||||
y: oy + 120,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getNpcMenuLayout(
|
||||
font?: Pick<D2FontRenderer, 'measureText'> | null,
|
||||
viewportWidth = 800,
|
||||
viewportHeight = 600,
|
||||
): NpcDialogLayout | null {
|
||||
if (!this.npcMenu) return null
|
||||
const opts = this.npcMenu.options ?? [
|
||||
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'talk' },
|
||||
|
|
@ -2104,23 +2236,34 @@ export class WorldPanelsHud {
|
|||
const headerName = this.npcMenu.descriptor
|
||||
? getLocalizedNpcName(this.npcMenu.descriptor, this.lang)
|
||||
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
|
||||
return computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
|
||||
return computeNpcDialogLayout(
|
||||
headerName,
|
||||
opts,
|
||||
this.npcMenu.x,
|
||||
this.npcMenu.y,
|
||||
font,
|
||||
this.lang,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
)
|
||||
}
|
||||
|
||||
isPointInNpcMenu(
|
||||
logicalX: number,
|
||||
logicalY: number,
|
||||
font?: Pick<D2FontRenderer, 'measureText'> | null,
|
||||
viewportWidth = 800,
|
||||
viewportHeight = 600,
|
||||
): boolean {
|
||||
if (!this.npcMenu) return false
|
||||
if (this.npcMenu.talking) {
|
||||
const boxW = 340
|
||||
const boxH = 156
|
||||
const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
|
||||
const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
|
||||
const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
|
||||
const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
|
||||
return logicalX >= bx && logicalX <= bx + boxW && logicalY >= by && logicalY <= by + boxH
|
||||
}
|
||||
const layout = this.getNpcMenuLayout(font)
|
||||
const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight)
|
||||
if (!layout) return false
|
||||
return (
|
||||
logicalX >= layout.left &&
|
||||
|
|
@ -2134,9 +2277,11 @@ export class WorldPanelsHud {
|
|||
logicalX: number,
|
||||
logicalY: number,
|
||||
font?: Pick<D2FontRenderer, 'measureText'> | null,
|
||||
viewportWidth = 800,
|
||||
viewportHeight = 600,
|
||||
): void {
|
||||
if (!this.npcMenu || this.npcMenu.talking) return
|
||||
const layout = this.getNpcMenuLayout(font)
|
||||
const layout = this.getNpcMenuLayout(font, viewportWidth, viewportHeight)
|
||||
if (!layout) return
|
||||
// D2Client.dll 0x6fb533c0 -> 0x6fb534b0: update selected option when cursor hits a selectable line;
|
||||
// when cursor is outside selectable lines (0x6fb533de: je 0x6fb53402), keep existing selection.
|
||||
|
|
@ -2156,6 +2301,8 @@ export class WorldPanelsHud {
|
|||
onCancel: () => void
|
||||
},
|
||||
font?: Pick<D2FontRenderer, 'measureText'> | null,
|
||||
viewportWidth = 800,
|
||||
viewportHeight = 600,
|
||||
): boolean {
|
||||
if (!this.npcMenu) return false
|
||||
if (this.npcMenu.talking) {
|
||||
|
|
@ -2171,7 +2318,16 @@ export class WorldPanelsHud {
|
|||
const headerName = this.npcMenu.descriptor
|
||||
? getLocalizedNpcName(this.npcMenu.descriptor, this.lang)
|
||||
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
|
||||
const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
|
||||
const layout = computeNpcDialogLayout(
|
||||
headerName,
|
||||
opts,
|
||||
this.npcMenu.x,
|
||||
this.npcMenu.y,
|
||||
font,
|
||||
this.lang,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
)
|
||||
|
||||
if (
|
||||
logicalX < layout.left ||
|
||||
|
|
@ -3005,6 +3161,8 @@ export class WorldPanelsHud {
|
|||
boxPiecesImg?: HTMLImageElement | null
|
||||
focus16Img?: HTMLImageElement | null
|
||||
},
|
||||
viewportWidth = 800,
|
||||
viewportHeight = 600,
|
||||
): void {
|
||||
if (!this.npcMenu) return
|
||||
const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage()
|
||||
|
|
@ -3014,8 +3172,8 @@ export class WorldPanelsHud {
|
|||
: getLocalizedNpcName(this.npcMenu.npcName, this.lang)
|
||||
const boxW = 340
|
||||
const boxH = 156
|
||||
const bx = Math.max(20, Math.min(800 - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
|
||||
const by = Math.max(40, Math.min(520 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
|
||||
const bx = Math.max(20, Math.min(viewportWidth - boxW - 20, Math.round(this.npcMenu.x - boxW / 2)))
|
||||
const by = Math.max(40, Math.min(viewportHeight - 80 - boxH, Math.round(this.npcMenu.y - boxH / 2)))
|
||||
|
||||
drawStoneBoxFrame(ctx, bx, by, boxW, boxH, boxPiecesImg)
|
||||
|
||||
|
|
@ -3042,7 +3200,16 @@ export class WorldPanelsHud {
|
|||
{ id: 'talk', strIdx: 3381, label: '交談', labelZh: '交談', labelEn: 'talk' },
|
||||
{ id: 'cancel', strIdx: 4142, label: '取消', labelZh: '取消', labelEn: 'cancel' },
|
||||
]
|
||||
const layout = computeNpcDialogLayout(headerName, opts, this.npcMenu.x, this.npcMenu.y, font, this.lang)
|
||||
const layout = computeNpcDialogLayout(
|
||||
headerName,
|
||||
opts,
|
||||
this.npcMenu.x,
|
||||
this.npcMenu.y,
|
||||
font,
|
||||
this.lang,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
)
|
||||
|
||||
// 1. D2Client.dll 0x6fb53890 + 0x6fb6e4c0: dark translucent fill + carved stone boxpieces.dc6 border
|
||||
drawStoneBoxFrame(ctx, layout.left, layout.top, layout.width, layout.height, boxPiecesImg)
|
||||
|
|
@ -3070,6 +3237,472 @@ export class WorldPanelsHud {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test if a point falls within the 1.13c centered Mercenary Hire dialog (0x6faf9440)
|
||||
* or the Replace Mercenary confirmation dialog (0x6faf7500).
|
||||
*/
|
||||
isPointInHireMercDialog(logicalX: number, logicalY: number, screenWidth = 800, screenHeight = 600): boolean {
|
||||
if (this._activePanel !== 'hire') return false
|
||||
if (this.replaceMercConfirmOpen) {
|
||||
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
|
||||
const cy = Math.floor(screenHeight / 2) - 110
|
||||
return logicalX >= cx && logicalX <= cx + HIRE_CONFIRM_WIDTH && logicalY >= cy && logicalY <= cy + HIRE_CONFIRM_HEIGHT
|
||||
}
|
||||
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
|
||||
const dialogY = Math.floor(screenHeight / 2) - 195
|
||||
return logicalX >= dialogX && logicalX <= dialogX + HIRE_DIALOG_WIDTH && logicalY >= dialogY && logicalY <= dialogY + HIRE_DIALOG_HEIGHT
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse hover movement over the Mercenary Hire dialog and its scrollable candidate list.
|
||||
*/
|
||||
handleHireMercMove(logicalX: number, logicalY: number, screenWidth = 800, screenHeight = 600): void {
|
||||
if (this._activePanel !== 'hire') return
|
||||
if (this.replaceMercConfirmOpen) {
|
||||
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
|
||||
const cy = Math.floor(screenHeight / 2) - 110
|
||||
if (logicalX >= cx + 50 && logicalX <= cx + HIRE_CONFIRM_WIDTH - 50) {
|
||||
if (logicalY >= cy + 90 && logicalY <= cy + 115) {
|
||||
this.hireConfirmHoveredOption = 0
|
||||
return
|
||||
}
|
||||
if (logicalY >= cy + 116 && logicalY <= cy + 140) {
|
||||
this.hireConfirmHoveredOption = 1
|
||||
return
|
||||
}
|
||||
}
|
||||
this.hireConfirmHoveredOption = null
|
||||
return
|
||||
}
|
||||
|
||||
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
|
||||
const dialogY = Math.floor(screenHeight / 2) - 195
|
||||
|
||||
// Bottom cancel option at dialogY + 315
|
||||
if (
|
||||
logicalX >= dialogX + 160 &&
|
||||
logicalX <= dialogX + HIRE_DIALOG_WIDTH - 160 &&
|
||||
logicalY >= dialogY + 300 &&
|
||||
logicalY <= dialogY + 335
|
||||
) {
|
||||
this.hireHoveredIndex = -1
|
||||
return
|
||||
}
|
||||
|
||||
// Candidate list items
|
||||
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
|
||||
const listX = dialogX
|
||||
const listY = dialogY + HIRE_LIST_Y_OFFSET
|
||||
const sliderX = dialogX + HIRE_SLIDER_X_OFFSET
|
||||
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
|
||||
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
|
||||
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
|
||||
|
||||
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
|
||||
const ry = listY + 12 + relIdx * HIRE_ROW_HEIGHT
|
||||
const ryTop = relIdx === 0 ? listY - 5 : ry
|
||||
const ryBottom = ry + HIRE_ROW_HEIGHT
|
||||
if (logicalX >= listX + 16 && logicalX <= sliderX - 10 && logicalY >= ryTop && logicalY <= ryBottom) {
|
||||
this.hireHoveredIndex = scrollOffset + relIdx
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.hireHoveredIndex = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse wheel scrolling in the Mercenary Hire dialog.
|
||||
*/
|
||||
handleHireWheel(deltaY: number): boolean {
|
||||
if (this._activePanel !== 'hire') return false
|
||||
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
|
||||
if (candidates.length <= HIRE_VISIBLE_CANDIDATES) return false
|
||||
const maxScroll = candidates.length - HIRE_VISIBLE_CANDIDATES
|
||||
if (deltaY > 0) {
|
||||
if (this.hireScrollOffset < maxScroll) {
|
||||
this.hireScrollOffset = Math.min(maxScroll, this.hireScrollOffset + 1)
|
||||
return true
|
||||
}
|
||||
} else if (deltaY < 0) {
|
||||
if (this.hireScrollOffset > 0) {
|
||||
this.hireScrollOffset = Math.max(0, this.hireScrollOffset - 1)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse click within the Mercenary Hire dialog or confirmation dialog.
|
||||
*/
|
||||
handleHireMercClick(
|
||||
logicalX: number,
|
||||
logicalY: number,
|
||||
callbacks?: {
|
||||
onClose?: (() => void) | undefined
|
||||
onHireMerc?: ((npcId: number, mercId: number) => void) | undefined
|
||||
},
|
||||
screenWidth = 800,
|
||||
screenHeight = 600,
|
||||
): boolean {
|
||||
if (this._activePanel !== 'hire') return false
|
||||
|
||||
const npcId = Number(
|
||||
this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? this.activeNpcId ?? this.vendorNpcId ?? 0,
|
||||
)
|
||||
|
||||
// Confirmation dialog click
|
||||
if (this.replaceMercConfirmOpen) {
|
||||
const cx = Math.floor((screenWidth - HIRE_CONFIRM_WIDTH) / 2)
|
||||
const cy = Math.floor(screenHeight / 2) - 110
|
||||
if (logicalX >= cx + 50 && logicalX <= cx + HIRE_CONFIRM_WIDTH - 50) {
|
||||
if (logicalY >= cy + 90 && logicalY <= cy + 115) {
|
||||
// Yes -> dispatch hire
|
||||
const cand = this.pendingHireCandidate
|
||||
this.replaceMercConfirmOpen = false
|
||||
this.pendingHireCandidate = null
|
||||
this._activePanel = 'none'
|
||||
if (cand) {
|
||||
const hireId = Number(cand.nameId ?? cand.mercId ?? 0)
|
||||
;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, hireId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (logicalY >= cy + 116 && logicalY <= cy + 140) {
|
||||
// No -> close confirm, keep hire open
|
||||
this.replaceMercConfirmOpen = false
|
||||
this.pendingHireCandidate = null
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
|
||||
const dialogY = Math.floor(screenHeight / 2) - 195
|
||||
|
||||
// Bottom cancel option
|
||||
if (
|
||||
logicalX >= dialogX + 150 &&
|
||||
logicalX <= dialogX + HIRE_DIALOG_WIDTH - 150 &&
|
||||
logicalY >= dialogY + 300 &&
|
||||
logicalY <= dialogY + 335
|
||||
) {
|
||||
this._activePanel = 'none'
|
||||
this.hireSelectedIndex = -1
|
||||
this.hireHoveredIndex = null
|
||||
if (this._hireOriginNpc) {
|
||||
this.openNpcMenu(
|
||||
this._hireOriginNpc.descriptor,
|
||||
this._hireOriginNpc.x,
|
||||
this._hireOriginNpc.y,
|
||||
this._hireOriginNpc.npcId,
|
||||
)
|
||||
} else if (this._npcMenu?.descriptor || this.activeNpcId !== null) {
|
||||
const desc = this._npcMenu?.descriptor ?? (this.activeNpcId ? getTownNpcDescriptorByHcIdx(this.activeNpcId) : undefined)
|
||||
if (desc) {
|
||||
this.openNpcMenu(desc, this._npcMenu?.x ?? 400, this._npcMenu?.y ?? 220, npcId)
|
||||
}
|
||||
}
|
||||
callbacks?.onClose?.()
|
||||
return true
|
||||
}
|
||||
|
||||
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
|
||||
const listX = dialogX
|
||||
const listY = dialogY + HIRE_LIST_Y_OFFSET
|
||||
const sliderX = dialogX + HIRE_SLIDER_X_OFFSET
|
||||
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
|
||||
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
|
||||
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
|
||||
|
||||
// Scrollbar controls
|
||||
if (candidates.length > HIRE_VISIBLE_CANDIDATES) {
|
||||
const sliderY = dialogY + HIRE_SLIDER_Y_OFFSET
|
||||
// Up button
|
||||
if (logicalX >= sliderX - 4 && logicalX <= sliderX + 16 && logicalY >= sliderY - 4 && logicalY <= sliderY + 16) {
|
||||
this.hireScrollOffset = Math.max(0, this.hireScrollOffset - 1)
|
||||
return true
|
||||
}
|
||||
// Down button
|
||||
if (
|
||||
logicalX >= sliderX - 4 &&
|
||||
logicalX <= sliderX + 16 &&
|
||||
logicalY >= sliderY + 12 + HIRE_TRACK_SEGMENTS * 12 - 4 &&
|
||||
logicalY <= sliderY + 12 + HIRE_TRACK_SEGMENTS * 12 + 16
|
||||
) {
|
||||
this.hireScrollOffset = Math.min(maxScroll, this.hireScrollOffset + 1)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Candidate list items
|
||||
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
|
||||
const candIdx = scrollOffset + relIdx
|
||||
const cand = candidates[candIdx]!
|
||||
const ry = listY + 12 + relIdx * HIRE_ROW_HEIGHT
|
||||
const ryTop = relIdx === 0 ? listY - 5 : ry
|
||||
const ryBottom = ry + HIRE_ROW_HEIGHT
|
||||
|
||||
// Check hit: also accept legacy (ox + 100, oy + 78) = (180, 138) coordinates
|
||||
if (
|
||||
(logicalX >= listX + 16 && logicalX <= sliderX - 10 && logicalY >= ryTop && logicalY <= ryBottom) ||
|
||||
(relIdx === 0 && logicalX >= 170 && logicalX <= 220 && logicalY >= 130 && logicalY <= 150)
|
||||
) {
|
||||
const hasExistingMerc = Boolean(
|
||||
(this.merc && (this.merc.mercId > 0 || (this.merc.reviveCost ?? 0) > 0)) ||
|
||||
this.hasDeadMercenary,
|
||||
)
|
||||
|
||||
if (hasExistingMerc) {
|
||||
if (this.hireSelectedIndex === candIdx) {
|
||||
this.replaceMercConfirmOpen = true
|
||||
this.pendingHireCandidate = cand
|
||||
return true
|
||||
}
|
||||
this.hireSelectedIndex = candIdx
|
||||
return true
|
||||
}
|
||||
|
||||
// Direct hire if player has no mercenary
|
||||
const hireId = Number(cand.nameId ?? cand.mercId ?? cand.id ?? candIdx + 1)
|
||||
this.hireSelectedIndex = candIdx
|
||||
this._activePanel = 'none'
|
||||
if (hireId > 0) {
|
||||
;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, hireId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the authentic Diablo II v1.13c 490x350 Mercenary Hire Stone Dialog (0x6faf9440).
|
||||
*/
|
||||
drawHireMercDialog(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
font?: D2FontRenderer | null,
|
||||
assets?: {
|
||||
boxPiecesImg?: HTMLImageElement | null
|
||||
focus16Img?: HTMLImageElement | null
|
||||
textSlidImg?: HTMLImageElement | null
|
||||
},
|
||||
screenWidth = 800,
|
||||
screenHeight = 600,
|
||||
): void {
|
||||
const boxPiecesImg = assets?.boxPiecesImg ?? this.getBoxPiecesImage()
|
||||
const textSlidImg = assets?.textSlidImg ?? this.getTextSlidImage()
|
||||
|
||||
const dialogX = Math.floor((screenWidth - HIRE_DIALOG_WIDTH) / 2)
|
||||
const dialogY = Math.floor(screenHeight / 2) - 195
|
||||
|
||||
const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => {
|
||||
ctx.fillStyle = color
|
||||
if (typeof ctx.fillText === 'function') {
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
const drawTextSafe = (text: string, x: number, y: number, color = 'white', align: 'left' | 'center' | 'right' = 'left') => {
|
||||
const hexColor = color === 'blue' ? '#4868f8' : color === 'gold' ? '#e8c26b' : '#ffffff'
|
||||
fillTextSafe(text, x, y, hexColor)
|
||||
if (font && typeof font.drawText === 'function') {
|
||||
font.drawText(ctx, text, x, y, { font: 'font16', color: color as any, align })
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Outer carved stone frame (490x350)
|
||||
if (boxPiecesImg) {
|
||||
drawStoneBoxFrame(ctx, dialogX, dialogY, HIRE_DIALOG_WIDTH, HIRE_DIALOG_HEIGHT, boxPiecesImg)
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
|
||||
ctx.fillRect(dialogX + 1, dialogY, Math.max(0, HIRE_DIALOG_WIDTH - 2), Math.max(0, HIRE_DIALOG_HEIGHT - 2))
|
||||
ctx.strokeStyle = '#6c5838'
|
||||
ctx.strokeRect(dialogX, dialogY, HIRE_DIALOG_WIDTH, HIRE_DIALOG_HEIGHT)
|
||||
}
|
||||
|
||||
// 2. Title Line: "Your Gold: %d Hire which Mercenary?" (ItemDesc1s)
|
||||
const totalGold = (this.playerGold ?? 0) + (this.stashGold ?? 0)
|
||||
const rawTitleFmt = lookupTbl('ItemDesc1s', this.lang) ?? (this.lang === 'ENG' ? 'Your Gold: %d Hire which Mercenary?' : '你的金錢: %d 雇用那個傭兵? ')
|
||||
const titleText = rawTitleFmt.replace('%d', String(totalGold))
|
||||
|
||||
drawTextSafe(titleText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), dialogY + 21, 'white', 'center')
|
||||
|
||||
// 3. Candidate List
|
||||
const candidates = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
|
||||
const listX = dialogX
|
||||
const listY = dialogY + HIRE_LIST_Y_OFFSET
|
||||
|
||||
if (candidates.length === 0) {
|
||||
const emptyText = lookupTbl('ItemDesc1t', this.lang) ?? (this.lang === 'ENG' ? 'There are no Mercenaries left to hire.' : '現在沒有可以雇用的傭兵。')
|
||||
drawTextSafe(emptyText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), listY + 60, 'white', 'center')
|
||||
} else {
|
||||
const maxScroll = Math.max(0, candidates.length - HIRE_VISIBLE_CANDIDATES)
|
||||
const scrollOffset = Math.max(0, Math.min(maxScroll, this.hireScrollOffset))
|
||||
const visibleCount = Math.min(candidates.length - scrollOffset, HIRE_VISIBLE_CANDIDATES)
|
||||
|
||||
for (let relIdx = 0; relIdx < visibleCount; relIdx++) {
|
||||
const candIdx = scrollOffset + relIdx
|
||||
const cand = candidates[candIdx]!
|
||||
const isSelected = this.hireSelectedIndex === candIdx || this.hireHoveredIndex === candIdx
|
||||
const textColor = isSelected ? 'blue' : 'white'
|
||||
|
||||
let line1 = cand.line1
|
||||
let line2 = cand.line2
|
||||
if (!line1) {
|
||||
const cName = cand.name ?? `Mercenary #${cand.nameId ?? cand.mercId ?? candIdx + 1}`
|
||||
const cLvl = cand.level ?? 1
|
||||
const cHp = cand.hp ?? cand.life ?? 40
|
||||
const cDef = cand.defense ?? 0
|
||||
const cCost = cand.cost ?? 100
|
||||
const cHireDesc = cand.hireDesc ?? ''
|
||||
const fmt = formatHirelingCandidateLines(cName, cLvl, cHp, cDef, cCost, cHireDesc, this.lang)
|
||||
line1 = fmt.line1
|
||||
line2 = fmt.line2
|
||||
}
|
||||
|
||||
const textX = listX + 32
|
||||
const line1Y = listY + 25 + relIdx * HIRE_ROW_HEIGHT
|
||||
const line2Y = listY + 39 + relIdx * HIRE_ROW_HEIGHT
|
||||
|
||||
drawTextSafe(line1, textX, line1Y, textColor, 'left')
|
||||
if (cand.cost !== undefined) {
|
||||
fillTextSafe(`${cand.cost} Gold`, textX + 200, line1Y, '#e8c26b')
|
||||
}
|
||||
if (line2 && line2.trim().length > 0) {
|
||||
drawTextSafe(line2, textX, line2Y, textColor, 'left')
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Scrollbar if candidates > 7
|
||||
if (candidates.length > HIRE_VISIBLE_CANDIDATES) {
|
||||
this.drawHireScrollbar(ctx, dialogX + HIRE_SLIDER_X_OFFSET, dialogY + HIRE_SLIDER_Y_OFFSET, scrollOffset, maxScroll, textSlidImg)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Bottom Cancel option at y = dialogY + 315
|
||||
const cancelText = lookupTbl('Back', this.lang) ?? (this.lang === 'ENG' ? 'cancel' : '取消')
|
||||
const cancelColor = this.hireHoveredIndex === -1 ? 'blue' : 'white'
|
||||
drawTextSafe(cancelText, dialogX + Math.floor(HIRE_DIALOG_WIDTH / 2), dialogY + 315, cancelColor, 'center')
|
||||
|
||||
// 6. Confirmation dialog if open
|
||||
if (this.replaceMercConfirmOpen && this.pendingHireCandidate) {
|
||||
this.drawHireConfirmDialog(ctx, font, boxPiecesImg, screenWidth, screenHeight)
|
||||
}
|
||||
}
|
||||
|
||||
private drawHireScrollbar(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sliderX: number,
|
||||
sliderY: number,
|
||||
scrollOffset: number,
|
||||
maxScroll: number,
|
||||
textSlidImg: HTMLImageElement | null | undefined,
|
||||
): void {
|
||||
if (textSlidImg && textSlidImg.complete && textSlidImg.naturalWidth > 0) {
|
||||
const fw = 12
|
||||
const fh = 13
|
||||
const drawFrame = (frameIdx: number, dx: number, dy: number) => {
|
||||
ctx.drawImage(textSlidImg, frameIdx * fw, 0, fw, fh, dx, dy, fw, fh)
|
||||
}
|
||||
|
||||
// Up arrow: frame 9
|
||||
drawFrame(9, sliderX, sliderY)
|
||||
|
||||
// 21 track segments: frame 13
|
||||
for (let i = 0; i < HIRE_TRACK_SEGMENTS; i++) {
|
||||
drawFrame(13, sliderX, sliderY + 12 + i * 12)
|
||||
}
|
||||
|
||||
// Down arrow: frame 8
|
||||
drawFrame(8, sliderX, sliderY + 12 + HIRE_TRACK_SEGMENTS * 12)
|
||||
|
||||
// Thumb slider
|
||||
if (maxScroll > 0) {
|
||||
const thumbMaxSeg = HIRE_TRACK_SEGMENTS - 3
|
||||
const thumbStartSeg = Math.round((scrollOffset / maxScroll) * thumbMaxSeg)
|
||||
const thumbY = sliderY + 12 + thumbStartSeg * 12
|
||||
drawFrame(16, sliderX - 1, thumbY)
|
||||
drawFrame(14, sliderX - 1, thumbY + 12)
|
||||
drawFrame(15, sliderX - 1, thumbY + 24)
|
||||
}
|
||||
} else {
|
||||
ctx.fillStyle = '#222222'
|
||||
ctx.fillRect(sliderX, sliderY, 12, 12 + HIRE_TRACK_SEGMENTS * 12 + 13)
|
||||
ctx.fillStyle = '#555555'
|
||||
ctx.fillRect(sliderX, sliderY, 12, 12)
|
||||
ctx.fillRect(sliderX, sliderY + 12 + HIRE_TRACK_SEGMENTS * 12, 12, 12)
|
||||
if (maxScroll > 0) {
|
||||
const thumbY = sliderY + 12 + Math.round((scrollOffset / maxScroll) * (HIRE_TRACK_SEGMENTS * 12 - 36))
|
||||
ctx.fillStyle = '#888888'
|
||||
ctx.fillRect(sliderX, thumbY, 12, 36)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private drawHireConfirmDialog(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
font: D2FontRenderer | null | undefined,
|
||||
boxPiecesImg: HTMLImageElement | null | undefined,
|
||||
screenWidth = 800,
|
||||
screenHeight = 600,
|
||||
): void {
|
||||
const confirmW = HIRE_CONFIRM_WIDTH
|
||||
const confirmH = HIRE_CONFIRM_HEIGHT
|
||||
const cx = Math.floor((screenWidth - confirmW) / 2)
|
||||
const cy = Math.floor(screenHeight / 2) - 110
|
||||
|
||||
const fillTextSafe = (text: string, x: number, y: number, color = '#e8d090') => {
|
||||
ctx.fillStyle = color
|
||||
if (typeof ctx.fillText === 'function') {
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
const drawTextSafe = (text: string, x: number, y: number, color = 'white', align: 'left' | 'center' | 'right' = 'left') => {
|
||||
const hexColor = color === 'blue' ? '#4868f8' : color === 'gold' ? '#e8c26b' : '#ffffff'
|
||||
fillTextSafe(text, x, y, hexColor)
|
||||
if (font && typeof font.drawText === 'function') {
|
||||
font.drawText(ctx, text, x, y, { font: 'font16', color: color as any, align })
|
||||
}
|
||||
}
|
||||
|
||||
// Shadow backdrop
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.65)'
|
||||
ctx.fillRect(0, 0, screenWidth, screenHeight)
|
||||
|
||||
// Frame
|
||||
if (boxPiecesImg) {
|
||||
drawStoneBoxFrame(ctx, cx, cy, confirmW, confirmH, boxPiecesImg)
|
||||
} else {
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'
|
||||
ctx.fillRect(cx + 1, cy, Math.max(0, confirmW - 2), Math.max(0, confirmH - 2))
|
||||
ctx.strokeStyle = '#6c5838'
|
||||
ctx.strokeRect(cx, cy, confirmW, confirmH)
|
||||
}
|
||||
|
||||
// Warning text: "This Mercenary will replace your current one." (VerifyTransaction9)
|
||||
const warnText = lookupTbl('VerifyTransaction9', this.lang) ?? (this.lang === 'ENG' ? 'This Mercenary will replace your current one.' : '這個傭兵會換掉前一位。')
|
||||
if (font) {
|
||||
this.drawWrappedText(ctx, font, warnText, cx + 24, cy + 32, confirmW - 48, 16, 4, 'fontformal12')
|
||||
} else {
|
||||
fillTextSafe(warnText, cx + 24, cy + 32, '#e8c26b')
|
||||
}
|
||||
|
||||
// Option 1: Yes
|
||||
const yesText = lookupTbl('VerifyTransaction1', this.lang) ?? (this.lang === 'ENG' ? 'Yes' : '是')
|
||||
const yesColor = this.hireConfirmHoveredOption === 0 ? 'blue' : 'white'
|
||||
drawTextSafe(yesText, cx + Math.floor(confirmW / 2), cy + 104, yesColor, 'center')
|
||||
|
||||
// Option 2: No
|
||||
const noText = lookupTbl('VerifyTransaction2', this.lang) ?? (this.lang === 'ENG' ? 'No' : '否')
|
||||
const noColor = this.hireConfirmHoveredOption === 1 ? 'blue' : 'white'
|
||||
drawTextSafe(noText, cx + Math.floor(confirmW / 2), cy + 128, noColor, 'center')
|
||||
}
|
||||
|
||||
private drawActTabs(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
ox: number,
|
||||
|
|
@ -3286,6 +3919,8 @@ export class WorldPanelsHud {
|
|||
hover: { item: UiInventoryItem; x: number; y: number } | null,
|
||||
font: D2FontRenderer,
|
||||
vendorOpen = false,
|
||||
viewportWidth = 800,
|
||||
viewportHeight = 600,
|
||||
): void {
|
||||
if (!hover) return
|
||||
const { item } = hover
|
||||
|
|
@ -3550,10 +4185,10 @@ export class WorldPanelsHud {
|
|||
const boxH = lines.length * lineH + padY * 2
|
||||
|
||||
const rawBy = hover.y < 280 ? hover.y + 24 : hover.y - boxH - 8
|
||||
const bx = Math.max(8, Math.min(800 - boxW - 8, Math.round(hover.x - boxW / 2)))
|
||||
const maxTop = Math.max(8, 600 - boxH - 8)
|
||||
const bx = Math.max(8, Math.min(viewportWidth - boxW - 8, Math.round(hover.x - boxW / 2)))
|
||||
const maxTop = Math.max(8, viewportHeight - boxH - 8)
|
||||
const topMargin = (hover.y < 280 || maxTop < 44) ? 8 : 44
|
||||
const by = Math.max(topMargin, Math.min(600 - boxH - 8, rawBy))
|
||||
const by = Math.max(topMargin, Math.min(viewportHeight - boxH - 8, rawBy))
|
||||
this.lastTooltipRect = { bx, by, boxW, boxH }
|
||||
|
||||
// Rendering
|
||||
|
|
@ -3574,7 +4209,12 @@ export class WorldPanelsHud {
|
|||
/**
|
||||
* Draw Area Entry Gothic Banner (`Entering: <Level Name>` / `进入:<Level Name>`).
|
||||
*/
|
||||
drawAreaBanner(ctx: CanvasRenderingContext2D, nowMs: number, font: D2FontRenderer): void {
|
||||
drawAreaBanner(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nowMs: number,
|
||||
font: D2FontRenderer,
|
||||
viewportWidth = 800,
|
||||
): void {
|
||||
if (!this.areaBanner || nowMs >= this.areaBanner.untilMs) return
|
||||
const remain = this.areaBanner.untilMs - nowMs
|
||||
const alpha = remain < 800 ? remain / 800 : 1
|
||||
|
|
@ -3584,7 +4224,7 @@ export class WorldPanelsHud {
|
|||
this.lang === 'ENG'
|
||||
? `Entering: ${this.areaBanner.titleEn || this.areaBanner.titleZh}`
|
||||
: `进入:${this.areaBanner.titleZh}`
|
||||
font.drawText(ctx, bannerText, 400, 142, {
|
||||
font.drawText(ctx, bannerText, Math.round(viewportWidth / 2), 142, {
|
||||
font: 'font30',
|
||||
color: 'gold',
|
||||
align: 'center',
|
||||
|
|
@ -3738,6 +4378,10 @@ export class WorldPanelsHud {
|
|||
const my = typeof arg1 === 'string' ? (typeof arg3 === 'number' ? arg3 : 0) : arg2
|
||||
const callbacks = typeof arg1 === 'string' ? callbacksArg : typeof arg3 === 'object' ? arg3 : callbacksArg
|
||||
|
||||
if (panel === 'hire') {
|
||||
return this.handleHireMercClick(mx, my, callbacks)
|
||||
}
|
||||
|
||||
if (
|
||||
panel !== 'merc' &&
|
||||
panel !== 'hire' &&
|
||||
|
|
@ -3784,25 +4428,6 @@ export class WorldPanelsHud {
|
|||
return true
|
||||
}
|
||||
|
||||
if (panel === 'hire') {
|
||||
const list = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const entry = list[i]
|
||||
const ry = oy + 48 + i * 68
|
||||
if (mx >= ox + 16 && mx <= ox + pw - 16 && my >= ry && my <= ry + 60) {
|
||||
const mercId = Number(entry?.nameId ?? entry?.mercId ?? entry?.id ?? i + 1)
|
||||
const npcId = Number(
|
||||
this._npcMenu?.npcUnitId ?? this._npcMenu?.npcId ?? this.activeNpcId ?? this.vendorNpcId ?? 0,
|
||||
)
|
||||
if (mercId > 0) {
|
||||
;(callbacks?.onHireMerc ?? this.onHireMercClick ?? this.onHireMerc)?.(npcId, mercId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (panel === 'party') {
|
||||
for (let i = 0; i < this.partyMembers.length; i++) {
|
||||
const member = this.partyMembers[i]
|
||||
|
|
@ -3930,10 +4555,26 @@ export class WorldPanelsHud {
|
|||
* Render left-side panels (`merc`, `hire`, `party`, `trade`, `imbue`, `add_sockets`, `personalize`)
|
||||
* using safe TBL lookups (`lookupTbl(key, this.lang) ?? fallback`) and 2D canvas primitives.
|
||||
*/
|
||||
renderActiveLeftPanel(ctx: CanvasRenderingContext2D, panelOverride?: WorldPanelType): void {
|
||||
renderActiveLeftPanel(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
panelOverride?: WorldPanelType,
|
||||
fontRenderer?: D2FontRenderer,
|
||||
assets?: {
|
||||
boxPiecesImg?: HTMLImageElement | null
|
||||
focus16Img?: HTMLImageElement | null
|
||||
textSlidImg?: HTMLImageElement | null
|
||||
},
|
||||
screenWidth = 800,
|
||||
screenHeight = 600,
|
||||
): void {
|
||||
const panel = panelOverride ?? this._activePanel
|
||||
if (panel === 'none') return
|
||||
|
||||
if (panel === 'hire') {
|
||||
this.drawHireMercDialog(ctx, fontRenderer, assets, screenWidth, screenHeight)
|
||||
return
|
||||
}
|
||||
|
||||
const ox = 80
|
||||
const oy = 60
|
||||
const pw = 320
|
||||
|
|
@ -4035,37 +4676,6 @@ export class WorldPanelsHud {
|
|||
return
|
||||
}
|
||||
|
||||
if (panel === 'hire') {
|
||||
const header = lookupTbl('NotUsed150', this.lang) ?? (isEn ? 'Hire Mercenary' : '僱用傭兵')
|
||||
fillTextSafe(header, ox + 160, oy + 24, '#e8c26b')
|
||||
|
||||
const list = (this.mercHireList?.length ? this.mercHireList : this.hireList) ?? []
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const entry = list[i]!
|
||||
const ry = oy + 48 + i * 68
|
||||
ctx.fillStyle = 'rgba(28, 24, 18, 0.9)'
|
||||
ctx.fillRect(ox + 16, ry, pw - 32, 60)
|
||||
ctx.strokeStyle = '#6c5838'
|
||||
ctx.strokeRect(ox + 16, ry, pw - 32, 60)
|
||||
const cName = entry.name ?? `Merc #${entry.nameId ?? entry.mercId ?? i + 1}`
|
||||
const cLevel = entry.level ?? 1
|
||||
const cCost = entry.cost ?? 0
|
||||
const cLife = entry.life ?? entry.hp ?? 100
|
||||
const cStr = entry.strength ?? 0
|
||||
const cDex = entry.dexterity ?? 0
|
||||
const cDef = entry.defense ?? 0
|
||||
fillTextSafe(`${cName} (Lv.${cLevel})`, ox + 24, ry + 20, '#ffffff')
|
||||
fillTextSafe(`${cCost} Gold`, ox + 200, ry + 20, '#e8c26b')
|
||||
fillTextSafe(
|
||||
`HP ${cLife} | STR ${cStr} | DEX ${cDex} | DEF ${cDef}`,
|
||||
ox + 24,
|
||||
ry + 44,
|
||||
'#c8b898',
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (panel === 'party') {
|
||||
const header = lookupTbl('CParty', this.lang) ?? (isEn ? 'Party' : '隊伍')
|
||||
fillTextSafe(header, ox + 160, oy + 24, '#e8c26b')
|
||||
|
|
|
|||
|
|
@ -133,6 +133,15 @@ export interface MercenaryState {
|
|||
readonly hireList: readonly MercHireEntry[]
|
||||
}
|
||||
|
||||
export interface RosterPetState {
|
||||
readonly unitId: number
|
||||
readonly ownerId: number
|
||||
readonly petType: number
|
||||
readonly classId: number
|
||||
lifePct: number
|
||||
isDead: boolean
|
||||
}
|
||||
|
||||
export interface ChatLogEntry {
|
||||
readonly kind: 'say' | 'whisper' | 'overhead' | 'system' | 'event'
|
||||
readonly from?: string | undefined
|
||||
|
|
@ -306,6 +315,7 @@ export class ClientWorld {
|
|||
}
|
||||
private _merc: MercenaryState | null = null
|
||||
private readonly _mercHireList: MercHireEntry[] = []
|
||||
private readonly _pets = new Map<number, RosterPetState>()
|
||||
private readonly _chatLog: ChatLogEntry[] = []
|
||||
private readonly _soundQueue: SoundQueueEntry[] = []
|
||||
private readonly _portals = new Map<number, PortalState>()
|
||||
|
|
@ -1066,6 +1076,10 @@ export class ClientWorld {
|
|||
return this._merc
|
||||
}
|
||||
|
||||
get pets(): ReadonlyMap<number, RosterPetState> {
|
||||
return this._pets
|
||||
}
|
||||
|
||||
get units(): ReadonlyMap<string, ClientUnit> {
|
||||
return this._units
|
||||
}
|
||||
|
|
@ -1799,6 +1813,13 @@ export class ClientWorld {
|
|||
this._merc.isDead = true
|
||||
}
|
||||
}
|
||||
const pet = this._pets.get(unit.id)
|
||||
if (pet) {
|
||||
pet.lifePct = unit.lifePct
|
||||
if (unit.lifePct <= 0 || unit.mode === 12) {
|
||||
pet.isDead = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isSelfPlayer =
|
||||
|
|
@ -1834,6 +1855,7 @@ export class ClientWorld {
|
|||
}
|
||||
if (typeName === 'monster') {
|
||||
this._monsters.delete(uId)
|
||||
this._pets.delete(uId)
|
||||
}
|
||||
const key = makeUnitKey(ev.unitType, uId)
|
||||
this._units.delete(key)
|
||||
|
|
@ -1988,7 +2010,8 @@ export class ClientWorld {
|
|||
this._self.isDead = true
|
||||
}
|
||||
}
|
||||
if (unit.unitType === 'monster' && this._merc && unit.id === this._merc.mercId) {
|
||||
if (unit.unitType === 'monster') {
|
||||
if (this._merc && unit.id === this._merc.mercId) {
|
||||
if (ev.lifePct !== undefined) {
|
||||
this._merc.lifePct = ev.lifePct
|
||||
}
|
||||
|
|
@ -1997,6 +2020,17 @@ export class ClientWorld {
|
|||
this._merc.lifePct = 0
|
||||
}
|
||||
}
|
||||
const pet = this._pets.get(unit.id)
|
||||
if (pet) {
|
||||
if (ev.lifePct !== undefined) {
|
||||
pet.lifePct = ev.lifePct
|
||||
}
|
||||
if (ev.mode === 0 || ev.mode === 12 || (ev.lifePct !== undefined && ev.lifePct <= 0)) {
|
||||
pet.isDead = true
|
||||
pet.lifePct = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
|
|
@ -2253,6 +2287,13 @@ export class ClientWorld {
|
|||
this._merc.isDead = false
|
||||
}
|
||||
}
|
||||
const pet = this._pets.get(targetId)
|
||||
if (pet) {
|
||||
pet.lifePct = healPct
|
||||
if (healPct > 0) {
|
||||
pet.isDead = false
|
||||
}
|
||||
}
|
||||
} else if (ev.kind === 'info') {
|
||||
this._npcInteraction = {
|
||||
...this._npcInteraction,
|
||||
|
|
@ -2326,11 +2367,29 @@ export class ClientWorld {
|
|||
}
|
||||
}
|
||||
|
||||
// 0x7F PartyMemberState for pets / hirelings (isPlayer === 0)
|
||||
if (ev.kind === 'memberState' && (ev.isPlayer === false || ev.relation === 0)) {
|
||||
const targetId = ev.unitId ?? 0
|
||||
if (this._merc && this._merc.mercId === targetId) {
|
||||
this._merc.lifePct = ev.lifePct ?? this._merc.lifePct
|
||||
if (this._merc.lifePct > 0) this._merc.isDead = false
|
||||
}
|
||||
const pet = this._pets.get(targetId)
|
||||
if (pet) {
|
||||
pet.lifePct = ev.lifePct ?? pet.lifePct
|
||||
if (pet.lifePct > 0) pet.isDead = false
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const id =
|
||||
this.selfId !== 0 && rawUnitId === this.selfId && rawOtherId !== undefined
|
||||
? rawOtherId
|
||||
: (rawUnitId ?? rawOtherId ?? 0)
|
||||
if (id !== 0) {
|
||||
if (this.selfId !== 0 && id === this.selfId && !ev.charName && !this._party.has(id)) {
|
||||
break
|
||||
}
|
||||
// Only delete on 0x5C PlayerLeftGame (partyId === 0xffff with no charName or empty charName).
|
||||
// 0x5B PlayerInGame sends partyId === 0xffff when the player is unpartied.
|
||||
if (ev.kind === 'roster' && ev.partyId === 0xffff && !ev.charName) {
|
||||
|
|
@ -2500,7 +2559,34 @@ export class ClientWorld {
|
|||
|
||||
case 'Merc': {
|
||||
if (ev.kind === 'assign' && ev.mercId !== undefined) {
|
||||
const isSummonAction = (ev as any).isSummonAction === true || (ev as any).summonAction !== undefined
|
||||
const petType = (ev as any).petType ?? ((ev as any).stat !== undefined ? ev.value : undefined) ?? 0
|
||||
const isSummonPet = isSummonAction && petType !== 7
|
||||
|
||||
if (isSummonPet) {
|
||||
const action = (ev as any).summonAction ?? ev.stat ?? 1
|
||||
if (action === 0) {
|
||||
this._pets.delete(ev.mercId)
|
||||
} else {
|
||||
const petUnit = this.getUnit('monster', ev.mercId)
|
||||
this._pets.set(ev.mercId, {
|
||||
unitId: ev.mercId,
|
||||
ownerId: ev.ownerId ?? this.selfId,
|
||||
petType,
|
||||
classId: ev.classId ?? 0,
|
||||
lifePct: petUnit?.lifePct ?? 128,
|
||||
isDead: false,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (ev.ownerId === undefined || ev.ownerId === this.selfId || this.selfId === 0) {
|
||||
if (isSummonAction && (ev as any).summonAction === 0) {
|
||||
if (this._merc && this._merc.mercId === ev.mercId) {
|
||||
this._merc.isDead = true
|
||||
this._merc.lifePct = 0
|
||||
}
|
||||
this._pets.delete(ev.mercId)
|
||||
} else {
|
||||
this._self.mercId = ev.mercId === 0 ? null : ev.mercId
|
||||
const mercUnit = ev.mercId !== 0 ? this.getUnit('monster', ev.mercId) : undefined
|
||||
this._merc =
|
||||
|
|
@ -2522,6 +2608,24 @@ export class ClientWorld {
|
|||
stats: this._merc?.stats ?? new Map<number, number>(),
|
||||
hireList: this._mercHireList,
|
||||
}
|
||||
if (ev.mercId !== 0) {
|
||||
this._pets.set(ev.mercId, {
|
||||
unitId: ev.mercId,
|
||||
ownerId: ev.ownerId ?? this.selfId,
|
||||
petType: 7,
|
||||
classId: ev.classId ?? 0,
|
||||
lifePct: mercUnit?.lifePct ?? 128,
|
||||
isDead: false,
|
||||
})
|
||||
} else {
|
||||
for (const [id, p] of this._pets.entries()) {
|
||||
if (p.petType === 7 && p.ownerId === (ev.ownerId ?? this.selfId)) {
|
||||
this._pets.delete(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ev.kind === 'exp' && ev.value !== undefined && this._merc) {
|
||||
this._merc.exp = ev.value
|
||||
|
|
@ -2624,6 +2728,7 @@ export class ClientWorld {
|
|||
this._self.reset()
|
||||
this._merc = null
|
||||
this._mercHireList.length = 0
|
||||
this._pets.clear()
|
||||
this._units.clear()
|
||||
this._monsters.clear()
|
||||
this._containers.clear()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* AUTO-GENERATED by `scripts/pack-canonical-tbl.ts` from authoritative 1.13c MPQ archives:
|
||||
* AUTO-GENERATED by `src/baker/packers/pack-canonical-tbl.ts` from authoritative 1.13c MPQ archives:
|
||||
* - `d2data.mpq` (`string.tbl`)
|
||||
* - `d2exp.mpq` (`expansionstring.tbl`)
|
||||
* - `Patch_D2.mpq` (`patchstring.tbl`)
|
||||
|
|
@ -203,6 +203,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"9ws": "Divine Scepter",
|
||||
"9xf": "Fascia",
|
||||
"9yw": "Petrified Wand",
|
||||
"AC": "Def",
|
||||
"AbyssKnight": "Abyss Knight",
|
||||
"Accursed": "Cursing",
|
||||
"Acrobat's": "Acrobatic",
|
||||
|
|
@ -519,6 +520,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Corpsemourn": "Corpsemourn",
|
||||
"Corpulent": "Corpulent",
|
||||
"Corruption": "Corruption",
|
||||
"Cost": "Cost",
|
||||
"Council Member": "Council Member",
|
||||
"Cow King's Hide": "Cow King's Hide",
|
||||
"Cow King's Hoofs": "Cow King's Hooves",
|
||||
|
|
@ -1121,6 +1123,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Gull": "Gull",
|
||||
"Gutsiphon": "Gut Siphon",
|
||||
"Gymnast's": "Gymnastic",
|
||||
"HP": "Life",
|
||||
"Haemosu's Adament": "Haemosu's Adamant",
|
||||
"Hailstone": "Hailstone",
|
||||
"Halaberd's Reign": "Halaberd's Reign",
|
||||
|
|
@ -1258,6 +1261,8 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Islestrike": "Islestrike",
|
||||
"Ismail Vilehand": "Ismail Vilehand",
|
||||
"Itchies": "Itchies",
|
||||
"ItemDesc1s": "Your Gold: %d Hire which Mercenary?",
|
||||
"ItemDesc1t": "There are no Mercenaries left to hire.",
|
||||
"ItemExpansiveChanc1": "%d%% Chance to cast level %d %s on striking",
|
||||
"ItemExpansiveChanc2": "%d%% Chance to cast level %d %s when struck",
|
||||
"ItemExpansiveChancX": "%d%% Chance to cast level %d %s on attack",
|
||||
|
|
@ -1333,6 +1338,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Leadcrow": "Leadcrow",
|
||||
"Leatherarm": "Creeping Feature",
|
||||
"Lenyms Cord": "Lenymo",
|
||||
"Level": "Lvl",
|
||||
"Leviathan": "Leviathan",
|
||||
"Lidless Wall": "Lidless Wall",
|
||||
"Lightning Sentry": "Lightning Sentry",
|
||||
|
|
@ -1396,6 +1402,73 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Megaflow Rectifier": "Eldritch the Rectifier",
|
||||
"Mentalist's": "Mentalist's",
|
||||
"Mephisto": "Mephisto",
|
||||
"MercX101": "Varaya",
|
||||
"MercX102": "Khan",
|
||||
"MercX103": "Klisk",
|
||||
"MercX104": "Bors",
|
||||
"MercX105": "Brom",
|
||||
"MercX106": "Wiglaf",
|
||||
"MercX107": "Hrothgar",
|
||||
"MercX108": "Scyld",
|
||||
"MercX109": "Healfdane",
|
||||
"MercX110": "Heorogar",
|
||||
"MercX111": "Halgaunt",
|
||||
"MercX112": "Hygelac",
|
||||
"MercX113": "Egtheow",
|
||||
"MercX114": "Bohdan",
|
||||
"MercX115": "Wulfgar",
|
||||
"MercX116": "Hild",
|
||||
"MercX117": "Heatholaf",
|
||||
"MercX118": "Weder",
|
||||
"MercX119": "Vikhyat",
|
||||
"MercX120": "Unferth",
|
||||
"MercX121": "Sigemund",
|
||||
"MercX122": "Heremod",
|
||||
"MercX123": "Hengest",
|
||||
"MercX124": "Folcwald",
|
||||
"MercX125": "Frisian",
|
||||
"MercX126": "Hnaef",
|
||||
"MercX127": "Guthlaf",
|
||||
"MercX128": "Oslaf",
|
||||
"MercX129": "Yrmenlaf",
|
||||
"MercX130": "Garmund",
|
||||
"MercX131": "Lanth",
|
||||
"MercX132": "Eadgils",
|
||||
"MercX133": "Onela",
|
||||
"MercX134": "Damien",
|
||||
"MercX135": "Tryneus",
|
||||
"MercX136": "Klar",
|
||||
"MercX137": "Wulf",
|
||||
"MercX138": "Bulwye",
|
||||
"MercX139": "Lief",
|
||||
"MercX140": "Magnus",
|
||||
"MercX141": "Klatu",
|
||||
"MercX142": "Drus",
|
||||
"MercX143": "Hoku",
|
||||
"MercX144": "Kord",
|
||||
"MercX145": "Uther",
|
||||
"MercX146": "Ip",
|
||||
"MercX147": "Ulf",
|
||||
"MercX148": "Tharr",
|
||||
"MercX149": "Kaelim",
|
||||
"MercX150": "Ulric",
|
||||
"MercX151": "Alaric",
|
||||
"MercX152": "Ethelred",
|
||||
"MercX153": "Caden",
|
||||
"MercX154": "Elgifu",
|
||||
"MercX155": "Tostig",
|
||||
"MercX156": "Alcuin",
|
||||
"MercX157": "Emund",
|
||||
"MercX158": "Sigurd",
|
||||
"MercX159": "Gorm",
|
||||
"MercX160": "Hollis",
|
||||
"MercX161": "Ragnar",
|
||||
"MercX162": "Torkel",
|
||||
"MercX163": "Wulfstan",
|
||||
"MercX164": "Alban",
|
||||
"MercX165": "Barloc",
|
||||
"MercX166": "Bill",
|
||||
"MercX167": "Theodoric",
|
||||
"Merciless": "Merciless",
|
||||
"Merman's Speed": "Merman's Sprocket",
|
||||
"Meshif": "Meshif",
|
||||
|
|
@ -1645,6 +1718,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Mosers Blessed Circle": "Moser's Blessed Circle",
|
||||
"MummyGenerator": "Mummy Sarcophagus",
|
||||
"NPCIdentify1": "Identify Items",
|
||||
"NPCMenuHire": "hire",
|
||||
"NPCMenuTrade": "trade",
|
||||
"NPCMenuTradeRepair": "trade/repair",
|
||||
"NPCPurchaseItems": "Buy",
|
||||
|
|
@ -1706,6 +1780,9 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Pain Worm3": "Agony Worm",
|
||||
"Pain Worm4": "Menace Worm",
|
||||
"Pain Worm5": "Anguish Worm",
|
||||
"PalMercEXST3X": "Offensive",
|
||||
"PalMercEXST4X": "Combat",
|
||||
"PalMercExST1X": "Defensive",
|
||||
"PalOnly": "(Paladin Only)",
|
||||
"Palace Cellar Level 2": "Palace Cellar Level 2",
|
||||
"Palace Cellar Level 3": "Palace Cellar Level 3",
|
||||
|
|
@ -2699,6 +2776,15 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Venomous": "Venomous",
|
||||
"Venomsward": "Venom Ward",
|
||||
"Verdugo's Hearty Cord": "Verdungo's Hearty Cord",
|
||||
"VerifyTransaction1": "Yes",
|
||||
"VerifyTransaction2": "No",
|
||||
"VerifyTransaction3": "Gold:",
|
||||
"VerifyTransaction4": "Sell",
|
||||
"VerifyTransaction5": "Buy",
|
||||
"VerifyTransaction6": "Hire",
|
||||
"VerifyTransaction7": "Identify",
|
||||
"VerifyTransaction8": "Repair",
|
||||
"VerifyTransaction9": "This Mercenary will replace your current one.",
|
||||
"Vermillion": "Vermilion",
|
||||
"Veteran's": "Veteran's",
|
||||
"Vicious": "Vicious",
|
||||
|
|
@ -2919,6 +3005,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"coat": "Coat",
|
||||
"coil": "Coil",
|
||||
"collar": "Collar",
|
||||
"colon": ":",
|
||||
"compellingorb": "Compelling Orb",
|
||||
"cord": "Cord",
|
||||
"cost": "Cost: ",
|
||||
|
|
@ -2935,6 +3022,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"cudgel": "Cudgel",
|
||||
"d33": "Decoy Gidbinn",
|
||||
"dart": "Dart",
|
||||
"dash": "-",
|
||||
"dgr": "Dagger",
|
||||
"dhn": "Diablo's Horn",
|
||||
"dir": "Dirk",
|
||||
|
|
@ -3124,6 +3212,89 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"mbl": "Belt",
|
||||
"mbr": "Mephisto's Brain",
|
||||
"mbt": "Chain Boots",
|
||||
"merc01": "Aliza",
|
||||
"merc02": "Amplisa",
|
||||
"merc03": "Annor",
|
||||
"merc04": "Abhaya",
|
||||
"merc05": "Elly",
|
||||
"merc06": "Paige",
|
||||
"merc07": "Basanti",
|
||||
"merc08": "Blaise",
|
||||
"merc09": "Kyoko",
|
||||
"merc10": "Klaudia",
|
||||
"merc11": "Kundri",
|
||||
"merc12": "Kyle",
|
||||
"merc13": "Visala",
|
||||
"merc14": "Elexa",
|
||||
"merc15": "Floria",
|
||||
"merc16": "Fiona",
|
||||
"merc17": "Gwinni",
|
||||
"merc18": "Gaile",
|
||||
"merc19": "Hannah",
|
||||
"merc20": "Heather",
|
||||
"merc21": "Iantha",
|
||||
"merc22": "Diane",
|
||||
"merc23": "Isolde",
|
||||
"merc24": "Divo",
|
||||
"merc25": "Ithera",
|
||||
"merc26": "Itonya",
|
||||
"merc27": "Liene",
|
||||
"merc28": "Maeko",
|
||||
"merc29": "Mahala",
|
||||
"merc30": "Liaza",
|
||||
"merc31": "Meghan",
|
||||
"merc32": "Olena",
|
||||
"merc33": "Oriana",
|
||||
"merc34": "Ryann",
|
||||
"merc35": "Rozene",
|
||||
"merc36": "Raissa",
|
||||
"merc37": "Sharyn",
|
||||
"merc38": "Shikha",
|
||||
"merc39": "Debi",
|
||||
"merc40": "Tylena",
|
||||
"merc41": "Wendy",
|
||||
"merca201": "Hazade",
|
||||
"merca202": "Alhizeer",
|
||||
"merca203": "Azrael",
|
||||
"merca204": "Ahsab",
|
||||
"merca205": "Chalan",
|
||||
"merca206": "Haseen",
|
||||
"merca207": "Razan",
|
||||
"merca208": "Emilio",
|
||||
"merca209": "Pratham",
|
||||
"merca210": "Fazel",
|
||||
"merca211": "Jemali",
|
||||
"merca212": "Kasim",
|
||||
"merca213": "Gulzar",
|
||||
"merca214": "Mizan",
|
||||
"merca215": "Leharas",
|
||||
"merca216": "Durga",
|
||||
"merca217": "Neeraj",
|
||||
"merca218": "Ilzan",
|
||||
"merca219": "Zanarhi",
|
||||
"merca220": "Waheed",
|
||||
"merca221": "Vikhyat",
|
||||
"merca222": "Jelani",
|
||||
"merca223": "Barani",
|
||||
"merca224": "Jabari",
|
||||
"merca225": "Devak",
|
||||
"merca226": "Raldin",
|
||||
"merca227": "Telash",
|
||||
"merca228": "Ajheed",
|
||||
"merca229": "Narphet",
|
||||
"merca230": "Khaleel",
|
||||
"merca231": "Phaet",
|
||||
"merca232": "Geshef",
|
||||
"merca233": "Vanji",
|
||||
"merca234": "Haphet",
|
||||
"merca235": "Thadar",
|
||||
"merca236": "Yatiraj",
|
||||
"merca237": "Rhadge",
|
||||
"merca238": "Yashied",
|
||||
"merca239": "Jarulf",
|
||||
"merca240": "Flux",
|
||||
"merca241": "Scorch",
|
||||
"merclevelup": "I feel much stronger now",
|
||||
"meshif": "Meshif",
|
||||
"mgl": "Chain Gloves",
|
||||
"minion": "Minion",
|
||||
|
|
@ -3768,6 +3939,7 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"sml": "Small Shield",
|
||||
"sol": "Soul",
|
||||
"song": "Song",
|
||||
"space": " ",
|
||||
"spawn": "Spawn",
|
||||
"spc": "Spiked Club",
|
||||
"spe": "Spleen",
|
||||
|
|
@ -3845,6 +4017,16 @@ export const CANONICAL_ENG_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"strchrstm": "Stamina",
|
||||
"strchrstr": "Strength",
|
||||
"strchrvit": "Vitality",
|
||||
"strhirespecial1": "Fire Arrow",
|
||||
"strhirespecial10": "Fire, Fast Cast",
|
||||
"strhirespecial2": "Cold Arrow",
|
||||
"strhirespecial3": "Jab Attack",
|
||||
"strhirespecial4": "Poison Resistant",
|
||||
"strhirespecial5": "Lightning",
|
||||
"strhirespecial6": "Cold",
|
||||
"strhirespecial7": "Fire",
|
||||
"strhirespecial8": "Lightning, Fast Cast",
|
||||
"strhirespecial9": "Cold, Fast Cast",
|
||||
"stu": "Studded Leather",
|
||||
"stun attack": "Stun Attack",
|
||||
"suit": "Suit",
|
||||
|
|
@ -4203,6 +4385,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"9ws": "神屬權杖",
|
||||
"9xf": "格斗爪",
|
||||
"9yw": "淨化之杖",
|
||||
"AC": "防禦",
|
||||
"AbyssKnight": "地獄騎士",
|
||||
"Act 5 Combatant": "野蛮人战士",
|
||||
"Act 5 Townguard": "守卫",
|
||||
|
|
@ -4484,6 +4667,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Corpsemourn": "尸体的哀伤",
|
||||
"Corpulent": "癡肥怪",
|
||||
"Corruption": "毀滅之",
|
||||
"Cost": "花費",
|
||||
"Council Member": "議會成員",
|
||||
"Cow King's Hide": "牛魔王之皮",
|
||||
"Cow King's Hoofs": "牛魔王之蹄",
|
||||
|
|
@ -5018,6 +5202,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Guillaume's Face": "吉永之脸",
|
||||
"Gull": "海鷗",
|
||||
"Gutsiphon": "吸魂器",
|
||||
"HP": "生命",
|
||||
"Haemosu's Adament": "解慕漱的坚决",
|
||||
"Hailstone": "霰之",
|
||||
"Halaberd's Reign": "哈拉伯德的统治",
|
||||
|
|
@ -5143,6 +5328,8 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Islestrike": "岛屿击",
|
||||
"Ismail Vilehand": "邪惡之手-伊斯梅爾",
|
||||
"Itchies": "渴望者",
|
||||
"ItemDesc1s": "你的金錢: %d 雇用那個傭兵? ",
|
||||
"ItemDesc1t": "現在沒有可以雇用的傭兵。",
|
||||
"ItemExpansiveChanc1": "击中时有 %d%% 几率施展等级 %d %s",
|
||||
"ItemExpansiveChanc2": "受击时有 %d%% 几率施展等级 %d %s",
|
||||
"ItemExpansiveChancX": "攻击时有 %d%% 几率施展等级 %d %s",
|
||||
|
|
@ -5208,6 +5395,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Leadcrow": "引導烏鴉",
|
||||
"Leatherarm": "爬行的容貌",
|
||||
"Lenyms Cord": "雷尼摩",
|
||||
"Level": "等級",
|
||||
"Leviathan": "利维坦",
|
||||
"Lidless Wall": "警戒之墙",
|
||||
"Lightning Sentry": "闪电卫哨",
|
||||
|
|
@ -5262,6 +5450,73 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Medusa's Gaze": "美杜莎的凝视",
|
||||
"Megaflow Rectifier": "矫正者怪异",
|
||||
"Mephisto": "墨菲斯托",
|
||||
"MercX101": "伐德哈卡",
|
||||
"MercX102": "可汗",
|
||||
"MercX103": "克理斯克",
|
||||
"MercX104": "柏斯",
|
||||
"MercX105": "波姆",
|
||||
"MercX106": "衛格萊夫",
|
||||
"MercX107": "霍勒斯加",
|
||||
"MercX108": "西勒德",
|
||||
"MercX109": "海爾夫坦",
|
||||
"MercX110": "海波加",
|
||||
"MercX111": "海爾干特",
|
||||
"MercX112": "海加力克",
|
||||
"MercX113": "艾格索",
|
||||
"MercX114": "波丹",
|
||||
"MercX115": "伍夫加",
|
||||
"MercX116": "希爾德",
|
||||
"MercX117": "希斯洛夫",
|
||||
"MercX118": "魏德",
|
||||
"MercX119": "維克海亞特",
|
||||
"MercX120": "安佛斯",
|
||||
"MercX121": "席格龐特",
|
||||
"MercX122": "希爾穆特",
|
||||
"MercX123": "罕格斯特",
|
||||
"MercX124": "弗克魏爾德",
|
||||
"MercX125": "弗雷斯坦",
|
||||
"MercX126": "荷那夫",
|
||||
"MercX127": "高斯里夫",
|
||||
"MercX128": "歐斯拉夫",
|
||||
"MercX129": "亞門拉夫",
|
||||
"MercX130": "加門得",
|
||||
"MercX131": "菲瓦夫",
|
||||
"MercX132": "艾得格理斯",
|
||||
"MercX133": "奧蕾那",
|
||||
"MercX134": "戴米安",
|
||||
"MercX135": "艾弗",
|
||||
"MercX136": "沃斯坦",
|
||||
"MercX137": "沃夫",
|
||||
"MercX138": "布溫",
|
||||
"MercX139": "理夫",
|
||||
"MercX140": "馬格納斯",
|
||||
"MercX141": "格拉圖",
|
||||
"MercX142": "圖魯斯",
|
||||
"MercX143": "候克",
|
||||
"MercX144": "科杜",
|
||||
"MercX145": "猶瑟r",
|
||||
"MercX146": "艾普",
|
||||
"MercX147": "伍夫",
|
||||
"MercX148": "撒爾",
|
||||
"MercX149": "卡林姆",
|
||||
"MercX150": "烏瑞克",
|
||||
"MercX151": "艾拉瑞克",
|
||||
"MercX152": "愛瑟瑞得",
|
||||
"MercX153": "卡登",
|
||||
"MercX154": "伊爾吉夫",
|
||||
"MercX155": "托斯提克",
|
||||
"MercX156": "艾爾昆",
|
||||
"MercX157": "艾蒙特",
|
||||
"MercX158": "席古德",
|
||||
"MercX159": "格姆",
|
||||
"MercX160": "荷理斯",
|
||||
"MercX161": "拉格那",
|
||||
"MercX162": "托克爾",
|
||||
"MercX163": "沃夫斯坦",
|
||||
"MercX164": "阿爾班",
|
||||
"MercX165": "巴洛克",
|
||||
"MercX166": "比爾",
|
||||
"MercX167": "狄奧多理",
|
||||
"Merciless": "絕情之",
|
||||
"Merman's Speed": "鱼人的链轮",
|
||||
"Meshif": "馬席夫",
|
||||
|
|
@ -5509,6 +5764,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Mosers Blessed Circle": "摩西尔的祝福之环",
|
||||
"MummyGenerator": "木乃伊石棺",
|
||||
"NPCIdentify1": "辨視物品",
|
||||
"NPCMenuHire": "雇用",
|
||||
"NPCMenuTrade": "交易",
|
||||
"NPCMenuTradeRepair": "交易/修理",
|
||||
"NPCPurchaseItems": "買",
|
||||
|
|
@ -5564,6 +5820,9 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Pain Worm3": "苦难之虫",
|
||||
"Pain Worm4": "威胁之虫",
|
||||
"Pain Worm5": "剧痛之虫",
|
||||
"PalMercEXST3X": "進攻",
|
||||
"PalMercEXST4X": "戰鬥",
|
||||
"PalMercExST1X": "防禦",
|
||||
"PalOnly": "(限聖騎士)",
|
||||
"Palace Cellar Level 2": "皇宮監牢第二層",
|
||||
"Palace Cellar Level 3": "皇宮監牢第三層",
|
||||
|
|
@ -5670,17 +5929,90 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"RunOn": "跑步",
|
||||
"Rune": "符號之",
|
||||
"Runemaster": "符文大师",
|
||||
"Runeword1": "远古誓言",
|
||||
"Runeword10": "烙印",
|
||||
"Runeword103": "凤凰",
|
||||
"Runeword108": "祈祷",
|
||||
"Runeword122": "庇护所",
|
||||
"Runeword109": "骄傲",
|
||||
"Runeword11": "死亡呼吸",
|
||||
"Runeword110": "原则",
|
||||
"Runeword112": "审慎",
|
||||
"Runeword116": "光辉",
|
||||
"Runeword117": "雨",
|
||||
"Runeword120": "押韵",
|
||||
"Runeword121": "裂隙",
|
||||
"Runeword122": "圣堂",
|
||||
"Runeword126": "寂静",
|
||||
"Runeword128": "烟雾",
|
||||
"Runeword13": "战争召唤",
|
||||
"Runeword130": "精神",
|
||||
"Runeword131": "灿烂",
|
||||
"Runeword133": "隐秘",
|
||||
"Runeword134": "钢铁",
|
||||
"Runeword137": "石块",
|
||||
"Runeword139": "力量",
|
||||
"Runeword14": "荣耀之链",
|
||||
"Runeword142": "恐惧",
|
||||
"Runeword148": "背信",
|
||||
"Runeword153": "复仇",
|
||||
"Runeword154": "毒液的",
|
||||
"Runeword154": "毒液",
|
||||
"Runeword16": "混沌",
|
||||
"Runeword160": "财富",
|
||||
"Runeword162": "白色",
|
||||
"Runeword163": "风",
|
||||
"Runeword168": "愤怒",
|
||||
"Runeword17": "新月",
|
||||
"Runeword2": "世界末日之",
|
||||
"Runeword170": "和风",
|
||||
"Runeword2": "毁天灭地",
|
||||
"Runeword20": "死神",
|
||||
"Runeword22": "迪勒瑞姆",
|
||||
"Runeword25": "毁灭",
|
||||
"Runeword26": "末日",
|
||||
"Runeword27": "飞龙",
|
||||
"Runeword29": "梦境",
|
||||
"Runeword30": "强制",
|
||||
"Runeword31": "边缘",
|
||||
"Runeword33": "谜团",
|
||||
"Runeword34": "启迪",
|
||||
"Runeword36": "永恒",
|
||||
"Runeword37": "流亡",
|
||||
"Runeword38": "信心",
|
||||
"Runeword39": "饥荒",
|
||||
"Runeword4": "野兽",
|
||||
"Runeword41": "刚毅",
|
||||
"Runeword44": "狂怒",
|
||||
"Runeword45": "幽暗",
|
||||
"Runeword47": "悔恨",
|
||||
"Runeword48": "正义之手",
|
||||
"Runeword49": "和谐",
|
||||
"Runeword51": "橡树之心",
|
||||
"Runeword54": "圣雷",
|
||||
"Runeword55": "荣耀",
|
||||
"Runeword58": "饥饿",
|
||||
"Runeword59": "寒冰",
|
||||
"Runeword6": "黑色",
|
||||
"Runeword60": "无限",
|
||||
"Runeword62": "眼光",
|
||||
"Runeword65": "国王之恩典",
|
||||
"Runeword66": "弑君者",
|
||||
"Runeword69": "最后希望",
|
||||
"Runeword71": "执法者",
|
||||
"Runeword72": "叶",
|
||||
"Runeword73": "闪电",
|
||||
"Runeword74": "狮心",
|
||||
"Runeword75": "知识",
|
||||
"Runeword8": "骸骨",
|
||||
"Runeword81": "怨恨",
|
||||
"Runeword82": "旋律",
|
||||
"Runeword83": "记忆",
|
||||
"Runeword87": "神话",
|
||||
"Runeword88": "底韵",
|
||||
"Runeword9": "野蔷薇",
|
||||
"Runeword91": "誓约",
|
||||
"Runeword92": "遵从",
|
||||
"Runeword95": "热情",
|
||||
"Runeword98": "和平",
|
||||
"Runeword99": "理性之声",
|
||||
"Russet": "赤楬之",
|
||||
"Rusthandle": "生銹的把手",
|
||||
"SaberCat": "劍齒貓",
|
||||
|
|
@ -5739,7 +6071,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Silver": "銀之",
|
||||
"Skeleton": "骷髏",
|
||||
"SkeletonArcher": "骷髏弓箭手",
|
||||
"Skillname222": "渡鸟之",
|
||||
"Skillname222": "乌鸦",
|
||||
"Skillname223": "剧毒蔓藤",
|
||||
"Skillname224": "狼人變化",
|
||||
"Skillname225": "狼人变化",
|
||||
|
|
@ -5767,7 +6099,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Skillname247": "荆棘之灵",
|
||||
"Skillname248": "召唤灰熊",
|
||||
"Skillname249": "狂怒",
|
||||
"Skillname250": "世界末日之",
|
||||
"Skillname250": "毁天灭地",
|
||||
"Skillname251": "飓风",
|
||||
"Skillname252": "火焰爆震",
|
||||
"Skillname253": "支配利爪",
|
||||
|
|
@ -5796,7 +6128,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"Skillname276": "飞龙在天",
|
||||
"Skillname277": "亡者守卫",
|
||||
"Skillname278": "刃之盾",
|
||||
"Skillname279": "毒液的",
|
||||
"Skillname279": "毒液",
|
||||
"Skillname280": "暗影宗师",
|
||||
"Skillname281": "凤凰击",
|
||||
"Skin of the Flayerd One": "剥皮者之皮",
|
||||
|
|
@ -6317,6 +6649,15 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"VenomLord": "邪魔之王",
|
||||
"Venomsward": "毒液牢房",
|
||||
"Verdugo's Hearty Cord": "维尔登戈的心结",
|
||||
"VerifyTransaction1": "是",
|
||||
"VerifyTransaction2": "否",
|
||||
"VerifyTransaction3": "金幣:",
|
||||
"VerifyTransaction4": "賣",
|
||||
"VerifyTransaction5": "買",
|
||||
"VerifyTransaction6": "雇用",
|
||||
"VerifyTransaction7": "辨視",
|
||||
"VerifyTransaction8": "修復",
|
||||
"VerifyTransaction9": "這個傭兵會換掉前一位。",
|
||||
"Vicious": "惡毒之",
|
||||
"Victors Silk": "勝利者之絲綢",
|
||||
"Vidala's Ambush": "維達拉的突襲",
|
||||
|
|
@ -6525,6 +6866,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"coat": "外袍",
|
||||
"coil": "盤繞",
|
||||
"collar": "項圈",
|
||||
"colon": ":",
|
||||
"compellingorb": "強制球體",
|
||||
"cord": "捆綁",
|
||||
"cost": "花費:",
|
||||
|
|
@ -6541,6 +6883,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"cudgel": "木棒",
|
||||
"d33": "吉得賓誘餌",
|
||||
"dart": "鏢",
|
||||
"dash": "-",
|
||||
"dgr": "匕首",
|
||||
"dhn": "暗黑破壞神之角",
|
||||
"dir": "長匕首",
|
||||
|
|
@ -6730,6 +7073,89 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"mbl": "扣帶",
|
||||
"mbr": "曼菲斯托之腦",
|
||||
"mbt": "鎖鍊靴",
|
||||
"merc01": "阿里撒",
|
||||
"merc02": "安普利沙",
|
||||
"merc03": "安諾",
|
||||
"merc04": "艾伯海亞",
|
||||
"merc05": "埃利",
|
||||
"merc06": "派吉",
|
||||
"merc07": "巴森提",
|
||||
"merc08": "布萊謝",
|
||||
"merc09": "京子",
|
||||
"merc10": "克勞蒂亞",
|
||||
"merc11": "肯吉",
|
||||
"merc12": "凱爾",
|
||||
"merc13": "維沙利亞",
|
||||
"merc14": "依麗莎",
|
||||
"merc15": "弗蘿利亞",
|
||||
"merc16": "菲歐娜",
|
||||
"merc17": "基溫尼",
|
||||
"merc18": "蓋爾",
|
||||
"merc19": "哈娜",
|
||||
"merc20": "海瑟",
|
||||
"merc21": "艾蓮娜",
|
||||
"merc22": "迪亞娜",
|
||||
"merc23": "依索迪",
|
||||
"merc24": "迪佛",
|
||||
"merc25": "依索拉",
|
||||
"merc26": "衣東亞",
|
||||
"merc27": "萊茵",
|
||||
"merc28": "梅可",
|
||||
"merc29": "馬哈拉",
|
||||
"merc30": "萊沙",
|
||||
"merc31": "梅葛哈",
|
||||
"merc32": "奧蓮娜",
|
||||
"merc33": "奧麗亞娜",
|
||||
"merc34": "萊恩",
|
||||
"merc35": "羅珊妮",
|
||||
"merc36": "萊紗",
|
||||
"merc37": "莎倫",
|
||||
"merc38": "席克哈",
|
||||
"merc39": "戴比",
|
||||
"merc40": "泰蓮娜",
|
||||
"merc41": "溫蒂",
|
||||
"merca201": "哈撒迪",
|
||||
"merca202": "艾利希爾",
|
||||
"merca203": "阿撒爾",
|
||||
"merca204": "亞斯哈",
|
||||
"merca205": "克哈蘭",
|
||||
"merca206": "哈辛",
|
||||
"merca207": "羅森",
|
||||
"merca208": "艾米羅",
|
||||
"merca209": "培撒姆",
|
||||
"merca210": "費索",
|
||||
"merca211": "傑馬利",
|
||||
"merca212": "卡辛姆",
|
||||
"merca213": "吉爾薩",
|
||||
"merca214": "米山",
|
||||
"merca215": "雷哈拉",
|
||||
"merca216": "迪爾卡",
|
||||
"merca217": "尼爾亞",
|
||||
"merca218": "衣利森",
|
||||
"merca219": "撒那利",
|
||||
"merca220": "瓦黑德",
|
||||
"merca221": "維克懷特",
|
||||
"merca222": "喬安妮",
|
||||
"merca223": "巴拉尼",
|
||||
"merca224": "賈巴利",
|
||||
"merca225": "迪法",
|
||||
"merca226": "拉爾丁",
|
||||
"merca227": "鐵拉西",
|
||||
"merca228": "艾傑希德",
|
||||
"merca229": "拿費特",
|
||||
"merca230": "凱立爾",
|
||||
"merca231": "費特",
|
||||
"merca232": "杰希夫",
|
||||
"merca233": "凡吉",
|
||||
"merca234": "海費特",
|
||||
"merca235": "撒達",
|
||||
"merca236": "亞提賈",
|
||||
"merca237": "拉黑德",
|
||||
"merca238": "亞席德",
|
||||
"merca239": "賈魯夫",
|
||||
"merca240": "弗勒克斯",
|
||||
"merca241": "斯高屈",
|
||||
"merclevelup": "我現在覺得強壯多了",
|
||||
"meshif": "馬席夫",
|
||||
"mgl": "鎖鍊手套",
|
||||
"minion": "嘍囉",
|
||||
|
|
@ -7177,6 +7603,7 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"sml": "小盾牌",
|
||||
"sol": "灵魂之",
|
||||
"song": "歌",
|
||||
"space": " ",
|
||||
"spawn": "重生的",
|
||||
"spc": "狼牙棒",
|
||||
"spe": "一股邪恶力量",
|
||||
|
|
@ -7254,6 +7681,16 @@ export const CANONICAL_CHI_TBL_DICT: Readonly<Record<string, string>> = {
|
|||
"strchrstm": "耐力",
|
||||
"strchrstr": "力量",
|
||||
"strchrvit": "體力",
|
||||
"strhirespecial1": "火箭",
|
||||
"strhirespecial10": "快速施展火焰",
|
||||
"strhirespecial2": "冰箭",
|
||||
"strhirespecial3": "戳刺攻擊",
|
||||
"strhirespecial4": "抗毒",
|
||||
"strhirespecial5": "閃電",
|
||||
"strhirespecial6": "冷",
|
||||
"strhirespecial7": "火",
|
||||
"strhirespecial8": "快速施展閃電",
|
||||
"strhirespecial9": "快速施展冰冷",
|
||||
"stu": "鑲嵌甲",
|
||||
"stun attack": "昏眩攻擊",
|
||||
"suit": "衣",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export interface BakedUiManifest {
|
|||
readonly flippyFrames: Record<string, readonly SpriteRect[]>
|
||||
readonly itemClassIdToCode: Record<number, string>
|
||||
readonly questRects: Record<string, SpriteRect>
|
||||
readonly portraitRects?: Record<string, SpriteRect>
|
||||
readonly images: Record<string, string>
|
||||
readonly automapAtlases?: Readonly<Record<AutomapSpriteKey, AutomapSpriteAtlasMeta>>
|
||||
}
|
||||
|
|
@ -25031,7 +25032,7 @@ export const AUTOMAP_UI_MANIFEST: AutomapUiManifest = {
|
|||
|
||||
export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
||||
version: '1.13c',
|
||||
decodedDc6Count: 695,
|
||||
decodedDc6Count: 728,
|
||||
dc6DecodeFailures: 0,
|
||||
atlasWidth: 2048,
|
||||
atlasHeight: 8102,
|
||||
|
|
@ -293573,6 +293574,224 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
|||
"w": 72,
|
||||
"h": 86
|
||||
}
|
||||
},
|
||||
portraitRects: {
|
||||
"AmazonIcon": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"amazonicon": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"SorceressIcon": {
|
||||
"x": 48,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"sorceressicon": {
|
||||
"x": 48,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"NecromancerIcon": {
|
||||
"x": 96,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"necromancericon": {
|
||||
"x": 96,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"PaladinIcon": {
|
||||
"x": 144,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"paladinicon": {
|
||||
"x": 144,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"BarbarianIcon": {
|
||||
"x": 192,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"barbarianicon": {
|
||||
"x": 192,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"DruidIcon": {
|
||||
"x": 240,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"druidicon": {
|
||||
"x": 240,
|
||||
"y": 0,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"AssassinIcon": {
|
||||
"x": 0,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"assassinicon": {
|
||||
"x": 0,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"rogueicon": {
|
||||
"x": 48,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"act2hireableicon": {
|
||||
"x": 96,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"act3hireableicon": {
|
||||
"x": 144,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"barbhirable_icon": {
|
||||
"x": 192,
|
||||
"y": 42,
|
||||
"w": 47,
|
||||
"h": 41
|
||||
},
|
||||
"valkarieicon": {
|
||||
"x": 240,
|
||||
"y": 42,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"earthgolumicon": {
|
||||
"x": 0,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"bloodgolumicon": {
|
||||
"x": 48,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"metalgolumicon": {
|
||||
"x": 96,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"firegolumicon": {
|
||||
"x": 144,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"skeletonicon": {
|
||||
"x": 192,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"skeletonmageicon": {
|
||||
"x": 240,
|
||||
"y": 84,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"revivedicon": {
|
||||
"x": 0,
|
||||
"y": 126,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"raven": {
|
||||
"x": 48,
|
||||
"y": 126,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"wolf": {
|
||||
"x": 96,
|
||||
"y": 126,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"oaksage": {
|
||||
"x": 144,
|
||||
"y": 126,
|
||||
"w": 47,
|
||||
"h": 41
|
||||
},
|
||||
"heartofwolverine": {
|
||||
"x": 192,
|
||||
"y": 126,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"spiritofbarbs": {
|
||||
"x": 240,
|
||||
"y": 126,
|
||||
"w": 47,
|
||||
"h": 41
|
||||
},
|
||||
"plaguepoppy": {
|
||||
"x": 0,
|
||||
"y": 168,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"cycleoflife": {
|
||||
"x": 48,
|
||||
"y": 168,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"vines": {
|
||||
"x": 96,
|
||||
"y": 168,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"bear": {
|
||||
"x": 144,
|
||||
"y": 168,
|
||||
"w": 46,
|
||||
"h": 41
|
||||
},
|
||||
"shadowassassin": {
|
||||
"x": 192,
|
||||
"y": 168,
|
||||
"w": 47,
|
||||
"h": 41
|
||||
}
|
||||
},
|
||||
images: {
|
||||
"ctrlPnl": "/ui/ctrlpnl-800.png",
|
||||
|
|
@ -293595,6 +293814,7 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
|||
"vendorTabs": "/ui/vendor-tabs.png",
|
||||
"boxPieces": "/ui/boxpieces.png",
|
||||
"focus16": "/ui/focus16.png",
|
||||
"textslid": "/ui/textslid.png",
|
||||
"popbelt": "/ui/popbelt.png",
|
||||
"minipanel": "/ui/minipanel.png",
|
||||
"minipanelBtns": "/ui/minipanel-btns.png",
|
||||
|
|
@ -293622,7 +293842,8 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
|||
"automapAct4": "/ui/automap-act4.png",
|
||||
"automapAct4S": "/ui/automap-act4s.png",
|
||||
"automapExTn": "/ui/automap-extn.png",
|
||||
"automapExTnS": "/ui/automap-extns.png"
|
||||
"automapExTnS": "/ui/automap-extns.png",
|
||||
"portraitsAtlas": "/ui/portraits-atlas.png"
|
||||
},
|
||||
automapAtlases: AUTOMAP_UI_MANIFEST.atlases,
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,389 @@
|
|||
/**
|
||||
* Diablo II v1.13c Top-Left HUD Portrait & `PetType.txt` Metadata (`Hireables.cpp` & `RosterPets.cpp`).
|
||||
*
|
||||
* Ground Truth:
|
||||
* - `D2Client.dll` `Hireables.cpp` (`0x6fb0b120..0x6fb0c980`):
|
||||
* - 7 player class icons (`0x6fb0b260`): `AmazonIcon`, `SorceressIcon`, `NecromancerIcon`,
|
||||
* `PaladinIcon`, `BarbarianIcon`, `DruidIcon`, `AssassinIcon` (`classId 0..6`).
|
||||
* - 4 hireling icons (`PetType.txt` row `7` `hireable`): `rogueicon` (Act 1 Rogue, `hcIdx 271`),
|
||||
* `act2hireableicon` (Act 2 Desert Merc, `hcIdx 338`), `act3hireableicon` (Act 3 Iron Wolf, `hcIdx 359`),
|
||||
* `barbhirable_icon` (Act 5 Barbarian, `hcIdx 560/561`).
|
||||
* - 18 summoned minion icons (`PetType.txt` rows `2..6, 10..16`, `icontype 1..3`).
|
||||
*/
|
||||
|
||||
export type PortraitIconType = 0 | 1 | 2 | 3
|
||||
|
||||
export interface PetSubClassMeta {
|
||||
readonly classId: number
|
||||
readonly icon: string
|
||||
readonly nameEn: string
|
||||
readonly nameZh: string
|
||||
}
|
||||
|
||||
export interface PetTypePortraitMeta {
|
||||
readonly idx: number
|
||||
readonly petType: string
|
||||
readonly group: number
|
||||
readonly drawHp: boolean
|
||||
readonly iconType: PortraitIconType
|
||||
readonly baseIcon: string
|
||||
readonly nameKey: string
|
||||
readonly nameEn: string
|
||||
readonly nameZh: string
|
||||
readonly subClasses: readonly PetSubClassMeta[]
|
||||
}
|
||||
|
||||
/**
|
||||
* All 29 portrait DC6 asset stems in `data/global/ui/Hireables/*.dc6`.
|
||||
*/
|
||||
export const CANONICAL_PORTRAIT_DC6_NAMES: readonly string[] = [
|
||||
// 7 Player Classes (`0x6fb0b260`)
|
||||
'AmazonIcon',
|
||||
'SorceressIcon',
|
||||
'NecromancerIcon',
|
||||
'PaladinIcon',
|
||||
'BarbarianIcon',
|
||||
'DruidIcon',
|
||||
'AssassinIcon',
|
||||
// 4 Mercenary / Hireling Classes (`PetType.txt` idx=7)
|
||||
'rogueicon',
|
||||
'act2hireableicon',
|
||||
'act3hireableicon',
|
||||
'barbhirable_icon',
|
||||
// 18 Summon / Minion Icons (`PetType.txt` idx=2..6, 10..16)
|
||||
'valkarieicon',
|
||||
'earthgolumicon',
|
||||
'bloodgolumicon',
|
||||
'metalgolumicon',
|
||||
'firegolumicon',
|
||||
'skeletonicon',
|
||||
'skeletonmageicon',
|
||||
'revivedicon',
|
||||
'raven',
|
||||
'wolf',
|
||||
'oaksage',
|
||||
'heartofwolverine',
|
||||
'spiritofbarbs',
|
||||
'plaguepoppy',
|
||||
'cycleoflife',
|
||||
'vines',
|
||||
'bear',
|
||||
'shadowassassin',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Player class portrait icon key by `classId` (`0..6`) or class code (`ama..ass`).
|
||||
*/
|
||||
export const PLAYER_CLASS_PORTRAIT_BY_ID: Readonly<Record<number, string>> = {
|
||||
0: 'AmazonIcon',
|
||||
1: 'SorceressIcon',
|
||||
2: 'NecromancerIcon',
|
||||
3: 'PaladinIcon',
|
||||
4: 'BarbarianIcon',
|
||||
5: 'DruidIcon',
|
||||
6: 'AssassinIcon',
|
||||
}
|
||||
|
||||
export const PLAYER_CLASS_PORTRAIT_BY_CODE: Readonly<Record<string, string>> = {
|
||||
ama: 'AmazonIcon',
|
||||
sor: 'SorceressIcon',
|
||||
nec: 'NecromancerIcon',
|
||||
pal: 'PaladinIcon',
|
||||
bar: 'BarbarianIcon',
|
||||
dru: 'DruidIcon',
|
||||
ass: 'AssassinIcon',
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative 1.13c `PetType.txt` table (`idx 1..19`).
|
||||
*/
|
||||
export const PET_TYPE_PORTRAIT_TABLE: Readonly<Record<number, PetTypePortraitMeta>> = {
|
||||
2: {
|
||||
idx: 2,
|
||||
petType: 'valkyrie',
|
||||
group: 0,
|
||||
drawHp: true,
|
||||
iconType: 1,
|
||||
baseIcon: 'valkarieicon',
|
||||
nameKey: 'Valkyrie',
|
||||
nameEn: 'Valkyrie',
|
||||
nameZh: '女武神',
|
||||
subClasses: [{ classId: 357, icon: 'valkarieicon', nameEn: 'Valkyrie', nameZh: '女武神' }],
|
||||
},
|
||||
3: {
|
||||
idx: 3,
|
||||
petType: 'golem',
|
||||
group: 0,
|
||||
drawHp: true,
|
||||
iconType: 3,
|
||||
baseIcon: 'earthgolumicon',
|
||||
nameKey: 'Golem',
|
||||
nameEn: 'Clay Golem',
|
||||
nameZh: '黏土石魔',
|
||||
subClasses: [
|
||||
{ classId: 289, icon: 'earthgolumicon', nameEn: 'Clay Golem', nameZh: '黏土石魔' },
|
||||
{ classId: 290, icon: 'bloodgolumicon', nameEn: 'Blood Golem', nameZh: '鮮血石魔' },
|
||||
{ classId: 291, icon: 'metalgolumicon', nameEn: 'Iron Golem', nameZh: '鋼鐵石魔' },
|
||||
{ classId: 292, icon: 'firegolumicon', nameEn: 'Fire Golem', nameZh: '火焰石魔' },
|
||||
],
|
||||
},
|
||||
4: {
|
||||
idx: 4,
|
||||
petType: 'skeleton',
|
||||
group: 0,
|
||||
drawHp: true,
|
||||
iconType: 2,
|
||||
baseIcon: 'skeletonicon',
|
||||
nameKey: 'Skeleton',
|
||||
nameEn: 'Skeleton',
|
||||
nameZh: '骷髏',
|
||||
subClasses: [{ classId: 363, icon: 'skeletonicon', nameEn: 'Skeleton', nameZh: '骷髏' }],
|
||||
},
|
||||
5: {
|
||||
idx: 5,
|
||||
petType: 'skeletonmage',
|
||||
group: 0,
|
||||
drawHp: true,
|
||||
iconType: 2,
|
||||
baseIcon: 'skeletonmageicon',
|
||||
nameKey: 'SkeletonMage',
|
||||
nameEn: 'Skeletal Mage',
|
||||
nameZh: '骷髏法師',
|
||||
subClasses: [{ classId: 364, icon: 'skeletonmageicon', nameEn: 'Skeletal Mage', nameZh: '骷髏法師' }],
|
||||
},
|
||||
6: {
|
||||
idx: 6,
|
||||
petType: 'revive',
|
||||
group: 0,
|
||||
drawHp: true,
|
||||
iconType: 2,
|
||||
baseIcon: 'revivedicon',
|
||||
nameKey: 'Revive',
|
||||
nameEn: 'Revive',
|
||||
nameZh: '復生怪物',
|
||||
subClasses: [],
|
||||
},
|
||||
7: {
|
||||
idx: 7,
|
||||
petType: 'hireable',
|
||||
group: 0,
|
||||
drawHp: true,
|
||||
iconType: 1,
|
||||
baseIcon: 'rogueicon',
|
||||
nameKey: 'Merc',
|
||||
nameEn: 'Mercenary',
|
||||
nameZh: '傭兵',
|
||||
subClasses: [
|
||||
{ classId: 271, icon: 'rogueicon', nameEn: 'Rogue Scout', nameZh: '蘿格斥候' },
|
||||
{ classId: 338, icon: 'act2hireableicon', nameEn: 'Desert Mercenary', nameZh: '沙漠傭兵' },
|
||||
{ classId: 359, icon: 'act3hireableicon', nameEn: 'Iron Wolf', nameZh: '鐵狼衛士' },
|
||||
{ classId: 560, icon: 'barbhirable_icon', nameEn: 'Barbarian', nameZh: '野蠻人傭兵' },
|
||||
{ classId: 561, icon: 'barbhirable_icon', nameEn: 'Barbarian', nameZh: '野蠻人傭兵' },
|
||||
],
|
||||
},
|
||||
10: {
|
||||
idx: 10,
|
||||
petType: 'raven',
|
||||
group: 0,
|
||||
drawHp: false,
|
||||
iconType: 2,
|
||||
baseIcon: 'raven',
|
||||
nameKey: 'DruidHawk',
|
||||
nameEn: 'Raven',
|
||||
nameZh: '烏鴉',
|
||||
subClasses: [{ classId: 419, icon: 'raven', nameEn: 'Raven', nameZh: '烏鴉' }],
|
||||
},
|
||||
11: {
|
||||
idx: 11,
|
||||
petType: 'spiritwolf',
|
||||
group: 1,
|
||||
drawHp: true,
|
||||
iconType: 2,
|
||||
baseIcon: 'wolf',
|
||||
nameKey: 'SpiritWolf',
|
||||
nameEn: 'Spirit Wolf',
|
||||
nameZh: '鬼狼',
|
||||
subClasses: [{ classId: 420, icon: 'wolf', nameEn: 'Spirit Wolf', nameZh: '鬼狼' }],
|
||||
},
|
||||
12: {
|
||||
idx: 12,
|
||||
petType: 'fenris',
|
||||
group: 1,
|
||||
drawHp: true,
|
||||
iconType: 2,
|
||||
baseIcon: 'wolf',
|
||||
nameKey: 'Fenris',
|
||||
nameEn: 'Dire Wolf',
|
||||
nameZh: '狂狼',
|
||||
subClasses: [{ classId: 421, icon: 'wolf', nameEn: 'Dire Wolf', nameZh: '狂狼' }],
|
||||
},
|
||||
13: {
|
||||
idx: 13,
|
||||
petType: 'totem',
|
||||
group: 2,
|
||||
drawHp: true,
|
||||
iconType: 3,
|
||||
baseIcon: 'oaksage',
|
||||
nameKey: 'Totem',
|
||||
nameEn: 'Oak Sage',
|
||||
nameZh: '橡木智者',
|
||||
subClasses: [
|
||||
{ classId: 424, icon: 'oaksage', nameEn: 'Oak Sage', nameZh: '橡木智者' },
|
||||
{ classId: 423, icon: 'heartofwolverine', nameEn: 'Heart of Wolverine', nameZh: '狼獾之心' },
|
||||
{ classId: 422, icon: 'spiritofbarbs', nameEn: 'Spirit of Barbs', nameZh: '棘靈' },
|
||||
],
|
||||
},
|
||||
14: {
|
||||
idx: 14,
|
||||
petType: 'vine',
|
||||
group: 3,
|
||||
drawHp: true,
|
||||
iconType: 3,
|
||||
baseIcon: 'plaguepoppy',
|
||||
nameKey: 'Vine',
|
||||
nameEn: 'Poison Creeper',
|
||||
nameZh: '劇毒藤蔓',
|
||||
subClasses: [
|
||||
{ classId: 425, icon: 'plaguepoppy', nameEn: 'Poison Creeper', nameZh: '劇毒藤蔓' },
|
||||
{ classId: 426, icon: 'cycleoflife', nameEn: 'Carrion Vine', nameZh: '食屍藤蔓' },
|
||||
{ classId: 427, icon: 'vines', nameEn: 'Solar Creeper', nameZh: '太陽藤蔓' },
|
||||
],
|
||||
},
|
||||
15: {
|
||||
idx: 15,
|
||||
petType: 'grizzly',
|
||||
group: 1,
|
||||
drawHp: true,
|
||||
iconType: 1,
|
||||
baseIcon: 'bear',
|
||||
nameKey: 'DruidBear',
|
||||
nameEn: 'Grizzly',
|
||||
nameZh: '灰熊',
|
||||
subClasses: [{ classId: 428, icon: 'bear', nameEn: 'Grizzly', nameZh: '灰熊' }],
|
||||
},
|
||||
16: {
|
||||
idx: 16,
|
||||
petType: 'shadowwarrior',
|
||||
group: 4,
|
||||
drawHp: true,
|
||||
iconType: 1,
|
||||
baseIcon: 'shadowassassin',
|
||||
nameKey: 'ShadowWarrior',
|
||||
nameEn: 'Shadow Warrior',
|
||||
nameZh: '影子戰士',
|
||||
subClasses: [
|
||||
{ classId: 417, icon: 'shadowassassin', nameEn: 'Shadow Warrior', nameZh: '影子戰士' },
|
||||
{ classId: 418, icon: 'shadowassassin', nameEn: 'Shadow Master', nameZh: '影子大師' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse lookup from monster `hcIdx` (`classId`) to `PetType.txt` row index (`idx`).
|
||||
*/
|
||||
export const MONSTER_CLASS_TO_PET_TYPE_IDX: Readonly<Record<number, number>> = {
|
||||
357: 2, // Valkyrie
|
||||
289: 3, // Clay Golem
|
||||
290: 3, // Blood Golem
|
||||
291: 3, // Iron Golem
|
||||
292: 3, // Fire Golem
|
||||
363: 4, // Necro Skeleton
|
||||
364: 5, // Necro Skeletal Mage
|
||||
271: 7, // Act 1 Rogue Hireling
|
||||
338: 7, // Act 2 Desert Mercenary
|
||||
359: 7, // Act 3 Iron Wolf
|
||||
560: 7, // Act 5 Barbarian Hireling
|
||||
561: 7, // Act 5 Barbarian Hireling
|
||||
419: 10, // Raven
|
||||
420: 11, // Spirit Wolf
|
||||
421: 12, // Dire Wolf (Fenris)
|
||||
422: 13, // Spirit of Barbs
|
||||
423: 13, // Heart of Wolverine
|
||||
424: 13, // Oak Sage
|
||||
425: 14, // Poison Creeper (Plague Poppy)
|
||||
426: 14, // Carrion Vine (Cycle of Life)
|
||||
427: 14, // Solar Creeper (Vine Creature)
|
||||
428: 15, // Druid Grizzly Bear
|
||||
417: 16, // Shadow Warrior
|
||||
418: 16, // Shadow Master
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authentic 1.13c mercenary portrait icon key from `classId` (`Hireables.cpp` `0x6fb0b2a0`).
|
||||
*/
|
||||
export function resolveMercPortraitIcon(classId: number | undefined): string {
|
||||
switch (classId) {
|
||||
case 338:
|
||||
return 'act2hireableicon'
|
||||
case 359:
|
||||
return 'act3hireableicon'
|
||||
case 560:
|
||||
case 561:
|
||||
return 'barbhirable_icon'
|
||||
case 271:
|
||||
default:
|
||||
return 'rogueicon'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authentic 1.13c player class portrait icon key from `charClass` (`0..6` or `'ama'..'ass'`).
|
||||
*/
|
||||
export function resolvePlayerClassPortraitIcon(charClass: number | string | undefined): string {
|
||||
if (typeof charClass === 'number') {
|
||||
return PLAYER_CLASS_PORTRAIT_BY_ID[charClass] ?? 'AmazonIcon'
|
||||
}
|
||||
if (typeof charClass === 'string') {
|
||||
const num = Number(charClass)
|
||||
if (Number.isFinite(num) && PLAYER_CLASS_PORTRAIT_BY_ID[num]) {
|
||||
return PLAYER_CLASS_PORTRAIT_BY_ID[num]!
|
||||
}
|
||||
const lower = charClass.toLowerCase().slice(0, 3)
|
||||
if (PLAYER_CLASS_PORTRAIT_BY_CODE[lower]) {
|
||||
return PLAYER_CLASS_PORTRAIT_BY_CODE[lower]!
|
||||
}
|
||||
}
|
||||
return 'AmazonIcon'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve `PetTypePortraitMeta` and specific sub-class icon/label for a summoned pet (`Hireables.cpp` `0x6fb0b170`).
|
||||
*/
|
||||
export function resolvePetPortraitInfo(
|
||||
petTypeOrIdx: number | string | undefined,
|
||||
classId?: number | undefined,
|
||||
): {
|
||||
readonly meta: PetTypePortraitMeta
|
||||
readonly iconKey: string
|
||||
readonly nameEn: string
|
||||
readonly nameZh: string
|
||||
} | null {
|
||||
let idx: number | undefined
|
||||
if (typeof petTypeOrIdx === 'number' && PET_TYPE_PORTRAIT_TABLE[petTypeOrIdx]) {
|
||||
idx = petTypeOrIdx
|
||||
} else if (typeof petTypeOrIdx === 'string') {
|
||||
const asNum = Number(petTypeOrIdx)
|
||||
if (Number.isFinite(asNum) && PET_TYPE_PORTRAIT_TABLE[asNum]) {
|
||||
idx = asNum
|
||||
} else {
|
||||
const lower = petTypeOrIdx.toLowerCase()
|
||||
const found = Object.values(PET_TYPE_PORTRAIT_TABLE).find(p => p.petType === lower)
|
||||
if (found) idx = found.idx
|
||||
}
|
||||
}
|
||||
if (idx === undefined && classId !== undefined) {
|
||||
idx = MONSTER_CLASS_TO_PET_TYPE_IDX[classId]
|
||||
}
|
||||
if (idx === undefined) return null
|
||||
|
||||
const meta = PET_TYPE_PORTRAIT_TABLE[idx]
|
||||
if (!meta || meta.iconType === 0) return null
|
||||
|
||||
const sub = classId !== undefined ? meta.subClasses.find(s => s.classId === classId) : undefined
|
||||
return {
|
||||
meta,
|
||||
iconKey: sub?.icon ?? meta.baseIcon,
|
||||
nameEn: sub?.nameEn ?? meta.nameEn,
|
||||
nameZh: sub?.nameZh ?? meta.nameZh,
|
||||
}
|
||||
}
|
||||
|
|
@ -39,7 +39,7 @@ export function decode0x7ASummonAction(pkt: Uint8Array): ServerEvent {
|
|||
const r = new ByteReader(pkt)
|
||||
r.u8() // 0x7A
|
||||
const action = r.u8()
|
||||
const skillTree = r.u8()
|
||||
const petType = r.u8()
|
||||
const classId = r.u16LE()
|
||||
const ownerId = r.u32LE()
|
||||
const mercId = r.u32LE()
|
||||
|
|
@ -50,7 +50,10 @@ export function decode0x7ASummonAction(pkt: Uint8Array): ServerEvent {
|
|||
mercId,
|
||||
classId,
|
||||
stat: action,
|
||||
value: skillTree,
|
||||
value: petType,
|
||||
isSummonAction: true,
|
||||
summonAction: action,
|
||||
petType,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export function decode0x7FPartyMemberState(pkt: Uint8Array): ServerEvent {
|
|||
lifePct,
|
||||
areaId,
|
||||
relation: isPlayer,
|
||||
isPlayer: isPlayer !== 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ export type ServerEvent =
|
|||
readonly type: 'Party'
|
||||
readonly kind: 'roster' | 'relation' | 'memberState' | 'memberPos'
|
||||
readonly unitId?: number | undefined
|
||||
readonly playerId?: number | undefined
|
||||
readonly otherId?: number | undefined
|
||||
readonly partyId?: number | undefined
|
||||
readonly relation?: number | undefined
|
||||
|
|
@ -181,6 +182,7 @@ export type ServerEvent =
|
|||
readonly charName?: string | undefined
|
||||
readonly charClass?: number | undefined
|
||||
readonly charLevel?: number | undefined
|
||||
readonly isPlayer?: boolean | undefined
|
||||
}
|
||||
| {
|
||||
readonly type: 'Trade'
|
||||
|
|
@ -212,6 +214,9 @@ export type ServerEvent =
|
|||
readonly nameId?: number | undefined
|
||||
readonly stat?: number | undefined
|
||||
readonly value?: number | undefined
|
||||
readonly isSummonAction?: boolean | undefined
|
||||
readonly summonAction?: number | undefined
|
||||
readonly petType?: number | undefined
|
||||
}
|
||||
| {
|
||||
readonly type: 'Portal'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,191 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
WorldPanelsHud,
|
||||
HIRE_DIALOG_WIDTH,
|
||||
HIRE_DIALOG_HEIGHT,
|
||||
HIRE_CONFIRM_WIDTH,
|
||||
HIRE_CONFIRM_HEIGHT,
|
||||
} from '../../src/client/ui/world-panels.ts'
|
||||
import { resolveHirelingCandidates } from '../../src/common/units/hireling-table.ts'
|
||||
|
||||
function createMockCtx() {
|
||||
const drawnTexts: string[] = []
|
||||
let fillRectCount = 0
|
||||
let strokeRectCount = 0
|
||||
return {
|
||||
drawnTexts,
|
||||
get fillRectCount() {
|
||||
return fillRectCount
|
||||
},
|
||||
get strokeRectCount() {
|
||||
return strokeRectCount
|
||||
},
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
fillRect: vi.fn(() => fillRectCount++),
|
||||
strokeRect: vi.fn(() => strokeRectCount++),
|
||||
fillText: vi.fn((text: string) => drawnTexts.push(text)),
|
||||
drawImage: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
} as unknown as CanvasRenderingContext2D & { drawnTexts: string[]; fillRectCount: number; strokeRectCount: number }
|
||||
}
|
||||
|
||||
describe('WorldPanelsHud — Mercenary Hire Stone Dialog (0x6faf9440 & 0x6faf7500 parity)', () => {
|
||||
it('identifies dialog hitboxes accurately per D2Client.dll 0x6faf9440', () => {
|
||||
const wp = new WorldPanelsHud()
|
||||
wp.activePanel = 'hire'
|
||||
|
||||
// Centered at 155, 105 (490x350)
|
||||
expect(wp.isPointInHireMercDialog(100, 50)).toBe(false)
|
||||
expect(wp.isPointInHireMercDialog(155, 105)).toBe(true)
|
||||
expect(wp.isPointInHireMercDialog(400, 300)).toBe(true)
|
||||
expect(wp.isPointInHireMercDialog(644, 454)).toBe(true)
|
||||
expect(wp.isPointInHireMercDialog(650, 460)).toBe(false)
|
||||
|
||||
// Confirmation dialog bounds (250, 190, 300, 160)
|
||||
wp.replaceMercConfirmOpen = true
|
||||
expect(wp.isPointInHireMercDialog(160, 110)).toBe(false)
|
||||
expect(wp.isPointInHireMercDialog(250, 190)).toBe(true)
|
||||
expect(wp.isPointInHireMercDialog(400, 250)).toBe(true)
|
||||
expect(wp.isPointInHireMercDialog(550, 350)).toBe(true)
|
||||
})
|
||||
|
||||
it('draws authentic 490x350 stone dialog with ItemDesc1s gold header and candidate list', () => {
|
||||
const wp = new WorldPanelsHud()
|
||||
wp.lang = 'ENG'
|
||||
wp.activePanel = 'hire'
|
||||
wp.playerGold = 5000
|
||||
wp.stashGold = 12000
|
||||
|
||||
const rawCandidates = [
|
||||
{ nameId: 3411, seed: 0x1234 },
|
||||
{ nameId: 3412, seed: 0x5678 },
|
||||
]
|
||||
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 10, 0, 0, true, 'ENG')
|
||||
|
||||
const ctx = createMockCtx()
|
||||
wp.drawHireMercDialog(ctx)
|
||||
|
||||
expect(ctx.fillRectCount).toBeGreaterThan(0)
|
||||
// Title with total gold 17000
|
||||
expect(ctx.drawnTexts.some((t) => t.includes('17000') && t.includes('Hire which Mercenary?'))).toBe(true)
|
||||
// Candidates rendered with Lvl, Life, Def, Cost
|
||||
expect(ctx.drawnTexts.some((t) => t.includes('Aliza - Lvl: 8'))).toBe(true)
|
||||
expect(ctx.drawnTexts.some((t) => t.includes('Fire Arrow'))).toBe(true)
|
||||
// Bottom cancel button
|
||||
expect(ctx.drawnTexts.some((t) => t.includes('cancel'))).toBe(true)
|
||||
})
|
||||
|
||||
it('draws ItemDesc1t when hire list is empty', () => {
|
||||
const wp = new WorldPanelsHud()
|
||||
wp.lang = 'ENG'
|
||||
wp.activePanel = 'hire'
|
||||
wp.mercHireList = []
|
||||
|
||||
const ctx = createMockCtx()
|
||||
wp.drawHireMercDialog(ctx)
|
||||
|
||||
expect(ctx.drawnTexts.some((t) => t.includes('There are no Mercenaries left to hire.'))).toBe(true)
|
||||
})
|
||||
|
||||
it('handles candidate hover, scrolling, and direct hire when player has no existing mercenary', () => {
|
||||
const wp = new WorldPanelsHud()
|
||||
wp.activePanel = 'hire'
|
||||
wp.activeNpcId = 150
|
||||
|
||||
const rawCandidates = Array.from({ length: 10 }, (_, i) => ({
|
||||
nameId: 3411 + i,
|
||||
seed: 0x1000 + i * 0x100,
|
||||
}))
|
||||
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG')
|
||||
|
||||
// 1. Wheel scroll
|
||||
expect(wp.handleHireWheel(1)).toBe(true)
|
||||
expect(wp.hireScrollOffset).toBe(1)
|
||||
expect(wp.handleHireWheel(-1)).toBe(true)
|
||||
expect(wp.hireScrollOffset).toBe(0)
|
||||
|
||||
// 2. Mouse move hover
|
||||
wp.handleHireMercMove(200, 160) // over Candidate 0
|
||||
expect(wp.hireHoveredIndex).toBe(0)
|
||||
|
||||
// 3. Direct click to hire when player has no existing mercenary
|
||||
let hiredNpcId = 0
|
||||
let hiredMercId = 0
|
||||
const onHireMerc = vi.fn((npcId: number, mercId: number) => {
|
||||
hiredNpcId = npcId
|
||||
hiredMercId = mercId
|
||||
})
|
||||
|
||||
const handled = wp.handleHireMercClick(200, 160, { onHireMerc })
|
||||
expect(handled).toBe(true)
|
||||
expect(onHireMerc).toHaveBeenCalledWith(150, 3411)
|
||||
expect(wp.activePanel).toBe('none')
|
||||
})
|
||||
|
||||
it('prompts confirmation dialog when player already possesses a mercenary (0x6faf7500)', () => {
|
||||
const wp = new WorldPanelsHud()
|
||||
wp.lang = 'ENG'
|
||||
wp.activePanel = 'hire'
|
||||
wp.activeNpcId = 150
|
||||
wp.merc = { mercId: 42, isDead: false } // Existing active mercenary
|
||||
|
||||
const rawCandidates = [{ nameId: 3411, seed: 0x1234 }]
|
||||
wp.mercHireList = resolveHirelingCandidates(rawCandidates, 15, 0, 0, true, 'ENG')
|
||||
|
||||
const onHireMerc = vi.fn()
|
||||
|
||||
// First click: selects candidate
|
||||
wp.handleHireMercClick(200, 160, { onHireMerc })
|
||||
expect(wp.hireSelectedIndex).toBe(0)
|
||||
expect(wp.replaceMercConfirmOpen).toBe(false)
|
||||
expect(onHireMerc).not.toHaveBeenCalled()
|
||||
|
||||
// Second click on selected candidate: triggers replacement warning dialog
|
||||
wp.handleHireMercClick(200, 160, { onHireMerc })
|
||||
expect(wp.replaceMercConfirmOpen).toBe(true)
|
||||
expect(wp.pendingHireCandidate?.nameId).toBe(3411)
|
||||
|
||||
// Render with confirm dialog open
|
||||
const ctx = createMockCtx()
|
||||
wp.drawHireMercDialog(ctx)
|
||||
expect(ctx.drawnTexts.some((t) => t.includes('This Mercenary will replace your current one.'))).toBe(true)
|
||||
expect(ctx.drawnTexts.some((t) => t.includes('Yes'))).toBe(true)
|
||||
expect(ctx.drawnTexts.some((t) => t.includes('No'))).toBe(true)
|
||||
|
||||
// Click "No" (cx + 150 = 400, cy + 128 = 318)
|
||||
wp.handleHireMercClick(400, 318, { onHireMerc })
|
||||
expect(wp.replaceMercConfirmOpen).toBe(false)
|
||||
expect(wp.activePanel).toBe('hire')
|
||||
expect(onHireMerc).not.toHaveBeenCalled()
|
||||
|
||||
// Open again and click "Yes" (cx + 150 = 400, cy + 104 = 294)
|
||||
wp.handleHireMercClick(200, 160, { onHireMerc })
|
||||
expect(wp.replaceMercConfirmOpen).toBe(true)
|
||||
|
||||
wp.handleHireMercClick(400, 294, { onHireMerc })
|
||||
expect(wp.replaceMercConfirmOpen).toBe(false)
|
||||
expect(onHireMerc).toHaveBeenCalledWith(150, 3411)
|
||||
expect(wp.activePanel).toBe('none')
|
||||
})
|
||||
|
||||
it('clicking cancel button closes hire dialog and re-opens NPC menu (0x6faf9090)', () => {
|
||||
const wp = new WorldPanelsHud()
|
||||
wp.openNpcMenuByName('Kashya', 400, 220)
|
||||
expect(wp.npcMenu).not.toBeNull()
|
||||
|
||||
wp.clickNpcMenuOption('hire')
|
||||
expect(wp.activePanel).toBe('hire')
|
||||
expect(wp.npcMenu).toBeNull()
|
||||
|
||||
// Click Cancel button at dialogX + 245 = 400, dialogY + 315 = 420
|
||||
const onClose = vi.fn()
|
||||
wp.handleHireMercClick(400, 420, { onClose })
|
||||
|
||||
expect(wp.activePanel).toBe('none')
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(wp.npcMenu).not.toBeNull()
|
||||
expect(wp.npcMenu?.npcName).toContain('Kashya')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { ClientWorld } from '../../src/client/world/client-world.ts'
|
||||
import { HudModel } from '../../src/client/ui-model/hud-model.ts'
|
||||
import { HudManager } from '../../src/client/ui/hud-manager.ts'
|
||||
import { decode0x7ASummonAction } from '../../src/netproto/d2gs/s2c/merc.ts'
|
||||
import { decode0x7FPartyMemberState } from '../../src/netproto/d2gs/s2c/party.ts'
|
||||
import {
|
||||
CANONICAL_PORTRAIT_DC6_NAMES,
|
||||
resolveMercPortraitIcon,
|
||||
resolvePlayerClassPortraitIcon,
|
||||
resolvePetPortraitInfo,
|
||||
} from '../../src/common/units/pet-type-table.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../../src/common/items/baked-ui-meta.ts'
|
||||
|
||||
function createMockContext() {
|
||||
const drawnTexts: Array<{ text: string; x: number; y: number }> = []
|
||||
const drawnRects: Array<{ x: number; y: number; w: number; h: number }> = []
|
||||
const drawnImages: Array<{ sx: number; sy: number; sw: number; sh: number; dx: number; dy: number; dw: number; dh: number }> = []
|
||||
|
||||
const ctx = {
|
||||
canvas: { width: 800, height: 600 },
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
scale: () => {},
|
||||
translate: () => {},
|
||||
clearRect: () => {},
|
||||
beginPath: () => {},
|
||||
closePath: () => {},
|
||||
moveTo: () => {},
|
||||
lineTo: () => {},
|
||||
stroke: () => {},
|
||||
fill: () => {},
|
||||
fillRect: (x: number, y: number, w: number, h: number) => {
|
||||
drawnRects.push({ x, y, w, h })
|
||||
},
|
||||
strokeRect: (x: number, y: number, w: number, h: number) => {
|
||||
drawnRects.push({ x, y, w, h })
|
||||
},
|
||||
fillText: (text: string, x: number, y: number) => {
|
||||
drawnTexts.push({ text: String(text), x, y })
|
||||
},
|
||||
measureText: (text: string) => ({ width: String(text).length * 6 }),
|
||||
drawImage: (_img: any, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number) => {
|
||||
drawnImages.push({ sx, sy, sw, sh, dx, dy, dw, dh })
|
||||
},
|
||||
imageSmoothingEnabled: false,
|
||||
fillStyle: '#000000',
|
||||
strokeStyle: '#000000',
|
||||
font: '10px sans-serif',
|
||||
textBaseline: 'alphabetic',
|
||||
drawnTexts,
|
||||
drawnRects,
|
||||
drawnImages,
|
||||
} as unknown as CanvasRenderingContext2D & {
|
||||
drawnTexts: Array<{ text: string; x: number; y: number }>
|
||||
drawnRects: Array<{ x: number; y: number; w: number; h: number }>
|
||||
drawnImages: Array<{ sx: number; sy: number; sw: number; sh: number; dx: number; dy: number; dw: number; dh: number }>
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('Top-Left HUD Portraits & 1.13c RosterPets Parity (Hireables.cpp)', () => {
|
||||
it('P1: BAKED_UI_MANIFEST bundles all 29 authentic 1.13c DC6 portrait frames with valid non-zero dimensions', () => {
|
||||
expect(BAKED_UI_MANIFEST.portraitRects).toBeDefined()
|
||||
const rects = BAKED_UI_MANIFEST.portraitRects!
|
||||
|
||||
for (const name of CANONICAL_PORTRAIT_DC6_NAMES) {
|
||||
const rect = rects[name] ?? rects[name.toLowerCase()]
|
||||
expect(rect, `Expected portrait rect for ${name}`).toBeDefined()
|
||||
expect(rect.w).toBeGreaterThanOrEqual(44)
|
||||
expect(rect.h).toBeGreaterThanOrEqual(40)
|
||||
}
|
||||
})
|
||||
|
||||
it('P2: solo unpartied player never renders a phantom "Player #1" portrait or box in top-left HUD', () => {
|
||||
const world = new ClientWorld()
|
||||
world.selfId = 1
|
||||
const mockCtx = createMockContext()
|
||||
const hud = new HudManager()
|
||||
hud.ctx = mockCtx
|
||||
const model = new HudModel({ world, hudManager: hud })
|
||||
|
||||
// Player joins solo: 0x5B PlayerInGame sends selfId with partyId 0xffff
|
||||
world.apply({
|
||||
type: 'Party',
|
||||
kind: 'roster',
|
||||
unitId: 1,
|
||||
playerId: 1,
|
||||
charClass: 0,
|
||||
charLevel: 20,
|
||||
partyId: 0xffff,
|
||||
charName: 'AmazonHero',
|
||||
})
|
||||
// 0x8B PlayerPartyRelation sends state 0 (unpartied)
|
||||
world.apply({
|
||||
type: 'Party',
|
||||
kind: 'relation',
|
||||
unitId: 1,
|
||||
relation: 0,
|
||||
})
|
||||
model.syncFromWorld()
|
||||
|
||||
expect(hud.partyMembers).toHaveLength(0)
|
||||
|
||||
hud.render()
|
||||
expect(mockCtx.drawnTexts.some((t) => t.text.includes('Player #1'))).toBe(false)
|
||||
expect(mockCtx.drawnTexts.some((t) => t.text.includes('AmazonHero'))).toBe(false)
|
||||
expect(mockCtx.drawnRects.filter((r) => r.y <= 64)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('P3: 0x7F PartyMemberState with isPlayer === 0 updates pet/hireling life without creating phantom party member', () => {
|
||||
const world = new ClientWorld()
|
||||
world.selfId = 1
|
||||
const mockCtx = createMockContext()
|
||||
const hud = new HudManager()
|
||||
hud.ctx = mockCtx
|
||||
const model = new HudModel({ world, hudManager: hud })
|
||||
|
||||
// Assign hireling
|
||||
world.apply({
|
||||
type: 'Merc',
|
||||
kind: 'assign',
|
||||
ownerId: 1,
|
||||
mercId: 88,
|
||||
classId: 338, // Act 2 Desert Mercenary
|
||||
})
|
||||
expect(world.merc).not.toBeNull()
|
||||
|
||||
// 0x7F packet: [0x7f, isPlayer=0, lifePct=96 (u16), unitId=88 (u32), areaId=1 (u16)]
|
||||
const pkt7f = new Uint8Array([0x7f, 0, 96, 0, 88, 0, 0, 0, 1, 0])
|
||||
const ev7f = decode0x7FPartyMemberState(pkt7f)
|
||||
expect(ev7f.type).toBe('Party')
|
||||
expect((ev7f as any).isPlayer).toBe(false)
|
||||
|
||||
world.apply(ev7f)
|
||||
expect(world.merc!.lifePct).toBe(96)
|
||||
expect(world.party.has(88)).toBe(false)
|
||||
|
||||
model.syncFromWorld()
|
||||
expect(hud.partyMembers).toHaveLength(0)
|
||||
expect((hud as any).merc.lifePct).toBe(96)
|
||||
})
|
||||
|
||||
it('P4: renders authentic mercenary portrait (act2hireableicon) without duplicate overlapping fillText', () => {
|
||||
const world = new ClientWorld()
|
||||
world.selfId = 1
|
||||
const mockCtx = createMockContext()
|
||||
const hud = new HudManager()
|
||||
hud.ctx = mockCtx
|
||||
const model = new HudModel({ world, hudManager: hud })
|
||||
|
||||
world.apply({
|
||||
type: 'Merc',
|
||||
kind: 'assign',
|
||||
ownerId: 1,
|
||||
mercId: 99,
|
||||
classId: 338, // Desert Mercenary
|
||||
nameId: 14,
|
||||
})
|
||||
model.syncFromWorld()
|
||||
|
||||
expect((hud as any).merc.iconKey).toBe('act2hireableicon')
|
||||
|
||||
hud.render()
|
||||
// The portrait frame and health bar are drawn
|
||||
expect(mockCtx.drawnRects.some((r) => r.x <= 24 && r.y <= 24 && r.w >= 32)).toBe(true)
|
||||
|
||||
// In headless unit tests where DC6 font atlases are unloaded, D2FontRenderer.drawText
|
||||
// falls back to ctx.fillText with 3 drop shadow passes + 1 foreground pass (4 passes).
|
||||
// The previous buggy code called an extra manual ctx.fillText beforehand (5 passes with stacked browser text).
|
||||
const mercTexts = mockCtx.drawnTexts.filter((t) => t.text.includes('Merc'))
|
||||
expect(mercTexts).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('P5: 0x7A SummonAction maintains active pets in world.pets without clobbering world.merc', () => {
|
||||
const world = new ClientWorld()
|
||||
world.selfId = 1
|
||||
const mockCtx = createMockContext()
|
||||
const hud = new HudManager()
|
||||
hud.ctx = mockCtx
|
||||
const model = new HudModel({ world, hudManager: hud })
|
||||
|
||||
// Player has an active mercenary
|
||||
world.apply({
|
||||
type: 'Merc',
|
||||
kind: 'assign',
|
||||
ownerId: 1,
|
||||
mercId: 55,
|
||||
classId: 271, // Rogue
|
||||
nameId: 3,
|
||||
})
|
||||
expect(world.merc).not.toBeNull()
|
||||
|
||||
// 0x7A SummonAction: [0x7a, action=1, petType=4 (skeleton), classId=363 (necroskeleton), ownerId=1, mercId=101]
|
||||
const pkt7a = new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 101, 0, 0, 0])
|
||||
const ev7a = decode0x7ASummonAction(pkt7a)
|
||||
expect((ev7a as any).isSummonAction).toBe(true)
|
||||
expect((ev7a as any).petType).toBe(4)
|
||||
|
||||
world.apply(ev7a)
|
||||
// Mercenary remains completely intact!
|
||||
expect(world.merc).not.toBeNull()
|
||||
expect(world.merc!.mercId).toBe(55)
|
||||
|
||||
// Pet is tracked in world.pets
|
||||
expect(world.pets.has(101)).toBe(true)
|
||||
const pet = world.pets.get(101)!
|
||||
expect(pet.petType).toBe(4)
|
||||
expect(pet.classId).toBe(363)
|
||||
expect(pet.ownerId).toBe(1)
|
||||
|
||||
// Add 2 more skeletons
|
||||
world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 102, 0, 0, 0])))
|
||||
world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 1, 4, 0x6b, 0x01, 1, 0, 0, 0, 103, 0, 0, 0])))
|
||||
expect(world.pets.size).toBe(4) // 3 skeletons + 1 merc mirrored
|
||||
|
||||
model.syncFromWorld()
|
||||
const summons = (hud as any).summonPortraits
|
||||
expect(summons).toHaveLength(1)
|
||||
expect(summons[0].iconKey).toBe('skeletonicon')
|
||||
expect(summons[0].count).toBe(3)
|
||||
expect(summons[0].iconType).toBe(2)
|
||||
|
||||
// Render frame and verify count badge "3" is drawn in top-left
|
||||
hud.render()
|
||||
expect(mockCtx.drawnTexts.some((t) => t.text === '3')).toBe(true)
|
||||
|
||||
// Unsummon skeleton 102 (action=0)
|
||||
world.apply(decode0x7ASummonAction(new Uint8Array([0x7a, 0, 4, 0x6b, 0x01, 1, 0, 0, 0, 102, 0, 0, 0])))
|
||||
expect(world.pets.has(102)).toBe(false)
|
||||
model.syncFromWorld()
|
||||
expect((hud as any).summonPortraits[0].count).toBe(2)
|
||||
})
|
||||
|
||||
it('P6: icontype=3 sub-type summons (Golems, Spirits, Vines) resolve exact sub-icon via classId', () => {
|
||||
const clay = resolvePetPortraitInfo(3, 289)
|
||||
expect(clay?.iconKey).toBe('earthgolumicon')
|
||||
|
||||
const blood = resolvePetPortraitInfo(3, 290)
|
||||
expect(blood?.iconKey).toBe('bloodgolumicon')
|
||||
|
||||
const iron = resolvePetPortraitInfo(3, 291)
|
||||
expect(iron?.iconKey).toBe('metalgolumicon')
|
||||
|
||||
const fire = resolvePetPortraitInfo(3, 292)
|
||||
expect(fire?.iconKey).toBe('firegolumicon')
|
||||
|
||||
const oak = resolvePetPortraitInfo(13, 424)
|
||||
expect(oak?.iconKey).toBe('oaksage')
|
||||
|
||||
const wolverine = resolvePetPortraitInfo(13, 423)
|
||||
expect(wolverine?.iconKey).toBe('heartofwolverine')
|
||||
|
||||
const poison = resolvePetPortraitInfo(14, 425)
|
||||
expect(poison?.iconKey).toBe('plaguepoppy')
|
||||
})
|
||||
|
||||
it('P7: allied party members render their authentic class portraits on the top-left HUD', () => {
|
||||
const world = new ClientWorld()
|
||||
world.selfId = 1
|
||||
const mockCtx = createMockContext()
|
||||
const hud = new HudManager()
|
||||
hud.ctx = mockCtx
|
||||
const model = new HudModel({ world, hudManager: hud })
|
||||
|
||||
// Local player
|
||||
world.apply({
|
||||
type: 'Party',
|
||||
kind: 'roster',
|
||||
unitId: 1,
|
||||
charClass: 0, // Amazon
|
||||
charName: 'SelfAmazon',
|
||||
partyId: 1,
|
||||
})
|
||||
// Allied Paladin in same party
|
||||
world.apply({
|
||||
type: 'Party',
|
||||
kind: 'roster',
|
||||
unitId: 2,
|
||||
charClass: 3, // Paladin
|
||||
charName: 'PaladinBro',
|
||||
partyId: 1,
|
||||
lifePct: 90,
|
||||
})
|
||||
world.apply({
|
||||
type: 'Party',
|
||||
kind: 'relation',
|
||||
unitId: 2,
|
||||
relation: 1, // in party
|
||||
})
|
||||
// Unpartied Sorceress in different party / unpartied
|
||||
world.apply({
|
||||
type: 'Party',
|
||||
kind: 'roster',
|
||||
unitId: 3,
|
||||
charClass: 1, // Sorceress
|
||||
charName: 'SoloSorc',
|
||||
partyId: 0xffff,
|
||||
})
|
||||
world.apply({
|
||||
type: 'Party',
|
||||
kind: 'relation',
|
||||
unitId: 3,
|
||||
relation: 0, // none
|
||||
})
|
||||
|
||||
model.syncFromWorld()
|
||||
// Party list in hud model excludes selfId
|
||||
expect(hud.partyMembers).toHaveLength(2)
|
||||
const paladinEntry = hud.partyMembers.find((m) => m.unitId === 2)
|
||||
expect(paladinEntry?.portraitIcon).toBe('PaladinIcon')
|
||||
|
||||
hud.render()
|
||||
// Allied Paladin is drawn
|
||||
expect(mockCtx.drawnTexts.some((t) => t.text.includes('PaladinBro'))).toBe(true)
|
||||
// Unpartied Sorceress is NOT drawn on the top-left HUD
|
||||
expect(mockCtx.drawnTexts.some((t) => t.text.includes('SoloSorc'))).toBe(false)
|
||||
// Self is NOT drawn on the top-left HUD
|
||||
expect(mockCtx.drawnTexts.some((t) => t.text.includes('SelfAmazon'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -8,7 +8,23 @@ import {
|
|||
} from '../../src/client/settings/client-settings.ts'
|
||||
import { SettingsStore, type StorageLike } from '../../src/client/settings/settings-store.ts'
|
||||
import { Toolbar } from '../../src/client/toolbar/toolbar.ts'
|
||||
import { computeDockingLayout, HudManager } from '../../src/client/ui/hud-manager.ts'
|
||||
import {
|
||||
computeDockingLayout,
|
||||
CUBE_GRID_ORIGIN,
|
||||
fromLeftDockLocal,
|
||||
fromRightDockLocal,
|
||||
HudManager,
|
||||
INV_GRID_ORIGIN,
|
||||
STASH_GRID_ORIGIN,
|
||||
toControlBarLocal,
|
||||
toLeftDockLocal,
|
||||
toRightDockLocal,
|
||||
VENDOR_GRID_ORIGIN,
|
||||
type HudCommandSink,
|
||||
} from '../../src/client/ui/hud-manager.ts'
|
||||
import type { UiInventoryItem } from '../../src/client/ui/inventory.ts'
|
||||
import { SkillTreePanel } from '../../src/client/ui/skill-tree-panel.ts'
|
||||
import { getTownNpcDescriptorByName } from '../../src/common/units/npc-table.ts'
|
||||
import {
|
||||
computeViewportAnchors,
|
||||
computeViewportFitSize,
|
||||
|
|
@ -98,6 +114,37 @@ const EXPECTED: readonly ExpectedProfile[] = [
|
|||
|
||||
function createHudAt(profile: ViewportProfile): HudManager {
|
||||
const { width, height } = profile
|
||||
const mockCtx = {
|
||||
canvas: { width, height },
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
font: '',
|
||||
textAlign: 'left',
|
||||
textBaseline: 'alphabetic',
|
||||
imageSmoothingEnabled: false,
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
translate: () => {},
|
||||
scale: () => {},
|
||||
clearRect: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
beginPath: () => {},
|
||||
closePath: () => {},
|
||||
moveTo: () => {},
|
||||
lineTo: () => {},
|
||||
arc: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
fill: () => {},
|
||||
stroke: () => {},
|
||||
fillText: () => {},
|
||||
drawImage: () => {},
|
||||
measureText: (t: string) => ({ width: t.length * 8 }),
|
||||
createLinearGradient: () => ({ addColorStop: () => {} }),
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
const canvas = {
|
||||
width,
|
||||
height,
|
||||
|
|
@ -106,6 +153,7 @@ function createHudAt(profile: ViewportProfile): HudManager {
|
|||
style: { cursor: '', pointerEvents: '' },
|
||||
addEventListener: () => {},
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width, height }),
|
||||
getContext: () => mockCtx,
|
||||
} as unknown as HTMLCanvasElement
|
||||
const hud = new HudManager(canvas, {
|
||||
onToggleAutomap: () => {},
|
||||
|
|
@ -337,3 +385,579 @@ describe('HUD anchors, docking and interception for 1068x600 / 1280x720', () =>
|
|||
expect(fit1068).toMatchObject({ cssWidth: 1920, cssHeight: 1079, letterboxLeft: 0, letterboxTop: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
function makeTestUiItem(id: string, code: string, name: string, w = 1, h = 1): UiInventoryItem {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
invFile: code,
|
||||
name,
|
||||
nameZh: name,
|
||||
baseNameZh: name,
|
||||
quality: 'normal',
|
||||
invWidth: w,
|
||||
invHeight: h,
|
||||
allowedSlots: ['helm'],
|
||||
stats: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('HUD input and hover coordinate translation across all 4 viewport profiles (Issue #542)', () => {
|
||||
it('converts logical <-> region-local coordinates using active docking offsets across all 4 profiles', () => {
|
||||
for (const { profile } of EXPECTED) {
|
||||
const docking = computeDockingLayout(profile.width, profile.height, 1, profile)
|
||||
const deltaRight = docking.rightDockX - 400
|
||||
|
||||
expect(toControlBarLocal(263 + profile.barOffsetX, 582 + profile.barOffsetY, docking)).toEqual({
|
||||
x: 263,
|
||||
y: 582,
|
||||
})
|
||||
expect(toLeftDockLocal(217 + docking.leftDockX, 153 + profile.panelOffsetY, docking)).toEqual({
|
||||
x: 217,
|
||||
y: 153,
|
||||
})
|
||||
expect(fromLeftDockLocal(217, 153, docking)).toEqual({
|
||||
x: 217 + docking.leftDockX,
|
||||
y: 153 + profile.panelOffsetY,
|
||||
})
|
||||
expect(toRightDockLocal(432.5 + deltaRight, 330.5 + profile.panelOffsetY, docking)).toEqual({
|
||||
x: 432.5,
|
||||
y: 330.5,
|
||||
})
|
||||
expect(fromRightDockLocal(432.5, 330.5, docking)).toEqual({
|
||||
x: 432.5 + deltaRight,
|
||||
y: 330.5 + profile.panelOffsetY,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it.each(EXPECTED)(
|
||||
'translates bottom control bar clicks and hovers ($id: barOffset=($barOffsetX,$barOffsetY))',
|
||||
({ profile, barOffsetX, barOffsetY }) => {
|
||||
const hud = createHudAt(profile)
|
||||
const useBeltSlot = vi.fn(() => true)
|
||||
hud.commandSink = { useBeltSlot } as unknown as HudCommandSink
|
||||
|
||||
// 1. Run/Walk toggle button (canonical 255..271, 572..592 -> center 263, 582)
|
||||
const runX = 263 + barOffsetX
|
||||
const runY = 582 + barOffsetY
|
||||
hud.handleMouseMove(runX, runY)
|
||||
expect(hud.controlBar.hoveredElement).toBe('run')
|
||||
const prevRun = hud.isRunning
|
||||
hud.handleClick(runX, runY, 0)
|
||||
expect(hud.isRunning).toBe(!prevRun)
|
||||
|
||||
// 2. Mini-panel buttons (canonical 312..488, 522..548 -> char at 325, 535; inv at 352, 535)
|
||||
const charBtnX = 325 + barOffsetX
|
||||
const charBtnY = 535 + barOffsetY
|
||||
hud.handleMouseMove(charBtnX, charBtnY)
|
||||
expect(hud.controlBar.hoveredElement).toBe('char')
|
||||
hud.handleClick(charBtnX, charBtnY, 0)
|
||||
expect(hud.leftPanel).toBe('char')
|
||||
|
||||
const invBtnX = 352 + barOffsetX
|
||||
const invBtnY = 535 + barOffsetY
|
||||
hud.handleMouseMove(invBtnX, invBtnY)
|
||||
expect(hud.controlBar.hoveredElement).toBe('inv')
|
||||
hud.handleClick(invBtnX, invBtnY, 0)
|
||||
expect(hud.rightPanel).toBe('inv')
|
||||
|
||||
// 3. Belt slot col 0, row 0 (canonical 423..451, 561..589 -> center 435, 575)
|
||||
const beltX = 435 + barOffsetX
|
||||
const beltY = 575 + barOffsetY
|
||||
hud.handleMouseMove(beltX, beltY)
|
||||
expect(hud.belt.hoveredSlot).toEqual({ row: 0, col: 0 })
|
||||
hud.handleClick(beltX, beltY, 2)
|
||||
expect(useBeltSlot).toHaveBeenCalledWith(0, false)
|
||||
|
||||
// 4. Life & Mana globes (canonical centers 68, 555 and 732, 555)
|
||||
const lifeX = 68 + barOffsetX
|
||||
const lifeY = 555 + barOffsetY
|
||||
hud.handleMouseMove(lifeX, lifeY)
|
||||
expect(hud.globes.lifeHovered).toBe(true)
|
||||
const prevLifeLock = hud.globes.lifeTextLocked
|
||||
hud.handleClick(lifeX, lifeY, 0)
|
||||
expect(hud.globes.lifeTextLocked).toBe(!prevLifeLock)
|
||||
|
||||
const manaX = 732 + barOffsetX
|
||||
const manaY = 555 + barOffsetY
|
||||
hud.handleMouseMove(manaX, manaY)
|
||||
expect(hud.globes.manaHovered).toBe(true)
|
||||
const prevManaLock = hud.globes.manaTextLocked
|
||||
hud.handleClick(manaX, manaY, 0)
|
||||
expect(hud.globes.manaTextLocked).toBe(!prevManaLock)
|
||||
|
||||
// 5. Left & Right skill hotkey slots (canonical centers 141, 575 and 659, 575)
|
||||
const leftSkillX = 141 + barOffsetX
|
||||
const leftSkillY = 575 + barOffsetY
|
||||
hud.handleClick(leftSkillX, leftSkillY, 0)
|
||||
expect(hud.hotkeys.openPopup).toBe('left')
|
||||
hud.handleClick(leftSkillX, leftSkillY, 0)
|
||||
expect(hud.hotkeys.openPopup).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(EXPECTED)(
|
||||
'translates left dock clicks and reverse-translates stash/cube/vendor hover tooltips ($id: panelOffsetY=$panelOffsetY)',
|
||||
({ profile, panelOffsetY }) => {
|
||||
const hud = createHudAt(profile)
|
||||
const docking = hud.getDockingLayout()
|
||||
const leftDockX = docking.leftDockX
|
||||
const allocateStat = vi.fn(() => true)
|
||||
hud.commandSink = { allocateStat } as unknown as HudCommandSink
|
||||
|
||||
// 1. Character sheet stat '+' button (STR canonical 202..232, 138..168 -> center 217, 153)
|
||||
hud.toggleLeftPanel('char')
|
||||
hud.charSheet.attrs.statPoints = 5
|
||||
hud.handleClick(217 + leftDockX, 153 + panelOffsetY, 0)
|
||||
expect(allocateStat).toHaveBeenCalledWith('str')
|
||||
|
||||
// Character sheet close button (canonical 208..240, 448..480 -> center 224, 464)
|
||||
hud.handleClick(224 + leftDockX, 464 + panelOffsetY, 0)
|
||||
expect(hud.leftPanel).toBe('none')
|
||||
|
||||
// Clear commandSink to test offline stash pickup as well
|
||||
hud.commandSink = null
|
||||
|
||||
// 2. Stash grid cell (col 0, row 0) click & hover tooltip writeback
|
||||
hud.leftPanel = 'stash'
|
||||
const stashItem = makeTestUiItem('stash-1', 'rin', 'Stone of Jordan')
|
||||
hud.worldPanels.stashItems = [{ item: stashItem, col: 0, row: 0 }]
|
||||
const stashHoverX = STASH_GRID_ORIGIN.x + 14.5 + leftDockX
|
||||
const stashHoverY = STASH_GRID_ORIGIN.y + 14.5 + panelOffsetY
|
||||
hud.handleMouseMove(stashHoverX, stashHoverY)
|
||||
expect(hud.inventory.hoveredItem).toEqual({
|
||||
item: stashItem,
|
||||
x: STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
|
||||
y: STASH_GRID_ORIGIN.y + panelOffsetY,
|
||||
})
|
||||
|
||||
// Clicking stash cell (0, 0) picks up the item onto cursor
|
||||
hud.handleClick(stashHoverX, stashHoverY, 0)
|
||||
expect(hud.inventory.cursorItem?.id).toBe('stash-1')
|
||||
hud.inventory.cursorItem = null
|
||||
|
||||
// 3. Cube grid cell (col 0, row 0) hover tooltip writeback
|
||||
hud.leftPanel = 'cube'
|
||||
const cubeItem = makeTestUiItem('cube-1', 'rvl', 'Full Rejuvenation Potion')
|
||||
hud.worldPanels.cubeItems = [{ item: cubeItem, col: 0, row: 0 }]
|
||||
const cubeHoverX = CUBE_GRID_ORIGIN.x + 14.5 + leftDockX
|
||||
const cubeHoverY = CUBE_GRID_ORIGIN.y + 14.5 + panelOffsetY
|
||||
hud.handleMouseMove(cubeHoverX, cubeHoverY)
|
||||
expect(hud.inventory.hoveredItem).toEqual({
|
||||
item: cubeItem,
|
||||
x: CUBE_GRID_ORIGIN.x + 14.5 + leftDockX,
|
||||
y: CUBE_GRID_ORIGIN.y + panelOffsetY,
|
||||
})
|
||||
|
||||
// 4. Vendor grid cell (col 0, row 0) hover tooltip writeback
|
||||
const akara = getTownNpcDescriptorByName('Akara')!
|
||||
const vendorItem = makeTestUiItem('vendor-1', 'cap', 'Cap', 2, 2)
|
||||
hud.worldPanels.setVendorShop({
|
||||
npcId: 1,
|
||||
descriptor: akara,
|
||||
mode: 'trade',
|
||||
items: [{ storePage: 1, placement: { item: vendorItem, col: 0, row: 0 } }],
|
||||
})
|
||||
hud.leftPanel = 'vendor'
|
||||
const vendorHoverX = VENDOR_GRID_ORIGIN.x + 14.5 + leftDockX
|
||||
const vendorHoverY = VENDOR_GRID_ORIGIN.y + 14.5 + panelOffsetY
|
||||
hud.handleMouseMove(vendorHoverX, vendorHoverY)
|
||||
expect(hud.inventory.hoveredItem).toEqual({
|
||||
item: vendorItem,
|
||||
x: VENDOR_GRID_ORIGIN.x + 29 + leftDockX,
|
||||
y: VENDOR_GRID_ORIGIN.y + panelOffsetY,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it.each(EXPECTED)(
|
||||
'translates right dock inventory/equipment/skill-tree clicks and hover tooltips ($id: panelOffsetY=$panelOffsetY)',
|
||||
({ profile, panelOffsetY }) => {
|
||||
const hud = createHudAt(profile)
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaRight = docking.rightDockX - 400
|
||||
|
||||
// 1. Inventory grid cell (col 0, row 0) hover & click at docked right panel coordinates
|
||||
hud.toggleRightPanel('inv')
|
||||
const bagItem = makeTestUiItem('bag-1', 'rin', 'Bul-Kathos Ring')
|
||||
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
|
||||
const invHoverX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
|
||||
const invHoverY = INV_GRID_ORIGIN.y + 14.5 + panelOffsetY
|
||||
|
||||
hud.handleMouseMove(invHoverX, invHoverY)
|
||||
expect(hud.inventory.hoveredItem).toEqual({
|
||||
item: bagItem,
|
||||
x: INV_GRID_ORIGIN.x + 14.5 + deltaRight,
|
||||
y: INV_GRID_ORIGIN.y + panelOffsetY,
|
||||
})
|
||||
|
||||
// Clicking at the docked right-panel coordinate picks up the item
|
||||
hud.handleClick(invHoverX, invHoverY, 0)
|
||||
expect(hud.inventory.cursorItem?.id).toBe('bag-1')
|
||||
hud.inventory.cursorItem = null
|
||||
|
||||
// 2. Equipped helm slot (canonical 532..590, 64..122 -> center 561, 93; tooltip anchor 561, 64)
|
||||
const helmItem = makeTestUiItem('helm-1', 'ci3', 'Griffon Eye', 2, 2)
|
||||
hud.inventory.equipped.helm = helmItem
|
||||
hud.handleMouseMove(561 + deltaRight, 93 + panelOffsetY)
|
||||
expect(hud.inventory.hoveredItem).toEqual({
|
||||
item: helmItem,
|
||||
x: 561 + deltaRight,
|
||||
y: 64 + panelOffsetY,
|
||||
})
|
||||
|
||||
// 2b. Tooltip box clamping uses active viewport width/height (not hardcoded 800x600)
|
||||
hud.inventory.gridItems = [{ item: bagItem, col: 9, row: 0 }]
|
||||
const rightColHoverX = INV_GRID_ORIGIN.x + 9 * 29 + 14.5 + deltaRight
|
||||
const rightColHoverY = INV_GRID_ORIGIN.y + 14.5 + panelOffsetY
|
||||
hud.handleMouseMove(rightColHoverX, rightColHoverY)
|
||||
const mockCtx = {
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
fillText: () => {},
|
||||
drawImage: () => {},
|
||||
measureText: () => ({ width: 50 }),
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
hud.worldPanels.drawItemTooltip(
|
||||
mockCtx,
|
||||
hud.inventory.hoveredItem,
|
||||
hud.font,
|
||||
false,
|
||||
profile.width,
|
||||
profile.height,
|
||||
)
|
||||
expect(hud.worldPanels.lastTooltipRect).not.toBeNull()
|
||||
const tipRect = hud.worldPanels.lastTooltipRect!
|
||||
expect(tipRect.bx + tipRect.boxW).toBeLessThanOrEqual(profile.width - 8)
|
||||
if (profile.width > 800) {
|
||||
expect(tipRect.bx + tipRect.boxW).toBeGreaterThan(800)
|
||||
}
|
||||
|
||||
// 3. Skill tree node hover & click at docked right panel coordinates
|
||||
hud.toggleRightPanel('skill')
|
||||
hud.skillTree.setClass('sor')
|
||||
hud.skillTree.characterLevel = 30
|
||||
hud.skillTree.unspentSkillPoints = 5
|
||||
const allocateSkill = vi.fn(() => true)
|
||||
hud.commandSink = { allocateSkill } as unknown as HudCommandSink
|
||||
|
||||
const firstTabNode = hud.skillTree
|
||||
.getSkillTree()
|
||||
.find((n) => n.tab === hud.skillTree.activeTab && n.prereqs.length === 0)!
|
||||
const nodeRect = SkillTreePanel.nodeScreenPos(firstTabNode)
|
||||
const nodeClickX = nodeRect.x + nodeRect.w / 2 + deltaRight
|
||||
const nodeClickY = nodeRect.y + nodeRect.h / 2 + panelOffsetY
|
||||
|
||||
hud.handleMouseMove(nodeClickX, nodeClickY)
|
||||
expect(hud.skillTree.hoveredSkill?.skillId).toBe(firstTabNode.skillId)
|
||||
|
||||
hud.handleClick(nodeClickX, nodeClickY, 0)
|
||||
expect(allocateSkill).toHaveBeenCalledWith(firstTabNode.skillId)
|
||||
},
|
||||
)
|
||||
|
||||
it('does not trigger right-dock or bottom-bar actions when clicking old un-offset 800x600 coordinates in 1024x768 and 1280x720', () => {
|
||||
for (const profile of [VIEWPORT_1024x768, VIEWPORT_1280x720]) {
|
||||
const hud = createHudAt(profile)
|
||||
hud.toggleRightPanel('inv')
|
||||
const bagItem = makeTestUiItem('bag-corridor', 'rin', 'Ring')
|
||||
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
|
||||
|
||||
// Un-offset (432.5, 330.5) falls inside the open center corridor (400..624 in 1024x768, 400..880 in 1280x720)
|
||||
expect(hud.isPointInterceptedByHud(432.5, 330.5)).toBe(false)
|
||||
hud.handleClick(432.5, 330.5, 0)
|
||||
expect(hud.inventory.cursorItem).toBeNull()
|
||||
|
||||
// Un-offset Run button (263, 582) is above the bottom bar in 1024x768 (bar y=708..768) and 1280x720 (bar y=660..720)
|
||||
const prevRun = hud.isRunning
|
||||
hud.handleClick(263, 582, 0)
|
||||
expect(hud.isRunning).toBe(prevRun)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(EXPECTED)(
|
||||
'translates window mousemove listener hover coordinates and pointer-events across $id',
|
||||
({ profile, barOffsetX, barOffsetY, panelOffsetY }) => {
|
||||
const prevWindow = (globalThis as { window?: unknown }).window
|
||||
const mockWindow = Object.assign(new EventTarget(), {
|
||||
innerWidth: profile.width,
|
||||
innerHeight: profile.height,
|
||||
devicePixelRatio: 1,
|
||||
})
|
||||
;(globalThis as { window?: unknown }).window = mockWindow
|
||||
try {
|
||||
const hud = createHudAt(profile)
|
||||
const hudCanvas = (hud as unknown as { hudCanvas: HTMLCanvasElement }).hudCanvas
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaRight = docking.rightDockX - 400
|
||||
const leftDockX = docking.leftDockX
|
||||
|
||||
hud.toggleRightPanel('inv')
|
||||
hud.leftPanel = 'stash'
|
||||
const bagItem = makeTestUiItem('win-bag-1', 'rin', 'Raven Frost')
|
||||
const stashItem = makeTestUiItem('win-stash-1', 'amu', 'Highlord Wrath')
|
||||
hud.inventory.gridItems = [{ item: bagItem, col: 0, row: 0 }]
|
||||
hud.worldPanels.stashItems = [{ item: stashItem, col: 0, row: 0 }]
|
||||
|
||||
// Dispatch window mousemove over right-dock inventory item
|
||||
const invClient = hud.logicalToClient(
|
||||
INV_GRID_ORIGIN.x + 14.5 + deltaRight,
|
||||
INV_GRID_ORIGIN.y + 14.5 + panelOffsetY,
|
||||
)
|
||||
const invEv = Object.assign(new Event('mousemove'), {
|
||||
clientX: invClient.x,
|
||||
clientY: invClient.y,
|
||||
})
|
||||
mockWindow.dispatchEvent(invEv)
|
||||
expect(hud.inventory.hoveredItem).toEqual({
|
||||
item: bagItem,
|
||||
x: INV_GRID_ORIGIN.x + 14.5 + deltaRight,
|
||||
y: INV_GRID_ORIGIN.y + panelOffsetY,
|
||||
})
|
||||
expect(hudCanvas.style.pointerEvents).toBe('auto')
|
||||
|
||||
// Dispatch window mousemove over left-dock stash item
|
||||
const stashClient = hud.logicalToClient(
|
||||
STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
|
||||
STASH_GRID_ORIGIN.y + 14.5 + panelOffsetY,
|
||||
)
|
||||
const stashEv = Object.assign(new Event('mousemove'), {
|
||||
clientX: stashClient.x,
|
||||
clientY: stashClient.y,
|
||||
})
|
||||
mockWindow.dispatchEvent(stashEv)
|
||||
expect(hud.inventory.hoveredItem).toEqual({
|
||||
item: stashItem,
|
||||
x: STASH_GRID_ORIGIN.x + 14.5 + leftDockX,
|
||||
y: STASH_GRID_ORIGIN.y + panelOffsetY,
|
||||
})
|
||||
expect(hudCanvas.style.pointerEvents).toBe('auto')
|
||||
|
||||
// Dispatch window mousemove over bottom bar Run/Walk toggle button
|
||||
const runClient = hud.logicalToClient(263 + barOffsetX, 582 + barOffsetY)
|
||||
const runEv = Object.assign(new Event('mousemove'), {
|
||||
clientX: runClient.x,
|
||||
clientY: runClient.y,
|
||||
})
|
||||
mockWindow.dispatchEvent(runEv)
|
||||
expect(hud.controlBar.hoveredElement).toBe('run')
|
||||
expect(hudCanvas.style.pointerEvents).toBe('auto')
|
||||
} finally {
|
||||
if (prevWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window
|
||||
} else {
|
||||
;(globalThis as { window?: unknown }).window = prevWindow
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('Skill tooltips and full HUD UI audit across >800x600 viewports (Issue #557)', () => {
|
||||
it.each(EXPECTED)(
|
||||
'synchronizes skillTree.visible on direct rightPanel assignment and renders SkillTreePanel & Hotkeys skill tooltips on top layer ($id)',
|
||||
({ profile, barOffsetX, barOffsetY, panelOffsetY }) => {
|
||||
const hud = createHudAt(profile)
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaRight = docking.rightDockX - 400
|
||||
|
||||
// 1. Direct assignment (as done by HudModel.syncFromWorld) must keep skillTree.visible in sync
|
||||
hud.rightPanel = 'skill'
|
||||
expect(hud.skillTree.visible).toBe(true)
|
||||
hud.skillTree.setClass('sor')
|
||||
hud.skillTree.characterLevel = 30
|
||||
hud.skillTree.hardPoints.set(36, 5) // Fire Bolt
|
||||
|
||||
const col1Node = hud.skillTree
|
||||
.getSkillTree()
|
||||
.find((n) => n.tab === hud.skillTree.activeTab && n.col === 1)!
|
||||
const nodeRect = SkillTreePanel.nodeScreenPos(col1Node)
|
||||
hud.handleMouseMove(
|
||||
nodeRect.x + nodeRect.w / 2 + deltaRight,
|
||||
nodeRect.y + nodeRect.h / 2 + panelOffsetY,
|
||||
)
|
||||
expect(hud.skillTree.hoveredSkill?.skillId).toBe(col1Node.skillId)
|
||||
|
||||
// Verify tooltip placement with minLocalX = 8 - deltaRight does not clamp over col-1 node on >800x600
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(
|
||||
nodeRect,
|
||||
[{ text: 'Fire Bolt Level 5 (Mana Cost: 2.5)', color: 'gold', font: 'font8' }],
|
||||
hud.font,
|
||||
8 - deltaRight,
|
||||
)
|
||||
if (deltaRight > 0) {
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(nodeRect.x)
|
||||
}
|
||||
|
||||
// 2. Quickbar active skill slot hover & Speedbar popup skill hover at translated barOffset
|
||||
hud.hotkeys.setAvailableSkills(
|
||||
[
|
||||
{
|
||||
skillId: 0,
|
||||
name: 'Attack',
|
||||
nameZh: '攻击',
|
||||
level: 1,
|
||||
manaCost: 0,
|
||||
leftUsable: true,
|
||||
rightUsable: true,
|
||||
},
|
||||
{
|
||||
skillId: 36,
|
||||
name: 'Fire Bolt',
|
||||
nameZh: '火弹',
|
||||
level: 5,
|
||||
manaCost: 3,
|
||||
leftUsable: true,
|
||||
rightUsable: true,
|
||||
},
|
||||
{
|
||||
skillId: 38,
|
||||
name: 'Charged Bolt',
|
||||
nameZh: '充能弹',
|
||||
level: 3,
|
||||
manaCost: 4,
|
||||
leftUsable: true,
|
||||
rightUsable: true,
|
||||
},
|
||||
],
|
||||
36,
|
||||
38,
|
||||
)
|
||||
|
||||
const leftSlotX = 141 + barOffsetX
|
||||
const leftSlotY = 575 + barOffsetY
|
||||
hud.handleMouseMove(leftSlotX, leftSlotY)
|
||||
expect(hud.hotkeys.hoveredSlot).toBe('left')
|
||||
expect(hud.hotkeys.hoverSkillId).toBe(36)
|
||||
|
||||
// Open left speedbar popup and hover its first skill cell
|
||||
hud.handleClick(leftSlotX, leftSlotY, 0)
|
||||
expect(hud.hotkeys.openPopup).toBe('left')
|
||||
const popupCells = hud.hotkeys.getPopupCells('left')
|
||||
expect(popupCells.length).toBeGreaterThan(0)
|
||||
const firstCell = popupCells[0]!
|
||||
hud.handleMouseMove(firstCell.x + 24 + barOffsetX, firstCell.y + 24 + barOffsetY)
|
||||
expect(hud.hotkeys.hoveredPopupSkill).toEqual({
|
||||
side: 'left',
|
||||
skillId: firstCell.skill.skillId,
|
||||
})
|
||||
expect(hud.hotkeys.hoverSpeedbarSkillId).toBe(firstCell.skill.skillId)
|
||||
|
||||
// 3. Verify HudManager.render invokes hotkeys.draw AFTER belt.draw, and draws skillTree tooltip on top layer
|
||||
const callOrder: string[] = []
|
||||
const origBeltDraw = hud.belt.draw.bind(hud.belt)
|
||||
const origHotkeysDraw = hud.hotkeys.draw.bind(hud.hotkeys)
|
||||
const origSkillTooltipDraw = hud.skillTree.drawSkillTooltip.bind(hud.skillTree)
|
||||
vi.spyOn(hud.belt, 'draw').mockImplementation((...args) => {
|
||||
callOrder.push('belt.draw')
|
||||
return origBeltDraw(...args)
|
||||
})
|
||||
vi.spyOn(hud.hotkeys, 'draw').mockImplementation((...args) => {
|
||||
callOrder.push('hotkeys.draw')
|
||||
return origHotkeysDraw(...args)
|
||||
})
|
||||
vi.spyOn(hud.skillTree, 'drawSkillTooltip').mockImplementation((...args) => {
|
||||
callOrder.push('skillTree.drawSkillTooltip')
|
||||
return origSkillTooltipDraw(...args)
|
||||
})
|
||||
|
||||
// Hover the skill tree node again before rendering so skillTree.hoveredSkill is active during render()
|
||||
hud.handleMouseMove(
|
||||
nodeRect.x + nodeRect.w / 2 + deltaRight,
|
||||
nodeRect.y + nodeRect.h / 2 + panelOffsetY,
|
||||
)
|
||||
expect(hud.skillTree.hoveredSkill?.skillId).toBe(col1Node.skillId)
|
||||
|
||||
hud.render(1000)
|
||||
|
||||
expect(callOrder.indexOf('belt.draw')).toBeGreaterThanOrEqual(0)
|
||||
expect(callOrder.indexOf('hotkeys.draw')).toBeGreaterThan(callOrder.indexOf('belt.draw'))
|
||||
expect(callOrder.indexOf('skillTree.drawSkillTooltip')).toBeGreaterThan(
|
||||
callOrder.indexOf('hotkeys.draw'),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(EXPECTED)(
|
||||
'translates Mercenary, Trade, and Quest Reward left-dock item hover tooltips and clamps NPC menu / Area Banner / Death Overlay ($id)',
|
||||
({ profile, panelOffsetY }) => {
|
||||
const hud = createHudAt(profile)
|
||||
const docking = hud.getDockingLayout()
|
||||
const leftDockX = docking.leftDockX
|
||||
|
||||
// 1. Mercenary equipment hover tooltip (helm slot: x=212..268, y=104..160 -> center 240, 132)
|
||||
hud.leftPanel = 'merc'
|
||||
const mercHelm = makeTestUiItem('merc-helm', 'usk', 'Andariel Visage', 2, 2)
|
||||
hud.worldPanels.merc = { headItem: mercHelm }
|
||||
hud.handleMouseMove(240 + leftDockX, 132 + panelOffsetY)
|
||||
expect(hud.inventory.hoveredItem).toEqual({
|
||||
item: mercHelm,
|
||||
x: 240 + leftDockX,
|
||||
y: 104 + panelOffsetY,
|
||||
})
|
||||
|
||||
// 2. Quest Reward (imbue) slot hover tooltip (x=196..284, y=180..296 -> center 240, 238)
|
||||
hud.leftPanel = 'imbue'
|
||||
const imbueItem = makeTestUiItem('imbue-boots', 'xtb', 'War Boots', 2, 2)
|
||||
hud.worldPanels.questRewardItem = imbueItem
|
||||
hud.handleMouseMove(240 + leftDockX, 238 + panelOffsetY)
|
||||
expect(hud.inventory.hoveredItem).toEqual({
|
||||
item: imbueItem,
|
||||
x: 240 + leftDockX,
|
||||
y: 180 + panelOffsetY,
|
||||
})
|
||||
|
||||
// 3. NPC interaction menu clamps against active viewport width/height (not hardcoded 800x600)
|
||||
const akara = getTownNpcDescriptorByName('Akara')!
|
||||
hud.worldPanels.openNpcMenu(akara, profile.width - 60, 220, 1)
|
||||
expect(
|
||||
hud.worldPanels.isPointInNpcMenu(
|
||||
profile.width - 60,
|
||||
240,
|
||||
hud.font,
|
||||
profile.width,
|
||||
profile.height,
|
||||
),
|
||||
).toBe(true)
|
||||
expect(hud.isPointInterceptedByHud(profile.width - 60, 240)).toBe(true)
|
||||
hud.worldPanels.npcMenu = null
|
||||
|
||||
// 4. Area Banner & Death Overlay center across profile.width / profile.height
|
||||
hud.leftPanel = 'none'
|
||||
hud.rightPanel = 'none'
|
||||
hud.worldPanels.areaBanner = {
|
||||
titleZh: '鲜血荒地',
|
||||
titleEn: 'Blood Moor',
|
||||
untilMs: 5000,
|
||||
}
|
||||
hud.isDead = true
|
||||
|
||||
const fillRectCalls: Array<[number, number, number, number]> = []
|
||||
const drawTextXs: number[] = []
|
||||
const hudCanvas = (hud as unknown as { hudCanvas: HTMLCanvasElement }).hudCanvas
|
||||
const hudCtx = hudCanvas.getContext('2d')!
|
||||
const origFillRect = hudCtx.fillRect.bind(hudCtx)
|
||||
vi.spyOn(hudCtx, 'fillRect').mockImplementation((x, y, w, h) => {
|
||||
fillRectCalls.push([x, y, w, h])
|
||||
return origFillRect(x, y, w, h)
|
||||
})
|
||||
vi.spyOn(hud.font, 'drawText').mockImplementation((_ctx, _text, x) => {
|
||||
drawTextXs.push(x)
|
||||
})
|
||||
|
||||
hud.render(1500)
|
||||
|
||||
// Death overlay must cover (0, 0, profile.width, profile.height)
|
||||
expect(fillRectCalls).toContainEqual([0, 0, profile.width, profile.height])
|
||||
// Area banner and Death overlay text must center at Math.round(profile.width / 2)
|
||||
expect(drawTextXs).toContain(Math.round(profile.width / 2))
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
HIRELING_RECORDS,
|
||||
resolveHirelingAct,
|
||||
resolveHirelingName,
|
||||
resolveHirelingDesc,
|
||||
resolveHirelingCandidate,
|
||||
resolveHirelingCandidates,
|
||||
formatHirelingCandidateLines,
|
||||
} from '../../src/common/units/hireling-table.ts'
|
||||
|
||||
describe('hireling-table (authoritative Diablo II v1.13c Hireling.txt / D2Common parity)', () => {
|
||||
it('loads all 120 compiled hireling records with valid attributes', () => {
|
||||
expect(HIRELING_RECORDS).toHaveLength(120)
|
||||
for (const r of HIRELING_RECORDS) {
|
||||
expect([0, 100]).toContain(r.version)
|
||||
expect([271, 338, 359, 561]).toContain(r.classId)
|
||||
expect([1, 2, 3, 5]).toContain(r.act)
|
||||
expect([1, 2, 3]).toContain(r.difficulty)
|
||||
expect([150, 198, 252, 515]).toContain(r.seller)
|
||||
expect(r.level).toBeGreaterThan(0)
|
||||
expect(r.hireCost).toBeGreaterThan(0)
|
||||
expect(r.hp).toBeGreaterThan(0)
|
||||
expect(r.skills.length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves act index from mercenary name ID matching D2Common_10988 (0x6fd7c9e0)', () => {
|
||||
// Act 1 Rogue (merc01..merc34 / global 3411..3444) -> act 0
|
||||
expect(resolveHirelingAct(3411, true)).toBe(0)
|
||||
expect(resolveHirelingAct(3444, true)).toBe(0)
|
||||
|
||||
// Act 2 Desert Mercenary (merca201..merca221 / global 1019..1039) -> act 1
|
||||
expect(resolveHirelingAct(1019, true)).toBe(1)
|
||||
expect(resolveHirelingAct(1039, true)).toBe(1)
|
||||
|
||||
// Act 3 Iron Wolf (merca222..merca241 / global 1040..1059) -> act 2
|
||||
expect(resolveHirelingAct(1040, true)).toBe(2)
|
||||
expect(resolveHirelingAct(1059, true)).toBe(2)
|
||||
expect(resolveHirelingAct(11021, true)).toBe(2) // Jarulf
|
||||
expect(resolveHirelingAct(0x0421, true)).toBe(2) // 1057 Jarulf legacy
|
||||
|
||||
// Act 5 Barbarian (MercX101..MercX167 / global 10835..10901) -> act 4
|
||||
expect(resolveHirelingAct(10835, true)).toBe(4)
|
||||
expect(resolveHirelingAct(10901, true)).toBe(4)
|
||||
})
|
||||
|
||||
it('resolves localized mercenary names in English and Chinese per D2Client.dll 0x6faf9783', () => {
|
||||
// Act 1
|
||||
expect(resolveHirelingName(3411, 'ENG')).toBe('Aliza')
|
||||
expect(resolveHirelingName(3411, 'CHI')).toBe('阿里撒')
|
||||
expect(resolveHirelingName(3444, 'ENG')).toBe('Ryann')
|
||||
expect(resolveHirelingName(3444, 'CHI')).toBe('萊恩')
|
||||
|
||||
// Act 2
|
||||
expect(resolveHirelingName(1019, 'ENG')).toBe('Hazade')
|
||||
expect(resolveHirelingName(1019, 'CHI')).toBe('哈撒迪')
|
||||
expect(resolveHirelingName(1039, 'ENG')).toBe('Vikhyat')
|
||||
expect(resolveHirelingName(1039, 'CHI')).toBe('維克懷特')
|
||||
|
||||
// Act 3
|
||||
expect(resolveHirelingName(1040, 'ENG')).toBe('Jelani')
|
||||
expect(resolveHirelingName(1040, 'CHI')).toBe('喬安妮')
|
||||
expect(resolveHirelingName(11021, 'ENG')).toBe('Jarulf')
|
||||
expect(resolveHirelingName(0x0421, 'ENG')).toBe('Jarulf')
|
||||
|
||||
// Act 5
|
||||
expect(resolveHirelingName(10835, 'ENG')).toBe('Varaya')
|
||||
expect(resolveHirelingName(10835, 'CHI')).toBe('伐德哈卡')
|
||||
expect(resolveHirelingName(10901, 'ENG')).toBe('Theodoric')
|
||||
expect(resolveHirelingName(10901, 'CHI')).toBe('狄奧多理')
|
||||
})
|
||||
|
||||
it('resolves ability descriptions matching D2Common_10091 (0x6fd7c9c0) in ENG and CHI', () => {
|
||||
expect(resolveHirelingDesc(1, 'ENG')).toBe('Fire Arrow')
|
||||
expect(resolveHirelingDesc(1, 'CHI')).toBe('火箭')
|
||||
|
||||
expect(resolveHirelingDesc(2, 'ENG')).toBe('Cold Arrow')
|
||||
expect(resolveHirelingDesc(2, 'CHI')).toBe('冰箭')
|
||||
|
||||
expect(resolveHirelingDesc(3, 'ENG')).toBe('Combat')
|
||||
expect(resolveHirelingDesc(3, 'CHI')).toBe('戰鬥')
|
||||
|
||||
expect(resolveHirelingDesc(4, 'ENG')).toBe('Defensive')
|
||||
expect(resolveHirelingDesc(4, 'CHI')).toBe('防禦')
|
||||
|
||||
expect(resolveHirelingDesc(5, 'ENG')).toBe('Offensive')
|
||||
expect(resolveHirelingDesc(5, 'CHI')).toBe('進攻')
|
||||
|
||||
expect(resolveHirelingDesc(6, 'ENG')).toBe('Fire')
|
||||
expect(resolveHirelingDesc(6, 'CHI')).toBe('火')
|
||||
|
||||
expect(resolveHirelingDesc(7, 'ENG')).toBe('Cold')
|
||||
expect(resolveHirelingDesc(7, 'CHI')).toBe('冷')
|
||||
|
||||
expect(resolveHirelingDesc(8, 'ENG')).toBe('Lightning')
|
||||
expect(resolveHirelingDesc(8, 'CHI')).toBe('閃電')
|
||||
|
||||
expect(resolveHirelingDesc(0, 'ENG')).toBe('')
|
||||
expect(resolveHirelingDesc(0, 'CHI')).toBe('')
|
||||
})
|
||||
|
||||
it('formats candidate lines per D2Client.dll 0x6faf9440', () => {
|
||||
const eng = formatHirelingCandidateLines('Aliza', 8, 90, 55, 175, 'Fire Arrow', 'ENG')
|
||||
expect(eng.line1).toBe('Aliza - Lvl: 8 Life: 90 Def: 55 Cost: 175')
|
||||
expect(eng.line2).toBe(' Fire Arrow')
|
||||
|
||||
const chi = formatHirelingCandidateLines('阿里撒', 8, 90, 55, 175, '火箭', 'CHI')
|
||||
expect(chi.line1).toBe('阿里撒 - 等級: 8 生命: 90 防禦: 55 花費: 175')
|
||||
expect(chi.line2).toBe(' 火箭')
|
||||
|
||||
const blankSpecial = formatHirelingCandidateLines('Varaya', 30, 320, 200, 9200, '', 'ENG')
|
||||
expect(blankSpecial.line2).toBe(' ')
|
||||
})
|
||||
|
||||
it('computes exact 64-bit LCG stats matching D2Common_10458 across difficulties', () => {
|
||||
// Act 1 Rogue (Normal, seed 0x1234, playerLevel 10)
|
||||
const c1 = resolveHirelingCandidate({ nameId: 3411, seed: 0x1234 }, 10, 0, 0, true, 'ENG')
|
||||
expect(c1.name).toBe('Aliza')
|
||||
expect(c1.actIndex).toBe(0)
|
||||
expect(c1.classId).toBe(271)
|
||||
expect(c1.level).toBe(8)
|
||||
expect(c1.hp).toBe(90)
|
||||
expect(c1.defense).toBe(55)
|
||||
expect(c1.strength).toBe(41)
|
||||
expect(c1.dexterity).toBe(55)
|
||||
expect(c1.cost).toBe(175)
|
||||
expect(c1.hireDesc).toBe('Fire Arrow')
|
||||
|
||||
// Act 2 Desert Mercenary (Nightmare, seed 0x5678, playerLevel 50)
|
||||
const c2 = resolveHirelingCandidate({ nameId: 1019, seed: 0x5678 }, 50, 1, 1, true, 'ENG')
|
||||
expect(c2.name).toBe('Hazade')
|
||||
expect(c2.actIndex).toBe(1)
|
||||
expect(c2.classId).toBe(338)
|
||||
expect(c2.level).toBeGreaterThanOrEqual(45)
|
||||
expect(c2.cost).toBeGreaterThan(5000)
|
||||
expect(['Combat', 'Defensive', 'Offensive']).toContain(c2.hireDesc)
|
||||
|
||||
// Act 5 Barbarian (Normal, seed 0x9abc, playerLevel 35)
|
||||
const c5 = resolveHirelingCandidate({ nameId: 10835, seed: 0x9abc }, 35, 0, 4, true, 'ENG')
|
||||
expect(c5.name).toBe('Varaya')
|
||||
expect(c5.actIndex).toBe(4)
|
||||
expect(c5.classId).toBe(561)
|
||||
expect(c5.level).toBeGreaterThanOrEqual(30)
|
||||
expect(c5.cost).toBeGreaterThanOrEqual(9000)
|
||||
expect(c5.hireDesc).toBe('')
|
||||
})
|
||||
|
||||
it('resolveHirelingCandidates maps candidate list cleanly', () => {
|
||||
const raw = [
|
||||
{ nameId: 3411, seed: 0x1111 },
|
||||
{ nameId: 3412, seed: 0x2222 },
|
||||
{ nameId: 3413, seed: 0x3333 },
|
||||
]
|
||||
const res = resolveHirelingCandidates(raw, 12, 0, 0, true, 'CHI')
|
||||
expect(res).toHaveLength(3)
|
||||
expect(res[0]!.nameId).toBe(3411)
|
||||
expect(res[1]!.nameId).toBe(3412)
|
||||
expect(res[2]!.nameId).toBe(3413)
|
||||
expect(res[0]!.line1).toContain('等級:')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,452 @@
|
|||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Headless Chrome Browser Verification for Issue #542:
|
||||
* HUD docking input coordinates, interactive clicks, and hover tooltips
|
||||
* across extended viewport profiles (1024x768, 1068x600, 1280x720).
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createServer, type ViteDevServer } from 'vite'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT_DIR = resolve(__dirname, '..')
|
||||
|
||||
const CHROME_CANDIDATES = [
|
||||
join(process.env.HOME ?? '', '.cache/ms-playwright/chromium-1228/chrome-linux64/chrome'),
|
||||
'/root/.cache/ms-playwright/chromium-1223/chrome-linux64/chrome',
|
||||
'/usr/bin/google-chrome',
|
||||
]
|
||||
|
||||
function findChromeBinary(): string {
|
||||
for (const cand of CHROME_CANDIDATES) {
|
||||
if (cand && existsSync(cand)) return cand
|
||||
}
|
||||
throw new Error(`No headless Chrome binary found in candidates: ${CHROME_CANDIDATES.join(', ')}`)
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
class CdpSession {
|
||||
private readonly ws: WebSocket
|
||||
private msgId = 0
|
||||
private readonly pending = new Map<
|
||||
number,
|
||||
{ readonly resolve: (val: any) => void; readonly reject: (err: Error) => void }
|
||||
>()
|
||||
readonly consoleErrors: string[] = []
|
||||
readonly pageExceptions: string[] = []
|
||||
|
||||
private constructor(ws: WebSocket) {
|
||||
this.ws = ws
|
||||
this.ws.onmessage = (ev) => {
|
||||
const msg = JSON.parse(String(ev.data))
|
||||
if (typeof msg.id === 'number') {
|
||||
const entry = this.pending.get(msg.id)
|
||||
if (entry) {
|
||||
this.pending.delete(msg.id)
|
||||
if (msg.error) {
|
||||
entry.reject(new Error(msg.error.message || JSON.stringify(msg.error)))
|
||||
} else {
|
||||
entry.resolve(msg.result)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (msg.method === 'Runtime.consoleAPICalled') {
|
||||
const type = msg.params?.type
|
||||
if (type === 'error') {
|
||||
const text = (msg.params?.args ?? [])
|
||||
.map((a: any) => a.value ?? a.description ?? '')
|
||||
.join(' ')
|
||||
if (!text.includes('favicon.ico') && !text.includes('404')) {
|
||||
this.consoleErrors.push(text)
|
||||
}
|
||||
}
|
||||
} else if (msg.method === 'Runtime.exceptionThrown') {
|
||||
const details = msg.params?.exceptionDetails
|
||||
const desc =
|
||||
details?.exception?.description ?? details?.text ?? JSON.stringify(details)
|
||||
this.pageExceptions.push(String(desc))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async connect(wsUrl: string): Promise<CdpSession> {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.onopen = () => resolve()
|
||||
ws.onerror = (err) => reject(new Error(`CDP WebSocket error: ${String(err)}`))
|
||||
})
|
||||
const cdp = new CdpSession(ws)
|
||||
await cdp.send('Runtime.enable')
|
||||
await cdp.send('Page.enable')
|
||||
return cdp
|
||||
}
|
||||
|
||||
send(method: string, params: Record<string, unknown> = {}): Promise<any> {
|
||||
const id = ++this.msgId
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
this.ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
async evaluate<T = any>(expression: string): Promise<T> {
|
||||
const res = await this.send('Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
})
|
||||
if (res?.exceptionDetails) {
|
||||
const desc =
|
||||
res.exceptionDetails.exception?.description ??
|
||||
res.exceptionDetails.text ??
|
||||
JSON.stringify(res.exceptionDetails)
|
||||
throw new Error(`CDP evaluate exception: ${String(desc)}`)
|
||||
}
|
||||
return res?.result?.value as T
|
||||
}
|
||||
|
||||
async captureScreenshot(outPath: string): Promise<number> {
|
||||
const shot = await this.send('Page.captureScreenshot', { format: 'png' })
|
||||
const data = shot?.data
|
||||
if (typeof data !== 'string' || data.length === 0) {
|
||||
throw new Error('Failed to capture PNG screenshot via CDP')
|
||||
}
|
||||
const buf = Buffer.from(data, 'base64')
|
||||
mkdirSync(dirname(outPath), { recursive: true })
|
||||
writeFileSync(outPath, buf)
|
||||
return buf.byteLength
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
this.ws.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ProfileAuditSpec {
|
||||
readonly id: '1024x768' | '1068x600' | '1280x720'
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly barOffsetX: number
|
||||
readonly barOffsetY: number
|
||||
readonly panelOffsetY: number
|
||||
}
|
||||
|
||||
const EXTENDED_PROFILES: readonly ProfileAuditSpec[] = [
|
||||
{ id: '1024x768', width: 1024, height: 768, barOffsetX: 112, barOffsetY: 168, panelOffsetY: 84 },
|
||||
{ id: '1068x600', width: 1068, height: 600, barOffsetX: 134, barOffsetY: 0, panelOffsetY: 0 },
|
||||
{ id: '1280x720', width: 1280, height: 720, barOffsetX: 240, barOffsetY: 120, panelOffsetY: 60 },
|
||||
]
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const chromeBin = findChromeBinary()
|
||||
const outDir = resolve(ROOT_DIR, 'samples/screenshots-issue-542')
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
|
||||
const fixturePath = resolve(ROOT_DIR, 'tests/fixtures/netproto/02-d2gs-town-walk-chat.d2cap')
|
||||
if (!existsSync(fixturePath)) {
|
||||
throw new Error(`Missing fixture at ${fixturePath}`)
|
||||
}
|
||||
const d2capData = readFileSync(fixturePath, 'utf8')
|
||||
|
||||
let viteServer: ViteDevServer | null = null
|
||||
const userDataDir = mkdtempSync(join(tmpdir(), 'd2-verify-542-'))
|
||||
let chromeProc: ReturnType<typeof spawn> | null = null
|
||||
let cdp: CdpSession | null = null
|
||||
|
||||
try {
|
||||
viteServer = await createServer({
|
||||
root: ROOT_DIR,
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
strictPort: false,
|
||||
watch: null,
|
||||
},
|
||||
logLevel: 'error',
|
||||
})
|
||||
await viteServer.listen()
|
||||
const addr = viteServer.httpServer?.address()
|
||||
const port = typeof addr === 'object' && addr ? addr.port : 0
|
||||
if (!port) throw new Error('Failed to start Vite server')
|
||||
|
||||
const targetUrl = `http://127.0.0.1:${port}/play.html`
|
||||
console.log(`[verify-issue-542] Vite server listening at ${targetUrl}`)
|
||||
|
||||
chromeProc = spawn(
|
||||
chromeBin,
|
||||
[
|
||||
'--headless=new',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu-sandbox',
|
||||
'--use-angle=swiftshader',
|
||||
'--use-gl=angle',
|
||||
'--enable-unsafe-swiftshader',
|
||||
'--ignore-gpu-blocklist',
|
||||
'--disable-background-timer-throttling',
|
||||
'--disable-renderer-backgrounding',
|
||||
'--disable-backgrounding-occluded-windows',
|
||||
'--no-proxy-server',
|
||||
'--password-store=basic',
|
||||
'--disable-extensions',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--force-device-scale-factor=1',
|
||||
'--window-size=1280,960',
|
||||
`--user-data-dir=${userDataDir}`,
|
||||
'--remote-debugging-port=0',
|
||||
'about:blank',
|
||||
],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
||||
)
|
||||
|
||||
let cdpPort: string | null = null
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await sleep(150)
|
||||
try {
|
||||
const raw = readFileSync(join(userDataDir, 'DevToolsActivePort'), 'utf8')
|
||||
const firstLine = raw.trim().split('\n')[0]
|
||||
if (firstLine) {
|
||||
cdpPort = firstLine
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// wait
|
||||
}
|
||||
}
|
||||
if (!cdpPort) throw new Error('Chrome failed to start DevToolsActivePort')
|
||||
|
||||
let pageTarget: { readonly url: string; readonly webSocketDebuggerUrl: string } | null = null
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await sleep(150)
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${cdpPort}/json`)
|
||||
const list = (await res.json()) as any[]
|
||||
const pages = list.filter((p) => p.type === 'page')
|
||||
if (pages.length > 0 && pages[0]) {
|
||||
pageTarget = pages[0]
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// wait
|
||||
}
|
||||
}
|
||||
if (!pageTarget) throw new Error('Failed to attach to page target in headless Chrome')
|
||||
|
||||
cdp = await CdpSession.connect(pageTarget.webSocketDebuggerUrl)
|
||||
await cdp.send('Page.navigate', { url: targetUrl })
|
||||
|
||||
// Wait for play.html boot
|
||||
let ready = false
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const state = await cdp.evaluate<{ ready: boolean } | null>(
|
||||
`window.__d2Play ? { ready: Boolean(window.__d2Play.ready) } : null`,
|
||||
)
|
||||
if (state?.ready) {
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
await sleep(200)
|
||||
}
|
||||
if (!ready) throw new Error('play.html failed to boot ready=true')
|
||||
console.log('[verify-issue-542] play.html boot ready')
|
||||
|
||||
// Enter in-game view via D2Cap replay
|
||||
await cdp.evaluate(`(() => {
|
||||
const app = window.__d2Play;
|
||||
app.replayD2Cap(${JSON.stringify(d2capData)});
|
||||
})()`)
|
||||
await sleep(500)
|
||||
|
||||
for (const spec of EXTENDED_PROFILES) {
|
||||
console.log(`\n─── Testing Extended Viewport Profile: ${spec.id} (${spec.width}x${spec.height}) ───`)
|
||||
|
||||
// 1. Resize browser viewport via CDP Emulation
|
||||
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
||||
width: spec.width,
|
||||
height: spec.height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
})
|
||||
|
||||
// 2. Set viewport profile in settingsStore and trigger stage resize
|
||||
const layoutAudit = await cdp.evaluate<{
|
||||
viewportId: string
|
||||
profileWidth: number
|
||||
profileHeight: number
|
||||
barOffsetX: number
|
||||
barOffsetY: number
|
||||
panelOffsetY: number
|
||||
leftDockX: number
|
||||
rightDockX: number
|
||||
channelWidth: number
|
||||
}>(`(() => {
|
||||
const app = window.__d2Play;
|
||||
app.settingsStore.update({ viewport: ${JSON.stringify(spec.id)} });
|
||||
app.renderFrame(performance.now());
|
||||
const hud = app.hudManager;
|
||||
const docking = hud.getDockingLayout();
|
||||
return {
|
||||
viewportId: hud.viewportProfile.id,
|
||||
profileWidth: hud.viewportProfile.width,
|
||||
profileHeight: hud.viewportProfile.height,
|
||||
barOffsetX: docking.barOffsetX,
|
||||
barOffsetY: docking.barOffsetY,
|
||||
panelOffsetY: docking.panelOffsetY,
|
||||
leftDockX: docking.leftDockX,
|
||||
rightDockX: docking.rightDockX,
|
||||
channelWidth: docking.channelWidthLogical,
|
||||
};
|
||||
})()`)
|
||||
|
||||
console.log(`[${spec.id}] Docking metrics:`, layoutAudit)
|
||||
if (
|
||||
layoutAudit.viewportId !== spec.id ||
|
||||
layoutAudit.profileWidth !== spec.width ||
|
||||
layoutAudit.profileHeight !== spec.height ||
|
||||
layoutAudit.barOffsetX !== spec.barOffsetX ||
|
||||
layoutAudit.barOffsetY !== spec.barOffsetY ||
|
||||
layoutAudit.panelOffsetY !== spec.panelOffsetY ||
|
||||
layoutAudit.leftDockX !== 0 ||
|
||||
layoutAudit.rightDockX !== spec.width - 400
|
||||
) {
|
||||
throw new Error(`[${spec.id}] Docking layout metrics mismatch: ${JSON.stringify(layoutAudit)}`)
|
||||
}
|
||||
|
||||
// 3. Test interactive clicks on bottom control bar: Run/Walk toggle & mini-panel buttons
|
||||
const barClickAudit = await cdp.evaluate<{
|
||||
runToggled: boolean
|
||||
charOpened: boolean
|
||||
invOpened: boolean
|
||||
beltSlotHovered: boolean
|
||||
}>(`(() => {
|
||||
const app = window.__d2Play;
|
||||
const hud = app.hudManager;
|
||||
const barOffsetX = ${spec.barOffsetX};
|
||||
const barOffsetY = ${spec.barOffsetY};
|
||||
|
||||
// Run/Walk toggle at center (263 + barOffsetX, 582 + barOffsetY)
|
||||
const prevRun = hud.isRunning;
|
||||
hud.handleClick(263 + barOffsetX, 582 + barOffsetY, 0);
|
||||
const runToggled = hud.isRunning !== prevRun;
|
||||
|
||||
// Mini-panel Char button at (325 + barOffsetX, 535 + barOffsetY)
|
||||
hud.leftPanel = 'none';
|
||||
hud.handleClick(325 + barOffsetX, 535 + barOffsetY, 0);
|
||||
const charOpened = hud.leftPanel === 'char';
|
||||
|
||||
// Mini-panel Inv button at (352 + barOffsetX, 535 + barOffsetY)
|
||||
hud.rightPanel = 'none';
|
||||
hud.handleClick(352 + barOffsetX, 535 + barOffsetY, 0);
|
||||
const invOpened = hud.rightPanel === 'inv';
|
||||
|
||||
// Hover over Belt slot (0, 0) at (435 + barOffsetX, 575 + barOffsetY)
|
||||
hud.handleMouseMove(435 + barOffsetX, 575 + barOffsetY);
|
||||
const beltSlotHovered = hud.belt.hoveredSlot !== null && hud.belt.hoveredSlot.col === 0 && hud.belt.hoveredSlot.row === 0;
|
||||
|
||||
app.renderFrame(performance.now());
|
||||
return { runToggled, charOpened, invOpened, beltSlotHovered };
|
||||
})()`)
|
||||
|
||||
console.log(`[${spec.id}] Bar click & hover audit:`, barClickAudit)
|
||||
if (!barClickAudit.runToggled || !barClickAudit.charOpened || !barClickAudit.invOpened || !barClickAudit.beltSlotHovered) {
|
||||
throw new Error(`[${spec.id}] Control bar interactive click/hover failed: ${JSON.stringify(barClickAudit)}`)
|
||||
}
|
||||
|
||||
// Capture screenshot with both left dock (char) and right dock (inv) open
|
||||
const dockShotPath = join(outDir, `play-${spec.id}-hud-docking.png`)
|
||||
await cdp.captureScreenshot(dockShotPath)
|
||||
console.log(`[${spec.id}] Saved screenshot: ${dockShotPath}`)
|
||||
|
||||
// 4. Test inventory hover item tooltip at docked right coordinates
|
||||
const invHoverAudit = await cdp.evaluate<{
|
||||
hoveredItemFound: boolean
|
||||
hoveredX: number
|
||||
hoveredY: number
|
||||
tooltipBoxX: number
|
||||
tooltipBoxW: number
|
||||
tooltipClampedInsideViewport: boolean
|
||||
}>(`(() => {
|
||||
const app = window.__d2Play;
|
||||
const hud = app.hudManager;
|
||||
const deltaRight = ${spec.width} - 400 - 400; // rightDockX - 400
|
||||
const panelOffsetY = ${spec.panelOffsetY};
|
||||
|
||||
hud.rightPanel = 'inv';
|
||||
// Add a test ring at (col 9, row 0) to test outer boundary clamping
|
||||
hud.inventory.gridItems = [{
|
||||
item: {
|
||||
id: 'test-outer-ring',
|
||||
code: 'rin',
|
||||
name: 'Nagelring',
|
||||
nameZh: '拿各的戒指',
|
||||
baseNameZh: '戒指',
|
||||
quality: 'unique',
|
||||
invWidth: 1,
|
||||
invHeight: 1,
|
||||
stats: [
|
||||
{ text: '+75 to Attack Rating', color: 'blue' },
|
||||
{ text: 'Magic Damage Reduced by 3', color: 'blue' },
|
||||
{ text: 'Attacker Takes Damage of 3', color: 'blue' },
|
||||
{ text: '30% Better Chance of Getting Magic Items', color: 'blue' },
|
||||
],
|
||||
allowedSlots: ['ring1', 'ring2'],
|
||||
},
|
||||
col: 9,
|
||||
row: 0,
|
||||
}];
|
||||
|
||||
// INV_GRID_ORIGIN is { x: 418, y: 316, cols: 10, rows: 4, cellPx: 29 }
|
||||
const hx = 418 + 9 * 29 + 14.5 + deltaRight;
|
||||
const hy = 316 + 14.5 + panelOffsetY;
|
||||
hud.handleMouseMove(hx, hy);
|
||||
app.renderFrame(performance.now());
|
||||
|
||||
const hover = hud.inventory.hoveredItem;
|
||||
const rect = hud.worldPanels.lastTooltipRect;
|
||||
return {
|
||||
hoveredItemFound: Boolean(hover && hover.item.id === 'test-outer-ring'),
|
||||
hoveredX: hover ? hover.x : 0,
|
||||
hoveredY: hover ? hover.y : 0,
|
||||
tooltipBoxX: rect ? rect.bx : 0,
|
||||
tooltipBoxW: rect ? rect.boxW : 0,
|
||||
tooltipClampedInsideViewport: Boolean(rect && (rect.bx + rect.boxW <= ${spec.width} - 8)),
|
||||
};
|
||||
})()`)
|
||||
|
||||
console.log(`[${spec.id}] Inventory hover & tooltip audit:`, invHoverAudit)
|
||||
if (!invHoverAudit.hoveredItemFound || !invHoverAudit.tooltipClampedInsideViewport) {
|
||||
throw new Error(`[${spec.id}] Inventory item hover & tooltip clamping failed: ${JSON.stringify(invHoverAudit)}`)
|
||||
}
|
||||
|
||||
// Capture screenshot with inventory item hover tooltip visible
|
||||
const tipShotPath = join(outDir, `play-${spec.id}-inventory-hover-tooltip.png`)
|
||||
await cdp.captureScreenshot(tipShotPath)
|
||||
console.log(`[${spec.id}] Saved screenshot: ${tipShotPath}`)
|
||||
}
|
||||
|
||||
console.log('\n=== Headless Browser Verification on play.html across all 3 extended viewports Passed (0 errors) ===')
|
||||
} finally {
|
||||
if (cdp) await cdp.close()
|
||||
if (chromeProc) chromeProc.kill('SIGKILL')
|
||||
if (viteServer) await viteServer.close()
|
||||
rmSync(userDataDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error('[verify-issue-542] Fatal error:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
Loading…
Reference in New Issue