diablo2-web/src/ui/world-panels.ts

1076 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Diablo II v1.13c World & Extended UI Panels:
* 1. Quest Log Panel (`Menu\questbackground.dc6`, hotkey `Q`)
* 2. Waypoint Teleport Panel (`Menu\waygatebackground.dc6`, hotkey `V` or clicking scene Waypoint)
* 3. Private Stash (`Panel\TradeStash.dc6`, 6x8 grid) & Horadric Cube (`Panel\supertransmogrifier.dc6`, 3x4 grid)
* 4. Town NPC Stone Interaction Menu (`npcmenu.cpp`) & Vendor Panel (`Panel\NPCInv.dc6`)
* 5. Monster Target Top Plaque (`text.cpp`), Item Hover Tooltip (`showitems.cpp`),
* Ground `Alt` Item Labels, Area Entry Gothic Banner (`Font30`), and Escape Menu (`menupanel.dc6`).
*/
import type { D2ColorCode, D2FontRenderer } from './font.ts'
import type { GridPlacement, InventoryPanel, UiInventoryItem } from './inventory.ts'
import { resolveItemSpriteRect } from './inventory.ts'
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' | 'cube' | 'vendor'
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
export const STASH_GOLD_CAP = 2_500_000
export const PLAYER_GOLD_CAP = 2_500_000
export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 } as const
export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const
export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const
import {
CANONICAL_QUESTS_BY_ACT,
type CanonicalQuestItem,
type CanonicalQuestStage,
} from '../data/canonical-quest-data.ts'
export interface QuestEntry {
readonly id: string
readonly act: number
readonly index: number
readonly titleEn: string
readonly titleZh: string
readonly titleKey: string
readonly status: 'completed' | 'active' | 'locked'
readonly description: string
readonly objectiveKey: string
readonly objectiveEn: string
readonly objectiveZh: string
readonly completedKey: string
readonly completedEn: string
readonly completedZh: string
readonly rewardSummaryEn: string
readonly rewardSummaryZh: string
readonly stages: readonly CanonicalQuestStage[]
}
export type QuestTblLookup = (key: string) => string | undefined
export interface QuestDisplayText {
readonly title: string
readonly titleKey: string
readonly body: string
readonly status: 'completed' | 'active' | 'locked'
readonly statusTag: string
readonly rewardText: string
}
export interface WaypointEntry {
readonly act: number
readonly index: number
readonly waypointId: number
readonly levelId: number
readonly slug: string
readonly nameEn: string
readonly nameZh: string
readonly unlocked: boolean
}
const DEFAULT_COMPLETED_QUEST_IDS = new Set(['a1q1', 'a1q2', 'a1q3'])
function createQuestEntry(item: CanonicalQuestItem): QuestEntry {
const isCompleted = DEFAULT_COMPLETED_QUEST_IDS.has(item.id)
return {
...item,
status: isCompleted ? 'completed' : 'active',
description: isCompleted
? `${item.completedZh} (奖励: ${item.rewardSummaryZh})`
: `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`,
}
}
export const ACT_QUESTS: Readonly<Record<number, readonly QuestEntry[]>> = {
1: CANONICAL_QUESTS_BY_ACT[1]!.map(createQuestEntry),
2: CANONICAL_QUESTS_BY_ACT[2]!.map(createQuestEntry),
3: CANONICAL_QUESTS_BY_ACT[3]!.map(createQuestEntry),
4: CANONICAL_QUESTS_BY_ACT[4]!.map(createQuestEntry),
5: CANONICAL_QUESTS_BY_ACT[5]!.map(createQuestEntry),
}
/** Flat list of all 27 canonical Diablo II quests across Acts 1..5. */
export const ALL_QUESTS: readonly QuestEntry[] = [
...ACT_QUESTS[1]!,
...ACT_QUESTS[2]!,
...ACT_QUESTS[3]!,
...ACT_QUESTS[4]!,
...ACT_QUESTS[5]!,
]
export const ACT_WAYPOINTS: Readonly<Record<number, readonly WaypointEntry[]>> = {
1: [
{ act: 1, index: 0, waypointId: 0, levelId: 1, slug: 'rogue-encampment', nameEn: 'Rogue Encampment', nameZh: '罗格营地', unlocked: true },
{ act: 1, index: 1, waypointId: 1, levelId: 3, slug: 'cold-plains', nameEn: 'Cold Plains', nameZh: '冰冷之原', unlocked: true },
{ act: 1, index: 2, waypointId: 2, levelId: 4, slug: 'stony-field', nameEn: 'Stony Field', nameZh: '乱石旷野', unlocked: true },
{ act: 1, index: 3, waypointId: 3, levelId: 5, slug: 'dark-wood', nameEn: 'Dark Wood', nameZh: '黑暗森林', unlocked: true },
{ act: 1, index: 4, waypointId: 4, levelId: 6, slug: 'black-marsh', nameEn: 'Black Marsh', nameZh: '黑色荒地', unlocked: true },
{ act: 1, index: 5, waypointId: 5, levelId: 27, slug: 'outer-cloister', nameEn: 'Outer Cloister', nameZh: '外侧回廊', unlocked: true },
{ act: 1, index: 6, waypointId: 6, levelId: 29, slug: 'jail-level-1', nameEn: 'Jail Level 1', nameZh: '监牢第一层', unlocked: true },
{ act: 1, index: 7, waypointId: 7, levelId: 32, slug: 'inner-cloister', nameEn: 'Inner Cloister', nameZh: '内侧回廊', unlocked: true },
{ act: 1, index: 8, waypointId: 8, levelId: 35, slug: 'catacombs-level-2', nameEn: 'Catacombs Level 2', nameZh: '地下墓穴第二层', unlocked: true },
],
2: [
{ act: 2, index: 0, waypointId: 9, levelId: 40, slug: 'lut-gholein', nameEn: 'Lut Gholein', nameZh: '鲁·高因', unlocked: true },
{ act: 2, index: 1, waypointId: 10, levelId: 48, slug: 'sewers-level-2', nameEn: 'Sewers Level 2', nameZh: '下水道第二层', unlocked: true },
{ act: 2, index: 2, waypointId: 11, levelId: 42, slug: 'dry-hills', nameEn: 'Dry Hills', nameZh: '干燥高地', unlocked: true },
{ act: 2, index: 3, waypointId: 12, levelId: 57, slug: 'halls-of-the-dead-level-2', nameEn: 'Halls of the Dead Level 2', nameZh: '死亡之殿第二层', unlocked: true },
{ act: 2, index: 4, waypointId: 13, levelId: 43, slug: 'far-oasis', nameEn: 'Far Oasis', nameZh: '遥远绿洲', unlocked: true },
{ act: 2, index: 5, waypointId: 14, levelId: 44, slug: 'lost-city', nameEn: 'Lost City', nameZh: '遗失古城', unlocked: true },
{ act: 2, index: 6, waypointId: 15, levelId: 52, slug: 'palace-cellar-level-1', nameEn: 'Palace Cellar Level 1', nameZh: '王宫监牢第一层', unlocked: true },
{ act: 2, index: 7, waypointId: 16, levelId: 74, slug: 'arcane-sanctuary', nameEn: 'Arcane Sanctuary', nameZh: '神秘避难所', unlocked: true },
{ act: 2, index: 8, waypointId: 17, levelId: 46, slug: 'canyon-of-the-magi', nameEn: 'Canyon of the Magi', nameZh: '术士的峡谷', unlocked: true },
],
3: [
{ act: 3, index: 0, waypointId: 18, levelId: 75, slug: 'kurast-docks', nameEn: 'Kurast Docks', nameZh: '库拉斯特海港', unlocked: true },
{ act: 3, index: 1, waypointId: 19, levelId: 76, slug: 'spider-forest', nameEn: 'Spider Forest', nameZh: '蜘蛛森林', unlocked: true },
{ act: 3, index: 2, waypointId: 20, levelId: 77, slug: 'great-marsh', nameEn: 'Great Marsh', nameZh: '庞大湿地', unlocked: true },
{ act: 3, index: 3, waypointId: 21, levelId: 78, slug: 'flayer-jungle', nameEn: 'Flayer Jungle', nameZh: '剥皮丛林', unlocked: true },
{ act: 3, index: 4, waypointId: 22, levelId: 79, slug: 'lower-kurast', nameEn: 'Lower Kurast', nameZh: '库拉斯特下层', unlocked: true },
{ act: 3, index: 5, waypointId: 23, levelId: 80, slug: 'kurast-bazaar', nameEn: 'Kurast Bazaar', nameZh: '库拉斯特商场', unlocked: true },
{ act: 3, index: 6, waypointId: 24, levelId: 81, slug: 'upper-kurast', nameEn: 'Upper Kurast', nameZh: '库拉斯特上层', unlocked: true },
{ act: 3, index: 7, waypointId: 25, levelId: 83, slug: 'travincal', nameEn: 'Travincal', nameZh: '崔凡克', unlocked: true },
{ act: 3, index: 8, waypointId: 26, levelId: 101, slug: 'durance-of-hate-level-2', nameEn: 'Durance of Hate Level 2', nameZh: '憎恨的囚牢第二层', unlocked: true },
],
4: [
{ act: 4, index: 0, waypointId: 27, levelId: 103, slug: 'the-pandemonium-fortress', nameEn: 'The Pandemonium Fortress', nameZh: '群魔堡垒', unlocked: true },
{ act: 4, index: 1, waypointId: 28, levelId: 106, slug: 'city-of-the-damned', nameEn: 'City of the Damned', nameZh: '神罚之城', unlocked: true },
{ act: 4, index: 2, waypointId: 29, levelId: 107, slug: 'river-of-flame', nameEn: 'River of Flame', nameZh: '火焰之河', unlocked: true },
],
5: [
{ act: 5, index: 0, waypointId: 30, levelId: 109, slug: 'harrogath', nameEn: 'Harrogath', nameZh: '哈洛加斯', unlocked: true },
{ act: 5, index: 1, waypointId: 31, levelId: 111, slug: 'frigid-highlands', nameEn: 'Frigid Highlands', nameZh: '冰冻高地', unlocked: true },
{ act: 5, index: 2, waypointId: 32, levelId: 112, slug: 'arreat-plateau', nameEn: 'Arreat Plateau', nameZh: '亚瑞特高原', unlocked: true },
{ act: 5, index: 3, waypointId: 33, levelId: 113, slug: 'crystalline-passage', nameEn: 'Crystalline Passage', nameZh: '水晶通道', unlocked: true },
{ act: 5, index: 4, waypointId: 34, levelId: 115, slug: 'glacial-trail', nameEn: 'Glacial Trail', nameZh: '冰河路径', unlocked: true },
{ act: 5, index: 5, waypointId: 35, levelId: 123, slug: 'halls-of-pain', nameEn: 'Halls of Pain', nameZh: '痛苦之厅', unlocked: true },
{ act: 5, index: 6, waypointId: 36, levelId: 117, slug: 'frozen-tundra', nameEn: 'Frozen Tundra', nameZh: '冰冻苔原', unlocked: true },
{ act: 5, index: 7, waypointId: 37, levelId: 118, slug: 'the-ancients-way', nameEn: "The Ancients' Way", nameZh: '远古之路', unlocked: true },
{ act: 5, index: 8, waypointId: 38, levelId: 129, slug: 'the-worldstone-keep-level-2', nameEn: 'Worldstone Keep Level 2', nameZh: '世界之石要塞第二层', unlocked: true },
],
}
/** Authentic D2Client.dll 1.13c waypoint row Y offsets table at VA 0x6fb8e6d8. */
export const WAYPOINT_ROW_OFFSETS_Y: readonly number[] = [60, 96, 132, 168, 205, 241, 277, 313, 349] as const
export function findWaypointBySlug(slug: string): WaypointEntry | undefined {
for (const list of Object.values(ACT_WAYPOINTS)) {
const found = list.find(w => w.slug === slug)
if (found) return found
}
return undefined
}
export class WorldPanelsHud {
selectedActTab = 1
selectedQuestIdx = 0
stashGold = 2_500_000
cubeTransmuteCount = 0
escapeMenuOpen = false
lastTooltipRect: { bx: number; by: number; boxW: number; boxH: number } | null = null
npcMenu: {
npcName: string
x: number
y: number
dialogLines: readonly string[]
talking: boolean
} | null = null
/** Runtime quest status overrides: questId -> 'completed' | 'active' | 'locked' */
private questStatuses = new Map<string, 'completed' | 'active' | 'locked'>()
private cachedQuestsAtlasImg: HTMLImageElement | null = null
getQuestStatus(quest: QuestEntry): 'completed' | 'active' | 'locked' {
return this.questStatuses.get(quest.id) ?? quest.status
}
setQuestStatus(questId: string, status: 'completed' | 'active' | 'locked'): void {
this.questStatuses.set(questId, status)
}
private questTblLookup?: QuestTblLookup | undefined
private questStringTableChain?: { get(key: string): string | undefined } | null | undefined
setQuestTblLookup(lookup: QuestTblLookup | null | undefined): void {
this.questTblLookup = lookup ?? undefined
}
setQuestStringTableChain(chain: { get(key: string): string | undefined } | null | undefined): void {
this.questStringTableChain = chain ?? undefined
}
lookupQuestString(key: string): string | undefined {
if (this.questTblLookup) {
const val = this.questTblLookup(key)
if (val !== undefined && val.trim().length > 0) return val.trim()
}
if (this.questStringTableChain) {
const val = this.questStringTableChain.get(key)
if (val !== undefined && val.trim().length > 0) return val.trim()
}
return undefined
}
getQuestDisplayText(quest: QuestEntry): QuestDisplayText {
const status = this.getQuestStatus(quest)
const statusTag = status === 'completed' ? '[已完成]' : status === 'active' ? '[进行中]' : '[未开启]'
const title = this.lookupQuestString(quest.titleKey) ?? quest.titleZh ?? quest.titleEn
let body = ''
const rewardText = quest.rewardSummaryZh || quest.rewardSummaryEn
if (status === 'completed') {
const tblCompleted = this.lookupQuestString(quest.completedKey)
body = tblCompleted || quest.completedZh || quest.completedEn || quest.description
} else if (status === 'active') {
const tblObjective = this.lookupQuestString(quest.objectiveKey)
body = tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description
} else {
body = '此任务尚未开启。与城镇居民对话或探索荒野以开启任务。'
}
return {
title,
titleKey: quest.titleKey,
body,
status,
statusTag,
rewardText,
}
}
private getQuestsAtlasImage(): HTMLImageElement | null {
if (this.cachedQuestsAtlasImg) return this.cachedQuestsAtlasImg
if (typeof Image !== 'undefined') {
const img = new Image()
img.src = BAKED_UI_MANIFEST.images.questsAtlas ?? '/ui/quests-atlas.png'
this.cachedQuestsAtlasImg = img
return img
}
return null
}
/** Area entry banner state (`Entering: <Level Name>`). */
areaBanner: { titleZh: string; titleEn: string; untilMs: number } | null = null
showAreaBanner(titleZh: string, titleEn: string, nowMs = performance.now()): void {
this.areaBanner = { titleZh, titleEn, untilMs: nowMs + 3600 }
}
stashItems: GridPlacement[] = []
hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null
hoveredWaypointIdx: number | null = null
/**
* Check whether an item of (w, h) can be placed at (col, row) in the 6x8 stash grid.
* Throws RangeError if placed out of bounds. Returns all overlapping GridPlacements.
*/
getStashOverlaps(col: number, row: number, w: number, h: number): GridPlacement[] {
if (col < 0 || row < 0 || col + w > STASH_GRID_ORIGIN.cols || row + h > STASH_GRID_ORIGIN.rows) {
throw new RangeError('Out of 6x8 stash grid bounds')
}
return this.stashItems.filter(p => {
const ox = col < p.col + p.item.invWidth && col + w > p.col
const oy = row < p.row + p.item.invHeight && row + h > p.row
return ox && oy
})
}
canPlaceInStash(col: number, row: number, w: number, h: number): boolean {
if (col < 0 || row < 0 || col + w > STASH_GRID_ORIGIN.cols || row + h > STASH_GRID_ORIGIN.rows) {
return false
}
return this.getStashOverlaps(col, row, w, h).length === 0
}
autoPlaceInStash(item: UiInventoryItem): boolean {
for (let col = 0; col <= STASH_GRID_ORIGIN.cols - item.invWidth; col++) {
for (let row = 0; row <= STASH_GRID_ORIGIN.rows - item.invHeight; row++) {
if (this.canPlaceInStash(col, row, item.invWidth, item.invHeight)) {
this.stashItems.push({ item, col, row })
return true
}
}
}
return false
}
clickStashCell(
col: number,
row: number,
cursorItem: UiInventoryItem | null,
): { handled: boolean; newCursorItem: UiInventoryItem | null } {
if (!cursorItem) {
const hit = this.stashItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (!hit) return { handled: false, newCursorItem: null }
this.stashItems = this.stashItems.filter(p => p !== hit)
return { handled: true, newCursorItem: hit.item }
}
if (
col < 0 ||
row < 0 ||
col + cursorItem.invWidth > STASH_GRID_ORIGIN.cols ||
row + cursorItem.invHeight > STASH_GRID_ORIGIN.rows
) {
return { handled: false, newCursorItem: cursorItem }
}
const overlaps = this.getStashOverlaps(col, row, cursorItem.invWidth, cursorItem.invHeight)
if (overlaps.length === 0) {
this.stashItems.push({ item: cursorItem, col, row })
return { handled: true, newCursorItem: null }
}
if (overlaps.length === 1) {
const single = overlaps[0]!
this.stashItems = this.stashItems.filter(p => p !== single)
this.stashItems.push({ item: cursorItem, col, row })
return { handled: true, newCursorItem: single.item }
}
return { handled: false, newCursorItem: cursorItem }
}
quickTransferToStash(item: UiInventoryItem, fromInventory: InventoryPanel): boolean {
const hitIdx = fromInventory.gridItems.findIndex(p => p.item.id === item.id)
if (hitIdx === -1) return false
if (!this.autoPlaceInStash(item)) return false
fromInventory.gridItems.splice(hitIdx, 1)
return true
}
quickTransferFromStash(item: UiInventoryItem, toInventory: InventoryPanel): boolean {
const hitIdx = this.stashItems.findIndex(p => p.item.id === item.id)
if (hitIdx === -1) return false
if (!toInventory.autoPlaceInGrid(item)) return false
this.stashItems.splice(hitIdx, 1)
return true
}
depositGold(amount: number, playerInventory: { gold: number }): number {
if (amount <= 0) return 0
const available = Math.max(0, Math.min(amount, playerInventory.gold, STASH_GOLD_CAP - this.stashGold))
if (available <= 0) return 0
playerInventory.gold -= available
this.stashGold += available
return available
}
withdrawGold(amount: number, playerInventory: { gold: number }): number {
if (amount <= 0) return 0
const available = Math.max(0, Math.min(amount, this.stashGold, PLAYER_GOLD_CAP - playerInventory.gold))
if (available <= 0) return 0
this.stashGold -= available
playerInventory.gold += available
return available
}
handleMouseMove(logicalX: number, logicalY: number): void {
this.hoveredStashItem = null
this.hoveredWaypointIdx = null
const ox = 80
const oy = 60
if (logicalX >= ox + 17 && logicalX <= ox + 298) {
const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
for (let i = 0; i < wps.length; i++) {
const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36)
const wy = oy + rowY
if (logicalY >= wy && logicalY <= wy + 31) {
this.hoveredWaypointIdx = i
break
}
}
}
if (
logicalX >= STASH_GRID_ORIGIN.x &&
logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx &&
logicalY >= STASH_GRID_ORIGIN.y &&
logicalY < STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx
) {
const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx)
const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx)
const hit = this.stashItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (hit) {
this.hoveredStashItem = {
item: hit.item,
x: STASH_GRID_ORIGIN.x + (hit.col + hit.item.invWidth / 2) * STASH_GRID_ORIGIN.cellPx,
y: STASH_GRID_ORIGIN.y + hit.row * STASH_GRID_ORIGIN.cellPx,
}
}
}
}
handleLeftDockClick(
kind: Exclude<LeftDockPanelKind, 'none' | 'char'>,
logicalX: number,
logicalY: number,
callbacks: {
onClose: () => void
onWaypointTeleport: (act: number, slug: string, levelId: number) => void
inventory?: InventoryPanel
isShiftClick?: boolean
isRightClick?: boolean
},
): boolean {
if (logicalX > 400) return false
const ox = 80
const oy = 60
// Close button (`x = ox + 272, y = oy + 388` or `ox + 18, oy + 385`)
if (logicalY >= oy + 380 && logicalY <= oy + 422 && (logicalX <= ox + 58 || logicalX >= ox + 260)) {
callbacks.onClose()
return true
}
// Act Tabs (`Act I..V` at top of Quest / Waypoint panels: `oy + 8 .. oy + 40`)
if ((kind === 'quest' || kind === 'waypoint') && logicalY >= oy + 6 && logicalY <= oy + 42) {
const tabIdx = Math.min(5, Math.max(1, Math.floor((logicalX - (ox + 8)) / 61) + 1))
this.selectedActTab = tabIdx
if (kind === 'quest') {
const actQuests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]!
if (this.selectedQuestIdx >= actQuests.length) {
this.selectedQuestIdx = 0
}
}
return true
}
if (kind === 'quest') {
// 6 Quest Sockets (`2 rows × 3 cols`)
const actQuests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]!
for (let i = 0; i < actQuests.length; i++) {
const col = i % 3
const row = Math.floor(i / 3)
const sx = ox + 28 + col * 92
const sy = oy + 64 + row * 100
if (logicalX >= sx && logicalX <= sx + 80 && logicalY >= sy && logicalY <= sy + 92) {
this.selectedQuestIdx = i
return true
}
}
} else if (kind === 'waypoint') {
const list = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
for (let i = 0; i < list.length; i++) {
const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36)
const wy = oy + rowY
if (logicalX >= ox + 17 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
const wp = list[i]!
if (!wp.unlocked) return true
callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId)
callbacks.onClose()
return true
}
}
} else if (kind === 'cube') {
// Transmute button at bottom center of Horadric Cube (`ox + 132, oy + 336, 56x36`)
if (logicalX >= ox + 120 && logicalX <= ox + 200 && logicalY >= oy + 330 && logicalY <= oy + 380) {
this.cubeTransmuteCount += 1
return true
}
} else if (kind === 'stash') {
// 1. Gold deposit button
if (
logicalX >= STASH_DEPOSIT_BTN_BOUNDS.x &&
logicalX <= STASH_DEPOSIT_BTN_BOUNDS.x + STASH_DEPOSIT_BTN_BOUNDS.w &&
logicalY >= STASH_DEPOSIT_BTN_BOUNDS.y &&
logicalY <= STASH_DEPOSIT_BTN_BOUNDS.y + STASH_DEPOSIT_BTN_BOUNDS.h
) {
if (callbacks.inventory) {
const depositAmt = Math.min(50_000, callbacks.inventory.gold, STASH_GOLD_CAP - this.stashGold)
if (depositAmt > 0) {
this.depositGold(depositAmt, callbacks.inventory)
}
}
return true
}
// 2. Gold withdraw button
if (
logicalX >= STASH_WITHDRAW_BTN_BOUNDS.x &&
logicalX <= STASH_WITHDRAW_BTN_BOUNDS.x + STASH_WITHDRAW_BTN_BOUNDS.w &&
logicalY >= STASH_WITHDRAW_BTN_BOUNDS.y &&
logicalY <= STASH_WITHDRAW_BTN_BOUNDS.y + STASH_WITHDRAW_BTN_BOUNDS.h
) {
if (callbacks.inventory) {
const withdrawAmt = Math.min(50_000, this.stashGold, PLAYER_GOLD_CAP - callbacks.inventory.gold)
if (withdrawAmt > 0) {
this.withdrawGold(withdrawAmt, callbacks.inventory)
}
}
return true
}
// 3. Stash 6x8 Grid
if (
logicalX >= STASH_GRID_ORIGIN.x &&
logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx &&
logicalY >= STASH_GRID_ORIGIN.y &&
logicalY < STASH_GRID_ORIGIN.y + STASH_GRID_ORIGIN.rows * STASH_GRID_ORIGIN.cellPx
) {
const col = Math.floor((logicalX - STASH_GRID_ORIGIN.x) / STASH_GRID_ORIGIN.cellPx)
const row = Math.floor((logicalY - STASH_GRID_ORIGIN.y) / STASH_GRID_ORIGIN.cellPx)
if ((callbacks.isShiftClick || callbacks.isRightClick) && callbacks.inventory) {
const hit = this.stashItems.find(
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
)
if (hit) {
this.quickTransferFromStash(hit.item, callbacks.inventory)
return true
}
}
if (callbacks.inventory) {
const result = this.clickStashCell(col, row, callbacks.inventory.cursorItem)
if (result.handled) {
callbacks.inventory.cursorItem = result.newCursorItem
return true
}
}
return true
}
}
return true
}
drawLeftDockPanel(
ctx: CanvasRenderingContext2D,
kind: Exclude<LeftDockPanelKind, 'none' | 'char'>,
assets: {
borderLeftImg: HTMLImageElement | null
questBgImg: HTMLImageElement | null
waypointBgImg: HTMLImageElement | null
stashBgImg: HTMLImageElement | null
cubeBgImg: HTMLImageElement | null
vendorBgImg: HTMLImageElement | null
buySellBtnImg: HTMLImageElement | null
questsAtlasImg?: HTMLImageElement | null
itemsAtlasImg?: HTMLImageElement | null
waypointTabsImg?: HTMLImageElement | null
waypointIconsImg?: HTMLImageElement | null
questTabsImg?: HTMLImageElement | null
},
font: D2FontRenderer,
): void {
// 1. Left 800x600 Gothic Stone Border (`800BorderFrame.dc6` frames 0..4)
if (assets.borderLeftImg) {
ctx.drawImage(assets.borderLeftImg, 0, 0)
}
const ox = 80
const oy = 60
if (kind === 'quest') {
if (assets.questBgImg) ctx.drawImage(assets.questBgImg, ox, oy)
this.drawActTabs(ctx, ox, oy, font, assets.questTabsImg)
const quests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]!
if (this.selectedQuestIdx >= quests.length) {
this.selectedQuestIdx = 0
}
const atlas = assets.questsAtlasImg ?? this.getQuestsAtlasImage()
for (let i = 0; i < 6; i++) {
// In Act 4, sockets 3, 4, 5 (indices 3, 4, 5) are unassigned
if (i >= quests.length) {
continue
}
const q = quests[i]!
const col = i % 3
const row = Math.floor(i / 3)
const sx = ox + 28 + col * 92
const sy = oy + 64 + row * 100
const selected = this.selectedQuestIdx === i
const status = this.getQuestStatus(q)
const rectKey = status === 'completed' ? `questdone_${q.id}` : q.id
const rect = BAKED_UI_MANIFEST.questRects[rectKey]
const ix = sx + 4
const iy = sy + 1
if (atlas && rect) {
ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, ix, iy, rect.w, rect.h)
} else {
ctx.fillStyle = status === 'completed' ? 'rgba(42, 36, 22, 0.85)' : 'rgba(18, 16, 14, 0.88)'
ctx.fillRect(sx, sy, 80, 88)
font.drawText(ctx, `Q${i + 1}`, sx + 40, sy + 32, {
font: 'font16',
color: status === 'completed' ? 'gold' : 'white',
align: 'center',
})
font.drawText(ctx, q.titleZh, sx + 40, sy + 56, {
font: 'font8',
color: status === 'completed' ? 'gold' : 'tan',
align: 'center',
})
font.drawText(ctx, status === 'completed' ? '已完成' : '进行中', sx + 40, sy + 76, {
font: 'font8',
color: status === 'completed' ? 'green' : 'blue',
align: 'center',
})
}
ctx.strokeStyle = selected ? '#e8c26b' : status === 'completed' ? '#6c5838' : '#3a3224'
ctx.lineWidth = selected ? 2 : 1
ctx.strokeRect(sx + 0.5, sy + 0.5, 79, 87)
}
const activeQ = quests[this.selectedQuestIdx] ?? quests[0]!
if (activeQ) {
const qInfo = this.getQuestDisplayText(activeQ)
ctx.fillStyle = 'rgba(8, 7, 6, 0.92)'
ctx.fillRect(ox + 18, oy + 264, 284, 116)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(ox + 18.5, oy + 264.5, 283, 115)
font.drawText(ctx, `${qInfo.title} (${activeQ.titleEn}) ${qInfo.statusTag}`, ox + 160, oy + 282, {
font: 'fontexocet10',
color: qInfo.status === 'completed' ? 'gold' : 'white',
align: 'center',
})
const rewardSuffix = qInfo.status === 'completed'
? (qInfo.rewardText ? `\n[奖励: ${qInfo.rewardText}]` : '')
: (qInfo.rewardText ? `\n[目标: ${qInfo.rewardText}]` : '')
this.drawWrappedText(ctx, font, qInfo.body + rewardSuffix, ox + 26, oy + 300, 268, 14, 5)
}
} else if (kind === 'waypoint') {
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
this.drawActTabs(ctx, ox, oy, font, assets.waypointTabsImg)
const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
const activeIconIdx = Math.max(0, Math.min(4, this.selectedActTab - 1))
const iconSx = activeIconIdx * 30
for (let i = 0; i < wps.length; i++) {
const wp = wps[i]!
const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36)
const wy = oy + rowY
const isHovered = this.hoveredWaypointIdx === i
ctx.fillStyle = isHovered ? 'rgba(42, 34, 22, 0.88)' : 'rgba(18, 16, 13, 0.85)'
ctx.fillRect(ox + 17, wy, 281, 31)
ctx.strokeStyle = isHovered ? '#e8c26b' : '#5a4a32'
ctx.strokeRect(ox + 17.5, wy + 0.5, 280, 30)
// Authentic D2 waypoint icon from waygateicons.dc6 (30x30)
if (assets.waypointIconsImg && assets.waypointIconsImg.complete && assets.waypointIconsImg.naturalWidth > 0) {
ctx.save()
if (!wp.unlocked) ctx.globalAlpha = 0.35
ctx.drawImage(assets.waypointIconsImg, iconSx, 0, 30, 30, ox + 17, wy + 1, 30, 30)
ctx.restore()
} else {
// Fallback stone diamond
ctx.fillStyle = wp.unlocked ? '#4888ff' : '#4a4a4a'
ctx.fillRect(ox + 26, wy + 9, 12, 12)
}
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 80, wy + 21, {
font: 'fontexocet10',
color: wp.unlocked ? (isHovered ? 'white' : 'gold') : 'gray',
})
}
} else if (kind === 'stash') {
if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy)
font.drawText(ctx, '私人储物箱 (Private Stash)', ox + 160, oy + 26, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
font.drawText(ctx, `储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 48, {
font: 'font8',
color: 'white',
align: 'center',
})
// 6x8 grid cell borders
for (let r = 0; r < STASH_GRID_ORIGIN.rows; r++) {
for (let c = 0; c < STASH_GRID_ORIGIN.cols; c++) {
const cx = STASH_GRID_ORIGIN.x + c * STASH_GRID_ORIGIN.cellPx
const cy = STASH_GRID_ORIGIN.y + r * STASH_GRID_ORIGIN.cellPx
ctx.strokeStyle = 'rgba(64, 52, 38, 0.4)'
ctx.strokeRect(cx + 0.5, cy + 0.5, STASH_GRID_ORIGIN.cellPx - 1, STASH_GRID_ORIGIN.cellPx - 1)
}
}
// Draw items stored in stash
const atlas = assets.itemsAtlasImg ?? null
for (const placed of this.stashItems) {
const gx = STASH_GRID_ORIGIN.x + placed.col * STASH_GRID_ORIGIN.cellPx
const gy = STASH_GRID_ORIGIN.y + placed.row * STASH_GRID_ORIGIN.cellPx
const gw = placed.item.invWidth * STASH_GRID_ORIGIN.cellPx
const gh = placed.item.invHeight * STASH_GRID_ORIGIN.cellPx
ctx.fillStyle =
placed.item.quality === 'unique'
? 'rgba(58, 42, 16, 0.45)'
: placed.item.quality === 'set'
? 'rgba(16, 52, 22, 0.45)'
: 'rgba(20, 32, 54, 0.45)'
ctx.fillRect(gx + 1, gy + 1, gw - 2, gh - 2)
const rect = resolveItemSpriteRect(placed.item, BAKED_UI_MANIFEST.itemRects)
if (atlas && rect) {
const scale = Math.min(gw / rect.w, gh / rect.h, 1)
const dw = Math.round(rect.w * scale)
const dh = Math.round(rect.h * scale)
const dx = gx + Math.round((gw - dw) / 2)
const dy = gy + Math.round((gh - dh) / 2)
ctx.drawImage(atlas, rect.x, rect.y, rect.w, rect.h, dx, dy, dw, dh)
} else {
ctx.fillStyle =
placed.item.quality === 'unique' ? '#908858' : placed.item.quality === 'set' ? '#00c400' : '#4850b8'
ctx.fillRect(gx + 2, gy + 2, gw - 4, gh - 4)
}
}
// Deposit button
ctx.fillStyle = 'rgba(20, 16, 12, 0.9)'
ctx.fillRect(
STASH_DEPOSIT_BTN_BOUNDS.x,
STASH_DEPOSIT_BTN_BOUNDS.y,
STASH_DEPOSIT_BTN_BOUNDS.w,
STASH_DEPOSIT_BTN_BOUNDS.h,
)
ctx.strokeStyle = '#8a7248'
ctx.strokeRect(
STASH_DEPOSIT_BTN_BOUNDS.x + 0.5,
STASH_DEPOSIT_BTN_BOUNDS.y + 0.5,
STASH_DEPOSIT_BTN_BOUNDS.w - 1,
STASH_DEPOSIT_BTN_BOUNDS.h - 1,
)
font.drawText(
ctx,
'存入',
STASH_DEPOSIT_BTN_BOUNDS.x + STASH_DEPOSIT_BTN_BOUNDS.w / 2,
STASH_DEPOSIT_BTN_BOUNDS.y + 16,
{
font: 'font8',
color: 'gold',
align: 'center',
},
)
// Withdraw button
ctx.fillStyle = 'rgba(20, 16, 12, 0.9)'
ctx.fillRect(
STASH_WITHDRAW_BTN_BOUNDS.x,
STASH_WITHDRAW_BTN_BOUNDS.y,
STASH_WITHDRAW_BTN_BOUNDS.w,
STASH_WITHDRAW_BTN_BOUNDS.h,
)
ctx.strokeStyle = '#8a7248'
ctx.strokeRect(
STASH_WITHDRAW_BTN_BOUNDS.x + 0.5,
STASH_WITHDRAW_BTN_BOUNDS.y + 0.5,
STASH_WITHDRAW_BTN_BOUNDS.w - 1,
STASH_WITHDRAW_BTN_BOUNDS.h - 1,
)
font.drawText(
ctx,
'取出',
STASH_WITHDRAW_BTN_BOUNDS.x + STASH_WITHDRAW_BTN_BOUNDS.w / 2,
STASH_WITHDRAW_BTN_BOUNDS.y + 16,
{
font: 'font8',
color: 'gold',
align: 'center',
},
)
} else if (kind === 'cube') {
if (assets.cubeBgImg) ctx.drawImage(assets.cubeBgImg, ox, oy)
font.drawText(ctx, `赫拉迪克方块 (已合成 ${this.cubeTransmuteCount} 次)`, ox + 160, oy + 32, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
} else if (kind === 'vendor') {
if (assets.vendorBgImg) ctx.drawImage(assets.vendorBgImg, ox, oy)
font.drawText(ctx, '阿卡拉 · 魔法药剂与卷轴商店', ox + 160, oy + 26, {
font: 'fontexocet10',
color: 'gold',
align: 'center',
})
}
if (assets.buySellBtnImg) {
ctx.drawImage(assets.buySellBtnImg, 10 * 32, 0, 32, 32, ox + 272, oy + 388, 32, 32)
}
}
private drawActTabs(
ctx: CanvasRenderingContext2D,
ox: number,
oy: number,
font: D2FontRenderer,
tabsImg?: HTMLImageElement | null,
): void {
if (tabsImg && tabsImg.complete && tabsImg.naturalWidth > 0) {
for (let i = 0; i < 5; i++) {
const act = i + 1
const active = this.selectedActTab === act
const frameIdx = active ? 5 + i : i
const sx = frameIdx * 63
const tx = ox + 4 + i * 62
ctx.drawImage(tabsImg, sx, 0, 63, 31, tx, oy + 8, 63, 31)
}
return
}
const numerals = ['I', 'II', 'III', 'IV', 'V'] as const
for (let i = 0; i < 5; i++) {
const act = i + 1
const active = this.selectedActTab === act
const tx = ox + 8 + i * 61
ctx.fillStyle = active ? '#32281c' : '#181410'
ctx.fillRect(tx, oy + 8, 58, 28)
ctx.strokeStyle = active ? '#e8c26b' : '#584a34'
ctx.strokeRect(tx + 0.5, oy + 8.5, 57, 27)
font.drawText(ctx, `Act ${numerals[i]!}`, tx + 29, oy + 27, {
font: 'fontexocet10',
color: active ? 'gold' : 'tan',
align: 'center',
})
}
}
private drawWrappedText(
ctx: CanvasRenderingContext2D,
font: D2FontRenderer,
text: string,
x: number,
y: number,
maxW: number,
lineH: number,
maxLines = 10,
): void {
const paragraphs = text.split(/\r?\n/)
let curY = y
let linesDrawn = 0
for (const p of paragraphs) {
if (linesDrawn >= maxLines) break
let line = ''
for (let i = 0; i < p.length; i++) {
const ch = p[i]!
if (font.measureText(line + ch, 'font8') > maxW) {
if (linesDrawn + 1 >= maxLines) {
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
font.drawText(ctx, truncated, x, curY, { font: 'font8', color: 'white' })
return
}
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
linesDrawn++
line = ch
curY += lineH
} else {
line += ch
}
}
if (line && linesDrawn < maxLines) {
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
linesDrawn++
curY += lineH
}
}
}
/**
* Draw Top-Center Monster Target Health Bar & Affix Plaque (`text.cpp` / `panel.cpp`).
*/
drawMonsterTargetBar(
ctx: CanvasRenderingContext2D,
monster: {
name: string
hp: number
maxHp: number
rank?: 'normal' | 'champion' | 'unique' | 'superunique' | 'boss'
affixes?: readonly string[]
subType?: string
} | null,
font: D2FontRenderer,
): void {
if (!monster || monster.hp <= 0) return
const barW = 220
const barH = 22
const x = 400 - barW / 2
const y = 42
const ratio = Math.max(0, Math.min(1, monster.hp / Math.max(1, monster.maxHp)))
ctx.fillStyle = 'rgba(28, 8, 8, 0.88)'
ctx.fillRect(x, y, barW, barH)
ctx.fillStyle = 'rgba(156, 20, 20, 0.92)'
ctx.fillRect(x + 1, y + 1, Math.round((barW - 2) * ratio), barH - 2)
ctx.strokeStyle = '#6c5838'
ctx.strokeRect(x + 0.5, y + 0.5, barW - 1, barH - 1)
const titleColor: D2ColorCode =
monster.rank === 'unique' || monster.rank === 'superunique' || monster.rank === 'boss'
? 'gold'
: monster.rank === 'champion'
? 'blue'
: 'white'
font.drawText(ctx, monster.name, 400, y + 16, {
font: 'fontexocet10',
color: titleColor,
align: 'center',
})
if (monster.affixes && monster.affixes.length > 0) {
font.drawText(ctx, monster.affixes.join(' · '), 400, y + 36, {
font: 'font8',
color: 'tan',
align: 'center',
})
} else if (monster.subType) {
font.drawText(ctx, monster.subType, 400, y + 36, {
font: 'font8',
color: 'white',
align: 'center',
})
}
}
/**
* Draw Canonical Diablo II Item Hover Tooltip (`showitems.cpp`).
*/
drawItemTooltip(
ctx: CanvasRenderingContext2D,
hover: { item: UiInventoryItem; x: number; y: number } | null,
font: D2FontRenderer,
): void {
if (!hover) return
const { item } = hover
const lines: { text: string; color: D2ColorCode; font: 'fontexocet10' | 'font8' }[] = []
const titleColor: D2ColorCode =
item.quality === 'unique' || item.quality === 'rune'
? 'gold'
: item.quality === 'set'
? 'green'
: item.quality === 'rare'
? 'yellow'
: item.quality === 'magic'
? 'blue'
: item.quality === 'craft'
? 'orange'
: 'white'
// Header: Title, Runeword Runes line, Base item name
lines.push({ text: item.nameZh, color: titleColor, font: 'fontexocet10' })
if (item.runewordRunes) {
lines.push({ text: item.runewordRunes, color: 'gold', font: 'font8' })
}
if (item.baseNameZh) {
lines.push({ text: item.baseNameZh, color: 'white', font: 'font8' })
}
// Base Stats Block
if (item.defense !== undefined) {
lines.push({ text: '防御: ' + item.defense, color: 'white', font: 'font8' })
}
if (item.damage !== undefined) {
const dmgText = item.damage.includes('伤害') ? item.damage : ('单手伤害: ' + item.damage)
lines.push({ text: dmgText, color: 'white', font: 'font8' })
}
if (item.durability !== undefined) {
lines.push({
text: '耐久度: ' + item.durability.current + ' 之 ' + item.durability.max,
color: 'white',
font: 'font8',
})
}
if (item.speedText) {
lines.push({ text: item.speedText, color: 'white', font: 'font8' })
}
if (item.reqStr !== undefined) {
lines.push({ text: '需要力量: ' + item.reqStr, color: 'white', font: 'font8' })
}
if (item.reqLevel !== undefined) {
lines.push({ text: '需要等级: ' + item.reqLevel, color: 'white', font: 'font8' })
}
// Magic Affixes
for (const st of item.stats) {
lines.push({ text: st.text, color: st.color ?? 'blue', font: 'font8' })
}
// Sockets Block
if (
item.sockets !== undefined &&
item.sockets > 0 &&
!item.stats.some(s => s.text.includes('凹槽') || s.text.includes('Socketed'))
) {
lines.push({ text: '凹槽 (' + item.sockets + ')', color: 'blue', font: 'font8' })
}
// Set Pieces & Set Bonuses Block
if (item.setPieces) {
for (const piece of item.setPieces) {
lines.push({ text: piece, color: 'gray', font: 'font8' })
}
}
if (item.setBonuses) {
for (const bonus of item.setBonuses) {
lines.push({ text: bonus.text, color: bonus.color ?? 'green', font: 'font8' })
}
}
// Viewport Dimensions & Dynamic Vertical Flip / Clamping
const lineH = 17, padX = 16, padY = 12
const maxW = Math.max(...lines.map(l => font.measureText(l.text, l.font)))
const boxW = Math.max(190, maxW + padX * 2)
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 topMargin = (hover.y < 280 || maxTop < 44) ? 8 : 44
const by = Math.max(topMargin, Math.min(600 - boxH - 8, rawBy))
this.lastTooltipRect = { bx, by, boxW, boxH }
// Rendering
ctx.fillStyle = 'rgba(4, 4, 4, 0.94)'
ctx.fillRect(bx, by, boxW, boxH)
ctx.strokeStyle = '#8c7244'
ctx.strokeRect(bx + 0.5, by + 0.5, boxW - 1, boxH - 1)
lines.forEach((l, idx) => {
font.drawText(ctx, l.text, bx + Math.round(boxW / 2), by + padY + 12 + idx * lineH, {
font: l.font,
color: l.color,
align: 'center',
})
})
}
/**
* Draw Area Entry Gothic Banner (`Entering: <Level Name>`).
*/
drawAreaBanner(ctx: CanvasRenderingContext2D, nowMs: number, font: D2FontRenderer): void {
if (!this.areaBanner || nowMs >= this.areaBanner.untilMs) return
const remain = this.areaBanner.untilMs - nowMs
const alpha = remain < 800 ? remain / 800 : 1
ctx.save()
ctx.globalAlpha = Math.max(0, Math.min(1, alpha))
font.drawText(ctx, `进入:${this.areaBanner.titleZh}`, 400, 142, {
font: 'font30',
color: 'gold',
align: 'center',
})
font.drawText(ctx, `Entering ${this.areaBanner.titleEn}`, 400, 166, {
font: 'fontexocet10',
color: 'tan',
align: 'center',
})
ctx.restore()
}
}