[功能] NPC:把城镇 NPC 画出来并可对话(凯恩 / 杰海因 / 赫拉铁力 / 拉祖克 / 尼拉塞克…) #6

Closed
opened 2026-09-14 05:56:45 +00:00 by troytt · 5 comments
Owner

本 issue 是 #5(对象层绘制)的一个特例,单独跟踪:NPC 的摆放数据已经在包里,缺的是美术基址解析、真实名字与交互,且它和"可破坏对象"是两套语义。

现象

acts.html 的城镇里没有任何 NPC:罗格营地没有凯恩、鲁高因没有杰海因、库拉斯特没有赫拉铁力、哈洛加斯没有拉祖克/德蕾雅、尼拉塞克神殿里也没有尼拉塞克。src/scene/act-scene.ts 里既没有 NPC 实体、也没有对话/任务代码。

排查:数据在哪、卡在哪

① 摆放数据已经在包里(被算进"无美术成员")

samples/d2-packs/**/scene.json 的对象列表里有 17 个 NPC:

关卡 token 是谁 个数
Act 1 - Town DC Deckard Cain(cain1) 4
Act 2 - Town JE Jerhyn 4
Act 3 - Town HR Hratli 2
Act 5 - Town XR / XS Larzuk / Drehya 2 / 1
Act 5 - Temple Boss 0J Nihlathak 4

它们都出现在打包器的"无美术成员"名单里(JE (act 2) has no art members、DC (act 1) has no art members…),而它们的坐标、深度、mode 都在包里,可以直接用。

② 卡在"美术基址"

这些行在 act 对象表里带 :m 标记,含义是 Base = /Data/Global/Monsters 而不是 /Data/Global/Objects(src/game/object-lookup-data.ts 文件头写明;reference/opendiablo2/d2core/d2records/object_lookup_record_data.go:12/259/311/569/570/572 就是这几行,Type: ObjectTypeCharacter、Base: "/Data/Global/Monsters")。

而 src/game/objects.ts:76 的 OBJECT_ROOT 固定是 data\global\objects\,objectSpriteMember / objectCofMember 都基于它拼路径 → DC/JE/HR/XR/XS/0J 全都解析不到美术。

美术本身在归档里是有的(samples/d2/listfile_113c.txt 计数):

data\global\monsters\JE\cof\JENUHTH.cof       data\global\monsters\JE\TR\JETRLITNUHTH.dcc
data\global\monsters\DC\COF\dca1hth.cof       (DC 共 11 个成员)
HR 7 个 / XR 6 个 / XS 8 个 / 0J 8 个成员

结构与对象完全一样:COF + TR/HD/… 组件 DCC,lit 装甲类、hth 武器类、NU/WL 模式。也就是说接上基址后,src/game/character.ts 那套 COF+DCC 合成可直接复用。

③ 另一条路:DS1 的"怪物/NPC 刷新点"被主动丢弃

  • src/game/objects.ts:461 resolveDs1Object():objectType !== OBJECT_TYPE_OBJECT(2) 时直接返回 { kind: 'monster', artless: true };
  • scripts/pack-act-assets.ts(对象循环里)if (resolved.kind === 'monster') continue —— 这类摆放根本不进包;
  • scripts/port-object-lookup.ts:30 注明只移植 ObjectTypeItem,ObjectTypeCharacter(怪物/NPC 行)明确未移植。

(当前 62 张图里的对象 DS1 type 全是 2,所以 NPC 是以"对象"形态进来、再卡在基址上的;一旦以后烘随机迷宫/野外或别的关卡,type=1 的刷新点会走上面这条丢弃路径。)

④ 对话/任务的地基在沙盒页,但没接到真实 NPC

src/scene/map-scene.ts(map.html 沙盒)已经有一整套 NPC 脚手架:TALK_RADIUS = 80(第 105 行)、DEMO_NPCS 回退 NPC(第 110 行,"Elder" + offer/progress/done 三阶段台词)、任务表 DEMO_QUESTS、npcEntities 在出生点附近找空地摆放(第 470-483 行)、T 键取最近 NPC 对话(第 567-580 行)、把 NPC 按同一画家序入队绘制(第 755-767 行,用的是夹具美术 actorGroups[2] + 金色 tint)、并把 state.npcs / state.npcsNear 发布给自动化检查。

缺的是:act 页完全没有这套东西,而且沙盒里那套用的是夹具精灵与自造台词,不是真实 NPC 的摆放/美术/名字。

⑤ 名字要另找来源

包里这些 NPC 的 name 是 Dummy(DC/HR/XR)或 jerhyn(JE)——来自 Objects.txt 行,而 NPC 的名字其实在 MonStats.txt/string.tbl(object_lookup_record_data.go 的行里另有 MonstatsTxtId 字段)。另外 HANDOVER.md 已注明本仓库的 .tbl 解码器尚未对真文件校准(中文场景名是手工整理的 level-names-zh.ts),所以 NPC 名字要么先走手工表,要么先把 tbl 修准。

需要的工作

  1. 基址:把 Base(/Data/Global/Objects vs /Data/Global/Monsters)纳入成员解析(src/game/objects.ts 的 objectSpriteMember/objectCofMember/resolveObjectArt),并把 :m 标记从 object-lookup-data.ts 一路带到调用方;
  2. 别再把 NPC 丢掉:打包器里 kind === 'monster' 的分支要区分"NPC(有名字要画)"与"怪物刷新点(本项目暂无怪物)",前者进包;
  3. 绘制:复用 #5 的对象层通路(解 COF+DCC → 中性站立 NU 首帧 → 锚点 → 按 depth 入画家序),朝向按 #3 的方向口径;NPC 不参与 hp/可破坏体系,也不该被攻击;
  4. 交互:把 map-scene.ts 的 TALK_RADIUS + T 键 + 三阶段台词/任务钩子搬到 act 页并接到真实 NPC 上(真实台词/任务文本来自 string.tbl 与任务表);
  5. 名字/标签:NPC 头顶名字(先手工表,tbl 修准后切真数据);
  6. 验收:进入 Act 1/2/3/5 城镇时应各有 1 组 NPC;scripts/browser/checks/ 增加断言(NPC 数、最近 NPC 距离、按 T 后对话行非空);发布状态里加 npcs 计数。

