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. */ /** NPC/stash state seen by the last sync; panels react to transitions only. */
private prevNpc: NpcInteractionState = NPC_STATE_CLOSED private prevNpc: NpcInteractionState = NPC_STATE_CLOSED
private prevTradeActive = false private prevTradeActive = false
private prevAct = -1
/** `lastNpcTransaction.seq` already reacted to (each S2C 0x2A is shown once). */ /** `lastNpcTransaction.seq` already reacted to (each S2C 0x2A is shown once). */
private handledNpcTransactionSeq: number private handledNpcTransactionSeq: number
private readonly lastNpcActionAt = new Map<string, number>() private readonly lastNpcActionAt = new Map<string, number>()
@ -411,6 +412,28 @@ export class HudModel implements HudCommandSink {
return false 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`. * Build a pure snapshot of the HUD state derived from `ClientWorld`.
*/ */
@ -1016,7 +1039,10 @@ export class HudModel implements HudCommandSink {
// 6. Area, Waypoints & Quests // 6. Area, Waypoints & Quests
if (this.world.areaId > 0) { if (this.world.areaId > 0) {
hud.worldPanels.setCurrentLevel(this.world.areaId) 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)) 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.setUnlockedWaypoints([...this.world.waypoints])
hud.worldPanels.applyServerQuestFlags(this.world.quests) hud.worldPanels.applyServerQuestFlags(this.world.quests)
@ -1050,10 +1076,19 @@ export class HudModel implements HudCommandSink {
if (desc) { if (desc) {
const hasDeadMercenary = Boolean(this.world.merc?.isDead && (this.world.merc?.reviveCost ?? 0) > 0) const hasDeadMercenary = Boolean(this.world.merc?.isDead && (this.world.merc?.reviveCost ?? 0) > 0)
const deadMercenaryReviveCost = this.world.merc?.reviveCost ?? 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, { ;(wp as any).openNpcMenu(desc, wp.npcMenu?.x ?? 400, wp.npcMenu?.y ?? 220, npc.npcId, {
hasUnidentifiedItems: true, unidentifiedItemCount,
hasUnidentifiedItems: unidentifiedItemCount > 0,
hasDeadMercenary, hasDeadMercenary,
deadMercenaryReviveCost, deadMercenaryReviveCost,
deadMercenaryName,
playerLevel,
questFlags: this.world.quests,
}) })
} }
} }

View File

@ -373,6 +373,7 @@ export type NpcMenuActionId =
| 'imbue' | 'imbue'
| 'add_sockets' | 'add_sockets'
| 'personalize' | 'personalize'
| 'respec'
| 'cancel' | 'cancel'
export interface NpcMenuOption { export interface NpcMenuOption {
@ -385,10 +386,14 @@ export interface NpcMenuOption {
export interface NpcMenuContext { export interface NpcMenuContext {
readonly hasUnidentifiedItems?: boolean | undefined readonly hasUnidentifiedItems?: boolean | undefined
readonly unidentifiedItemCount?: number | undefined
readonly hasDeadMercenary?: boolean | undefined readonly hasDeadMercenary?: boolean | undefined
readonly deadMercenaryReviveCost?: number | undefined readonly deadMercenaryReviveCost?: number | undefined
readonly deadMercenaryName?: string | undefined
readonly includeQuestRewards?: boolean | undefined readonly includeQuestRewards?: boolean | undefined
readonly legacyTradeId?: boolean | undefined readonly legacyTradeId?: boolean | undefined
readonly playerLevel?: number | undefined
readonly questFlags?: ArrayLike<number> | undefined
} }
export function buildNpcMenuOptions( export function buildNpcMenuOptions(
@ -426,64 +431,164 @@ export function buildNpcMenuOptions(
labelEn: 'Gamble', 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({ opts.push({
id: 'identify', id: 'identify',
strIdx: 4020, strIdx: 4020,
label: '辨識物品', label: '辨視物品',
labelZh: '辨識物品', labelZh: '辨視物品',
labelEn: 'Identify Items', labelEn: 'Identify Items',
}) })
} }
}
}
// Hire:
if (descriptor.canHire) { 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({ opts.push({
id: 'hire', id: 'hire',
label: '僱傭', strIdx: 3397,
labelZh: '僱傭', label: '雇用',
labelZh: '雇用',
labelEn: 'Hire', labelEn: 'Hire',
}) })
} }
}
// Resurrect:
if ( 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 context?.hasDeadMercenary
) { ) {
const cost = context?.deadMercenaryReviveCost ?? 0 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({ opts.push({
id: 'resurrect', id: 'resurrect',
label: `復活傭兵 (${cost})`, strIdx: 22696,
labelZh: `復活傭兵 (${cost})`, label: labelZh,
labelEn: `Resurrect (${cost})`, labelZh,
labelEn,
}) })
} }
// Travel actions:
for (const travelAct of descriptor.travelActions ?? []) { 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 (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({ opts.push({
id: 'travel_east', id: 'travel_east',
label: '向東旅行', strIdx: 3384,
labelZh: '向東旅行', label: '航向東邊',
labelEn: 'Travel East', labelZh: '航向東邊',
labelEn: 'Sail East',
}) })
} else {
opts.push({
id: 'travel_east',
strIdx: 3382,
label: '向東走',
labelZh: '向東走',
labelEn: 'Go East',
})
}
} else if (travelAct === 'travel_west') { } else if (travelAct === 'travel_west') {
if (descriptor.hcIdx === 264 || descriptor.npcTxtKey === 'meshif2' || descriptor.hcIdx === 258) {
opts.push({ opts.push({
id: 'travel_west', id: 'travel_west',
label: '向西旅行', strIdx: 3385,
labelZh: '向西旅行', label: '航向西邊',
labelEn: 'Travel West', labelZh: '航向西邊',
labelEn: 'Sail West',
}) })
} else {
opts.push({
id: 'travel_west',
strIdx: 3383,
label: '向西走',
labelZh: '向西走',
labelEn: 'Go West',
})
}
} else if (travelAct === 'travel_harrogath') { } else if (travelAct === 'travel_harrogath') {
opts.push({ opts.push({
id: 'travel_harrogath', id: 'travel_harrogath',
strIdx: 22738,
label: '前往哈洛加斯', label: '前往哈洛加斯',
labelZh: '前往哈洛加斯', labelZh: '前往哈洛加斯',
labelEn: 'Travel to Harrogath', labelEn: 'Travel to Harrogath',
}) })
} }
} }
}
// Quest rewards (imbue, add_sockets, personalize):
if (context?.includeQuestRewards !== false) { if (context?.includeQuestRewards !== false) {
for (const rewardAct of descriptor.questRewardActions ?? []) { 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') { if (rewardAct === 'imbue') {
opts.push({ opts.push({
id: 'imbue', id: 'imbue',
strIdx: 4017,
label: '注入', label: '注入',
labelZh: '注入', labelZh: '注入',
labelEn: 'Imbue', labelEn: 'Imbue',
@ -491,13 +596,15 @@ export function buildNpcMenuOptions(
} else if (rewardAct === 'add_sockets') { } else if (rewardAct === 'add_sockets') {
opts.push({ opts.push({
id: 'add_sockets', id: 'add_sockets',
label: '打孔', strIdx: 22748,
labelZh: '打孔', label: '增加凹槽',
labelZh: '增加凹槽',
labelEn: 'Add Sockets', labelEn: 'Add Sockets',
}) })
} else if (rewardAct === 'personalize') { } else if (rewardAct === 'personalize') {
opts.push({ opts.push({
id: 'personalize', id: 'personalize',
strIdx: 22749,
label: '個人化', label: '個人化',
labelZh: '個人化', labelZh: '個人化',
labelEn: 'Personalize', 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({ opts.push({
id: 'cancel', id: 'cancel',
strIdx: 4142, strIdx: 4142,
@ -1265,12 +1392,16 @@ export class WorldPanelsHud {
} }
const effectiveContext: NpcMenuContext = { 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), hasDeadMercenary: context?.hasDeadMercenary ?? this.hasDeadMercenary ?? (this.merc ? !this.merc.alive : false),
deadMercenaryReviveCost: deadMercenaryReviveCost:
context?.deadMercenaryReviveCost ?? this.deadMercenaryReviveCost ?? this.merc?.reviveCost ?? 0, context?.deadMercenaryReviveCost ?? this.deadMercenaryReviveCost ?? this.merc?.reviveCost ?? 0,
deadMercenaryName: context?.deadMercenaryName,
includeQuestRewards: context?.includeQuestRewards, includeQuestRewards: context?.includeQuestRewards,
legacyTradeId: context?.legacyTradeId, legacyTradeId: context?.legacyTradeId,
playerLevel: context?.playerLevel,
questFlags: context?.questFlags ?? (this.serverQuestFlags.length > 0 ? this.serverQuestFlags : undefined),
} }
const options = this.buildNpcMenuOptions(descriptor, effectiveContext) const options = this.buildNpcMenuOptions(descriptor, effectiveContext)
const resolvedUnitId = npcId ?? 0 const resolvedUnitId = npcId ?? 0
@ -1411,6 +1542,12 @@ export class WorldPanelsHud {
return true 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') { if (opt.id === 'trade' || opt.id === 'trade_repair' || opt.id === 'gamble') {
this._npcMenu = null this._npcMenu = null
this._activePanel = 'vendor' this._activePanel = 'vendor'
@ -1542,11 +1679,14 @@ export class WorldPanelsHud {
this.questStatuses.set(questId, status) this.questStatuses.set(questId, status)
} }
private serverQuestFlags: number[] = []
/** /**
* Replace every quest status from the server quest-flag words (`ClientWorld.quests`): * 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). * bit 0 set = completed, any other bit set = active (started), 0 = locked (not started).
*/ */
applyServerQuestFlags(flags: ArrayLike<number>): void { applyServerQuestFlags(flags: ArrayLike<number>): void {
this.serverQuestFlags = Array.from(flags)
this.questStatuses.clear() this.questStatuses.clear()
for (const quest of ALL_QUESTS) { for (const quest of ALL_QUESTS) {
const word = flags[questFlagWordIndex(quest)] ?? 0 const word = flags[questFlagWordIndex(quest)] ?? 0
@ -1577,6 +1717,8 @@ export class WorldPanelsHud {
const val = this.questStringTableChain.get(key) const val = this.questStringTableChain.get(key)
if (val !== undefined && val.trim().length > 0) return val.trim() 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 return undefined
} }
@ -1609,10 +1751,27 @@ export class WorldPanelsHud {
? quest.completedEn || tblCompleted || quest.completedZh || quest.description ? quest.completedEn || tblCompleted || quest.completedZh || quest.description
: tblCompleted || quest.completedZh || quest.completedEn || quest.description : tblCompleted || quest.completedZh || quest.completedEn || quest.description
} else if (status === 'active') { } 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) const tblObjective = this.lookupQuestString(quest.objectiveKey)
body = isEn stageText = isEn
? quest.objectiveEn || tblObjective || quest.objectiveZh || quest.description ? quest.objectiveEn || tblObjective || quest.objectiveZh || quest.description
: tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description : tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description
}
body = stageText
} else { } else {
body = isEn body = isEn
? 'This quest has not begun yet. Speak to townsfolk or explore the wilderness to begin.' ? '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]! const activeQ = quests[this.selectedQuestIdx] ?? quests[0]!
if (activeQ) { if (activeQ) {
const qInfo = this.getQuestDisplayText(activeQ) const qInfo = this.getQuestDisplayText(activeQ)
ctx.fillStyle = 'rgba(8, 7, 6, 0.92)' font.drawText(ctx, qInfo.title, ox + 160, oy + 282, {
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: 'fontexocet10', font: 'fontexocet10',
color: qInfo.status === 'completed' ? 'gold' : 'white', color: qInfo.status === 'completed' ? 'gold' : 'white',
align: 'center', align: 'center',
}) })
const rewardSuffix = qInfo.status === 'completed' this.drawWrappedText(ctx, font, qInfo.body, ox + 26, oy + 300, 268, 14, 6, 'fontformal12')
? (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')
} }
} else if (kind === 'waypoint') { } else if (kind === 'waypoint') {
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy) if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
@ -2921,19 +3072,6 @@ export class WorldPanelsHud {
} }
} else if (kind === 'cube') { } else if (kind === 'cube') {
if (assets.cubeBgImg) ctx.drawImage(assets.cubeBgImg, ox, oy) 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) // 3x4 Horadric Cube grid cell borders (`Inventory.txt` Transmogrify Box1)
for (let r = 0; r < CUBE_GRID_ORIGIN.rows; r++) { 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 // Sockets Block
if ( if (
item.sockets !== undefined && item.sockets !== undefined &&
@ -4419,10 +4568,10 @@ export class WorldPanelsHud {
if (panel === 'merc') { if (panel === 'merc') {
const slots: ReadonlyArray<{ slot: number; x: number; y: number; w: number; h: number }> = [ 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: 1, x: 215, y: 68, w: 54, h: 51 },
{ slot: 3, x: ox + 132, y: oy + 112, w: 56, h: 84 }, { slot: 3, x: 213, y: 137, w: 56, h: 82 },
{ slot: 4, x: ox + 36, y: oy + 98, w: 56, h: 112 }, { slot: 4, x: 100, y: 107, w: 55, h: 112 },
{ slot: 5, x: ox + 228, y: oy + 98, w: 56, h: 112 }, { slot: 5, x: 331, y: 107, w: 55, h: 112 },
] ]
for (const s of slots) { for (const s of slots) {
if (mx >= s.x && mx <= s.x + s.w && my >= s.y && my <= s.y + s.h) { 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') { if (panel === 'trade') {
// Player 10x4 trade grid box (ox + 15, oy + 220, 290, 116) // Player 10x4 trade grid box (100, 315, 290, 116) per inventory.txt Trade Page 2-2
if (mx >= ox + 15 && mx < ox + 15 + 290 && my >= oy + 220 && my < oy + 220 + 116) { if (mx >= 100 && mx < 100 + 290 && my >= 315 && my < 315 + 116) {
const col = Math.max(0, Math.min(9, Math.floor((mx - (ox + 15)) / 29))) const col = Math.max(0, Math.min(9, Math.floor((mx - 100) / 29)))
const row = Math.max(0, Math.min(3, Math.floor((my - (oy + 220)) / 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 selfItems = (this.trade?.selfItems ?? this.trade?.myItems ?? []) as UiInventoryItem[]
const hitItem = const hitItem =
selfItems.find((it: any) => { selfItems.find((it: any) => {
@ -4616,12 +4765,12 @@ export class WorldPanelsHud {
const weaponItem = m?.weaponItem ?? eq?.weapon1 ?? eq?.rightHand ?? null const weaponItem = m?.weaponItem ?? eq?.weapon1 ?? eq?.rightHand ?? null
const shieldItem = m?.shieldItem ?? eq?.weapon2 ?? eq?.leftHand ?? 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 = [ const slots = [
{ label: isEn ? 'Head' : '頭部', x: ox + 132, y: oy + 44, w: 56, h: 56, item: headItem }, { label: isEn ? 'Head' : '頭部', x: 215, y: 68, w: 54, h: 51, item: headItem },
{ label: isEn ? 'Body' : '軀幹', x: ox + 132, y: oy + 112, w: 56, h: 84, item: bodyItem }, { label: isEn ? 'Body' : '軀幹', x: 213, y: 137, w: 56, h: 82, item: bodyItem },
{ label: isEn ? 'Weapon' : '武器', x: ox + 36, y: oy + 98, w: 56, h: 112, item: weaponItem }, { label: isEn ? 'Weapon' : '武器', x: 100, y: 107, w: 55, h: 112, item: weaponItem },
{ label: isEn ? 'Shield' : '副手', x: ox + 228, y: oy + 98, w: 56, h: 112, item: shieldItem }, { label: isEn ? 'Shield' : '副手', x: 331, y: 107, w: 55, h: 112, item: shieldItem },
] ]
for (const s of slots) { for (const s of slots) {
ctx.fillStyle = 'rgba(24, 20, 16, 0.9)' ctx.fillStyle = 'rgba(24, 20, 16, 0.9)'
@ -4704,24 +4853,24 @@ export class WorldPanelsHud {
const actionCode = this.resolvePartyActionCode(member) const actionCode = this.resolvePartyActionCode(member)
const partyBtnLabel = const partyBtnLabel =
actionCode === 0x08 actionCode === 0x08
? 'Accept' ? (isEn ? 'ACCEPT' : '同意')
: actionCode === 0x07 : actionCode === 0x07
? 'Cancel' ? (isEn ? 'CANCEL' : '取消')
: actionCode === 0x09 : actionCode === 0x09
? 'Leave' ? (isEn ? 'LEAVE' : '離開')
: 'Invite' : (isEn ? 'INVITE' : '邀請')
ctx.fillStyle = 'rgba(42, 34, 24, 0.95)' ctx.fillStyle = 'rgba(42, 34, 24, 0.95)'
ctx.fillRect(ox + 156, ry + 8, 52, 32) ctx.fillRect(ox + 156, ry + 8, 52, 32)
ctx.strokeRect(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.fillRect(ox + 212, ry + 8, 44, 32)
ctx.strokeRect(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.fillRect(ox + 258, ry + 8, 42, 32)
ctx.strokeRect(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 return
} }
@ -4733,12 +4882,12 @@ export class WorldPanelsHud {
const partnerName = this.trade?.partnerName || (isEn ? 'Partner' : '交易對象') const partnerName = this.trade?.partnerName || (isEn ? 'Partner' : '交易對象')
fillTextSafe(partnerName, ox + 24, oy + 48, '#ffffff') 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.fillStyle = 'rgba(20, 18, 14, 0.9)'
ctx.fillRect(ox + 15, oy + 60, 290, 116) ctx.fillRect(100, 101, 290, 116)
ctx.strokeRect(ox + 15, oy + 60, 290, 116) ctx.strokeRect(100, 101, 290, 116)
ctx.fillRect(ox + 15, oy + 220, 290, 116) ctx.fillRect(100, 315, 290, 116)
ctx.strokeRect(ox + 15, oy + 220, 290, 116) ctx.strokeRect(100, 315, 290, 116)
const partnerGold = this.trade?.partnerGold ?? 0 const partnerGold = this.trade?.partnerGold ?? 0
const myGold = this.trade?.selfGold ?? this.trade?.myGold ?? 0 const myGold = this.trade?.selfGold ?? this.trade?.myGold ?? 0
@ -4763,12 +4912,12 @@ export class WorldPanelsHud {
for (const pItem of this.trade?.partnerItems ?? []) { for (const pItem of this.trade?.partnerItems ?? []) {
const gx = pItem.gridX ?? pItem.col ?? 0 const gx = pItem.gridX ?? pItem.col ?? 0
const gy = pItem.gridY ?? pItem.row ?? 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 ?? []) { for (const mItem of this.trade?.selfItems ?? this.trade?.myItems ?? []) {
const gx = mItem.gridX ?? mItem.col ?? 0 const gx = mItem.gridX ?? mItem.col ?? 0
const gy = mItem.gridY ?? mItem.row ?? 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' 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, canRepair: false,
canGamble: false, canGamble: false,
canHeal: false, canHeal: false,
canIdentify: true, canIdentify: false,
greetingLines: [ greetingLines: [
'Stay awhile and listen!', 'Stay awhile and listen!',
'Bring me any unidentified items you have found, and I shall reveal their hidden properties.', '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', npcTxtKey: 'cain5',
displayName: 'Deckard Cain', displayName: 'Deckard Cain',
actIndex: 4, actIndex: 4,
@ -1012,7 +1012,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
}, },
}, },
{ {
hcIdx: 203, hcIdx: 331,
npcTxtKey: 'kaelan', npcTxtKey: 'kaelan',
displayName: 'Kaelan', displayName: 'Kaelan',
actIndex: 1, actIndex: 1,
@ -1055,7 +1055,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
}, },
}, },
{ {
hcIdx: 258, hcIdx: 264,
npcTxtKey: 'meshif2', npcTxtKey: 'meshif2',
displayName: 'Meshif', displayName: 'Meshif',
actIndex: 2, actIndex: 2,
@ -1098,7 +1098,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
}, },
}, },
{ {
hcIdx: 256, hcIdx: 297,
npcTxtKey: 'natalya', npcTxtKey: 'natalya',
displayName: 'Natalya', displayName: 'Natalya',
actIndex: 2, actIndex: 2,
@ -1140,7 +1140,7 @@ const RAW_TOWN_NPC_DESCRIPTORS: readonly RawTownNpcServiceDescriptor[] = [
}, },
}, },
{ {
hcIdx: 259, hcIdx: 408,
npcTxtKey: 'hadriel', npcTxtKey: 'hadriel',
displayName: 'Hadriel', displayName: 'Hadriel',
actIndex: 3, 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.', 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[] & 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>> = { const HCIDX_ALIASES: Readonly<Record<number, string>> = {
297: 'natalya', 203: 'kaelan',
521: 'tyrael1', 256: 'natalya',
258: 'meshif2',
259: 'hadriel',
516: 'qual-kehk',
520: 'cain5',
534: 'cain1', 534: 'cain1',
}; };
@ -1284,6 +1433,9 @@ export const TOWN_NPC_SERVICES: Readonly<Record<string, TownNpcServiceDescriptor
if (rec.tyrael2) { if (rec.tyrael2) {
rec.tyrael_act2 = rec.tyrael2; rec.tyrael_act2 = rec.tyrael2;
} }
if (rec.tyrael3) {
rec.tyrael_act5 = rec.tyrael3;
}
if (rec['qual-kehk']) { if (rec['qual-kehk']) {
rec.qual_kehk = 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>> = { const NPC_NAME_TBL_KEYS: Readonly<Record<string, string>> = {
akara: 'akara', akara: 'Akara',
gheed: 'gheed', gheed: 'Gheed',
charsi: 'charsi', charsi: 'Charsi',
cain: 'DeckardCain', cain: 'DeckardCain',
cain1: 'DeckardCain', cain1: 'DeckardCain',
cain2: 'DeckardCain', cain2: 'DeckardCain',
@ -1355,190 +1507,50 @@ const NPC_NAME_TBL_KEYS: Readonly<Record<string, string>> = {
cain5: 'DeckardCain', cain5: 'DeckardCain',
cain6: 'DeckardCain', cain6: 'DeckardCain',
'deckard cain': 'DeckardCain', 'deckard cain': 'DeckardCain',
warriv: 'warriv', warriv: 'Warriv',
warriv1: 'warriv', warriv1: 'Warriv',
warriv2: 'warriv', warriv2: 'Warriv',
kashya: 'kashya', kashya: 'Kashya',
fara: 'fara', fara: 'Fara',
lysander: 'lysander', lysander: 'Lysander',
drognan: 'drognan', drognan: 'Drognan',
elzix: 'elzix', elzix: 'Elzix',
atma: 'atma', atma: 'Atma',
greiz: 'greiz', greiz: 'Greiz',
jerhyn: 'jerhyn', jerhyn: 'Jerhyn',
kaelan: 'kaelan', kaelan: 'Kaelan',
meshif: 'meshif', meshif: 'Meshif',
meshif1: 'meshif', meshif1: 'Meshif',
meshif2: 'meshif', meshif2: 'Meshif',
geglash: 'geglash', geglash: 'Geglash',
hratli: 'hratli', hratli: 'hratli',
alkor: 'alkor', alkor: 'alkor',
ormus: 'ormus', ormus: 'ormus',
asheara: 'asheara', asheara: 'asheara',
natalya: 'natalya', natalya: 'Natalya',
halbu: 'halbu', halbu: 'halbu',
jamella: 'jamella', jamella: 'Jamella',
hadriel: 'hadriel', hadriel: 'Malachai',
malachai: 'Malachai',
tyrael: 'tyrael', tyrael: 'tyrael',
tyrael1: 'tyrael', tyrael1: 'tyrael',
tyrael2: 'tyrael', tyrael2: 'tyrael',
malah: 'malah', tyrael3: 'tyrael',
larzuk: 'larzuk', malah: 'Malah',
drehya: 'anya', larzuk: 'Larzuk',
anya: 'anya', drehya: 'Drehya',
nihlathak: 'nihlathak', drehyaiced: 'Drehya',
'qual-kehk': 'qual-kehk', anya: 'Drehya',
}; nihlathak: 'Nihlathak Town',
'qual-kehk': 'Qual-Kehk',
const NPC_GREETING_ZH_BY_KEY: Readonly<Record<string, readonly string[]>> = { qualkehk: 'Qual-Kehk',
akara: [ izual: 'Izual',
'我是阿卡拉,目盲之眼修女会的高阶女祭司。', izualghost: 'Izual',
'欢迎回来,我的朋友。我们仍在清理修道院,但你可以随时在这里休息。', ancientstatue1: 'Ancient Statue 1',
], ancientstatue2: 'Ancient Statue 2',
gheed: [ ancientstatue3: 'Ancient Statue 3',
'祝你好运,伙伴!我是基德,在这被遗忘的营地里我一定会成为你最好的朋友。', act2guard4: 'Kaelan',
'快来看看!花点金币交易,或者赌一把神秘物品!', act2guard5: 'Kaelan',
],
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': '夸尔凯克',
}; };
export function getLocalizedNpcName( export function getLocalizedNpcName(
@ -1554,20 +1566,14 @@ export function getLocalizedNpcName(
const fallbackEn = const fallbackEn =
desc?.displayName ?? (typeof nameOrDesc === 'string' ? nameOrDesc.trim() : nameOrDesc.displayName); desc?.displayName ?? (typeof nameOrDesc === 'string' ? nameOrDesc.trim() : nameOrDesc.displayName);
const tblKey = NPC_NAME_TBL_KEYS[lowerKey] ?? rawKey; const tblKey = NPC_NAME_TBL_KEYS[lowerKey] ?? rawKey;
if (lang === 'ENG') { return resolveTblKey(tblKey, lang) ?? fallbackEn;
return resolveTblKey(tblKey, 'ENG') ?? fallbackEn;
}
return NPC_NAME_ZH_BY_KEY[lowerKey] ?? resolveTblKey(tblKey, 'CHI') ?? fallbackEn;
} }
export function getLocalizedNpcGreetings( export function getLocalizedNpcGreetings(
descriptor: TownNpcServiceDescriptor, descriptor: TownNpcServiceDescriptor,
lang: 'CHI' | 'ENG' = 'CHI', _lang: 'CHI' | 'ENG' = 'CHI',
): readonly string[] { ): readonly string[] {
if (lang === 'ENG') {
return descriptor.greetingLines; return descriptor.greetingLines;
}
return NPC_GREETING_ZH_BY_KEY[descriptor.npcTxtKey.toLowerCase()] ?? descriptor.greetingLines;
} }
export function npcDisplayName( export function npcDisplayName(