diff --git a/src/client/ui-model/hud-model.ts b/src/client/ui-model/hud-model.ts index 0395f31..a8a2c95 100644 --- a/src/client/ui-model/hud-model.ts +++ b/src/client/ui-model/hud-model.ts @@ -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() @@ -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) - hud.worldPanels.selectedActTab = Math.max(1, Math.min(5, this.world.act + 1)) + 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, }) } } diff --git a/src/client/ui/world-panels.ts b/src/client/ui/world-panels.ts index 5b4b70e..70316ae 100644 --- a/src/client/ui/world-panels.ts +++ b/src/client/ui/world-panels.ts @@ -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 | undefined } export function buildNpcMenuOptions( @@ -426,85 +431,207 @@ export function buildNpcMenuOptions( labelEn: 'Gamble', }) } - if (descriptor.canIdentify && context?.hasUnidentifiedItems !== false) { - opts.push({ - id: 'identify', - strIdx: 4020, - label: '辨識物品', - labelZh: '辨識物品', - labelEn: 'Identify Items', - }) - } - if (descriptor.canHire) { - opts.push({ - id: 'hire', - label: '僱傭', - labelZh: '僱傭', - labelEn: 'Hire', - }) - } - if ( - (descriptor.canHire || descriptor.npcTxtKey === 'tyrael1' || descriptor.npcTxtKey === 'qual-kehk') && - context?.hasDeadMercenary - ) { - const cost = context?.deadMercenaryReviveCost ?? 0 - opts.push({ - id: 'resurrect', - label: `復活傭兵 (${cost})`, - labelZh: `復活傭兵 (${cost})`, - labelEn: `Resurrect (${cost})`, - }) - } - for (const travelAct of descriptor.travelActions ?? []) { - if (travelAct === 'travel_east') { - opts.push({ - id: 'travel_east', - label: '向東旅行', - labelZh: '向東旅行', - labelEn: 'Travel East', - }) - } else if (travelAct === 'travel_west') { - opts.push({ - id: 'travel_west', - label: '向西旅行', - labelZh: '向西旅行', - labelEn: 'Travel West', - }) - } else if (travelAct === 'travel_harrogath') { - opts.push({ - id: 'travel_harrogath', - label: '前往哈洛加斯', - labelZh: '前往哈洛加斯', - labelEn: 'Travel to Harrogath', - }) - } - } - if (context?.includeQuestRewards !== false) { - for (const rewardAct of descriptor.questRewardActions ?? []) { - if (rewardAct === 'imbue') { + + // 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: 'imbue', - label: '注入', - labelZh: '注入', - labelEn: 'Imbue', + id: 'identify', + strIdx: 4021, + label: `辨視物品:${cost}`, + labelZh: `辨視物品:${cost}`, + labelEn: `Identify Items: ${cost}`, }) - } else if (rewardAct === 'add_sockets') { + } else { opts.push({ - id: 'add_sockets', - label: '打孔', - labelZh: '打孔', - labelEn: 'Add Sockets', - }) - } else if (rewardAct === 'personalize') { - opts.push({ - id: 'personalize', - label: '個人化', - labelZh: '個人化', - labelEn: 'Personalize', + id: 'identify', + strIdx: 4020, + 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', + strIdx: 3397, + label: '雇用', + labelZh: '雇用', + labelEn: 'Hire', + }) + } + } + + // Resurrect: + if ( + (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', + 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', + 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', + 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', + }) + } else if (rewardAct === 'add_sockets') { + opts.push({ + id: 'add_sockets', + strIdx: 22748, + label: '增加凹槽', + labelZh: '增加凹槽', + labelEn: 'Add Sockets', + }) + } else if (rewardAct === 'personalize') { + opts.push({ + id: 'personalize', + strIdx: 22749, + label: '個人化', + labelZh: '個人化', + labelEn: 'Personalize', + }) + } + } + } + } + + // 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): 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') { - const tblObjective = this.lookupQuestString(quest.objectiveKey) - body = isEn - ? quest.objectiveEn || tblObjective || quest.objectiveZh || quest.description - : tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description + 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) + 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' diff --git a/src/common/units/npc-table.ts b/src/common/units/npc-table.ts index ad1be00..e9a116f 100644 --- a/src/common/units/npc-table.ts +++ b/src/common/units/npc-table.ts @@ -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> = { - 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> = { - 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> = { 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> = { - 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> = { - 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,20 +1566,14 @@ 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; + return descriptor.greetingLines; } export function npcDisplayName(