(关联:#5 对象层渲染是前置;#3 决定 NPC 的朝向是否正确。)

> 本 issue 是 #5(对象层绘制)的一个特例,单独跟踪:NPC 的**摆放数据已经在包里**,缺的是美术基址解析、真实名字与交互,且它和"可破坏对象"是两套语义。 ## 现象 `acts.html` 的城镇里**没有任何 NPC**:罗格营地没有凯恩、鲁高因没有杰海因、库拉斯特没有赫拉铁力、哈洛加斯没有拉祖克/德蕾雅、尼拉塞克神殿里也没有尼拉塞克。`src/scene/act-scene.ts` 里既没有 NPC 实体、也没有对话/任务代码。 ## 排查:数据在哪、卡在哪 ### ① 摆放数据已经在包里(被算进"无美术成员") `samples/d2-packs/**/scene.json` 的对象列表里有 17 个 NPC: | 关卡 | token | 是谁 | 个数 | | --- | --- | --- | --- | | Act 1 - Town | `DC` | Deckard Cain(cain1) | 4 | | Act 2 - Town | `JE` | Jerhyn | 4 | | Act 3 - Town | `HR` | Hratli | 2 | | Act 5 - Town | `XR` / `XS` | Larzuk / Drehya | 2 / 1 | | Act 5 - Temple Boss | `0J` | Nihlathak | 4 | 它们都出现在打包器的"无美术成员"名单里(`JE (act 2) has no art members`、`DC (act 1) has no art members`…),而它们的**坐标、深度、mode 都在包里**,可以直接用。 ### ② 卡在"美术基址" 这些行在 act 对象表里带 `:m` 标记,含义是 `Base = /Data/Global/Monsters` 而不是 `/Data/Global/Objects`(`src/game/object-lookup-data.ts` 文件头写明;`reference/opendiablo2/d2core/d2records/object_lookup_record_data.go:12/259/311/569/570/572` 就是这几行,`Type: ObjectTypeCharacter`、`Base: "/Data/Global/Monsters"`)。 而 `src/game/objects.ts:76` 的 `OBJECT_ROOT` 固定是 `data\global\objects\`,`objectSpriteMember` / `objectCofMember` 都基于它拼路径 → `DC`/`JE`/`HR`/`XR`/`XS`/`0J` 全都解析不到美术。 **美术本身在归档里是有的**(`samples/d2/listfile_113c.txt` 计数): ``` data\global\monsters\JE\cof\JENUHTH.cof data\global\monsters\JE\TR\JETRLITNUHTH.dcc data\global\monsters\DC\COF\dca1hth.cof (DC 共 11 个成员) HR 7 个 / XR 6 个 / XS 8 个 / 0J 8 个成员 ``` 结构与对象完全一样:`COF` + `TR/HD/…` 组件 DCC,`lit` 装甲类、`hth` 武器类、`NU`/`WL` 模式。也就是说接上基址后,`src/game/character.ts` 那套 COF+DCC 合成可直接复用。 ### ③ 另一条路:DS1 的"怪物/NPC 刷新点"被主动丢弃 - `src/game/objects.ts:461 resolveDs1Object()`:`objectType !== OBJECT_TYPE_OBJECT(2)` 时直接返回 `{ kind: 'monster', artless: true }`; - `scripts/pack-act-assets.ts`(对象循环里)`if (resolved.kind === 'monster') continue` —— 这类摆放**根本不进包**; - `scripts/port-object-lookup.ts:30` 注明只移植 `ObjectTypeItem`,`ObjectTypeCharacter`(怪物/NPC 行)明确未移植。 (当前 62 张图里的对象 DS1 `type` 全是 2,所以 NPC 是以"对象"形态进来、再卡在基址上的;一旦以后烘随机迷宫/野外或别的关卡,type=1 的刷新点会走上面这条丢弃路径。) ### ④ 对话/任务的地基在沙盒页,但没接到真实 NPC `src/scene/map-scene.ts`(`map.html` 沙盒)已经有一整套 NPC 脚手架:`TALK_RADIUS = 80`(第 105 行)、`DEMO_NPCS` 回退 NPC(第 110 行,"Elder" + offer/progress/done 三阶段台词)、任务表 `DEMO_QUESTS`、`npcEntities` 在出生点附近找空地摆放(第 470-483 行)、`T` 键取最近 NPC 对话(第 567-580 行)、把 NPC 按同一画家序入队绘制(第 755-767 行,用的是**夹具美术** `actorGroups[2]` + 金色 tint)、并把 `state.npcs` / `state.npcsNear` 发布给自动化检查。 缺的是:**act 页完全没有这套东西**,而且沙盒里那套用的是夹具精灵与自造台词,不是真实 NPC 的摆放/美术/名字。 ### ⑤ 名字要另找来源 包里这些 NPC 的 `name` 是 `Dummy`(DC/HR/XR)或 `jerhyn`(JE)——来自 `Objects.txt` 行,而 NPC 的名字其实在 `MonStats.txt`/`string.tbl`(`object_lookup_record_data.go` 的行里另有 `MonstatsTxtId` 字段)。另外 `HANDOVER.md` 已注明本仓库的 `.tbl` 解码器**尚未对真文件校准**(中文场景名是手工整理的 `level-names-zh.ts`),所以 NPC 名字要么先走手工表,要么先把 tbl 修准。 ## 需要的工作 1. **基址**:把 `Base`(`/Data/Global/Objects` vs `/Data/Global/Monsters`)纳入成员解析(`src/game/objects.ts` 的 `objectSpriteMember`/`objectCofMember`/`resolveObjectArt`),并把 `:m` 标记从 `object-lookup-data.ts` 一路带到调用方; 2. **别再把 NPC 丢掉**:打包器里 `kind === 'monster'` 的分支要区分"NPC(有名字要画)"与"怪物刷新点(本项目暂无怪物)",前者进包; 3. **绘制**:复用 #5 的对象层通路(解 COF+DCC → 中性站立 `NU` 首帧 → 锚点 → 按 `depth` 入画家序),朝向按 #3 的方向口径;NPC 不参与 `hp`/可破坏体系,也不该被攻击; 4. **交互**:把 `map-scene.ts` 的 `TALK_RADIUS` + `T` 键 + 三阶段台词/任务钩子搬到 act 页并接到真实 NPC 上(真实台词/任务文本来自 `string.tbl` 与任务表); 5. **名字/标签**:NPC 头顶名字(先手工表,`tbl` 修准后切真数据); 6. **验收**:进入 Act 1/2/3/5 城镇时应各有 1 组 NPC;`scripts/browser/checks/` 增加断言(NPC 数、最近 NPC 距离、按 T 后对话行非空);发布状态里加 `npcs` 计数。 (关联:#5 对象层渲染是前置;#3 决定 NPC 的朝向是否正确。)
Author
Owner

Issue #6 深度排查分析与解决步骤全景方案

经过对数据表结构、资源解析管线、资产打包器以及场景渲染器的系统性排查,现将城镇 NPC(凯恩、杰海因、赫拉铁力、拉祖克、德蕾雅、尼拉塞克等)在 acts.html 中缺失的根本原因分析与具体解决步骤汇报如下。


一、根本原因排查与深度剖析

1. 美术资源基址硬编码(Root Path Hardcoding)

  • 现象:打包器输出时,所有城镇 NPC 均被判定为 has no art members(例如 DC (act 1) has no art members, JE (act 2) has no art members),导致输出到 samples/d2-packs/**/scene.json 中的这 17 个 NPC 对象 member: null, frame: null。
  • 定位:
    • 在 src/game/objects.ts:76 中,根路径硬编码为:
      const OBJECT_ROOT = 'data\\global\\objects\\'
      
      无论是 objectCofMember 还是 objectSpriteMember,均基于 OBJECT_ROOT 拼装路径。
    • 在 src/game/object-lookup-data.ts 中,这 11 条 NPC 记录均带有 :m 后缀(如 110:385:DC:NU:d1:m,16:121:JE:NU:d1:m 等),表示其资源基址在 data\global\monsters\ 而非 data\global\objects\。
    • 虽然 src/game/object-lookup.ts 的 parseAct 正确将 :m 解析为了 baseIsMonsters: true,但该标志从未被传递至 resolveObjectArt、objectCofMember 或 objectSpriteMember。
    • 更关键的是,在 scripts/pack-act-assets.ts:338 中,打包器建立的 objectMembers 字典仅扫描了 OBJECT_PREFIX = 'data\\global\\objects\\',完全漏掉了 data\global\monsters\,导致 NPC 即使查找 token 也必定返回空(dirs === undefined)。

2. 打包器与对象解析对 DS1 怪物刷新点的过滤丢弃

  • 现象:当前关卡仅包含了 DS1 type = 2(作为 Object 摆放)的 NPC,而任何以 type = 1(怪物/NPC 刷新点)摆放的实体被完全抹去。
  • 定位:
    • src/game/objects.ts:470 resolveDs1Object():当 objectType !== OBJECT_TYPE_OBJECT (2) 时直接返回 { kind: 'monster', artless: true }。
    • scripts/pack-act-assets.ts:495:遇到 resolved.kind === 'monster' 直接 continue 略过。
    • scripts/port-object-lookup.ts:30 注明目前只移植了 ObjectTypeItem(3,615 条),ObjectTypeCharacter 尚未移植。若后续支持非预设关卡或野外地图,以 type = 1 放置的 NPC 会在此阶段直接丢失。

3. acts.html (src/scene/act-scene.ts) 渲染层与实体层断层

  • 现象:acts.html 画面中没有任何对象渲染,只有地面、墙体、屋顶和玩家。
  • 定位:
    • src/scene/act-scene.ts 中,MapRuntime 仅将 objects: scene.objects.length 作为一个只读数字存储用于 HUD 统计,完全没有将 scene.objects 解析为运行时实体。
    • 在渲染循环 drawFrame 中,仅绘制了:
      1. floors(地面)
      2. walls[0..insertAt](前层墙)
      3. character(玩家)
      4. walls[insertAt..length](后层墙)
      5. roofs(屋顶)
        根本没有对象(Objects)与 NPC 的绘制阶段。

4. 对话系统与交互 UI 仅停留在沙盒页

  • 现象:玩家走近 NPC 无法交谈,按 T 无反应,页面没有 #dialog 容器。
  • 定位:
    • src/scene/map-scene.ts(沙盒)虽然已经构建了 TALK_RADIUS = 80、键盘 T 键触发、QuestLog 三阶段对话逻辑(offerLines, progressLines, doneLines),但该套逻辑完全未引入 src/scene/act-scene.ts。
    • 沙盒使用的也是夹具精灵(actorGroups[2] + 金色 tint)与 DEMO_NPCS,并未对接真实 NPC 数据。

5. NPC 名称与文本来源断层

  • 现象:Objects.txt 中这些 NPC 的名字为占位符(Dummy)或内部小写代号(jerhyn)。
  • 定位:
    • 真实 NPC 名字保存在 MonStats.txt 中,并通过名字字符串引用 string.tbl。
    • 目前本项目的 .tbl 解码器尚未对官方真实 .tbl 文件进行全面校验(场景名也是通过 level-names-zh.ts 静态映射实现)。

二、分步解决步骤与实现指南

为了在 acts.html 中完整呈现城镇 NPC 并实现可交互对话,建议按以下 6 个步骤实施:

步骤 1:修复美术资源基址分流(Objects vs Monsters)

  1. 修改 src/game/objects.ts:
    • 增加基址常量与路径选择逻辑:
      const OBJECT_ROOT = 'data\\global\\objects\\'
      const MONSTER_ROOT = 'data\\global\\monsters\\'
      
      export function objectRoot(baseIsMonsters?: boolean): string {
        return baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT
      }
      
    • 扩展 objectCofMember 与 objectSpriteMember,接受 baseIsMonsters?: boolean 参数并使用对应前缀。
    • 更新 ObjectArtRequest 接口,增加 readonly baseIsMonsters?: boolean。
    • 在 resolveObjectArt 中,将 request.baseIsMonsters 传入 objectCofMember 与 objectSpriteMember,并在备选模糊匹配中根据该标志选择 prefix = {objectRoot(baseIsMonsters)}{token.toLowerCase()}\``。
  2. 修改 scripts/pack-act-assets.ts 打包管线:
    • 扫描归档列表时,同时收集 data\global\objects\ 与 data\global\monsters\ 的成员。
    • 使 objectMembers 能够根据 token 和 mode 正确匹配到 data\global\monsters\<TOKEN>\... 下的 COF 与 DCC 文件。
    • 在生成的 scene.json 中保留 baseIsMonsters、direction 等必要元数据。

步骤 2:建立静态 NPC 名称与身份字典

  • 在 string.tbl 校验完全成熟前,参照 level-names-zh.ts 在 src/game/npc-names.ts 中建立权威映射:
    export interface NpcIdentity {
      readonly token: string
      readonly nameEn: string
      readonly nameZh: string
      readonly isTownNpc: boolean
    }
    
    export const TOWN_NPCS: Record<string, NpcIdentity> = {
      DC: { token: 'DC', nameEn: 'Deckard Cain', nameZh: '迪卡·凯恩', isTownNpc: true },
      JE: { token: 'JE', nameEn: 'Jerhyn', nameZh: '杰海因', isTownNpc: true },
      HR: { token: 'HR', nameEn: 'Hratli', nameZh: '赫拉铁力', isTownNpc: true },
      XR: { token: 'XR', nameEn: 'Larzuk', nameZh: '拉祖克', isTownNpc: true },
      XS: { token: 'XS', nameEn: 'Drehya', nameZh: '德蕾雅(安亚)', isTownNpc: true },
      '0J': { token: '0J', nameEn: 'Nihlathak', nameZh: '尼拉塞克', isTownNpc: true },
      DI: { token: 'DI', nameEn: 'Hadriel', nameZh: '哈德尔', isTownNpc: true },
    }
    

步骤 3:NPC 实体独立建模与语义解耦

  • 在 src/scene/act-scene.ts 中,加载 scene.objects 时将 NPC 实体与普通物品分离:
    • 环境物件(箱子、门、神殿):具备 hp、破坏状态与开/关交互。
    • NPC 实体:
      • kind: 'npc'
      • 赋予 invulnerable: true:不参与攻击判定,不受伤害,无法被玩家技能命中。
      • 静态阻挡体积:在碰撞网格中保留其占位,防止玩家与 NPC 发生模型重叠穿模。
      • 初始状态为中性待机 NU(方向使用数据中的 direction,如 d1/d7)。

步骤 4:集成深度排序与精灵绘制(Painter's Algorithm)

  • 在 MapRuntime 中增加 npcs: readonly NpcDrawable[]。
  • 复用 src/game/character.ts 的 COF+DCC 合成渲染逻辑或加载预烘焙的 NPC 精灵帧。
  • 在 act-scene.ts 的主渲染循环中:
    • 将 NPC 作为 actor 纳入深度插入队列:
      const insertAt = depthInsertIndex(runtime.walls, npc.cellX, npc.cellY)
      
    • 根据脚底锚点(Bottom Center Anchor)绘制 NPC 的待机帧。
    • 在 NPC 头顶绘制高亮姓名标签(平时显示,走近时显示交互提示)。

步骤 5:移植交互系统与对话 UI

  • 在 acts.html 对应的 HTML DOM 中添加对话框容器 #dialog,并应用 Diablo II 风格半透明暗金边框样式。
  • 在 act-scene.ts 中引入输入监听(T 键 / 鼠标左键点击 NPC):
    • 当 Math.hypot(npc.x - player.x, npc.y - player.y) <= 80(TALK_RADIUS)时触发交互。
    • 对接 QuestLog 逻辑,分发对应 NPC 的引导与任务台词(offer / progress / done)。
    • 在 HUD 状态栏中同步暴露 state.npcs、state.npcsNear、state.dialog 供外部检测。

步骤 6:编写自动化回归测试门禁

  • 在 scripts/browser/checks/ 下新增 verify-town-npcs.ts:
    • 启动无头浏览器分别访问 Act 1、2、3、5 城镇:
      • 验证各城镇地图中对应 NPC(凯恩、杰海因、赫拉铁力、拉祖克等)数量大于 0。
      • 验证玩家移动到 NPC 附近 80px 范围内时,按 T 键能够呼出对话框并包含有效台词。
    • 确保与现有的 DT1 碰撞(Issue #4)、战斗、网络等测试套件协同,集成至 npm run verify:all。
## Issue #6 深度排查分析与解决步骤全景方案 经过对数据表结构、资源解析管线、资产打包器以及场景渲染器的系统性排查,现将城镇 NPC(凯恩、杰海因、赫拉铁力、拉祖克、德蕾雅、尼拉塞克等)在 `acts.html` 中缺失的**根本原因分析**与**具体解决步骤**汇报如下。 --- ### 一、根本原因排查与深度剖析 #### 1. 美术资源基址硬编码(Root Path Hardcoding) - **现象**:打包器输出时,所有城镇 NPC 均被判定为 `has no art members`(例如 `DC (act 1) has no art members`, `JE (act 2) has no art members`),导致输出到 `samples/d2-packs/**/scene.json` 中的这 17 个 NPC 对象 `member: null`, `frame: null`。 - **定位**: - 在 `src/game/objects.ts:76` 中,根路径硬编码为: ```ts const OBJECT_ROOT = 'data\\global\\objects\\' ``` 无论是 `objectCofMember` 还是 `objectSpriteMember`,均基于 `OBJECT_ROOT` 拼装路径。 - 在 `src/game/object-lookup-data.ts` 中,这 11 条 NPC 记录均带有 `:m` 后缀(如 `110:385:DC:NU:d1:m`,`16:121:JE:NU:d1:m` 等),表示其资源基址在 `data\global\monsters\` 而非 `data\global\objects\`。 - 虽然 `src/game/object-lookup.ts` 的 `parseAct` 正确将 `:m` 解析为了 `baseIsMonsters: true`,但该标志**从未被传递**至 `resolveObjectArt`、`objectCofMember` 或 `objectSpriteMember`。 - 更关键的是,在 `scripts/pack-act-assets.ts:338` 中,打包器建立的 `objectMembers` 字典**仅扫描了** `OBJECT_PREFIX = 'data\\global\\objects\\'`,完全漏掉了 `data\global\monsters\`,导致 NPC 即使查找 token 也必定返回空(`dirs === undefined`)。 #### 2. 打包器与对象解析对 DS1 怪物刷新点的过滤丢弃 - **现象**:当前关卡仅包含了 DS1 `type = 2`(作为 Object 摆放)的 NPC,而任何以 `type = 1`(怪物/NPC 刷新点)摆放的实体被完全抹去。 - **定位**: - `src/game/objects.ts:470 resolveDs1Object()`:当 `objectType !== OBJECT_TYPE_OBJECT (2)` 时直接返回 `{ kind: 'monster', artless: true }`。 - `scripts/pack-act-assets.ts:495`:遇到 `resolved.kind === 'monster'` 直接 `continue` 略过。 - `scripts/port-object-lookup.ts:30` 注明目前只移植了 `ObjectTypeItem`(3,615 条),`ObjectTypeCharacter` 尚未移植。若后续支持非预设关卡或野外地图,以 `type = 1` 放置的 NPC 会在此阶段直接丢失。 #### 3. `acts.html` (`src/scene/act-scene.ts`) 渲染层与实体层断层 - **现象**:`acts.html` 画面中没有任何对象渲染,只有地面、墙体、屋顶和玩家。 - **定位**: - `src/scene/act-scene.ts` 中,`MapRuntime` 仅将 `objects: scene.objects.length` 作为一个只读数字存储用于 HUD 统计,**完全没有将 `scene.objects` 解析为运行时实体**。 - 在渲染循环 `drawFrame` 中,仅绘制了: 1. `floors`(地面) 2. `walls[0..insertAt]`(前层墙) 3. `character`(玩家) 4. `walls[insertAt..length]`(后层墙) 5. `roofs`(屋顶) 根本没有对象(Objects)与 NPC 的绘制阶段。 #### 4. 对话系统与交互 UI 仅停留在沙盒页 - **现象**:玩家走近 NPC 无法交谈,按 `T` 无反应,页面没有 `#dialog` 容器。 - **定位**: - `src/scene/map-scene.ts`(沙盒)虽然已经构建了 `TALK_RADIUS = 80`、键盘 `T` 键触发、`QuestLog` 三阶段对话逻辑(`offerLines`, `progressLines`, `doneLines`),但该套逻辑完全未引入 `src/scene/act-scene.ts`。 - 沙盒使用的也是夹具精灵(`actorGroups[2]` + 金色 tint)与 `DEMO_NPCS`,并未对接真实 NPC 数据。 #### 5. NPC 名称与文本来源断层 - **现象**:`Objects.txt` 中这些 NPC 的名字为占位符(`Dummy`)或内部小写代号(`jerhyn`)。 - **定位**: - 真实 NPC 名字保存在 `MonStats.txt` 中,并通过名字字符串引用 `string.tbl`。 - 目前本项目的 `.tbl` 解码器尚未对官方真实 `.tbl` 文件进行全面校验(场景名也是通过 `level-names-zh.ts` 静态映射实现)。 --- ### 二、分步解决步骤与实现指南 为了在 `acts.html` 中完整呈现城镇 NPC 并实现可交互对话,建议按以下 6 个步骤实施: #### 步骤 1:修复美术资源基址分流(Objects vs Monsters) 1. **修改 `src/game/objects.ts`**: - 增加基址常量与路径选择逻辑: ```ts const OBJECT_ROOT = 'data\\global\\objects\\' const MONSTER_ROOT = 'data\\global\\monsters\\' export function objectRoot(baseIsMonsters?: boolean): string { return baseIsMonsters ? MONSTER_ROOT : OBJECT_ROOT } ``` - 扩展 `objectCofMember` 与 `objectSpriteMember`,接受 `baseIsMonsters?: boolean` 参数并使用对应前缀。 - 更新 `ObjectArtRequest` 接口,增加 `readonly baseIsMonsters?: boolean`。 - 在 `resolveObjectArt` 中,将 `request.baseIsMonsters` 传入 `objectCofMember` 与 `objectSpriteMember`,并在备选模糊匹配中根据该标志选择 `prefix = `${objectRoot(baseIsMonsters)}${token.toLowerCase()}\\``。 2. **修改 `scripts/pack-act-assets.ts` 打包管线**: - 扫描归档列表时,同时收集 `data\global\objects\` 与 `data\global\monsters\` 的成员。 - 使 `objectMembers` 能够根据 token 和 mode 正确匹配到 `data\global\monsters\<TOKEN>\...` 下的 COF 与 DCC 文件。 - 在生成的 `scene.json` 中保留 `baseIsMonsters`、`direction` 等必要元数据。 #### 步骤 2:建立静态 NPC 名称与身份字典 - 在 `string.tbl` 校验完全成熟前,参照 `level-names-zh.ts` 在 `src/game/npc-names.ts` 中建立权威映射: ```ts export interface NpcIdentity { readonly token: string readonly nameEn: string readonly nameZh: string readonly isTownNpc: boolean } export const TOWN_NPCS: Record<string, NpcIdentity> = { DC: { token: 'DC', nameEn: 'Deckard Cain', nameZh: '迪卡·凯恩', isTownNpc: true }, JE: { token: 'JE', nameEn: 'Jerhyn', nameZh: '杰海因', isTownNpc: true }, HR: { token: 'HR', nameEn: 'Hratli', nameZh: '赫拉铁力', isTownNpc: true }, XR: { token: 'XR', nameEn: 'Larzuk', nameZh: '拉祖克', isTownNpc: true }, XS: { token: 'XS', nameEn: 'Drehya', nameZh: '德蕾雅(安亚)', isTownNpc: true }, '0J': { token: '0J', nameEn: 'Nihlathak', nameZh: '尼拉塞克', isTownNpc: true }, DI: { token: 'DI', nameEn: 'Hadriel', nameZh: '哈德尔', isTownNpc: true }, } ``` #### 步骤 3:NPC 实体独立建模与语义解耦 - 在 `src/scene/act-scene.ts` 中,加载 `scene.objects` 时将 NPC 实体与普通物品分离: - **环境物件**(箱子、门、神殿):具备 `hp`、破坏状态与开/关交互。 - **NPC 实体**: - `kind: 'npc'` - 赋予 `invulnerable: true`:不参与攻击判定,不受伤害,无法被玩家技能命中。 - 静态阻挡体积:在碰撞网格中保留其占位,防止玩家与 NPC 发生模型重叠穿模。 - 初始状态为中性待机 `NU`(方向使用数据中的 `direction`,如 `d1`/`d7`)。 #### 步骤 4:集成深度排序与精灵绘制(Painter's Algorithm) - 在 `MapRuntime` 中增加 `npcs: readonly NpcDrawable[]`。 - 复用 `src/game/character.ts` 的 COF+DCC 合成渲染逻辑或加载预烘焙的 NPC 精灵帧。 - 在 `act-scene.ts` 的主渲染循环中: - 将 NPC 作为 actor 纳入深度插入队列: ```ts const insertAt = depthInsertIndex(runtime.walls, npc.cellX, npc.cellY) ``` - 根据脚底锚点(Bottom Center Anchor)绘制 NPC 的待机帧。 - 在 NPC 头顶绘制高亮姓名标签(平时显示,走近时显示交互提示)。 #### 步骤 5:移植交互系统与对话 UI - 在 `acts.html` 对应的 HTML DOM 中添加对话框容器 `#dialog`,并应用 Diablo II 风格半透明暗金边框样式。 - 在 `act-scene.ts` 中引入输入监听(`T` 键 / 鼠标左键点击 NPC): - 当 `Math.hypot(npc.x - player.x, npc.y - player.y) <= 80`(`TALK_RADIUS`)时触发交互。 - 对接 `QuestLog` 逻辑,分发对应 NPC 的引导与任务台词(offer / progress / done)。 - 在 HUD 状态栏中同步暴露 `state.npcs`、`state.npcsNear`、`state.dialog` 供外部检测。 #### 步骤 6:编写自动化回归测试门禁 - 在 `scripts/browser/checks/` 下新增 `verify-town-npcs.ts`: - 启动无头浏览器分别访问 Act 1、2、3、5 城镇: - 验证各城镇地图中对应 NPC(凯恩、杰海因、赫拉铁力、拉祖克等)数量大于 0。 - 验证玩家移动到 NPC 附近 80px 范围内时,按 `T` 键能够呼出对话框并包含有效台词。 - 确保与现有的 DT1 碰撞(Issue #4)、战斗、网络等测试套件协同,集成至 `npm run verify:all`。
Author
Owner

已修复 — feat(scene): 衔接 NPC 数据至引擎层及交互面板验证 (7d8629c)

  • 新增 src/game/npc.ts:token → 显示名解析(DC→Deckard Cain、JE→Jerhyn、HR→Hratli、XR→Larzuk、XS→Drehya、0J→Nihlathak)。
  • scripts/pack-act-assets.ts 此前在 resolved.kind === 'monster' 处直接 continue,把 NPC 整个丢掉了;现已保留并单独存储其地图定位。
  • src/game/objects.ts 打通 baseIsMonsters 寻址 —— NPC 的美术资源根目录是 monsters/ 而非 objects/,这是之前渲染不出来的直接原因。
  • 接入 #10 预留的引擎 NPC 接缝,补上标签 / 对话 DOM 与 scripts/browser/checks/* 的断言(npcs、nearestNpcDistance、dialogOpen、KeyT)。

验证(冷装): typecheck 0,全套件绿,shuffle 稳定。变异测试由我方另行选定(把 objects.ts:571 与 :593 的 root 强制指回 monsters base),对应的非 :m 回归用例正确转红。

⚠️ 未验证的部分(如实声明,非遗漏):

  1. 未能端到端跑通 pack-act-assets.ts 对真实 monsters/ token 的处理 —— 也就是说,DCC 精灵帧的对齐是否与 DS1 中的出生坐标严丝合缝、是否还需要偏移微调,目前无法证明。
  2. 未验证浏览器中的实际视觉效果。

根因是本仓库中 samples/d2(真实 MPQ 资源)与 samples/d2-packs 均不存在,只有 samples/fixtures/ 下的合成资源。拿到真实资源后,这两点需要复核。

### 已修复 — `feat(scene): 衔接 NPC 数据至引擎层及交互面板验证` (7d8629c) - 新增 `src/game/npc.ts`:token → 显示名解析(`DC`→Deckard Cain、`JE`→Jerhyn、`HR`→Hratli、`XR`→Larzuk、`XS`→Drehya、`0J`→Nihlathak)。 - `scripts/pack-act-assets.ts` 此前在 `resolved.kind === 'monster'` 处直接 `continue`,把 NPC 整个丢掉了;现已保留并单独存储其地图定位。 - `src/game/objects.ts` 打通 `baseIsMonsters` 寻址 —— NPC 的美术资源根目录是 `monsters/` 而非 `objects/`,这是之前渲染不出来的直接原因。 - 接入 #10 预留的引擎 NPC 接缝,补上标签 / 对话 DOM 与 `scripts/browser/checks/*` 的断言(`npcs`、`nearestNpcDistance`、`dialogOpen`、`KeyT`)。 **验证(冷装):** typecheck 0,全套件绿,shuffle 稳定。变异测试由我方另行选定(把 `objects.ts:571` 与 `:593` 的 root 强制指回 monsters base),对应的非 `:m` 回归用例正确转红。 > **⚠️ 未验证的部分(如实声明,非遗漏):** > 1. **未能端到端跑通 `pack-act-assets.ts` 对真实 `monsters/` token 的处理** —— 也就是说,DCC 精灵帧的对齐是否与 DS1 中的出生坐标严丝合缝、是否还需要偏移微调,目前无法证明。 > 2. **未验证浏览器中的实际视觉效果。** > > 根因是本仓库中 `samples/d2`(真实 MPQ 资源)与 `samples/d2-packs` 均不存在,只有 `samples/fixtures/` 下的合成资源。拿到真实资源后,这两点需要复核。
troytt reopened this issue 2026-09-14 14:59:56 +00:00
Author
Owner

追加修复:城镇 NPC 大面积缺失(罗格营地只显示凯恩)

现象

罗格营地只渲染出德卡·凯恩一人,瓦瑞夫、查希、卡夏、阿卡拉、基德以及罗格哨兵全部缺席。

根因(两层)

1. scripts/pack-act-assets.ts 中 data\global\monsters\ 前缀长度被手写成 22,实际是 21。
slice(22) 把 DC 截成了 C,导致所有 NPC 美术查找全部落空。改为从命名常量推导。

2. DS1 type === 1(MonPreset.txt 索引的怪物/NPC 放置)在整个代码库中没有任何实现。
resolveDs1Object 对非 type-2 的条目一律返回 kind:'monster' + 空 token,打包脚本随即 continue 丢弃。实测 Act 1 城镇四个分区共 67 条 type-1 条目被静默丢弃(TownN1 15 / TownE1 18 / TownS1 16 / TownW1 18),只剩 103 条 type-2。今天能渲染出来的 6 个 NPC 恰好就是 NPC_NAME_FALLBACKS 里硬编码的那 6 个 type-2 对象——所以只看得到凯恩。

修复

  • src/game/acts.ts:ActTables 增加 monstats / monpreset,loadActTables 一并解析。
  • src/game/objects.ts:导出 MONSTER_ROOT,新增 MonstersTable,resolveDs1Object 增加 type-1 分支(按 act 过滤 MonPreset,数组下标即 DS1 id → Place → monstats Code 美术 token)。Code 在出厂表里大小写不一致(K9/k9、ja、6z、7i、7j),在解析源头统一规范化。
  • scripts/pack-act-assets.ts:预建 presetPlaceByAct / statsById 索引;Place 为空时抛错而非跳过(数组下标即 id,跳行会让后续每个 NPC 整体错位,与 22/21 是同一类 bug;实测五幕零空行,该分支仅作为响亮的失败护栏);无美术的放置点计数跳过而不是烤成空对象。
  • src/game/npc.ts:补 RG/CK/CW 名称。

验证

项目 结果
npx tsc --noEmit 0 error
npx vitest run 429 passed / 2 skipped(431)
npm run pack:data 365 张地图,354.7 MB,无美术放置点 564,无美术 NPC 0
npm run verify:packs 1302/1302,逐像素比对 278.6 MB
NPC 实例 889 个,分布于 117 个场景,0 未解析,0 大小写歧义 token

修复前基线:103 个实例 / 27 个场景。

罗格营地 townw1 实测名册(19 人):瓦瑞夫(WA)、卡夏(RC)、阿卡拉(PS)、查希(CI)、基德(GH)、凯恩(DC),外加母鸡 5、罗格哨兵 5、牛 3。四个分区分别为 16 / 19 / 17 / 19 人(修复前各 1 人)。

新增回归护栏:

  • tests/resolve-type1.test.ts——type-1 解析链路,含小写 Code 规范化用例。
  • tests/packed-npc-art.test.ts——已打包的每个 NPC 都必须有 member 与 frame,且同一 token 不得出现两种拼写。

两个测试都做过变异验证:把 presets[objectId] 改成 presets[0] → 前者 2 fail;把 map.get(token.toUpperCase()) 退回 map.get(token) → 补测试前全绿(说明大小写修复原本零覆盖),补测试后转红。

落地提交:68c7890、ad7ced6、451816d、3cd4549、e180520,已推送 main。

## 追加修复:城镇 NPC 大面积缺失(罗格营地只显示凯恩) ### 现象 罗格营地只渲染出德卡·凯恩一人,瓦瑞夫、查希、卡夏、阿卡拉、基德以及罗格哨兵全部缺席。 ### 根因(两层) **1. `scripts/pack-act-assets.ts` 中 `data\global\monsters\` 前缀长度被手写成 22,实际是 21。** `slice(22)` 把 `DC` 截成了 `C`,导致**所有** NPC 美术查找全部落空。改为从命名常量推导。 **2. DS1 `type === 1`(MonPreset.txt 索引的怪物/NPC 放置)在整个代码库中没有任何实现。** `resolveDs1Object` 对非 type-2 的条目一律返回 `kind:'monster'` + 空 token,打包脚本随即 `continue` 丢弃。实测 Act 1 城镇四个分区共 **67 条 type-1 条目被静默丢弃**(TownN1 15 / TownE1 18 / TownS1 16 / TownW1 18),只剩 103 条 type-2。今天能渲染出来的 6 个 NPC 恰好就是 `NPC_NAME_FALLBACKS` 里硬编码的那 6 个 type-2 对象——所以只看得到凯恩。 ### 修复 - `src/game/acts.ts`:`ActTables` 增加 `monstats` / `monpreset`,`loadActTables` 一并解析。 - `src/game/objects.ts`:导出 `MONSTER_ROOT`,新增 `MonstersTable`,`resolveDs1Object` 增加 type-1 分支(按 act 过滤 MonPreset,数组下标即 DS1 id → `Place` → monstats `Code` 美术 token)。`Code` 在出厂表里大小写不一致(`K9`/`k9`、`ja`、`6z`、`7i`、`7j`),在解析源头统一规范化。 - `scripts/pack-act-assets.ts`:预建 `presetPlaceByAct` / `statsById` 索引;`Place` 为空时**抛错而非跳过**(数组下标即 id,跳行会让后续每个 NPC 整体错位,与 22/21 是同一类 bug;实测五幕零空行,该分支仅作为响亮的失败护栏);无美术的放置点计数跳过而不是烤成空对象。 - `src/game/npc.ts`:补 `RG`/`CK`/`CW` 名称。 ### 验证 | 项目 | 结果 | | :-- | :-- | | `npx tsc --noEmit` | 0 error | | `npx vitest run` | 429 passed / 2 skipped(431) | | `npm run pack:data` | 365 张地图,354.7 MB,无美术放置点 564,无美术 NPC **0** | | `npm run verify:packs` | **1302/1302**,逐像素比对 278.6 MB | | NPC 实例 | **889 个,分布于 117 个场景,0 未解析,0 大小写歧义 token** | 修复前基线:103 个实例 / 27 个场景。 罗格营地 townw1 实测名册(19 人):瓦瑞夫(WA)、卡夏(RC)、阿卡拉(PS)、查希(CI)、基德(GH)、凯恩(DC),外加母鸡 5、罗格哨兵 5、牛 3。四个分区分别为 16 / 19 / 17 / 19 人(修复前各 1 人)。 新增回归护栏: - `tests/resolve-type1.test.ts`——type-1 解析链路,含小写 `Code` 规范化用例。 - `tests/packed-npc-art.test.ts`——已打包的每个 NPC 都必须有 `member` 与 `frame`,且同一 token 不得出现两种拼写。 两个测试都做过变异验证:把 `presets[objectId]` 改成 `presets[0]` → 前者 2 fail;把 `map.get(token.toUpperCase())` 退回 `map.get(token)` → 补测试前全绿(说明大小写修复原本零覆盖),补测试后转红。 落地提交:`68c7890`、`ad7ced6`、`451816d`、`3cd4549`、`e180520`,已推送 `main`。
Author
Owner

追加:NPC 有立绘时不再叠加绿色占位方块

上一条修复把城镇 NPC 从 103 补到 889 之后暴露出一个渲染层面的旧问题:罗格营地一个分区会出现 19 个绿色方块糊在正常立绘上。

两套绘制路径各自独立判断——buildPackRuntime 用 if (obj.frame) 把 NPC 并入 objectDrawables 画真实立绘,而实体层对 engine.npcEntities 里每个 NPC 无条件再画一个 16×26 纯绿占位方块。只有凯恩一个 NPC 的年代看不出来,NPC 变多之后就藏不住了。

没有在实体层加一个平行判断(那只会把同一个条件抄成两份、早晚漂移),而是抽出 hasPackedSprite 放进 src/game/npc.ts,两条路径共用同一个函数:立绘层为真时画图,占位层为假时画框,互斥由函数本身保证。该函数写成类型守卫,立绘层在同一次调用里就把 frame 收窄成非空,连多余的二次判空都一并消掉,不留第二个会漂移的判断。

NpcEntity.hasSprite 设为必填而非可选,将来新增放置点必须表态。GameEngine 里那圈合成放置点显式标记 false——它们没有预烘焙美术,方块对它们仍是唯一可见形式。金色名字标签两类都保留。

验证:tsc 0 error;vitest 433 passed / 2 skipped(24 文件),较上一条 429 增加 4 条。

新增 tests/npc-placeholder.test.ts 的变异验证:

  • 去掉 hasPackedSprite 里的 null 判断 → 转红(exit 1)
  • 把 townw1 里基德的 frame 改成 null → 完备性断言转红(exit 1),还原后转绿

诚实说明覆盖边界:if (npc.hasSprite) continue 这一行本身没有单元测试覆盖,act-scene.ts 是浏览器入口模块,测试目录里没有任何文件 import 它。防线是结构性的——两条路径共用同一个断言函数,且 hasSprite 为必填字段。

提交 29b3a4a,已推送 main。

### 追加:NPC 有立绘时不再叠加绿色占位方块 上一条修复把城镇 NPC 从 103 补到 889 之后暴露出一个渲染层面的旧问题:罗格营地一个分区会出现 19 个绿色方块糊在正常立绘上。 两套绘制路径各自独立判断——`buildPackRuntime` 用 `if (obj.frame)` 把 NPC 并入 `objectDrawables` 画真实立绘,而实体层对 `engine.npcEntities` 里每个 NPC **无条件**再画一个 16×26 纯绿占位方块。只有凯恩一个 NPC 的年代看不出来,NPC 变多之后就藏不住了。 没有在实体层加一个平行判断(那只会把同一个条件抄成两份、早晚漂移),而是抽出 `hasPackedSprite` 放进 `src/game/npc.ts`,两条路径共用同一个函数:立绘层为真时画图,占位层为假时画框,互斥由函数本身保证。该函数写成类型守卫,立绘层在同一次调用里就把 `frame` 收窄成非空,连多余的二次判空都一并消掉,不留第二个会漂移的判断。 `NpcEntity.hasSprite` 设为必填而非可选,将来新增放置点必须表态。`GameEngine` 里那圈合成放置点显式标记 `false`——它们没有预烘焙美术,方块对它们仍是唯一可见形式。金色名字标签两类都保留。 验证:`tsc` 0 error;`vitest` **433 passed / 2 skipped(24 文件)**,较上一条 429 增加 4 条。 新增 `tests/npc-placeholder.test.ts` 的变异验证: - 去掉 `hasPackedSprite` 里的 null 判断 → 转红(exit 1) - 把 townw1 里基德的 `frame` 改成 `null` → 完备性断言转红(exit 1),还原后转绿 诚实说明覆盖边界:`if (npc.hasSprite) continue` 这一行本身没有单元测试覆盖,`act-scene.ts` 是浏览器入口模块,测试目录里没有任何文件 import 它。防线是结构性的——两条路径共用同一个断言函数,且 `hasSprite` 为必填字段。 提交 `29b3a4a`,已推送 `main`。
Author
Owner

用户已在浏览器中目视确认:罗格营地 NPC 完整显示,立绘正常,绿色占位方块已消失。

最终状态:NPC 实例 103 → 889,覆盖场景 27 → 117,未解析 0。verify:packs 1302/1302,vitest 433 通过 / 2 跳过,tsc 零错误。

落地提交:68c7890、ad7ced6、451816d、3cd4549、e180520、29b3a4a。

关闭。

用户已在浏览器中目视确认:罗格营地 NPC 完整显示,立绘正常,绿色占位方块已消失。 最终状态:NPC 实例 103 → **889**,覆盖场景 27 → **117**,未解析 **0**。`verify:packs` 1302/1302,`vitest` 433 通过 / 2 跳过,`tsc` 零错误。 落地提交:`68c7890`、`ad7ced6`、`451816d`、`3cd4549`、`e180520`、`29b3a4a`。 关闭。
Sign in to join this conversation.
No Label
No Milestone
No project
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: troytt/diablo2-web#6
No description provided.