635 lines
31 KiB
TypeScript
635 lines
31 KiB
TypeScript
/**
|
||
* 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 { UiInventoryItem } from './inventory.ts'
|
||
import { BAKED_UI_MANIFEST } from './baked-ui-meta.ts'
|
||
|
||
export type LeftDockPanelKind = 'none' | 'char' | 'quest' | 'waypoint' | 'stash' | 'cube' | 'vendor'
|
||
|
||
export interface QuestEntry {
|
||
readonly id: string
|
||
readonly act: number
|
||
readonly index: number
|
||
readonly titleEn: string
|
||
readonly titleZh: string
|
||
readonly status: 'completed' | 'active' | 'locked'
|
||
readonly description: 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
|
||
}
|
||
|
||
export const ACT_QUESTS: Readonly<Record<number, readonly QuestEntry[]>> = {
|
||
1: [
|
||
{ id: 'a1q1', act: 1, index: 0, titleEn: 'Den of Evil', titleZh: '邪恶洞窟', status: 'completed', description: '阿卡拉委托你肃清鲜血荒地地下的邪恶洞窟。任务已完成,奖励 +1 技能点。' },
|
||
{ id: 'a1q2', act: 1, index: 1, titleEn: "Sisters' Burial Grounds", titleZh: '修女埋骨之地', status: 'completed', description: '在冰冷之原的埋骨之地击败堕落的血鸟 (Blood Raven),卡夏为你配备了罗格佣兵。' },
|
||
{ id: 'a1q3', act: 1, index: 2, titleEn: 'The Search for Cain', titleZh: '凯恩的搜寻', status: 'completed', description: '从黑暗森林取得艾尼弗斯卷轴,激活乱石旷野的五根石柱,前往崔斯特瑞姆救出迪卡·凯恩。' },
|
||
{ id: 'a1q4', act: 1, index: 3, titleEn: 'The Forgotten Tower', titleZh: '遗忘之塔', status: 'active', description: '探索黑色荒地中的五层遗忘之塔,击败女伯爵 (The Countess) 夺回财宝与符文。' },
|
||
{ id: 'a1q5', act: 1, index: 4, titleEn: 'Tools of the Trade', titleZh: '交易的工具', status: 'active', description: '深入修道院兵营从铁匠手中夺回赫拉迪姆之锤,并交还给恰西进行灌魔。' },
|
||
{ id: 'a1q6', act: 1, index: 5, titleEn: 'Sisters to the Slaughter', titleZh: '屠杀修女', status: 'active', description: '进入地下墓穴第四层,消灭痛苦之王安达利尔 (Andariel),打通前往鲁高因的道路。' },
|
||
],
|
||
2: [
|
||
{ id: 'a2q1', act: 2, index: 0, titleEn: "Radament's Lair", titleZh: '罗达门特的巢穴', status: 'active', description: '在鲁·高因下水道第三层消灭罗达门特,夺回复仇之书(+1 技能点)与赫拉迪姆卷轴。' },
|
||
{ id: 'a2q2', act: 2, index: 1, titleEn: 'The Horadric Staff', titleZh: '赫拉迪克之杖', status: 'active', description: '在死亡之殿取得赫拉迪克方块,在遥远绿洲取得国王之杖,在利爪蝮蛇神殿取得蝮蛇护符,将其在方块中合成。' },
|
||
{ id: 'a2q3', act: 2, index: 2, titleEn: 'Tainted Sun', titleZh: '堕落的太阳', status: 'active', description: '利爪蝮蛇的邪恶仪式遮蔽了太阳,深入利爪蝮蛇神殿摧毁太阳祭坛,恢复鲁·高因的光明。' },
|
||
{ id: 'a2q4', act: 2, index: 3, titleEn: 'Arcane Sanctuary', titleZh: '神秘避难所', status: 'active', description: '穿过杰海因王宫底层的传送门,进入大法师赫拉森的神秘避难所,搜寻真正的塔·拉夏古墓印记。' },
|
||
{ id: 'a2q5', act: 2, index: 4, titleEn: 'The Summoner', titleZh: '召唤者', status: 'active', description: '在神秘避难所尽头击败堕落的召唤者,开启通往术士峡谷的传送门。' },
|
||
{ id: 'a2q6', act: 2, index: 5, titleEn: 'The Seven Tombs', titleZh: '七座古墓', status: 'active', description: '在术士峡谷搜寻带有真正印记的塔·拉夏古墓,用赫拉迪克之杖开启密室,击败督瑞尔 (Duriel)。' },
|
||
],
|
||
3: [
|
||
{ id: 'a3q1', act: 3, index: 0, titleEn: 'The Golden Bird', titleZh: '黄金鸟', status: 'active', description: '在库拉斯特丛林击败独特怪物取得玉制小人,交还给马席夫换取黄金鸟雕像,阿尔卡特为你配制永久增加 20 点生命上限的药剂。' },
|
||
{ id: 'a3q2', act: 3, index: 1, titleEn: 'Blade of the Old Religion', titleZh: '古代宗教之刃', status: 'active', description: '在剥皮丛林吉得宾祭坛夺回附魔匕首吉得宾,将其带给奥玛斯以加固海港魔法屏障。' },
|
||
{ id: 'a3q3', act: 3, index: 2, titleEn: "Khalim's Will", titleZh: '克林姆的意志', status: 'active', description: '搜集克林姆的眼球(蜘蛛洞穴)、脑(剥皮地窖)、心脏(库拉斯特下水道)与连枷,合成克林姆的意志击碎强制之球。' },
|
||
{ id: 'a3q4', act: 3, index: 3, titleEn: "Lam Esen's Tome", titleZh: '蓝·森的古书', status: 'active', description: '在库拉斯特商场的废弃神庙中搜寻蓝·森的黑书并交给艾席拉,获得 +5 属性点奖励。' },
|
||
{ id: 'a3q5', act: 3, index: 4, titleEn: 'The Blackened Temple', titleZh: '黑暗神殿', status: 'active', description: '在崔凡克消灭最高评议会的堕落狂热信徒,用克林姆的意志击碎强制之球,打开通往憎恨囚牢的大门。' },
|
||
{ id: 'a3q6', act: 3, index: 5, titleEn: 'The Guardian', titleZh: '守卫者', status: 'active', description: '深入憎恨囚牢第三层,击败憎恨之王墨菲斯托 (Mephisto),夺取他的灵魂石并穿越地狱之门。' },
|
||
],
|
||
4: [
|
||
{ id: 'a4q1', act: 4, index: 0, titleEn: 'The Fallen Angel', titleZh: '堕落天使', status: 'active', description: '泰瑞尔委托你进入绝望平原,击败被恶魔囚禁扭曲的堕落大天使衣卒尔 (Izual),释放其灵魂并获得 +2 技能点。' },
|
||
{ id: 'a4q2', act: 4, index: 1, titleEn: "Hell's Forge", titleZh: '地狱熔炉', status: 'active', description: '护送墨菲斯托的灵魂石前往火焰之河的地狱熔炉,击败铁匠海法斯特夺取地狱熔炉之锤,粉碎邪恶灵魂石。' },
|
||
{ id: 'a4q3', act: 4, index: 2, titleEn: "Terror's End", titleZh: '恐怖的终结', status: 'active', description: '深入混沌避难所激活五座封印,击败三位庇护所守卫,最终消灭恐惧之王迪亚波罗 (Diablo)。' },
|
||
],
|
||
5: [
|
||
{ id: 'a5q1', act: 5, index: 0, titleEn: 'Siege on Harrogath', titleZh: '围攻哈洛加斯', status: 'active', description: '在血腥丘陵击败督军山克 (Shenk the Overseer),解除恶魔投石机对哈洛加斯的围攻,拉苏克将为你的一件装备打孔。' },
|
||
{ id: 'a5q2', act: 5, index: 1, titleEn: 'Rescue on Mount Arreat', titleZh: '亚瑞特山救援', status: 'active', description: '在冰冻高地寻找并释放被恶魔囚禁的 15 名野蛮人斥候,夸尔凯克将奖励你符文组合与招募野蛮人佣兵的特权。' },
|
||
{ id: 'a5q3', act: 5, index: 2, titleEn: 'Prison of Ice', titleZh: '冰之囚', status: 'active', description: '在冰河深处寻找被尼拉塞克封冻的安亚 (Anya),使用马拉配制的解冻药水解救她,安亚将赠予全职业抗性卷轴(+10% 全抗)。' },
|
||
{ id: 'a5q4', act: 5, index: 3, titleEn: 'Betrayal of Harrogath', titleZh: '哈洛加斯的背叛', status: 'active', description: '穿过安亚开启的红门进入瓦特之厅,击败背叛者尼拉塞克 (Nihlathak),阻止他将圣山遗物交给巴尔。' },
|
||
{ id: 'a5q5', act: 5, index: 4, titleEn: 'Rite of Passage', titleZh: '通过祭典', status: 'active', description: '登上亚瑞特山顶触碰古代人祭坛,在不返回城镇的情况下同时击败三位远古野蛮人守卫(科力克、马道克、塔力克),获得海量经验晋级。' },
|
||
{ id: 'a5q6', act: 5, index: 5, titleEn: 'Eve of Destruction', titleZh: '毁灭前夕', status: 'active', description: '深入世界之石要塞与毁灭王座,清除巴尔的五波恶魔仆从,追击毁灭之王巴尔进入世界之石大厅并将其消灭。' },
|
||
],
|
||
}
|
||
|
||
/** 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 },
|
||
],
|
||
}
|
||
|
||
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 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 }
|
||
}
|
||
|
||
handleLeftDockClick(
|
||
kind: Exclude<LeftDockPanelKind, 'none' | 'char'>,
|
||
logicalX: number,
|
||
logicalY: number,
|
||
callbacks: {
|
||
onClose: () => void
|
||
onWaypointTeleport: (act: number, slug: string, levelId: number) => void
|
||
},
|
||
): 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 wy = oy + 62 + i * 36
|
||
if (logicalX >= ox + 22 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
|
||
const wp = list[i]!
|
||
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
|
||
}
|
||
}
|
||
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
|
||
},
|
||
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)
|
||
|
||
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 status = this.getQuestStatus(activeQ)
|
||
ctx.fillStyle = 'rgba(8, 7, 6, 0.92)'
|
||
ctx.fillRect(ox + 18, oy + 292, 284, 86)
|
||
ctx.strokeStyle = '#6c5838'
|
||
ctx.strokeRect(ox + 18.5, oy + 292.5, 283, 85)
|
||
|
||
const statusTag = status === 'completed' ? '[已完成]' : status === 'active' ? '[进行中]' : '[未开启]'
|
||
font.drawText(ctx, `${activeQ.titleZh} (${activeQ.titleEn}) ${statusTag}`, ox + 160, oy + 314, {
|
||
font: 'fontexocet10',
|
||
color: status === 'completed' ? 'gold' : 'white',
|
||
align: 'center',
|
||
})
|
||
this.drawWrappedText(ctx, font, activeQ.description, ox + 28, oy + 336, 264, 18)
|
||
}
|
||
} else if (kind === 'waypoint') {
|
||
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
|
||
this.drawActTabs(ctx, ox, oy, font)
|
||
const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
|
||
for (let i = 0; i < wps.length; i++) {
|
||
const wp = wps[i]!
|
||
const wy = oy + 62 + i * 36
|
||
ctx.fillStyle = 'rgba(18, 16, 13, 0.85)'
|
||
ctx.fillRect(ox + 22, wy, 276, 31)
|
||
ctx.strokeStyle = '#5a4a32'
|
||
ctx.strokeRect(ox + 22.5, wy + 0.5, 275, 30)
|
||
|
||
// Blue active waypoint diamond icon
|
||
ctx.fillStyle = '#4888ff'
|
||
ctx.fillRect(ox + 32, wy + 9, 12, 12)
|
||
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 56, wy + 21, {
|
||
font: 'fontexocet10',
|
||
color: 'gold',
|
||
})
|
||
}
|
||
} else if (kind === 'stash') {
|
||
if (assets.stashBgImg) ctx.drawImage(assets.stashBgImg, ox, oy)
|
||
font.drawText(ctx, `私人储物箱金币: ${this.stashGold.toLocaleString()}`, ox + 160, oy + 32, {
|
||
font: 'fontexocet10',
|
||
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): void {
|
||
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,
|
||
): void {
|
||
let line = ''
|
||
let curY = y
|
||
for (const ch of text) {
|
||
if (font.measureText(line + ch, 'font8') > maxW) {
|
||
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
|
||
line = ch
|
||
curY += lineH
|
||
} else {
|
||
line += ch
|
||
}
|
||
}
|
||
if (line) {
|
||
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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()
|
||
}
|
||
}
|