fix(ui): restore authentic 1.13c mercenary hire stone dialog and packet protocol
- Closes #558 - Ground truth parity with Diablo II v1.13c: * Replace 320x432 left dock panel placeholder with authentic centered 490x350 carved stone dialog (D2Client.dll 0x6faf9440 + dialog.cpp 0x6fb53e40) * Implement scrollable candidate list control (0x6fb17f20 / 0x6fb17d00) with authentic textslid.dc6 vertical scrollbar * Eliminate vertical overflow past HUD and prevent collision with NPC interaction menu * Implement D2Common.dll ordinal 10988 (0x6fd7c9e0) act resolution and ordinal 10458 (0x6fd7ccc0) 64-bit LCG candidate stat, level, cost, and ability evaluation * Fix C2S 0x36 MercHire (9B) to send wMercName (nameId) instead of dwSeed per D2Game.dll 0x6fcdd5e0 * Implement 300x160 stone confirmation warning dialog (0x6faf7500) when replacing an active/dead mercenary * Add unit tests for 1.13c Hireling.txt table and candidate dialog interaction
This commit is contained in:
parent
4a36b692c0
commit
710ea7a856
Binary file not shown.
|
After Width: | Height: | Size: 1.5 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)
|
||||
|
|
@ -1473,6 +1482,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',
|
||||
|
|
|
|||
|
|
@ -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,7 @@ 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'
|
||||
|
||||
export const CLASS_ID_TO_CODE: Readonly<Record<CharClassId, CharacterClassCode>> = {
|
||||
0: 'ama',
|
||||
|
|
@ -841,30 +842,25 @@ export class HudModel implements HudCommandSink {
|
|||
(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,
|
||||
|
|
@ -973,10 +969,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 +990,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 +1020,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 +1576,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -803,6 +803,7 @@ 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`,
|
||||
|
|
@ -1144,6 +1145,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' ||
|
||||
|
|
@ -1249,6 +1257,7 @@ export class HudManager {
|
|||
return false
|
||||
}
|
||||
if (this.worldPanels.isPointInNpcMenu(logicalX, logicalY, this.font)) 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
|
||||
|
|
@ -1270,6 +1279,7 @@ 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.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
|
||||
|
|
@ -1547,7 +1557,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)) {
|
||||
|
|
@ -2415,7 +2429,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,
|
||||
|
|
@ -2543,7 +2568,11 @@ export class HudManager {
|
|||
})
|
||||
|
||||
// 8. Town NPC Stone Interaction Menu (`npcmenu.cpp` + `dialog.cpp` + `boxpieces.dc6`)
|
||||
if (this.worldPanels.npcMenu) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,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 +90,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). */
|
||||
|
|
@ -848,6 +863,13 @@ export class WorldPanelsHud {
|
|||
merc: any = null
|
||||
hireList: any[] = []
|
||||
mercHireList: 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 +902,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 +1350,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 +1643,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') {
|
||||
|
|
@ -2005,6 +2051,10 @@ 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
|
||||
|
|
@ -3070,6 +3120,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, 'font16', 'gold', 'center')
|
||||
} 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,
|
||||
|
|
@ -3738,6 +4254,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 +4304,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 +4431,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 +4552,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')
|
||||
|
|
|
|||
|
|
@ -799,7 +799,7 @@ export class ClientWorld {
|
|||
this._containers.clearShop()
|
||||
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'gamble', activeWindow: 'gamble' }
|
||||
} else if (cmd.action === 3) {
|
||||
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'none', activeWindow: 'hire' }
|
||||
this._npcInteraction = { ...cur, npcId: cmd.npcId, window: 'hire', activeWindow: 'hire' }
|
||||
}
|
||||
break
|
||||
case 'NpcCancel':
|
||||
|
|
|
|||
|
|
@ -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": "衣",
|
||||
|
|
|
|||
|
|
@ -293595,6 +293595,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",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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,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('等級:')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue