# 怪物-NPC 脚本统一整改审计 日期:2026-04-06 ## 核心结论 当前工程仍然没有真正落实“怪物就是初始好感度为负数的 NPC”这一原则。 现状不是“NPC 脚本里支持 hostile 状态”,而是同时存在两条并行链路: 1. `npc / encounter / npcStates / npcInteraction` 2. `monster / hostileNpc / sceneMonsters / sceneHostileNpcs / hostileNpcPresets` 这会直接导致: - 同一个敌对实体同时拥有 NPC 身份和 monster 身份。 - 场景、战斗、渲染、提示词都在维护两套入口。 - 后续修 bug 时,任何位置、死亡、血条、入场、掉落问题都要同时查两条链路。 本次文档的目标不是立刻改代码,而是先把应该删除的分叉脚本、应该降级成素材层的文件、以及必须合并的字段全部列清楚,作为后续统一改造的依据。 ## 当前违背原则的根因 ### 1. 场景数据仍然把“怪物”和“NPC”当成两类实体 当前场景层同时维护: - `ScenePreset.monsterIds` - `SceneNpc[]` - `ScenePresetInfo.hostileNpcIds` - `SceneNpc.monsterPresetId / hostileNpcPresetId` 这意味着场景里一个敌对单位既可以来自 `monsterIds`,也可以来自 `npcs`,甚至会被脚本再生成为 hostile scene npc。 ### 2. 运行时仍然存在“怪物专用实体状态” 当前运行时仍然同时维护: - `GameState.sceneMonsters` - `GameState.sceneHostileNpcs` - `SceneHostileNpc / SceneMonster` 这和“怪物本质上只是 hostile NPC”是冲突的。真正统一后,运行时只应该保留一套“场景 NPC / 战斗 NPC”状态。 ### 3. 战斗脚本仍然把怪物当独立 actor 战斗层目前不是“NPC 战斗,只是 hostile 的那部分会出手”,而是显式写了: - `TurnActor = 'player' | 'companion' | 'monster'` - `getClosestMonster` - `resetCombatPresentation(monsters, ...)` - `sceneMonsters` 全链路结算 这会强制后面所有视觉、掉落、提示词、AI 上下文都跟着叫 monster。 ### 4. 渲染层仍然有 monster 专属显示入口 画布层当前仍然依赖: - `sceneMonsters` - `sceneHostileNpcs` - `monsterPresetId` - `HostileNpcAnimator` 也就是“敌对 NPC 是否按 NPC 脚本渲染”这件事,到最终显示层仍然没有统一。 ## 一级删除清单 下面这些文件属于“业务流程分叉脚本”,不是单纯资源适配层。后续统一时应优先删除或并入 NPC 主链路。 | 文件 | 当前分叉角色 | 处理建议 | | --- | --- | --- | | `src/data/monsters.ts` | 对 `hostileNpcs.ts` 的别名出口 | 直接删除,禁止继续保留 monster 专属入口名。 | | `src/data/hostileNpcs.ts` | 负责 monster 创建、编队、距离、朝向、变化、落位 | 按 hostile NPC 运行时工具重写并并入 NPC 体系;原文件名不应继续保留。 | | `src/data/sceneEncounterPreviews.ts` | 单独构造 hostile encounter group、auto battle、hostile preview | 删除 monster 专线逻辑,改为“负好感 NPC 预览/入场/转战斗”。 | | `src/hooks/combat/battlePlan.ts` | 使用 `monster` actor、`sceneMonsters`、`getClosestMonster` | 改成统一的 hostile NPC combatant 规划脚本;monster actor 概念应移除。 | | `src/hooks/combat/playback.ts` | 使用 `sceneMonsters` 播放怪物战斗演出 | 改成统一 NPC 战斗回放;不再区分 monster 播放器。 | | `src/components/game-canvas/GameCanvasRuntime.tsx` | 运行时按 `sceneMonsters / sceneHostileNpcs` 双数据源选敌方实体 | 删除双源兜底,统一成单一 hostile NPC 列表。 | | `src/components/game-canvas/GameCanvasEntityLayer.tsx` | 敌方实体按 monsterPreset 分支渲染 | 改成同一套 NPC 实体渲染,视觉差异仅由 visual preset 决定。 | | `src/components/game-canvas/GameCanvasShared.tsx` | 定义 `sceneMonsters / sceneHostileNpcs` props 与 monster 专属计算 | 删除这些字段与 helper,改成统一 NPC 画布协议。 | | `src/components/preset-editor/MonsterPresetPanel.tsx` | 独立怪物预设编辑面板 | 并回 NPC hostile visual preset 面板,或删除该独立编辑器入口。 | | `src/components/preset-editor/MonsterPresetTab.tsx` | 独立怪物预设页签 | 与上面一并删除或并入 NPC preset 编辑器。 | | `src/components/preset-editor/ScenePresetPanel.tsx` | 仍然单独编辑 `monsterIds` | 删除 `monsterIds` 编辑项,只保留场景 NPC 列表。 | ## 二级归并清单 下面这些文件不一定需要物理删除,但它们当前仍然在放大 monster / NPC 分轨,必须在统一改造时一起收口。 | 文件 | 当前问题 | 处理建议 | | --- | --- | --- | | `src/data/scenePresets.ts` | 通过 `monsterIds` 再生 hostile scene npc,并区分 `getSceneHostileNpcs / getSceneFriendlyNpcs` | 保留场景数据文件本身,但删除 `monsterIds` 体系,让敌对角色直接存在于 `npcs` 中。 | | `src/data/customWorldNpcMonsters.ts` | 用单独脚本推导“怪物型 NPC”预设 | 可保留为 hostile visual preset 选择器,但不能再生成第二套实体语义。 | | `src/data/hostileNpcPresets.ts` | 目前既是视觉预设库,也是独立 hostile 流程的数据源 | 降级为 hostile visual/combat preset 库;不再拥有独立实体生命周期。 | | `src/components/HostileNpcAnimator.tsx` | 当前名字和调用语义都在暗示“独立怪物实体” | 可以保留为贴图播放器,但应改为 hostile NPC 的视觉适配组件,而不是独立物种脚本。 | | `src/components/AdventureEntityModal.tsx` | 详情弹窗仍会优先查 monster preset / hostileNpcPreset | 统一读取 NPC 档案;视觉差异只通过 hostile preset 补充。 | | `src/components/SkillEffectPreview.tsx` | 预览器直接使用 `sceneMonsters` 和 `createSceneMonstersFromIds` | 改成统一 hostile NPC 预览态。 | | `src/components/StateFunctionEditor.tsx` | 编辑器里仍然直接造 monster battle preview | 改成 hostile NPC preview。 | | `src/components/preset-editor/SceneNpcPresetPanel.tsx` | 仍然暴露 `monsterPresetId` 字段 | 改成更明确的 hostile visual preset 字段,避免“怪物类型”和“NPC 类型”双语义。 | | `src/hooks/story/npcEncounterActions.ts` | 虽然入口叫 npc,但内部仍然写 `sceneMonsters / sceneHostileNpcs` | 改成统一 hostile NPC 战斗状态字段。 | | `src/hooks/story/choiceActions.ts` | 仍然有 `buildHostileNpcBattleReward` 和 `getResolvedSceneHostileNpcs` 这一层额外概念 | 统一到 hostile NPC 结算工具,不再把“敌对 NPC”和“monster”混称。 | | `src/hooks/useStoryGeneration.ts` | 给 AI/剧情层传入 `sceneMonsters` 或 `sceneHostileNpcs` | 改成统一的 hostile NPC 上下文切片。 | | `src/services/prompt.ts` | 仍然从 `monsterIds` 和 `createSceneMonstersFromIds` 组 prompt | 改成从场景 NPC 列表中筛出 hostile NPC。 | | `src/services/questDirector.ts` | 仍然依赖 `monsterPresetId` 推导当前敌对目标 | 统一改为基于负好感或 hostile 标记的 NPC。 | | `src/services/ai.ts` | 仍然混用 `monsterIds`、sceneNpc 的 hostile 判定 | 与场景统一后改成只读 NPC 列表。 | | `src/services/questTypes.ts` | 仍然把 `hostileNpcIds / monsterIds` 当作 scene 快照字段 | 删除 `monsterIds`,保留 hostile NPC 语义。 | ## 可保留但必须降级为“素材/配置层”的内容 下面这些内容不一定要消失,但不能继续作为独立业务链路存在: | 文件/内容 | 可以保留的原因 | 必须收口的边界 | | --- | --- | --- | | `src/components/HostileNpcAnimator.tsx` | 怪物贴图是特殊资源,需要专门 sprite sheet 播放器 | 只负责画图,不再决定实体类型、战斗身份、交互入口。 | | `src/data/hostileNpcPresets.ts` | hostile visual/combat preset 仍然有价值 | 只能作为 hostile NPC 的 visual/combat preset 库,不再驱动另一套“monster 实体”。 | | `src/data/hostileNpcOverrides.json` | 资源级 override 仍可继续用 | 不能再配套出独立 hostile 流程。 | | `src/data/monsterOverrides.json` | 如果只是素材映射,可迁移到 hostile visual preset override | 不应继续以 monster 专属命名长期存在。 | | `src/data/customWorldNpcMonsters.ts` | 自定义世界里确实需要从文本匹配 hostile visual preset | 只能产出“NPC 使用哪个 hostile visual preset”,不能产出独立 monster 身份。 | ## 字段级必须合并的内容 后续改代码时,至少要把下面这些字段和类型一起收口: | 当前字段/类型 | 问题 | 合并方向 | | --- | --- | --- | | `ScenePreset.monsterIds` | 场景里额外保存一份怪物池 | 删除,只保留 `npcs`。 | | `ScenePresetInfo.hostileNpcIds` | 历史遗留双字段 | 直接由 `npcs.filter(initialAffinity < 0 或 hostile)` 推导。 | | `Encounter.monsterPresetId` | 把 hostile NPC 再次物种化 | 改成 hostile visual preset 字段,或并入统一 visualRef。 | | `Encounter.hostileNpcPresetId` | 与 `monsterPresetId` 语义重叠 | 与上面合并为一个字段。 | | `GameState.sceneMonsters` | 把敌对 NPC 单独塞进 monster 容器 | 改成统一 `sceneNpcCombatants` 或等价单一列表。 | | `GameState.sceneHostileNpcs` | 历史兼容层,导致双数据源 | 删除。 | | `SceneHostileNpc / SceneMonster` | 类型名直接固化了分轨 | 改成统一的 hostile NPC / scene combat NPC 类型。 | | `SceneHostileNpcChange / SceneMonsterChange` | 继续复制同一套变更结构 | 合并成统一 NPC scene change。 | | `SceneNpc.monsterPresetId / hostileNpcPresetId` | 同一实体上挂两套 preset 入口 | 收敛为一个 hostile visual/combat preset 字段。 | ## 本轮最优先的删除顺序 建议后续真正改代码时,按下面顺序删并,风险最低: 1. 先删字段入口:`monsterIds / sceneHostileNpcs / hostileNpcPresetId` 2. 再删运行时双轨:`src/data/monsters.ts`、`src/data/hostileNpcs.ts`、`src/data/sceneEncounterPreviews.ts` 3. 再删战斗双轨:`battlePlan.ts`、`playback.ts` 里的 `monster` actor 与 `sceneMonsters` 4. 再删画布双轨:`GameCanvasRuntime.tsx`、`GameCanvasEntityLayer.tsx`、`GameCanvasShared.tsx` 5. 最后清编辑器和提示词:`MonsterPresetPanel.tsx`、`MonsterPresetTab.tsx`、`prompt.ts`、`questDirector.ts` ## 改造后的目标形态 统一后应只剩下这一套语义: - 场景中所有可见角色都放在 `npcs` - 怪物 = `initialAffinity < 0` 或 `hostile = true` 的 NPC - hostile 的视觉差异只来自 hostile visual preset - 战斗中所有敌方单位都属于 hostile NPC combatant - AI、任务、渲染、详情、掉落都只读同一套 NPC 数据 如果后面代码里还出现下面这些关键词,基本都说明分轨没有删干净: - `sceneMonsters` - `sceneHostileNpcs` - `monsterIds` - `hostileNpcPresetId` - `createSceneMonstersFromIds` - `getClosestMonster` - `TurnActor = 'monster'` ## 这份文档的使用方式 后续正式开始改造时,建议把文件分成三批执行: 1. “直接删掉”的入口脚本 2. “改名并并回 NPC 主链路”的桥接脚本 3. “仅保留素材职责”的 renderer / preset 文件 不要继续接受“名字叫 NPC,但内部仍然先转成 monster 再跑”的中间态。