Fix NPC menus, quest log progression, left panels inventory layouts, and item tooltips (Closes #655, #656, #657, #658, #659, #660, #661, #662, #663)

This commit is contained in:
troytt 2026-10-02 19:37:42 +00:00
parent 7136c3aefe
commit c6c2eb2007
3 changed files with 515 additions and 325 deletions

View File

@ -243,6 +243,7 @@ export class HudModel implements HudCommandSink {
/** NPC/stash state seen by the last sync; panels react to transitions only. */
private prevNpc: NpcInteractionState = NPC_STATE_CLOSED
private prevTradeActive = false
private prevAct = -1
/** `lastNpcTransaction.seq` already reacted to (each S2C 0x2A is shown once). */
private handledNpcTransactionSeq: number
private readonly lastNpcActionAt = new Map<string, number>()
@ -411,6 +412,28 @@ export class HudModel implements HudCommandSink {
return false
}
/**
* Count unidentified items carried by player (inventory + Horadric cube).
*/
getUnidentifiedItemCount(): number {
let count = 0
const checkItem = (item: ItemData | undefined): boolean => {
if (!item) return false
if (item.identified === false) return true
if (item.flags && typeof (item.flags as any).identified === 'boolean') {
return (item.flags as any).identified === false
}
return false
}
for (const item of this.world.containers.inventory.values()) {
if (checkItem(item)) count++
}
for (const item of this.world.containers.cube.values()) {
if (checkItem(item)) count++
}
return count
}
/**
* Build a pure snapshot of the HUD state derived from `ClientWorld`.
*/
@ -1016,7 +1039,10 @@ export class HudModel implements HudCommandSink {
// 6. Area, Waypoints & Quests
if (this.world.areaId > 0) {
hud.worldPanels.setCurrentLevel(this.world.areaId)
if (this.prevAct !== this.world.act) {
hud.worldPanels.selectedActTab = Math.max(1, Math.min(5, this.world.act + 1))
this.prevAct = this.world.act
}
}
hud.worldPanels.setUnlockedWaypoints([...this.world.waypoints])
hud.worldPanels.applyServerQuestFlags(this.world.quests)
@ -1050,10 +1076,19 @@ export class HudModel implements HudCommandSink {
if (desc) {
const hasDeadMercenary = Boolean(this.world.merc?.isDead && (this.world.merc?.reviveCost ?? 0) > 0)
const deadMercenaryReviveCost = this.world.merc?.reviveCost ?? 0
const unidentifiedItemCount = this.getUnidentifiedItemCount()
const deadMercenaryName =
(this.world.merc as any)?.name ??
((this.world.merc as any)?.nameId ? `Merc #${(this.world.merc as any).nameId}` : '')
const playerLevel = Math.max(1, this.world.self.level ?? (this.world.self as any).stats?.get?.(12) ?? 1)
;(wp as any).openNpcMenu(desc, wp.npcMenu?.x ?? 400, wp.npcMenu?.y ?? 220, npc.npcId, {
hasUnidentifiedItems: true,
unidentifiedItemCount,
hasUnidentifiedItems: unidentifiedItemCount > 0,
hasDeadMercenary,
deadMercenaryReviveCost,
deadMercenaryName,
playerLevel,
questFlags: this.world.quests,
})
}
}

View File

@ -373,6 +373,7 @@ export type NpcMenuActionId =
| 'imbue'
| 'add_sockets'
| 'personalize'
| 'respec'
| 'cancel'
export interface NpcMenuOption {
@ -385,10 +386,14 @@ export interface NpcMenuOption {
export interface NpcMenuContext {
readonly hasUnidentifiedItems?: boolean | undefined
readonly unidentifiedItemCount?: number | undefined
readonly hasDeadMercenary?: boolean | undefined
readonly deadMercenaryReviveCost?: number | undefined
readonly deadMercenaryName?: string | undefined
readonly includeQuestRewards?: boolean | undefined
readonly legacyTradeId?: boolean | undefined
readonly playerLevel?: number | undefined
readonly questFlags?: ArrayLike<number> | undefined
}
export function buildNpcMenuOptions(
@ -426,64 +431,164 @@ export function buildNpcMenuOptions(
labelEn: 'Gamble',
})
}
if (descriptor.canIdentify && context?.hasUnidentifiedItems !== false) {
// Identify Items (D2Client.dll 0x6faf8d22):
// Omit option if player has 0 unidentified items. Charge 100 gold/item if A1Q4 is not yet completed.
if (descriptor.canIdentify) {
const count = context?.unidentifiedItemCount ?? (context?.hasUnidentifiedItems === false ? 0 : 1)
const hasItems = context?.hasUnidentifiedItems !== undefined ? context.hasUnidentifiedItems : count > 0
if (hasItems && count > 0) {
const qFlags = context?.questFlags
const isPaid = qFlags !== undefined && ((qFlags[4] ?? 0) & 0x0003) === 0
if (isPaid) {
const cost = count * 100
opts.push({
id: 'identify',
strIdx: 4021,
label: `辨視物品:${cost}`,
labelZh: `辨視物品:${cost}`,
labelEn: `Identify Items: ${cost}`,
})
} else {
opts.push({
id: 'identify',
strIdx: 4020,
label: '辨識物品',
labelZh: '辨識物品',
label: '辨視物品',
labelZh: '辨視物品',
labelEn: 'Identify Items',
})
}
}
}
// Hire:
if (descriptor.canHire) {
let allowHire = true
if (context?.questFlags && context.questFlags.length > 0) {
const q = context.questFlags
if (descriptor.npcTxtKey === 'kashya' || descriptor.hcIdx === 150) {
allowHire = ((q[2] ?? 0) & 1) !== 0 || (context.playerLevel ?? 1) >= 9
} else if (descriptor.npcTxtKey === 'qual-kehk' || descriptor.hcIdx === 515 || descriptor.hcIdx === 516) {
allowHire = ((q[36] ?? 0) & 1) !== 0
}
}
if (allowHire) {
opts.push({
id: 'hire',
label: '僱傭',
labelZh: '僱傭',
strIdx: 3397,
label: '雇用',
labelZh: '雇用',
labelEn: 'Hire',
})
}
}
// Resurrect:
if (
(descriptor.canHire || descriptor.npcTxtKey === 'tyrael1' || descriptor.npcTxtKey === 'qual-kehk') &&
(descriptor.canHire ||
descriptor.npcTxtKey === 'tyrael1' ||
descriptor.npcTxtKey === 'qual-kehk' ||
descriptor.hcIdx === 367 ||
descriptor.hcIdx === 515 ||
descriptor.hcIdx === 516) &&
context?.hasDeadMercenary
) {
const cost = context?.deadMercenaryReviveCost ?? 0
const name = context?.deadMercenaryName
const labelZh = name ? `復活 ${name}:${cost}` : `復活傭兵 (${cost})`
const labelEn = name ? `Resurrect ${name}: ${cost}` : `Resurrect (${cost})`
opts.push({
id: 'resurrect',
label: `復活傭兵 (${cost})`,
labelZh: `復活傭兵 (${cost})`,
labelEn: `Resurrect (${cost})`,
strIdx: 22696,
label: labelZh,
labelZh,
labelEn,
})
}
// Travel actions:
for (const travelAct of descriptor.travelActions ?? []) {
let allowTravel = true
if (context?.questFlags && context.questFlags.length > 0) {
const q = context.questFlags
if (travelAct === 'travel_east') {
if (descriptor.hcIdx === 155 || descriptor.npcTxtKey === 'warriv1') {
allowTravel = ((q[6] ?? 0) & 1) !== 0
} else if (descriptor.hcIdx === 210 || descriptor.npcTxtKey === 'meshif1') {
allowTravel = ((q[14] ?? 0) & 1) !== 0
}
} else if (travelAct === 'travel_harrogath') {
allowTravel = ((q[26] ?? 0) & 1) !== 0
}
}
if (allowTravel) {
if (travelAct === 'travel_east') {
if (descriptor.hcIdx === 210 || descriptor.npcTxtKey === 'meshif1') {
opts.push({
id: 'travel_east',
label: '向東旅行',
labelZh: '向東旅行',
labelEn: 'Travel East',
strIdx: 3384,
label: '航向東邊',
labelZh: '航向東邊',
labelEn: 'Sail East',
})
} else {
opts.push({
id: 'travel_east',
strIdx: 3382,
label: '向東走',
labelZh: '向東走',
labelEn: 'Go East',
})
}
} else if (travelAct === 'travel_west') {
if (descriptor.hcIdx === 264 || descriptor.npcTxtKey === 'meshif2' || descriptor.hcIdx === 258) {
opts.push({
id: 'travel_west',
label: '向西旅行',
labelZh: '向西旅行',
labelEn: 'Travel West',
strIdx: 3385,
label: '航向西邊',
labelZh: '航向西邊',
labelEn: 'Sail West',
})
} else {
opts.push({
id: 'travel_west',
strIdx: 3383,
label: '向西走',
labelZh: '向西走',
labelEn: 'Go West',
})
}
} else if (travelAct === 'travel_harrogath') {
opts.push({
id: 'travel_harrogath',
strIdx: 22738,
label: '前往哈洛加斯',
labelZh: '前往哈洛加斯',
labelEn: 'Travel to Harrogath',
})
}
}
}
// Quest rewards (imbue, add_sockets, personalize):
if (context?.includeQuestRewards !== false) {
for (const rewardAct of descriptor.questRewardActions ?? []) {
let allowReward = true
if (context?.questFlags && context.questFlags.length > 0) {
const q = context.questFlags
if (rewardAct === 'imbue') {
allowReward = ((q[5] ?? 0) & 2) !== 0 && ((q[5] ?? 0) & 1) === 0
} else if (rewardAct === 'add_sockets') {
allowReward = ((q[35] ?? 0) & 2) !== 0 && ((q[35] ?? 0) & 1) === 0
} else if (rewardAct === 'personalize') {
allowReward = ((q[38] ?? 0) & 2) !== 0 && ((q[38] ?? 0) & 1) === 0
}
}
if (allowReward) {
if (rewardAct === 'imbue') {
opts.push({
id: 'imbue',
strIdx: 4017,
label: '注入',
labelZh: '注入',
labelEn: 'Imbue',
@ -491,13 +596,15 @@ export function buildNpcMenuOptions(
} else if (rewardAct === 'add_sockets') {
opts.push({
id: 'add_sockets',
label: '打孔',
labelZh: '打孔',
strIdx: 22748,
label: '增加凹槽',
labelZh: '增加凹槽',
labelEn: 'Add Sockets',
})
} else if (rewardAct === 'personalize') {
opts.push({
id: 'personalize',
strIdx: 22749,
label: '個人化',
labelZh: '個人化',
labelEn: 'Personalize',
@ -505,6 +612,26 @@ export function buildNpcMenuOptions(
}
}
}
}
// Respec: Akara (tbl #11168)
if (descriptor.npcTxtKey === 'akara' || descriptor.hcIdx === 148) {
let allowRespec = true
if (context?.questFlags && context.questFlags.length > 0) {
const q = context.questFlags
allowRespec = (((q[1] ?? 0) & 1) !== 0 || ((q[41] ?? 0) & 2) !== 0) && ((q[41] ?? 0) & 1) === 0
}
if (allowRespec) {
opts.push({
id: 'respec',
strIdx: 11168,
label: '重置屬性/技能點數',
labelZh: '重置屬性/技能點數',
labelEn: 'Reset Stat/Skill Points',
})
}
}
opts.push({
id: 'cancel',
strIdx: 4142,
@ -1265,12 +1392,16 @@ export class WorldPanelsHud {
}
const effectiveContext: NpcMenuContext = {
hasUnidentifiedItems: context?.hasUnidentifiedItems ?? true,
hasUnidentifiedItems: context?.hasUnidentifiedItems ?? (context?.unidentifiedItemCount !== undefined ? context.unidentifiedItemCount > 0 : true),
unidentifiedItemCount: context?.unidentifiedItemCount,
hasDeadMercenary: context?.hasDeadMercenary ?? this.hasDeadMercenary ?? (this.merc ? !this.merc.alive : false),
deadMercenaryReviveCost:
context?.deadMercenaryReviveCost ?? this.deadMercenaryReviveCost ?? this.merc?.reviveCost ?? 0,
deadMercenaryName: context?.deadMercenaryName,
includeQuestRewards: context?.includeQuestRewards,
legacyTradeId: context?.legacyTradeId,
playerLevel: context?.playerLevel,
questFlags: context?.questFlags ?? (this.serverQuestFlags.length > 0 ? this.serverQuestFlags : undefined),
}
const options = this.buildNpcMenuOptions(descriptor, effectiveContext)
const resolvedUnitId = npcId ?? 0
@ -1411,6 +1542,12 @@ export class WorldPanelsHud {
return true
}
if (opt.id === 'respec') {
this._npcMenu = null
this.onNpcEntityAction?.(0x0c, npcUnitId, 0)
return true
}
if (opt.id === 'trade' || opt.id === 'trade_repair' || opt.id === 'gamble') {
this._npcMenu = null
this._activePanel = 'vendor'
@ -1542,11 +1679,14 @@ export class WorldPanelsHud {
this.questStatuses.set(questId, status)
}
private serverQuestFlags: number[] = []
/**
* Replace every quest status from the server quest-flag words (`ClientWorld.quests`):
* bit 0 set = completed, any other bit set = active (started), 0 = locked (not started).
*/
applyServerQuestFlags(flags: ArrayLike<number>): void {
this.serverQuestFlags = Array.from(flags)
this.questStatuses.clear()
for (const quest of ALL_QUESTS) {
const word = flags[questFlagWordIndex(quest)] ?? 0
@ -1577,6 +1717,8 @@ export class WorldPanelsHud {
const val = this.questStringTableChain.get(key)
if (val !== undefined && val.trim().length > 0) return val.trim()
}
const fromTbl = lookupTbl(key, this.lang)
if (fromTbl !== undefined && fromTbl.trim().length > 0) return fromTbl.trim()
return undefined
}
@ -1609,10 +1751,27 @@ export class WorldPanelsHud {
? quest.completedEn || tblCompleted || quest.completedZh || quest.description
: tblCompleted || quest.completedZh || quest.completedEn || quest.description
} else if (status === 'active') {
let stageText: string | undefined
if (this.serverQuestFlags.length > 0) {
const word = this.serverQuestFlags[questFlagWordIndex(quest)] ?? 0
if (quest.stages && quest.stages.length > 0) {
for (let i = quest.stages.length - 1; i >= 0; i--) {
const st = quest.stages[i]!
if ((word & (1 << st.stage)) !== 0) {
const tblStage = this.lookupQuestString(st.key)
stageText = isEn ? (tblStage || st.textEn) : (tblStage || st.textZh || st.textEn)
break
}
}
}
}
if (!stageText) {
const tblObjective = this.lookupQuestString(quest.objectiveKey)
body = isEn
stageText = isEn
? quest.objectiveEn || tblObjective || quest.objectiveZh || quest.description
: tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description
}
body = stageText
} else {
body = isEn
? 'This quest has not begun yet. Speak to townsfolk or explore the wilderness to begin.'
@ -2803,20 +2962,12 @@ export class WorldPanelsHud {
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} ${qInfo.statusTag}`, ox + 160, oy + 282, {
font.drawText(ctx, qInfo.title, ox + 160, oy + 282, {
font: 'fontexocet10',
color: qInfo.status === 'completed' ? 'gold' : 'white',
align: 'center',
})
const rewardSuffix = qInfo.status === 'completed'
? (qInfo.rewardText ? (isEn ? `\n[Reward: ${qInfo.rewardText}]` : `\n[奖励: ${qInfo.rewardText}]`) : '')
: (qInfo.rewardText ? (isEn ? `\n[Objective: ${qInfo.rewardText}]` : `\n[目标: ${qInfo.rewardText}]`) : '')
this.drawWrappedText(ctx, font, qInfo.body + rewardSuffix, ox + 26, oy + 300, 268, 14, 6, 'fontformal12')
this.drawWrappedText(ctx, font, qInfo.body, ox + 26, oy + 300, 268, 14, 6, 'fontformal12')
}
} else if (kind === 'waypoint') {
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
@ -2921,19 +3072,6 @@ export class WorldPanelsHud {
}
} else if (kind === 'cube') {
if (assets.cubeBgImg) ctx.drawImage(assets.cubeBgImg, ox, oy)
font.drawText(
ctx,
isEn
? `Horadric Cube (Transmuted: ${this.cubeTransmuteCount})`
: `赫拉迪克方块 (已合成 ${this.cubeTransmuteCount} 次)`,
ox + 160,
oy + 32,
{
font: 'fontexocet10',
color: 'gold',
align: 'center',
},
)
// 3x4 Horadric Cube grid cell borders (`Inventory.txt` Transmogrify Box1)
for (let r = 0; r < CUBE_GRID_ORIGIN.rows; r++) {
@ -4147,6 +4285,17 @@ export class WorldPanelsHud {
}
}
if (
item.ethereal &&
!lines.some(s => s.text.includes('Ethereal') || s.text.includes('无形') || s.text.includes('無形'))
) {
lines.push({
text: isEn ? 'Ethereal (Cannot be Repaired)' : '无形的 (无法修复)',
color: 'blue',
font: 'font8',
})
}
// Sockets Block
if (
item.sockets !== undefined &&
@ -4419,10 +4568,10 @@ export class WorldPanelsHud {
if (panel === 'merc') {
const slots: ReadonlyArray<{ slot: number; x: number; y: number; w: number; h: number }> = [
{ slot: 1, x: ox + 132, y: oy + 44, w: 56, h: 56 },
{ slot: 3, x: ox + 132, y: oy + 112, w: 56, h: 84 },
{ slot: 4, x: ox + 36, y: oy + 98, w: 56, h: 112 },
{ slot: 5, x: ox + 228, y: oy + 98, w: 56, h: 112 },
{ slot: 1, x: 215, y: 68, w: 54, h: 51 },
{ slot: 3, x: 213, y: 137, w: 56, h: 82 },
{ slot: 4, x: 100, y: 107, w: 55, h: 112 },
{ slot: 5, x: 331, y: 107, w: 55, h: 112 },
]
for (const s of slots) {
if (mx >= s.x && mx <= s.x + s.w && my >= s.y && my <= s.y + s.h) {
@ -4469,10 +4618,10 @@ export class WorldPanelsHud {
}
if (panel === 'trade') {
// Player 10x4 trade grid box (ox + 15, oy + 220, 290, 116)
if (mx >= ox + 15 && mx < ox + 15 + 290 && my >= oy + 220 && my < oy + 220 + 116) {
const col = Math.max(0, Math.min(9, Math.floor((mx - (ox + 15)) / 29)))
const row = Math.max(0, Math.min(3, Math.floor((my - (oy + 220)) / 29)))
// Player 10x4 trade grid box (100, 315, 290, 116) per inventory.txt Trade Page 2-2
if (mx >= 100 && mx < 100 + 290 && my >= 315 && my < 315 + 116) {
const col = Math.max(0, Math.min(9, Math.floor((mx - 100) / 29)))
const row = Math.max(0, Math.min(3, Math.floor((my - 315) / 29)))
const selfItems = (this.trade?.selfItems ?? this.trade?.myItems ?? []) as UiInventoryItem[]
const hitItem =
selfItems.find((it: any) => {
@ -4616,12 +4765,12 @@ export class WorldPanelsHud {
const weaponItem = m?.weaponItem ?? eq?.weapon1 ?? eq?.rightHand ?? null
const shieldItem = m?.shieldItem ?? eq?.weapon2 ?? eq?.leftHand ?? null
// Equipment slots: head, body, rightHand (weapon), leftHand (shield)
// Equipment slots per inventory.txt Hireling2: head (215,68,54,51), torso (213,137,56,82), weapon (100,107,55,112), shield (331,107,55,112)
const slots = [
{ label: isEn ? 'Head' : '頭部', x: ox + 132, y: oy + 44, w: 56, h: 56, item: headItem },
{ label: isEn ? 'Body' : '軀幹', x: ox + 132, y: oy + 112, w: 56, h: 84, item: bodyItem },
{ label: isEn ? 'Weapon' : '武器', x: ox + 36, y: oy + 98, w: 56, h: 112, item: weaponItem },
{ label: isEn ? 'Shield' : '副手', x: ox + 228, y: oy + 98, w: 56, h: 112, item: shieldItem },
{ label: isEn ? 'Head' : '頭部', x: 215, y: 68, w: 54, h: 51, item: headItem },
{ label: isEn ? 'Body' : '軀幹', x: 213, y: 137, w: 56, h: 82, item: bodyItem },
{ label: isEn ? 'Weapon' : '武器', x: 100, y: 107, w: 55, h: 112, item: weaponItem },
{ label: isEn ? 'Shield' : '副手', x: 331, y: 107, w: 55, h: 112, item: shieldItem },
]
for (const s of slots) {
ctx.fillStyle = 'rgba(24, 20, 16, 0.9)'
@ -4704,24 +4853,24 @@ export class WorldPanelsHud {
const actionCode = this.resolvePartyActionCode(member)
const partyBtnLabel =
actionCode === 0x08
? 'Accept'
? (isEn ? 'ACCEPT' : '同意')
: actionCode === 0x07
? 'Cancel'
? (isEn ? 'CANCEL' : '取消')
: actionCode === 0x09
? 'Leave'
: 'Invite'
? (isEn ? 'LEAVE' : '離開')
: (isEn ? 'INVITE' : '邀請')
ctx.fillStyle = 'rgba(42, 34, 24, 0.95)'
ctx.fillRect(ox + 156, ry + 8, 52, 32)
ctx.strokeRect(ox + 156, ry + 8, 52, 32)
fillTextSafe(partyBtnLabel, ox + 162, ry + 28, '#e8c26b')
fillTextSafe(partyBtnLabel, ox + 160, ry + 28, '#e8c26b')
ctx.fillRect(ox + 212, ry + 8, 44, 32)
ctx.strokeRect(ox + 212, ry + 8, 44, 32)
fillTextSafe('Hostile', ox + 215, ry + 28, member.hostile ? '#ff6060' : '#d8c28a')
fillTextSafe(isEn ? 'HOSTILE' : '敵對', ox + 215, ry + 28, member.hostile ? '#ff6060' : '#d8c28a')
ctx.fillRect(ox + 258, ry + 8, 42, 32)
ctx.strokeRect(ox + 258, ry + 8, 42, 32)
fillTextSafe('Loot', ox + 264, ry + 28, member.lootPermit || member.lootAllowed ? '#90e890' : '#d8c28a')
fillTextSafe(isEn ? 'LOOT' : '搜屍', ox + 264, ry + 28, member.lootPermit || member.lootAllowed ? '#90e890' : '#d8c28a')
}
return
}
@ -4733,12 +4882,12 @@ export class WorldPanelsHud {
const partnerName = this.trade?.partnerName || (isEn ? 'Partner' : '交易對象')
fillTextSafe(partnerName, ox + 24, oy + 48, '#ffffff')
// Partner 10x4 grid box & Player 10x4 grid box
// Partner 10x4 grid box (100, 101, 290, 116) & Player 10x4 grid box (100, 315, 290, 116) per inventory.txt Trade Page 1-2 & 2-2
ctx.fillStyle = 'rgba(20, 18, 14, 0.9)'
ctx.fillRect(ox + 15, oy + 60, 290, 116)
ctx.strokeRect(ox + 15, oy + 60, 290, 116)
ctx.fillRect(ox + 15, oy + 220, 290, 116)
ctx.strokeRect(ox + 15, oy + 220, 290, 116)
ctx.fillRect(100, 101, 290, 116)
ctx.strokeRect(100, 101, 290, 116)
ctx.fillRect(100, 315, 290, 116)
ctx.strokeRect(100, 315, 290, 116)
const partnerGold = this.trade?.partnerGold ?? 0
const myGold = this.trade?.selfGold ?? this.trade?.myGold ?? 0
@ -4763,12 +4912,12 @@ export class WorldPanelsHud {
for (const pItem of this.trade?.partnerItems ?? []) {
const gx = pItem.gridX ?? pItem.col ?? 0
const gy = pItem.gridY ?? pItem.row ?? 0
fillTextSafe(String(pItem.name ?? pItem.code ?? 'Item'), ox + 20 + gx * 29, oy + 78 + gy * 29, '#c8b898')
fillTextSafe(String(pItem.name ?? pItem.code ?? 'Item'), 104 + gx * 29, 120 + gy * 29, '#c8b898')
}
for (const mItem of this.trade?.selfItems ?? this.trade?.myItems ?? []) {
const gx = mItem.gridX ?? mItem.col ?? 0
const gy = mItem.gridY ?? mItem.row ?? 0
fillTextSafe(String(mItem.name ?? mItem.code ?? 'Item'), ox + 20 + gx * 29, oy + 238 + gy * 29, '#c8b898')
fillTextSafe(String(mItem.name ?? mItem.code ?? 'Item'), 104 + gx * 29, 334 + gy * 29, '#c8b898')
}
const myCheck = this.trade?.selfAccepted ?? this.trade?.myAccepted ? 'ACCEPTED' : 'PENDING'

View File

@ -429,7 +429,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: true,
canIdentify: false,
greetingLines: [
'Stay awhile and listen!',
'Bring me any unidentified items you have found, and I shall reveal their hidden properties.',
@ -857,7 +857,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
},
},
{
hcIdx: 520,
hcIdx: 521,
npcTxtKey: 'cain5',
displayName: 'Deckard Cain',
actIndex: 4,
@ -1012,7 +1012,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
},
},
{
hcIdx: 203,
hcIdx: 331,
npcTxtKey: 'kaelan',
displayName: 'Kaelan',
actIndex: 1,
@ -1055,7 +1055,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
},
},
{
hcIdx: 258,
hcIdx: 264,
npcTxtKey: 'meshif2',
displayName: 'Meshif',
actIndex: 2,
@ -1098,7 +1098,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
},
},
{
hcIdx: 256,
hcIdx: 297,
npcTxtKey: 'natalya',
displayName: 'Natalya',
actIndex: 2,
@ -1140,7 +1140,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
},
},
{
hcIdx: 259,
hcIdx: 408,
npcTxtKey: 'hadriel',
displayName: 'Hadriel',
actIndex: 3,
@ -1206,6 +1206,151 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
rescue_on_mount_arreat: 'Free the fifteen Barbarian prisoners in the Frigid Highlands and take these runes: Ral, Ort, and Tal.',
},
},
// Additional talk-only NPCs per 1.13c MonStats.txt
{
hcIdx: 406,
npcTxtKey: 'izualghost',
displayName: 'Izual',
actIndex: 3,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
greetingLines: [
'Thank you, hero, for freeing me from this monstrous prison.',
],
gossipLines: [
'It was I who told Diablo and the Prime Evils how to bind themselves to Soulstones.',
],
},
{
hcIdx: 522,
npcTxtKey: 'tyrael3',
displayName: 'Tyrael',
actIndex: 4,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
greetingLines: [
'I am Tyrael, the Archangel of Justice.',
],
gossipLines: [
'The Worldstone is corrupted beyond repair. It must be destroyed.',
],
},
{
hcIdx: 528,
npcTxtKey: 'drehyaiced',
displayName: 'Anya',
actIndex: 4,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
greetingLines: [
'Help me... Nihlathak trapped me in this ice!',
],
gossipLines: [
'Malah gave you a thawing potion... please use it quickly!',
],
},
{
hcIdx: 377,
npcTxtKey: 'act2guard4',
displayName: 'Palace Guard',
actIndex: 1,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
greetingLines: [
'Halt! The palace is forbidden.',
],
gossipLines: [
'Lord Jerhyn placed us on high alert after demons emerged from below.',
],
},
{
hcIdx: 378,
npcTxtKey: 'act2guard5',
displayName: 'Palace Guard',
actIndex: 1,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
greetingLines: [
'Move along, traveler.',
],
gossipLines: [
'Keep your weapon sheathed inside the city walls.',
],
},
{
hcIdx: 538,
npcTxtKey: 'ancientstatue1',
displayName: 'Ancient Statue 1',
actIndex: 4,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
greetingLines: [
'We are the spirits of the Nephalem.',
],
gossipLines: [
'Only the worthy may pass into Mount Arreat.',
],
},
{
hcIdx: 539,
npcTxtKey: 'ancientstatue2',
displayName: 'Ancient Statue 2',
actIndex: 4,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
greetingLines: [
'Only the worthy may pass into Mount Arreat.',
],
gossipLines: [
'Prepare yourself for the trial of the Ancients.',
],
},
{
hcIdx: 540,
npcTxtKey: 'ancientstatue3',
displayName: 'Ancient Statue 3',
actIndex: 4,
vendorId: null,
canTrade: false,
canRepair: false,
canGamble: false,
canHeal: false,
canIdentify: false,
greetingLines: [
'Prepare yourself for the trial!',
],
gossipLines: [
'May Bul-Kathos guide your blade.',
],
},
];
export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] &
@ -1226,8 +1371,12 @@ export const TOWN_NPC_DESCRIPTORS: readonly TownNpcServiceDescriptor[] &
})();
const HCIDX_ALIASES: Readonly<Record<number, string>> = {
297: 'natalya',
521: 'tyrael1',
203: 'kaelan',
256: 'natalya',
258: 'meshif2',
259: 'hadriel',
516: 'qual-kehk',
520: 'cain5',
534: 'cain1',
};
@ -1284,6 +1433,9 @@ export const TOWN_NPC_SERVICES: Readonly<Record<string, TownNpcServiceDescriptor
if (rec.tyrael2) {
rec.tyrael_act2 = rec.tyrael2;
}
if (rec.tyrael3) {
rec.tyrael_act5 = rec.tyrael3;
}
if (rec['qual-kehk']) {
rec.qual_kehk = rec['qual-kehk'];
}
@ -1344,9 +1496,9 @@ export function getTownNpcDescriptorByVendorId(
}
const NPC_NAME_TBL_KEYS: Readonly<Record<string, string>> = {
akara: 'akara',
gheed: 'gheed',
charsi: 'charsi',
akara: 'Akara',
gheed: 'Gheed',
charsi: 'Charsi',
cain: 'DeckardCain',
cain1: 'DeckardCain',
cain2: 'DeckardCain',
@ -1355,190 +1507,50 @@ const NPC_NAME_TBL_KEYS: Readonly<Record<string, string>> = {
cain5: 'DeckardCain',
cain6: 'DeckardCain',
'deckard cain': 'DeckardCain',
warriv: 'warriv',
warriv1: 'warriv',
warriv2: 'warriv',
kashya: 'kashya',
fara: 'fara',
lysander: 'lysander',
drognan: 'drognan',
elzix: 'elzix',
atma: 'atma',
greiz: 'greiz',
jerhyn: 'jerhyn',
kaelan: 'kaelan',
meshif: 'meshif',
meshif1: 'meshif',
meshif2: 'meshif',
geglash: 'geglash',
warriv: 'Warriv',
warriv1: 'Warriv',
warriv2: 'Warriv',
kashya: 'Kashya',
fara: 'Fara',
lysander: 'Lysander',
drognan: 'Drognan',
elzix: 'Elzix',
atma: 'Atma',
greiz: 'Greiz',
jerhyn: 'Jerhyn',
kaelan: 'Kaelan',
meshif: 'Meshif',
meshif1: 'Meshif',
meshif2: 'Meshif',
geglash: 'Geglash',
hratli: 'hratli',
alkor: 'alkor',
ormus: 'ormus',
asheara: 'asheara',
natalya: 'natalya',
natalya: 'Natalya',
halbu: 'halbu',
jamella: 'jamella',
hadriel: 'hadriel',
jamella: 'Jamella',
hadriel: 'Malachai',
malachai: 'Malachai',
tyrael: 'tyrael',
tyrael1: 'tyrael',
tyrael2: 'tyrael',
malah: 'malah',
larzuk: 'larzuk',
drehya: 'anya',
anya: 'anya',
nihlathak: 'nihlathak',
'qual-kehk': 'qual-kehk',
};
const NPC_GREETING_ZH_BY_KEY: Readonly<Record<string, readonly string[]>> = {
akara: [
'我是阿卡拉,目盲之眼修女会的高阶女祭司。',
'欢迎回来,我的朋友。我们仍在清理修道院,但你可以随时在这里休息。',
],
gheed: [
'祝你好运,伙伴!我是基德,在这被遗忘的营地里我一定会成为你最好的朋友。',
'快来看看!花点金币交易,或者赌一把神秘物品!',
],
charsi: [
'你好!我是恰西,营地里的铁匠。',
'很高兴在这里看到强壮的冒险者。需要打造或修理你的装备吗?',
],
cain1: [
'请留步,听我一言!',
'把你找到的未辨识物品带给我,我会揭开它们隐藏的力量。',
],
cain2: [
'请留步,听我一言!',
'赫拉迪姆的奥秘深藏于鲁·高因之下。让我为你辨识圣物吧。',
],
cain3: [
'请留步,听我一言!',
'墨菲斯托的腐化已经扭曲了库拉斯特。让我看看你发现了什么遗物。',
],
cain4: [
'请留步,听我一言!',
'即便在地狱之门前,赫拉迪姆的智慧依然指引着我们。',
],
cain5: [
'请留步,听我一言!',
'亚瑞特山脉守护着世界之石。让我为你辨识你发现的宝物。',
],
cain6: [
'请留步,听我一言!',
'感谢你从崔斯特瑞姆救出我。让我为你辨识物品吧。',
],
fara: [
'你好,流浪者。我是法拉,曾经是东方萨卡兰姆的圣骑士。',
'如今我在鲁·高因以铁匠与治疗者的身份维生。',
],
lysander: [
'你想干什么?大声点!调配药剂对听力可不太好。',
'要买药水就快点,别等这儿炸了!',
],
drognan: [
'黑暗的时代确实已经降临鲁·高因。',
'如果你寻求奥术知识、法杖或手杖,不妨看看我的货物。',
],
elzix: [
'很高兴见到你,旅行者。我是艾尔夏克,旅店老板兼商人。',
'年轻时我曾是沙漠里的强盗。想交易还是试试手气?',
],
hratli: [
'欢迎,陌生人。我是赫拉铁力,一名专精金属附魔的铁匠。',
'我的结界守护着这片海港,我的铁砧能让你的刀剑更加锋利。',
],
alkor: [
'哦,太好了,又来一个打扰我研究的冒险者。',
'如果你想买药剂或者把金币赌光,那就快一点。',
],
ormus: [
'你正在和奥玛斯说话。他曾是一名伟大的法师,如今却像沉船里的老鼠一样苟活。',
'奥玛斯可以治愈你的创伤,并交易神秘的法袍。',
],
asheara: [
'你好。我是艾席拉,铁狼卫队的指挥官。',
'如果你打算深入丛林,不妨先看看我的武器与盔甲。',
],
halbu: ['凡人,随时让你的武器保持锋利,让你的盔甲坚固完好。'],
jamella: ['圣光祝福你,英雄。让我为你恢复体力并提供神圣的装备。'],
malah: [
'欢迎来到哈洛加斯,旅行者。我是马拉,这座围城要塞的治疗师。',
'让我为你包扎伤口,或提供药剂与卷轴。',
],
larzuk: [
'看什么看?没见过野蛮人铁匠吗?',
'看看我的钢铁作品——全北方高地最顶尖的装备!',
],
drehya: [
'感谢你从冰冻囚牢中救出我,英雄。',
'请看看我的商品,或者试试赌博的运气。',
],
nihlathak: [
'你为什么来烦我?哈洛加斯不需要外来者。',
'如果你非要浪费金币,我这里倒有一些古怪玩意儿可以让你赌一把。',
],
warriv1: ['你好,陌生人。一旦通往东方的关隘安全了,我的商队就会带你前往鲁·高因。'],
warriv2: ['很高兴在鲁·高因见到你。我的商队随时可以带你向西返回罗格营地。'],
kashya: ['我的罗格斥候伤亡惨重。在战场上证明你的勇气吧,战士。'],
atma: ['欢迎来到我的酒馆,旅行者。在躲避沙漠烈日的同时,让我为你治愈伤口吧。'],
greiz: ['我的沙漠佣兵日夜守卫着鲁·高因的城墙。'],
jerhyn: ['欢迎来到鲁·高因。黑暗力量正在沙漠深处蠢蠢欲动。'],
kaelan: ['站住!没有杰海因领主的许可,任何人不得进入王宫。'],
meshif1: ['嘿,旅行者!只要你的使命需要,我的帆船随时可以横渡双子海。'],
meshif2: ['欢迎再次登船,伙伴!如果你想向西航行返回鲁·高因,随时告诉我。'],
geglash: ['看什么看?要么再来一杯,要么赶紧走开!'],
natalya: ['在丛林里睁大眼睛。三大魔神正在崔凡克集结。'],
tyrael1: ['向你致敬,勇士。审判迪亚波罗的时刻已经到来。'],
tyrael2: ['干得好,凡人。快返回鲁·高因,乘马席夫的船前往库拉斯特。'],
hadriel: ['止步,凡人!前方就是迪亚波罗盘踞的混沌避难所。'],
'qual-kehk': ['一旦你解救了冰冻高地上的战士,最精锐的野蛮人将与你并肩作战。'],
};
const NPC_NAME_ZH_BY_KEY: Readonly<Record<string, string>> = {
akara: '阿卡拉',
gheed: '基德',
charsi: '恰西',
cain: '迪卡·凯恩',
cain1: '迪卡·凯恩',
cain2: '迪卡·凯恩',
cain3: '迪卡·凯恩',
cain4: '迪卡·凯恩',
cain5: '迪卡·凯恩',
cain6: '迪卡·凯恩',
'deckard cain': '迪卡·凯恩',
warriv: '瓦瑞夫',
warriv1: '瓦瑞夫',
warriv2: '瓦瑞夫',
kashya: '卡夏',
fara: '法拉',
lysander: '雷山德',
drognan: '卓格南',
elzix: '艾尔夏',
atma: '亚特玛',
greiz: '格雷兹',
jerhyn: '杰海因',
kaelan: '凯兰',
meshif: '马席夫',
meshif1: '马席夫',
meshif2: '马席夫',
geglash: '基格拉什',
hratli: '赫拉铁力',
alkor: '艾柯',
ormus: '奥玛斯',
asheara: '艾席拉',
natalya: '娜塔亚',
halbu: '海尔布',
jamella: '贾梅拉',
hadriel: '哈德瑞尔',
tyrael: '泰瑞尔',
tyrael1: '泰瑞尔',
tyrael2: '泰瑞尔',
malah: '玛拉',
larzuk: '拉苏克',
drehya: '安雅',
anya: '安雅',
nihlathak: '尼拉塞克',
'qual-kehk': '夸尔凯克',
tyrael3: 'tyrael',
malah: 'Malah',
larzuk: 'Larzuk',
drehya: 'Drehya',
drehyaiced: 'Drehya',
anya: 'Drehya',
nihlathak: 'Nihlathak Town',
'qual-kehk': 'Qual-Kehk',
qualkehk: 'Qual-Kehk',
izual: 'Izual',
izualghost: 'Izual',
ancientstatue1: 'Ancient Statue 1',
ancientstatue2: 'Ancient Statue 2',
ancientstatue3: 'Ancient Statue 3',
act2guard4: 'Kaelan',
act2guard5: 'Kaelan',
};
export function getLocalizedNpcName(
@ -1554,21 +1566,15 @@ export function getLocalizedNpcName(
const fallbackEn =
desc?.displayName ?? (typeof nameOrDesc === 'string' ? nameOrDesc.trim() : nameOrDesc.displayName);
const tblKey = NPC_NAME_TBL_KEYS[lowerKey] ?? rawKey;
if (lang === 'ENG') {
return resolveTblKey(tblKey, 'ENG') ?? fallbackEn;
}
return NPC_NAME_ZH_BY_KEY[lowerKey] ?? resolveTblKey(tblKey, 'CHI') ?? fallbackEn;
return resolveTblKey(tblKey, lang) ?? fallbackEn;
}
export function getLocalizedNpcGreetings(
descriptor: TownNpcServiceDescriptor,
lang: 'CHI' | 'ENG' = 'CHI',
_lang: 'CHI' | 'ENG' = 'CHI',
): readonly string[] {
if (lang === 'ENG') {
return descriptor.greetingLines;
}
return NPC_GREETING_ZH_BY_KEY[descriptor.npcTxtKey.toLowerCase()] ?? descriptor.greetingLines;
}
export function npcDisplayName(
nameOrDesc: TownNpcServiceDescriptor | string,