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:
parent
7136c3aefe
commit
c6c2eb2007
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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,21 +1566,15 @@ 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(
|
||||||
nameOrDesc: TownNpcServiceDescriptor | string,
|
nameOrDesc: TownNpcServiceDescriptor | string,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue