ERAPHANISECHO
project-state.md更新于 3 小时前 · 90 KB

Eraphanis 项目现状与 AI 上手手册

这是 docs/ 下唯一的“当前状态”入口。目标不是替代所有专题文档,而是让一个没有上下文的 AI/开发者在一轮阅读后知道:项目是什么、现在能做什么、代码从哪里进、哪些契约不能碰、改东西该去哪里、当前哪里坏了。

本文件是活文档。代码、测试、CMake 和数据文件是事实来源;本文件是它们的人工索引。发现不一致时,以代码/测试为准,并顺手更新本文件。

0. 文档元信息与维护契约

项当前值
最后核对日期2026-11-08
核对对象当前工作树(0.1.9.2 版本分支)
项目版本0.1.9.2(CMakeLists.txt;Android versionCode 11)
分支 / HEADecho(0.1.9.2 发布分支已合并回此)
工作树状态干净:0.1.9.2 已发布;之后新增了 P1 通用 AI 框架的地基(src/game/ai/、data/Eraphanis/ai/traits.tsv、两个新测试目标)与交互层(AI.3:身体分离、听觉事件接线、死亡掉落,data/Eraphanis/ai/drops.tsv、第三个测试目标),已提交
最近一次完整测试75 个 CTest 全部通过(MinGW Release,build-mingw64-release);2026-11-08 实跑,53 s
主要语言C++17,C11;CMake
文档语言中文叙述,代码标识、路径、测试名保留英文

0.1 维护规则

  1. 这是当前状态文档,不是更新日志。 历史演进写到 docs/update.txt;这里只写“现在是什么样”。
  2. 更新触发条件:版本号、存档格式、构建预设、测试结果、数据表结构、输入映射、核心不变量、已知缺口发生变化时,必须改本文件。
  3. 先验证再写。 至少运行: ``powershell cmake --preset mingw64-release cmake --build build-mingw64-release -j 8 ctest --test-dir build-mingw64-release --output-on-failure git status --short git rev-parse --abbrev-ref HEAD git rev-parse --short HEAD `` 如果测试没跑,明确写“未验证”,不要照抄旧结论。
  4. 工作树可能很脏。 不要为了“让文档干净”去 git reset、git checkout 或删除不认识的 evidence/。这些可能是用户正在进行的排查/基线材料。
  5. 旧文档可能过时。 已知过时点见 §12。遇到冲突时按“代码 > 测试 > CMake/数据 > 本文件 > 旧文档”排序。
  6. 状态标记:[当前] 表示与工作树一致;[部分过时] 表示仍有参考价值但不能直接照做;[历史] 表示只用于理解演进。

0.2 AI 推荐阅读顺序

  1. 本文 §1–§4:知道项目是什么、怎么构建、目录在哪。
  2. 本文 §5:掌握帧循环和 GameState 更新顺序。
  3. 本文 §10:记住不能破坏的契约。
  4. 本文 §11:按“我要改 X”查入口。
  5. 需要细节时再跳到 docs/developer-guide.md、docs/android.md、src/graphics/CharacterRig.README.md 等专题文档。

1. 60 秒速览

Eraphanis Echo 是一个俯视角 2D 生存/叙事游戏,使用自研引擎 Uniscope(2D 渲染库 uniscope-2d 已包含平台抽象层)实现。当前版本是 0.1.9.2,内部仍保留旧名 Reverseworks(CMake target、测试名、部分命名空间可见)。

当前可玩的闭环大致是:

主菜单
  → 新建/继续 Echo(存档)
  → 加载屏幕(生成世界、解析对话/PV、烘焙字体)
  → 淡入揭示
  → 新存档播放 opening_prologue
  → 在程序化世界中移动、交互、对话、拾取/装备、建造/挖掘、休息、战斗(动作框架)
  → 自动存档 / Esc 保存回主菜单

当前不是完成品。最明显的未完成项(0.1.9.2 逐条核对):

  • 武器能装备、能开火、能精修、能在世界里捡到、能装怀表校准(E1.9 后):21 把武器的数据表/物品层/装备路径/弹匣与换弹/装备槽 UI/战斗管线/精修曲线/程序化模型与材质遮罩/持枪渲染与射击表现/世界投放/怀表校准槽与降熵波都已落地——新档开局主手就是 W01 左轮(弹匣空、主背包 24 发,按 X 换弹),对着敌人能开枪、空仓自动收起并徒手出击,精修入口能花 weapon_parts 逐级升级并解锁校准槽,城镇地面按 spawns.tsv 摆着可拾取的武器(E 键拾取,年代门拦 Thompson),3/6 级校准槽可装配降熵波并改写对熵有效度。剩下的缺口:精修台/零件来源与怀表物品(E2)、敌人刷新与武器/NPC 持枪表现(E1.7 只画玩家)、HUD 弹匣/换弹/精修读数;敌人也仍只在测试里。
  • E2 怀表系统规格已定稿、未落地:docs/combat-e2-watch-design.md(怀表物品与 EquipSlot::Watch、过载/尖啸、迟滞场/加速/回溯/锚点重置、装配面板)。实现分 E2.1–E2.8,当前代码仍是 v11 之前的功能面(v11 已被第 2 期的快捷栏 arming 占用,见 §7.4;E2 的 [watch] 段落地时应改用 v12,启动 E2 前要先改该设计文档)。E1.9 的 4 种模式仍只有数据行。
  • 敌人只在测试里:EnemyCatalog / EnemyCombatant 与 GameState::spawnEnemy() 已就绪,但世界里没有刷怪点,spawnEnemy() 的调用点只有 tests/CombatP3Checks.cpp。
  • 物品栏 8 个页签只有 BAG(物品网格)与 CHAR(装备槽)实现,其余显示 NOT IMPLEMENTED YET。
  • 世界容器/箱子未实装,GameState::openContainer() 恒为 nullptr。
  • 战斗动作/技能/词缀/武器表/装备/弹匣/战斗管线(准确性、熵、耐久卡壳、噪音、十字)/精修/武器模型与图标/握持与射击表现/音色/世界拾取/怀表校准都能在无头测试里跑通;仍缺的是内容驱动:敌人刷新、精修台与零件来源(E2)、怀表物品与其余 4 种校准(E2)、HUD 读数。
  • 当前渲染器的两条既有失败(Reverseworks.AudioParser、Reverseworks.GroundFlicker)已于 2026-11-08 修复,基线 72/72 全绿(见 §9.2)。

2. 快速事实表

主题事实
游戏名 / 窗口标题Eraphanis;项目描述里仍可能看到 Reverseworks
版本0.1.9.2(Android versionCode 11)
入口src/main.cpp → uniscope::pal::createPlatform() → core::Application::run()
主循环Application::run();渲染、输入、音频、存档仓库都在这里装配
窗口 / 平台层uniscope::pal::IWindow(libs/uniscope-2d/include/uniscope/pal/);GLFW 与 Android 后端在 libs/uniscope-2d/src/pal/
场景层game::DemoScene;模式:MainMenu、LoadingWorld、LoadingReveal、Playing、PlayingPv
游戏状态层game::GameState;拥有 World、Player、Camera2D、EventBus、ObjectiveSystem、DialogueSystem、CombatFxSystem、CombatActionController/GuardController/SkillLoadout/CooldownTracker、EnemyCombatant、DoorTravel、PvSystem
世界程序化流式世界;32×32 tile chunk;生成/保留半径 4;运行时每帧最多生成 2 个新 chunk;世界半径 24000 tiles
世界模板forest(Deep Forest)、temperate_wilderness(Temperate Wilderness)、rugged_highlands(Rugged Highlands);默认第一个 forest
固定城镇Ronesville,96×80 tiles,中心在出生点;约 29 个建筑定义
角色Character → HumanoidCharacter → FemaleCharacter / MaleCharacter → Player;25 根骨骼(20 结构 + 2 眼 + 3 辫节);9 个循环动画状态 × 3 个视角(含 Swim / SwimIdle),另有一次性 Death 与 13 个战斗剪辑
生存8 个身体部位;每部位 25 HP / 37°C;状态效果与 10 种疾病;5 类环境风险
战斗数据驱动动作表(轻击三段 + 蓄力重击三档 + 射击四式 + 翻滚/冲刺 + 5 技能 + 3 敌人招式);格挡与 4 帧完美格挡;技能键 4–9;敌人 husk/stalker/brute/spitter,硬直等级 common/elite/boss;伤害类型物理/火/冰/毒;13 种装备词缀
AI通用 AI 框架 src/game/ai/:特征表 data/Eraphanis/ai/traits.tsv(11 列)、八邻域 A\* 寻路与两条射线(视线/走线)、感官(视/听/记忆)、通用状态机(10 态)、软身体分离、死亡掉落表 data/Eraphanis/ai/drops.tsv;敌人已接管该框架
物品15 个基础背包格 + 装备加成;3 个快捷栏;6 个装备槽;全局堆叠上限 172;items.tsv 39 行(含 21 把武器与 4 种弹药);弹药 icon 由弹药模型派生
数据data/Eraphanis/items/*.tsv;data/Eraphanis/weapons/weapons.tsv;data/Eraphanis/ai/traits.tsv;data/Eraphanis/ai/drops.tsv;data/Eraphanis/world/_template/{dialogue,pv}
存档INI 风格文本;SaveGameData::kFormatVersion == 11(v8 实例词缀,v9 武器实例状态,v10 怀表校准槽,v11 快捷栏 armed 槽:[hotbar] armed= 且不写该格物品行);流式世界格式门槛 kStreamingFormatVersion == 5;路径 <用户数据根>/archive/——根由 StdPathProvider 选:exe 目录可写就用它(便携/默认安装),否则 %LOCALAPPDATA%/Eraphanis;首次用便携根时自动搬一次旧目录(存档不覆盖、settings.cfg 用玩家的替换安装默认)
渲染桌面 OpenGL 3.3 core;Android GLES 3.0;uniscope::d2::Renderer2D 经 src/graphics/compat/UniscopeGfxAliases.h 暴露
音频uniscope::audio::IAudioDevice;OpenAL-soft(动态库)+ mpg123 + stb_vorbis;后端选择 OpenAL → Null(WinMM 为显式回退);程序化合成 SFX
平台Windows(MSVC / MinGW x64/x86)、Linux X11、macOS、Android;窗口/输入/生命周期/路径/资源统一走 PAL
测试75 个 CTest(73 个项目测试 + Uniscope2D.Smoke + Uniscope2D.Anomaly);2026-11-08 起 75 全部通过(P1 通用 AI 新增 Reverseworks.AiPathfinding、Reverseworks.AiBrain 与 Reverseworks.AiInteraction 三条)
规模(2026-11-08 统计)src 334 文件 / 79,594 行;libs 300 / 32,276;tests 74 / 48,414;tools 11 / 4,069(src/libs/tests 计入 .cpp+.h,tools 计入 .py+.ps1)

3. 构建、运行、测试

3.1 预设

CMakePresets.json 提供:

  • msvc-release-x64 / msvc-debug-x64:Visual Studio 18 2026,x64
  • mingw64-release / mingw64-debug:MinGW-w64 x64
  • mingw32-release / mingw32-debug:MinGW x86

CMake 最低版本是 3.15,但预设文件要求 CMake 3.25+。C++17、C11。

3.2 配置与构建

cmake --preset mingw64-release
cmake --build build-mingw64-release -j 8

注意:build preset 只构建 Eraphanis 目标(CMakePresets.json 里 targets: [ "Eraphanis" ])。测试目标不会跟着构建。要构建全部目标,直接调 build 目录:

cmake --build build-mingw64-release -j 8

或者只构建某个测试:

cmake --build build-mingw64-release --target Reverseworks-GroundFlicker

CMake 版本用 3.25+(DeployAssets.cmake 需要)。本机实测 CMake 4.4.4。

3.3 运行

典型 MinGW 单配置输出(当前工作区就是这一套):

build-mingw64-release/
  bin/
    Eraphanis.exe
    Reverseworks-*.exe       ← 自检/探针
    shaders/                ← 每个目标 POST_BUILD 部署(进程锁串行)
    data/                   ← 运行时数据
    resources/              ← 角色美术、字体、音频
  lib/                      ← 静态库

运行:

.\build-mingw64-release\bin\Eraphanis.exe

MSVC 多配置下可执行文件在 bin/Release/,而 data/、resources/ 仍部署到 bin/;游戏的资源查找会从可执行文件目录向上走父目录,所以能找到。

3.4 测试

ctest --test-dir build-mingw64-release --output-on-failure

当前结果(2026-11-08,MinGW Release,构建目录 build-mingw64-release):

74 tests total
74 passed
0 failed

单个测试:

ctest --test-dir build-mingw64-release -R Reverseworks.GroundFlicker --output-on-failure

3.5 测试的工作目录与资源部署

所有 CTest 都通过 Reverseworks_register_test() 注册,工作目录统一是 CMAKE_RUNTIME_OUTPUT_DIRECTORY(即部署目标写入 data/、resources/ 的目录)。原因是自检用字面量相对路径读内容(data/Eraphanis/items/items.tsv、resources/character/...),而 ctest 的默认工作目录是构建树根,那里没有这些文件。

以前只有一部分测试显式设了工作目录,于是 SaveSystem、SurvivalSystem、Character 在 ctest 下报“内容缺失”,手工从 bin/ 运行却通过。2026-11-03 已统一。新增测试请用 Reverseworks_register_test(Reverseworks.Xxx Reverseworks-XxxChecks),不要直接写 add_test。

MSVC 多配置若出现同类“读不到 data”的失败,先确认测试的工作目录确实是 ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}(bin/),而不是 bin/Release/。

3.6 Android

cd android
gradlew :app:assembleDebug
adb install -r app\build\outputs\apk\debug\app-debug.apk
adb logcat -s Eraphanis:V

Android 使用 EGL + GLES 3.0、android_main()、AAssetManager 读取 APK 内资源;详细约束见 docs/android.md。


4. 仓库地图

Eraphanis/
├─ src/
│  ├─ main.cpp                 ← main():建平台 → Application::run()
│  ├─ core/                    ← Application、Settings、路径薄包装、帧分析
│  ├─ platform/                ← AndroidMain、键码、分辨率目录、外链薄包装、触摸触点
│  ├─ graphics/                ← Renderer2D 兼容层、AssetLibrary、角色 rig、程序化贴图、字体
│  ├─ audio/                   ← AudioEngine、AudioFactory、openal/、winmm/、音乐桥
│  ├─ game/                    ← 游戏状态、世界、玩家、元素、存档、UI、叙事、FX
│  │  ├─ ai/                   ← 通用 AI:特征表、寻路、感官、状态机、世界接口、身体分离、掉落表
│  │  ├─ combat/               ← 动作表、动作状态机、格挡、技能/冷却、敌人、词缀
│  │  └─ playing/              ← PUI:HUD、物品栏、控制台、技能条
│  ├─ ui/                      ← 主菜单、加载视图、MenuContext、ComplexUI 桥
│  └─ ...
├─ libs/
│  ├─ cdlg/                    ← 对话脚本解析/运行时
│  ├─ pv/                      ← PV 剧情时间线解析/运行时
│  ├─ uniscope-2d/             ← 2D 渲染、骨骼动画、图集
│  │  ├─ include/uniscope/pal/   ← 平台抽象层接口(窗口/输入/资源/路径/显示/时钟/日志)
│  │  ├─ include/uniscope/audio/ ← 音频设备接口
│  │  └─ src/pal/{glfw,android,common}/ ← PAL 后端实现
│  └─ complexui/               ← UI 控件/HUD 绘制
├─ tests/                      ← 72 个项目自检/探针
├─ tools/
│  ├─ character/               ← 角色美术生成脚本
│  └─ *.py / *.ps1             ← 建筑验收、PPM→PNG、打包
├─ data/Eraphanis/             ← items TSV、武器 TSV、AI 特征 TSV、世界模板 dialogue/pv
├─ resources/                  ← 角色 PNG、世界贴图、字体、图标、音频
├─ cmake/                      ← 依赖、运行时部署、shader 部署、测试注册
├─ docs/                       ← 本文与专题文档
├─ android/                    ← Gradle/NDK 工程
└─ 3rdParty/                   ← GLFW、GLAD、stb、mpg123、openal-soft 等

4.1 关键目录职责

路径职责主要入口
src/core/应用循环、设置、路径薄包装、帧计时Application.cpp, Settings.cpp, ResourcePaths.cpp, DataPaths.cpp, UserDataPaths.cpp
src/platform/仅剩平台相关的一点点:Android 入口、键码、显示枚举、外链、触摸触点AndroidMain.cpp, KeyCodes.h, ResolutionCatalog.cpp, SystemBrowser.cpp, TouchContacts.h
libs/uniscope-2d/src/pal/真正的平台层:窗口/输入/生命周期/资源源/路径/时钟/日志PlatformFactory.cpp, glfw/GlfwPlatform.cpp, android/AndroidPlatform.cpp, common/
src/graphics/渲染资产、角色、程序化贴图、字体AssetLibrary.cpp, CharacterRig.cpp, WorldTextureAssets.cpp, ProceduralBuildings.cpp, compat/UniscopeGfxAliases.h
src/audio/音频前端与后端AudioEngine.cpp, AudioFactory.cpp, openal/OpenALAudioDevice.cpp, winmm/WinmmAudioDevice.cpp, MusicPlayerBridge.cpp
src/game/游戏模拟与呈现GameState.cpp, DemoScene.cpp, World.cpp, Level.cpp, Player.cpp, SaveSystem.cpp
src/game/combat/战斗数据与规则ActionCatalog.h, CombatAction.cpp, GuardController.cpp, SkillLoadout.cpp, EnemyCatalog.cpp, EnemyCombatant.cpp, AffixCatalog.cpp
src/game/playing/PUI(浮在世界上的界面)PlayingUI.cpp, PlayingHud.cpp, PlayingInventoryPanel.cpp, PlayingConsolePanel.cpp, PlayingCombatStrip.cpp
src/ui/菜单/加载/选项/档案MainMenu.cpp, MenuContext.h, loading/LoadingCoordinator.cpp, views/
libs/uniscope-2d/渲染器、骨骼动画、平台抽象层、音频接口src/gfx/Renderer2D.cpp, src/animation/, src/pal/
libs/cdlg/.cdlg 解析与对话会话src/
libs/pv/.pv 解析与时间线src/
tests/自检、视觉探针、无头模拟见 §9
data/Eraphanis/可编辑内容items/items.tsv, items/starters.tsv, world/_template/
resources/美术/字体/图标/音频resources/character/, resources/assets/eraphanis/, resources/audio/
docs/设计/审计/记录文档索引见 §12;入口是本文

4.2 构建目录与临时材料

  • build-mingw64-release/:当前已配置、已跑通测试的 MinGW Release 构建。
  • evidence/:建筑审计基线等产出物(building-audit/、character-rig/、deanna-art-review/ 等),被建筑文档引用,保留。
  • 构建产物(.vs/、android/app/.cxx/、android/app/build/、out/、Testing/、android/app/src/main/assets/)与排查截图(tmp/、tmp_frames/)已于 2026-11-03 清理,并写进 .gitignore;它们都可以重新生成。tmp_frames/ 里 290 个文件曾是提交内容,所以这次清理同时是 290 条跟踪文件删除——提交时单独一份 commit。

5. 运行时架构与帧流程

5.1 顶层对象图

main()
 └─ uniscope::pal::createPlatform(PlatformInit{"Eraphanis", "Leo Jeang"})
     └─ core::Application
     ├─ uniscope::pal::IWindow(createMainWindow → IPlatform::createWindow)
     ├─ graphics::Renderer2D
     ├─ game::DemoScene
     │   ├─ ui::MainMenu
     │   ├─ ui::LoadingCoordinator + LoadingWorldView
     │   ├─ game::playing::PlayingUI(PUI:HUD + 物品栏 + 控制台 + 技能条)
     │   ├─ game::DialogueView
     │   └─ game::GameState
     │       ├─ World
     │       │   ├─ Level(室外或室内)
     │       │   ├─ WorldClock
     │       │   ├─ EnvironmentSystem
     │       │   ├─ LightingSystem
     │       │   └─ BuildingInteriorManager
     │       ├─ Player
     │       ├─ Camera2D
     │       ├─ EventBus
     │       ├─ ObjectiveSystem
     │       ├─ DialogueSystem
     │       ├─ CombatFxSystem
     │       ├─ combat::CombatActionController / GuardController / SkillLoadout / CooldownTracker
     │       ├─ combat::EnemyCombatant[](会话内实体)
     │       ├─ DoorTravel
     │       └─ PvSystem
     ├─ game::SaveRepository
     ├─ audio::AudioEngine(持有 uniscope::audio::IAudioDevice)
     └─ audio::MusicPlayerBridge

IPlatform 由 PAL 的后端实现提供(桌面 GlfwPlatform、Android AndroidPlatform),进程内只有一个:createPlatform() 成功后登记,currentPlatform() 取用。游戏层的 src/core/*Paths.cpp 只是对 IPathProvider / IAssetSource 的薄包装。

5.2 Application::run() 每帧顺序

来自 src/core/Application.cpp:

  1. window_->pollEvents();处理关闭(shouldClose())。
  2. window_->beginInputFrame()。
  3. audio_.setWindowPresentation(焦点, 最小化)。
  4. 若 window_->shouldSuspendRendering():释放 GPU 资源、丢弃 atlas GPU 资源、waitEventsTimeout()、清输入边沿、重置帧时钟,continue。
  5. 从挂起恢复时:恢复 GPU 资源、重新上传缓存图集、重置输入(PAL 的 LifecycleEvent 提供 suspended/recovery)。
  6. 计算 deltaSeconds(window_->time(),上限 kMaxFrameDeltaSeconds),更新音频。
  7. 构造 ui::MenuContext:读取 GameInput、framebufferWidth/Height/Aspect、设置、音频、音乐、存档仓库、渲染器、捕获键。
  8. 应用渲染质量预设和 MSAA 设置。
  9. FrameProfiler::beginFrame();scene_.update(deltaSeconds, context)。
  10. 处理 MenuContext 请求:重置输入、预载 CJK 字体、应用显示/音频、打开 URL、键捕获。
  11. 设置脏则保存。
  12. renderer_.beginFrame() → scene_.render(...) → 触摸手柄 → renderer_.endFrame()。
  13. window_->swapBuffers()。
  14. FrameProfiler::endFrame();打印报告;处理退出;限帧;更新窗口标题。
  15. 退出循环后持久化待处理音频设置与当前存档。

5.3 DemoScene 模式

src/game/DemoScene.h 定义:

模式行为
MainMenu主菜单;可后台预热最近存档的世界;处理新建/继续/退出
LoadingWorldLoadingCoordinator 生成世界、解析对话/PV、烘焙字体;Esc 取消
LoadingReveal0.8s 淡到黑;新存档直接交接给开场 PV,其他存档再经 0.5s 淡入游戏
Playing正常游戏;PUI(PlayingUI)先消费输入,再 GameState::update
PlayingPv剧情时间线拥有相机和世界冻结;Esc 跳过;可交接给对话系统

5.4 加载与开局细节

  • 新存档:SaveRepository::createNew(),newSaveSession_ = true。
  • 加载屏幕完成后 finalizeWorldLoad():
    • 检查生成出的 Level::worldSpec() 是否等于 activeWorldSpec_,不匹配则放弃加载回主菜单。
    • applyTerrainModifications() 应用存档中的方块编辑。
    • World::adoptLevel()、GameState::attachLevel()。
    • 预热建筑外观与角色图集,避免首帧卡顿。
    • 切到 Playing,安排 3 帧后的初始存档。
  • LoadingReveal 对新存档只淡到黑,随后直接进入 PlayingPv;不再先淡入世界。其他存档完成 0.5s 淡入后进入 Playing。
  • 已播放场景记录在 SaveGameData::playedScenes,防止重复播放。
  • 自动存档间隔:WorldClock::kGameSecondsPerDay * 0.5f(半个游戏日)。
  • 从游戏内退出(Esc,且没有面板/对话)会先 saveCurrentGame() 再回主菜单。

5.5 GameState::update() 顺序

src/game/GameState.cpp:

  1. 没有 Level 直接返回。
  2. 相机缩放:鼠标滚轮/PageUp/PageDown,先于任何 early-return,所以对话/开门淡出时仍可缩放。
  3. 门旅行:DoorTravel 活跃时只推进淡出/换关/淡入,冻结世界;黑屏峰值交换 Level。
  4. 热源点光同步:syncHeatSourcePointLights。
  5. 对话:若对话活跃,只更新对话与相机,返回。
  6. 世界更新:world_.update(deltaSeconds, events_, player_.position()),把玩家位置传给环境系统采样局部气候。
  7. 玩家更新:player_.update(...)。
  8. 消费门请求:consumeDoorTravelRequest()。
  9. 目标系统:统计背包物品数,填充 ObjectiveSystem::FrameState,objectives_.update(frame)。
  10. 预算流式生成:streamAroundBudgeted(tileCoord, 2),在移动之后、相机跟随之前。
  11. 战斗/FX:武器检查、攻击、弹道、方块破坏、点光。
  12. PV 触发:pv_.pollTriggers(*this)。
  13. 相机跟随:camera_.follow(player_.cameraAnchor(), world_.bounds(), deltaSeconds)。

关键 early-return 语义:

  • 对话活跃:世界不更新,但相机继续跟随。
  • 门旅行活跃:世界完全冻结,避免黑屏下两侧状态漂移。
  • PUI 面板/控制台打开时,DemoScene 在调用 GameState::update 之前就消费输入并返回,所以世界暂停。

6. 系统与功能

6.1 世界、流式加载与城镇

核心文件:src/game/World.h/.cpp、src/game/Level.h/.cpp、src/game/WorldChunk.h、src/game/WorldGen/。

  • 世界的身份由 WorldSpec::templateId + 64-bit WorldSpec::seed 决定;worldRadius 是硬安全上限,当前为 24000 tiles。
  • 世界是确定性生成的:同一 (template, seed) 生成同一地形;存档只保存 WorldSpec 和玩家改过的方块,不保存整张地图。
  • LevelExtent::Streaming:以原点为中心、半径 24000 tiles 的方形世界;只有玩家附近的 chunk 驻留。
  • chunk 大小 kChunkSize = 32;kStreamKeepChunks = 4、kStreamGenerateChunks = 4。
  • 运行时 GameState 用 streamAroundBudgeted(center, 2),每帧最多生成 2 个新 chunk;加载/落地用同步 streamAround() 扩大初始区域。
  • 被玩家修改过的 chunk 永远不会被丢弃(Chunk::modified)。
  • 地形编辑通过 TerrainModificationSnapshot 保存:按 chunk → tile → block layer 记录,包含层类型、变体、状态、完整性、温度、湿度、发现/通电/不可破坏标志。
  • 世界模板注册在 WorldTemplate.cpp:
    • forest / Deep Forest:湿润、树木多、河湖明显。
    • temperate_wilderness / Temperate Wilderness:更干、更山、树少。
    • rugged_highlands / Rugged Highlands:短而密的雪顶山脉、松树谷地。
  • 默认模板是注册表第一个 forest;未知模板会被 WorldSpec::sanitized() 静默替换为默认。
  • Ronesville:每个世界恰好一个,96×80,中心在生成器出生点;出生点位于城镇广场,因此新世界玩家默认落在广场上。
  • 室内空间:每个建筑有独立 Level,进入时把室外 Level 暂存,退出恢复;室内运行时生成,不写进存档。存档在室内时记录室外门口位置,避免读档醒在已不存在/会重新生成的室内坐标。

关键不变量:

  • WorldSpec 必须随存档走。GameState::sessionWorldSpec_ 是为了覆盖“已准备会话但还没 adopt level”的窗口。
  • hasPlayerPosition 是“坐标是否有效”的唯一依据。不能再用 (0,0) 推断“从未移动”,因为世界以原点为中心,原点现在是合法位置。
  • 加载时必须校验生成出的 Level::worldSpec() 与会话请求的 WorldSpec 一致;不一致就放弃加载,不能“先 adopt 再说”。

6.2 角色、骨骼与动画

核心文件:src/game/Character.h/.cpp、HumanoidCharacter、FemaleCharacter、MaleCharacter、src/graphics/CharacterRig.cpp、src/graphics/CharacterClips.cpp、libs/uniscope-2d/src/animation/。

  • 继承链:Character → HumanoidCharacter → FemaleCharacter / MaleCharacter → Player。
  • 玩家默认是 Deanna:身高 0.93、体型 1.0、辫子、hairScale 1.08、headScale 0.95。
  • 25 根骨骼:20 根躯干/四肢结构骨 + 2 根眼睛骨 + 3 节辫子链(braid_root/braid_mid/braid_tip);无辫子 profile 会跳过辫子链。
  • 9 个动画状态:Idle、Walk、Run、JumpRise、JumpFall、Land、UseItem、Swim、SwimIdle;每个状态 3 个视角剪辑(Side/Front/Back)。新增状态必须同时补 characterAnimStateName()、CharacterStateTuning.h 的 kStateDesc 一行(有 static_assert 兜底)和 CharacterClips.cpp 的三视角剪辑。
  • 另有一次性死亡状态 Death(Side 1.45 s 三拍:recoil → collapse → settle;Front/Back 用压扁表达塌陷)。俯视 + billboard 的世界里画"平躺"会读成俯视尸体,所以死亡动画是塌陷不是躺平。
  • 死亡接线:HumanoidCharacter::markDead()(清 airborne/usingItem/recovery、rig_.restart(Death)、闭眼)+ Player::update() 的 Dead 分支只驱动 rig。详见 developer-guide §3.2.5。
  • 动画事件:footstep(Walk/Run 每循环两次)、land、use_item,由 rig 按 clip time 发出,游戏层决定播放什么。
  • 游泳(见 §6.11):Swim 是划水(1.15 s,手臂交替抱水 + 双腿两次打水/周期),SwimIdle 是踩水(2.0 s,双臂横扫 + 慢踢 + 起伏)。两者都是俯视 2.5D 可读的画法:身体前倾、头抬出水面,不做前爬的过头移臂(俯视看不出)。
  • 角色是数据:一个角色 = 一个文件夹 = 一个 model.rig(体型模板 + 逐项覆盖 + 程序化配色 + 美术绑定 + 开关,见 docs/developer-guide.md §3.2.2.1)。图集可以程序化生成,也可以绑定外部 PNG;外部美术缺失时回退程序化材质,并在渲染器里画调试骨骼线。
  • 预览器 Reverseworks-CharacterPreview 加载任意模型文件夹,渲染「四朝向 × 多状态」接触表。
  • 角色美术/骨骼的详细说明在 src/graphics/CharacterRig.README.md。

最重要的 rig 契约:

graphics::kRigUnitTiles   = 0.5f;   // 1 骨骼单位 = 0.5 格
graphics::kSolesBelowCentre = 0.33f;
graphics::kRigVisualScale = 4.5f;

不要自己重写渲染数学。使用:

  • drawnOriginLift()
  • cameraAnchor()
  • groundContactOffset()

旧代码曾把 1 骨骼单位当成 1 格,导致角色视觉脚底和碰撞盒错位约 0.85 格,表现为“树/栅栏/建筑的碰撞比模型偏北一格”。v0.1.8.8 已用 kRigUnitTiles 统一换算,并让探针复用同一套公式。

6.3 生命、疾病与生存

核心文件:src/game/LifeState.h/.cpp、DiseaseSystem.h/.cpp、DiseaseCatalog.h/.cpp、HeatExchange.h/.cpp、Player.cpp。

  • 8 个身体部位:Head、Neck、Chest、Abdomen、LeftArm、RightArm、LeftLeg、RightLeg。
  • 每部位默认 maxHealth = 25、health = 25、temperature = 37°C。
  • 生命状态:Alive、NearDeath、Dead。
  • 状态效果:Poison、Burn、Frostbite、Infection、Fever、Trauma。
  • 10 种疾病:Hypothermia、HeatStroke、Dehydration、CommonCold、Influenza、Infection、Pneumonia、FoodPoisoning、Bronchitis、Tuberculosis。
  • 生存风险:cold、heat、dehydration、wetCold、frostbite,随环境、装备保温、湿度、风速等变化。
  • 体温通过 LifeState::applyAmbientThermalExchange() 与环境交换;装备的 insulation 按身体部位生效。
  • 营火休息:InteractionEffect::CampfireRest,通过热交换恢复,不是简单加血。
  • 疾病会按严重度、持续时间、阶段推进,感染性疾病可传播;具体定义在 DiseaseCatalog.cpp。
  • 身体部位效率影响移动/手臂使用:movementEfficiency()、leftArmEfficiency()、rightArmEfficiency()。

6.4 元素/组件系统

核心文件:src/game/Element.h/.cpp、ElementComponent.h/.cpp、ElementRegistry.h/.cpp、ElementVisualRegistry.h/.cpp、ElementState.h。

元素是数据驱动的实体,组件类型共 9 种:

组件作用
CollisionBox碰撞半尺寸与中心偏移
Ground地面属性
Placeable是否可放置、是否阻挡移动
Interactable对话 id、是否启用、一次性、交互效果
LifeState元素自己的生命/疾病状态(树、作物等)
HeatSource基础温度与半径(营火)
Canopy树冠半径,用于生成间距
Species树种:Great、Pine、Maple、Aspen
Flora地表植物:4 种野花/灌木 + 4 种街道装饰

内置元素(ElementRegistry::registerBuiltins()):

  • 树:great_tree、pine、maple、aspen
  • 野外地表:flora_lupine、flora_coneflower、flora_sagebrush、flora_fern
  • 街道装饰:street_weed_tall、street_weed_clump、street_pebble_scatter、street_stones
  • 交互物:shrine、campfire、field_marker

注意:

  • great_tree/pine/maple 的碰撞盒按树干实际宽度,不是整格;树冠只是视觉。
  • aspen 是细长单 quad 树,碰撞盒更窄。
  • flora_* 和 street_* 都是 placeable(false),可以穿过。
  • shrine 和 campfire 不阻挡移动;营火有 HeatSource(50°C, 5 tiles)。
  • id() 是实例 id(如 pine_12_34),不是类型 id;视觉注册按组件匹配,不按实例 id 字符串。

6.5 物品、背包、装备与快捷栏

核心文件:src/game/Inventory.h/.cpp、PlayerInventory.h/.cpp、EquipmentTypes.h、EquipmentCatalog.h/.cpp、EquipmentLoadout.h/.cpp、ItemIcons.h/.cpp、src/game/playing/。

  • 背包基础 15 格;装备/属性可增加槽位(inventorySlotBonus)。
  • 全局堆叠上限 kMaxStackPerSlot = 172;具体物品可更小(衣物 1、水壶 6 等)。
  • 快捷栏 3 格,独立于背包,不占背包容量;1/2/3 选择,Q 循环到下一个非空格,R 使用。
  • 快捷栏按键走实时绑定而非写死数字:core::hotbarSlotKey/hotbarSlotForKey(src/core/Settings.h)是路由与 HUD 标签共用的唯一入口,改键后键随格走,格内小字画的就是真正会选中它的键(PlayingHud.cpp)。
  • 耐力条横贯整条合并栏:combatStripLayout() 直接借用 hotbarDockLayout() 的 dock.row 包络做宽度与圆心,两端与物品格左缘/技能格右缘齐平;布局全部出自同一套视口缩放,无绝对像素,任意分辨率下对齐不破(tests/CxInventoryPanelChecks.cpp 的 checkDockLayout 在 16:9 / 21:9 / 4:3 / 1:1 下断言)。
  • 装备槽 8 个:Headwear、Top、Pants、Boots、Armor、Backpack、MainHand、OffHand(后两个是 E1.2 追加的;枚举只追加不插入,值进存档)。
  • 每个装备槽有独立 InventorySnapshot,用于口袋/背包内容。
  • 物品定义来自 data/Eraphanis/items/items.tsv,新存档初始装备来自 starters.tsv。
  • 物品类别 ItemCategory:Headwear、Top、Pants、Boots、Armor、Backpack、Weapon(E1.2 追加);没有 material。
    • parseCategory("material") 会静默回落到 Headwear。
    • 方块物品仍能工作,因为真正决定行为的是 useKind=Block 和 blockTileId,不是 category。
    • 如果未来要给材料/方块加独立类别,需要同时改 ItemCategory、parseCategory()、slotForCategory() 和相关 UI/测试。
  • 使用类别 ItemUseKind:Wearable、Consumable、Medical、Block、Ammunition、Weapon、Material(后两个是 E1.2 追加的:武器穿戴不饮用,零件既不喝也不穿也不放)。
  • 武器走装备路径:slotForCategory(ItemCategory::Weapon) == EquipSlot::MainHand;equipInventorySlot() 接受武器,useInventorySlot() / useSelectedQuickItem() / applyItemUse() 一律拒绝武器(否则会把枪“用掉”)。
  • items.tsv 当前 39 行物品;starters.tsv 当前 11 条初始授予。
  • 物品图标 = 模型缩图(E1.16):背包/快捷栏里的物品图标是该物品模型的缩图,字节派生——武器走 buildWeaponIcon(buildWeaponSprite(spec)),弹药走 buildAmmunitionIcon(buildAmmunitionSprite(spec))(同一个 SpriteIconFit.h 拟合,同一套材质遮罩契约)。items.tsv 的 model_id 列指明这件物品用哪个模型(目前只有 4 种弹药有:cartridge_pistol/shell_shotgun/cartridge_rifle/round_special)。ItemDefinition.iconId 仍可覆盖帧名,但指向不存在的帧会被安全回落到同名帧(所以表里写错帧名不会让图标变成灰块)。
  • 方块物品:stone_block_item、wood_block_item、soil_block_item,useKind=block,在面向格放置,只有放置成功才消耗。
  • 快捷栏拖拽原语:takeHotbarStack、setHotbarStackDirectly、insertIntoHotbar;背包侧对应 takeInventoryStack 等。
  • Shift+左键 整叠在背包/快捷栏间快移;Ctrl+左键 目标是 GameState::openContainer(),当前恒为 nullptr,所以是无害空操作。

6.6 环境、时间、光照与阴影

核心文件:src/game/WorldTime.h/.cpp、EnvironmentSystem.h/.cpp、ClimateCalendar.h/.cpp、LightingSystem.h/.cpp、SunPath.h/.cpp、SolarModel.h/.cpp、WorldRenderer.h/.cpp、BuildingShadowVolume.h/.cpp。

  • WorldClock:gameSecondsOfDay、dayIndex、季节推导、tick 计数。
  • EnvironmentSystem:天气、温度、湿度、云量、降水、风速/风向、能见度、地表湿度、太阳辐照、阶段。
  • 季节由 dayIndex 推导;存档里 [environment] 的 season 只是写入时的派生值,读档仍以 dayIndex 为准。
  • LightingSystem 提供环境光/点光;营火等 HeatSource 会同步为点光。
  • WorldRenderer/BuildingShadowVolume 仍在游戏侧做建筑阴影;uniscope-2d 侧的 Light2D、LightingRenderer、CommandBuffer、FrameRecorder、RenderThread 已在当前重构中删除。
  • 太阳路径/阴影探针:SunPath、ShadowProbe、NightBrightness 测试锁定关键视觉契约。

6.7 叙事:对话、PV、目标

核心文件:src/game/DialogueSystem.h/.cpp、DialogueView.h/.cpp、NarrationBox.h/.cpp、NarrativeText.h/.cpp、PvSystem.h/.cpp、Objective.h/.cpp、libs/cdlg/、libs/pv/。

  • 对话脚本格式 .cdlg;解析/运行时在 libs/cdlg。
  • 剧情动画格式 .pv;解析/时间线在 libs/pv。
  • 每个存档有自己的世界数据包:
    • %LOCALAPPDATA%/Eraphanis/world/<saveId>/dialogue/
    • %LOCALAPPDATA%/Eraphanis/world/<saveId>/pv/
  • 首次加载时从 data/Eraphanis/world/_template/ 复制;存档自己的目录优先,模板兜底。
  • 新存档开场播放 opening_prologue:冷色雾场、漂浮尘粒、电影黑边、淡入淡出字幕与白光揭示;playedScenes 记录已播放场景,防止重复。
  • PvSystem::start() 在返回前以 0 秒步长应用首拍事件,保证宿主首次渲染时黑场、相机、字幕和音频 cue 已就绪;跳过/重启会清理残留白场和未播放 cue。
  • PlayingPv 期间世界冻结,PV 拥有相机;Esc 跳过。PV 可以把对话交给 DialogueSystem,时间线等待对话结束。
  • 目标链:ch1_first_steps → ch1_shelter → ch1_stock_up → ch1_first_winter;完成 id 存在 completedObjectives,未知 id 读档时丢弃。
  • 对话/剧情文本会参与字体烘焙:加载阶段先解析内容,再按实际字符集烘字体,避免 CJK 缺字。
  • 字体由 fonts/fonts.tsv 清单驱动(script × role 链):中/日/英各一条链,主 face 烘全部文案、 后续 face 只烘缺字;清单与字体文件一起部署,格式必须是 glyf 或 CFF1(stb 不认 CFF2)。
  • 对话编辑器已从本仓库剥离:C:\personal\VisualDialoguer 是独立的 C#/WinUI3 程序(不链接游戏运行时、不共享源码目录),读写的 .cdlg 与 libs/cdlg 解析器 兼容。

6.8 UI、菜单、HUD 与控制台

核心文件:src/ui/MainMenu.*、MenuContext.h、loading/、views/、src/game/playing/(PlayingUI.*、PlayingUILayout.*、PlayingHud.*、PlayingInventoryPanel.*、PlayingConsolePanel.*)。

  • 主菜单:新建、继续、档案列表、选项、退出;档案支持重命名、控制台开关、复制、删除。
  • 加载屏幕:进度、取消、失败回菜单;字体/对话/PV 准备。
  • 游戏内 UI:
    • 8 个页签:BAG、CHAR、LOG、MAP、SKY、TASKS、CLANS、DEEDS。
    • 只有 BAG 实现;其余显示 NOT IMPLEMENTED YET。
    • BAG 内支持背包/快捷栏/装备槽、拖拽、Shift 快移、Ctrl 快移(容器为空时无操作)。
  • 面板/控制台打开时,PUI 消费整帧输入,DemoScene 直接返回,世界暂停。
  • 开发者控制台:
    • 每个 Echo 单独开关,存在存档 [meta] consoleEnabled。
    • 桌面端固定反引号 ` `` 开合,不可重绑定;Android 没有实体反引号,当前只有桌面可触发。
    • 当前命令:
      • time
      • time dawn|day|dusk|night|midnight
      • time HH:MM
      • suicide:通过正常生命状态与事件链立即杀死角色,并关闭控制台。
    • 其他命令回显后输出 UNKNOWN COMMAND。
  • HUD 会显示生命/体温/风险、快捷栏、交互提示、目标等;具体布局在 PlayingHud.cpp / PlayingUILayout.cpp。
  • 死亡操作直接留在 HUD:
    • 保留现有 CRITICAL FAILURE 横幅,不再显示独立死亡菜单或中文摘要。
    • 玩家倒下 1.45 s(= 死亡动画片段长度)后,仅在横幅下方启用英文 RESTART。
    • 点击或确认键重新载入当前存档槽,不重放开场 PV;命中与绘制共用 deathRestartRect()。
    • saveCurrentGame() 拒绝写死人的存档,所以重开不会把玩家送回同一具尸体。

GUI 与 PUI 是两套东西:

GUI(src/ui/ + libs/complexui)PUI(src/game/playing/)
场景菜单、存档、设置(背后没有世界)HUD、物品栏、控制台(浮在会动的世界上)
表面不透明实体板:落影 + 轮廓线 + 双棱安静板:半透明烟灰、无落影、只有顶部一道受光棱
命名空间Eraphanis::complexuiEraphanis::game::playing

把 GUI 的板子直接搬到 PUI 上会读成"贴在屏幕上的招牌",而不是浮在世界里的一层 界面——所以两套表面各画各的(theme/CxTheme.h 的 plate / well 对 hudPlate / hudWell)。详见 developer-guide.md §5.5–§5.7。

6.9 战斗、敌人与词缀

核心文件:src/game/combat/ 全部、src/game/CombatStats.*、src/game/fx/WeaponCatalog.h、CombatFx.h/.cpp、src/game/playing/PlayingCombatStrip.*、GameState.cpp。

三层结构:数据(动作表/敌人表/词缀表)→ 规则(状态机/格挡/冷却/伤害管线)→ 呈现(剪辑 + FX + 技能条)。

动作表(ActionCatalog.h,header-only)是唯一真值。每条 ActionDef 说明:分类、动画剪辑、起手/有效/收招时长、连招下一式与索引、蓄力档位、命中几何、伤害类型与倍率、耐力/冷却、是否可取消。当前行:

组id
轻击链melee_light_1 → melee_light_2 → melee_light_3
蓄力重击melee_heavy_1/2/3(蓄力 0/20/45)
远程ranged_shot、ranged_burst、ranged_shotgun、ranged_sniper(+ ranged_sniper_full,蓄力 24)
移动action_dodge、action_dash
技能skill_counter、skill_whirl、skill_throw、skill_lunge、skill_execute
敌人enemy_claw、enemy_slam、enemy_spit
  • 三段状态机:起手 → 有效(命中窗口)→ 收招;连招窗口在收招内,按 chainNext 进入下一式。
  • 翻滚的无敌从有效窗口第二帧开始(贴边命中仍算),位移在世界里解算,撞墙停下而不是穿墙。
  • 格挡是姿态不是按键:GuardController 的数值是数据,完美窗口 4 帧,完美格挡反伤一半、硬直攻击者并送一次免费反击。
  • 技能栏 6 格:Basic + Core1-4 + Ultimate,键位 4–9(skillSlotKey()),冷却从触发瞬间起算;冷却缩减词缀同时缩时钟和 HUD 遮罩,两边不会对不上。

敌人(EnemyCatalog.cpp / EnemyCombatant.*):

  • 名册:enemy_husk、enemy_stalker、enemy_brute、enemy_spitter;招式是动作表的行,所以敌人的起手抖动和玩家是同一条数据。
  • 硬直等级 common / elite / boss 是数据:精英不是血更厚,而是韧池更大、吃削韧更少、伤害更重。
  • 伤害类型 Physical / Fire / Cold / Poison;身体带 DamageAffinity,抗性是乘数、免疫等于伤害不存在;护甲只作用于物理通道。
  • GameState 持有 enemies_(会话实体):不参与碰撞、不绘制。会话把 Level 交给敌人,敌人绕墙走、有视线;死亡时按掉落表在地上留下掉落物(AI.3)。spawnEnemy() 目前只有测试调用——世界里还没有刷怪。

通用 AI 框架(src/game/ai/,P1 的 AI.1–AI.3 已落地;设计见 docs/ai-generic-design.md):

  • 特征:data/Eraphanis/ai/traits.tsv(11 列:id/视野/听觉/仇恨/牵引/耐心/胆量/逃跑血线/偏好距离/警戒半径/速度倍率)→ ai/AiTraits.{h,cpp}。EnemyDef::aggroRange/leashRange 已迁出(数值不变:husk 7/12、stalker 8.5/14、brute 7.5/13、spitter 10/15,由 tests/AiBrainChecks.cpp 钉住)。表无 C++ 兜底,缺行/坏行在启动期报错(Application::validateAiContent()),且 validateEnemyCatalog() 反查「每个敌人必有一行 traits」。
  • 寻路:ai/Pathfinder.{h,cpp}——八邻域 A\*(不切角、八分距启发、节点预算 kDefaultPathNodeBudget)、路径拉紧(string pulling)、PathAgent(重寻路节流 0.25 s + 目标漂移 2 格)。两条射线是两个函数:hasLineOfSight(挡视线才算断)与 hasClearWalkLine(不能站才算断)——水能看见、走不过去。
  • 世界接口:ai/AiWorld.h 只有四个问题(能不能站/挡不挡视线/位置属于哪格/格子中心),游戏里是 LevelWorld(包 Level),测试里是手写网格。ai/ 不依赖 combat/,也不直接调 Level 的方法。
  • 感官与行为:ai/AiSenses.{h,cpp}(视/听/最后已知位置记忆)、ai/AiBrain.{h,cpp}(10 态通用状态机:dormant/alert/approach/reposition/attack/investigate/return/flee/staggered/dead)。听觉的有效范围是 max(hearing_range, 噪音自带半径)——取更远的那个,不是相加。
  • 敌人已接管:EnemyCombatant 改由 AiBrain 驱动(EnemyState 增加 investigate/return/flee 三态),会话把 Level 交给敌人(GameState::updateEnemies)——敌人不再穿墙,并且有了视线(旧代码只有距离判断)。能力暂由 EnemyAttackDef 翻译成 AiAbility(冷却真值留在身体里),abilities.tsv 留到 AI.4(迁移路径见设计文档 §7.2)。
  • 交互(AI.3):ai/AiSeparation.{h,cpp} 是纯函数的软分离求解器(体重大的少动:玩家权重 4、敌人 1;单帧最多推 0.25 格;重合时按 pair 定轴,可重放),会话把它接到敌人之间与敌人对玩家上;墙优先——落点站不住就不推。听觉接线:会话订阅 NoiseEmitted → 每个敌人 hearNoise → 听得到就进 Investigate(听见不等于仇恨)。死亡掉落:ai/AiDrops.{h,cpp} + data/Eraphanis/ai/drops.tsv(id/item_id/quantity/chance),每个敌人必须有一行,掷点确定性(xorshift32+FNV,同种子同结果),掉落是会话内容不进存档,交互键的地面归属者按「近的先拿」,掉落用物品 icon 图集绘制。
  • 未落地:AI.4 能力表、AI.5 世界刷怪/渲染/存档。

词缀(AffixCatalog.* + CombatStats):

  • 13 种词缀(affix_sharp、affix_heavy、affix_sturdy、affix_vital、affix_keen、affix_swift、affix_hasty、affix_fiery、affix_frost、affix_venom、affix_fireskin、affix_iceskin、affix_antitoxin)。
  • roll 属于物品实例,不属于定义;xorshift32 确定性生成,随物品进背包、进存档(v8 的 item 行词缀后缀;v9 在其后再追加武器实例状态后缀;v10 再追加怀表校准槽)。
  • CombatStats 从穿戴装备 + 词缀汇总伤害/暴击/耐力回复/冷却缩减/抗性;伤害管线、冷却时钟、耐力条都读这一份汇总。

武器(E1.1 已落地):

  • 武器定义不再是代码里的静态表,而是数据表:data/Eraphanis/weapons/weapons.tsv(21 把 × 39 列;第 38 列 ammo_per_shot 是 E1.4 补的,第 39 列 model_variant 是 E1.16 补的)→ src/game/weapons/WeaponCatalog.{h,cpp}(解析 + 启动期校验 + 别名表)→ src/game/fx/WeaponCatalog.h(门面,产出运行时的 fx::WeaponDef)。
  • 21 把武器的编号、数值、模型/材质 id、能力标签、握持、动作接线全在表里;加一把武器是改表,不是改代码(设计 S3)。表没有 C++ 兜底:数据包是唯一来源,缺文件即校验报错。
  • 旧 6 把(knife/fireaxe/shovel/pistol/shotgun/rifle)退役为别名,解析到新行;equipWeapon() 存的是规范 id,背包里放的也是规范 id。
  • 表带三列是设计表没有的:cooldown_seconds(武器自己的射速,§2.2 第 37 列)与 ammo_per_shot(一次扳机扣几发弹匣,§2.12 #2)与 melee_reach_tiles(引擎的近战半径;表里的「射程 1 格」是设计距离),以及第 39 列 model_variant(族内的形状:0 = 该族默认形状,1 = 第二个形状;E1.16)。
  • model_variant 是模型的一部分,不是表现参数:model_id 选族(轮廓),model_variant 选族里的形状。柯尔特警用左轮与 S&W 警用左轮同族不同形,温彻斯特 1897 与雷明顿 10 同族不同形,春田 1903 与莫辛-纳甘同族不同形——三对枪在 E1.16 之前画的是逐字节相同的模型、背包里是同一张图。变体号必须落在族已有的形状里(graphics::isKnownWeaponVariant),启动期校验拦下错号;绘制层对不存在的变体回落到默认形状(画不崩,但启动校验会先报错)。
  • E1.1 顺带补了两条动作行 melee_thrust / ranged_cross 与 HitShape::Cross(武器表的启动期校验要求它们存在)。
  • E1.3(装备路径 / 弹匣)已落地:
    • 武器实例状态(设计 E1 §2.3)随实例走:WeaponInstanceState{refineLevel, durability, loadedAmmo, jammed} 挂在 InventoryEntry/ElementStack/装备槽上,随背包↔装备↔存档移动;耐久在进背包/读档时由武器行的 durability_max 物化。
    • 装备槽是主手的唯一真相:GameState::syncEquippedWeapon() 每帧读 EquipSlot::MainHand,equippedWeapon() 直接读槽(不再用「背包里有没有」判定,旧的静默收枪检查已删除)。equipWeapon(id) 语义是「把背包里的该武器移进主手」,快捷栏选中武器时按使用键即调用它。
    • 主手/副手:两个手槽都接受武器,装备互不干扰;攻击/弹匣只读主手,副手只提供能力与模型(offhandWeapon())。
    • 弹匣与换弹(设计 E1 §2.5):弹匣按武器行容量;X(可改键,InputAction::Reload)开始换弹,时长取武器 reload_seconds(1.4–2.2 s),期间不可开火、可移动,完成时才从主背包(穿戴背包容器兜底)搬弹、最多填满。开火扣已装填弹药:霰弹枪一发扳机 1 发、点射 3 发;loadedAmmo == 0 的枪械收起(不绘制、rig 握持 None),轻击改用徒手档案(伤害 9 / 命中 65 / 距离 1.0 格 / 噪音 1 / 冷却 0.32 s,动作仍是 melee_light_1)。
    • 握持进 rig:graphics::CharacterRig::WeaponHoldStyle(None/OneHand/TwoHand/Pole)由主手武器每帧同步;E1.7 的握持修正层直接读它。
    • 装备槽 UI:物品栏 CHAR 页显示 8 个装备槽(2×4,主手/副手标签 HAND.R/HAND.L)。光标携带物品点槽=装备(换下件接回光标)、空手点槽=卸下回包、右键槽=卸下;BAG 页的布局/命中/渲染未变。
    • 存档 v9/v10:物品行/装备行追加第二段实例后缀 <refine>,<durability>,<loaded>,<jammed>(E1.3);E1.9 升 v10,在同一逗号段末尾再追加 <mode0>,<mode1>(怀表校准槽)。非武器行与 v8 逐字节相同。SaveRepository::load 的 normalizeLoadedWeaponState 负责把 v8/v9 旧档物化/补空,并把字段钳到武器行上界、校准槽按精修槽数与模式合法性归一化。
    • 设计偏差与交给后续阶段的发现见 docs/combat-e1-weapon-design.md §2.11(弹匣/换装口径);E1.4 之后 roundsPerShot 已改读武器表第 38 列 ammo_per_shot(§2.12 #2)。
  • 仍未落地:敌人/NPC 的持枪模型与姿势不画(E1 只画玩家的武器);真实弹丸碰撞仍是 hitscan(E1.7 只做表现);tests/CombatWeaponChecks 验的是表/规则/装备/弹匣/战斗管线/精修/拾取与会话内路径,不等于世界内容全量完成(敌对 NPC、商店、掉落品质在 E2)。
  • E1.2(物品层)已落地:装备槽追加 MainHand/OffHand,ItemCategory 追加 Weapon,ItemUseKind 追加 Weapon/Material;items.tsv 加了 21 行武器物品(category=weapon、use_kind=weapon、stack_limit=1、槽位 1–3、词缀 1–3)、4 种弹药(ammo_handgun/ammo_shotgun/ammo_rifle/ammo_special)与 weapon_parts 零件;旧弹药 id(pistol_ammo/shotgun_shells/rifle_ammo)退役,读档时重映射到新 id(SaveSystem.cpp 的 remapLegacyItemId)。
  • E1.4(战斗管线)已落地:
    • 准确性掷骰(§3.1):combat::accuracyChance/rangeAccuracyFactor/accuracySeed/accuracyRollHits(src/game/combat/CombatResolution.h,纯函数)。命中率 = clamp(accuracy + refine×2 + action.accuracyBonus − movePenalty);距离 0.6R 内 1.0、到 R 线性降到 0.5、超出 0;种子 hash(weaponId, attackSerial, targetSalt),同种子必同结果;近战也掷骰(kMeleeAccuracyFloor 默认 0)。近战只走几何+准确性、不做距离衰减。
    • 熵有效度(§1.4/§3.2):CombatTarget::isEntropy + resolveDamage;熵目标伤害 ×(entropyEffectiveness + entropyBonus) 且跳过护甲、只读 DamageAffinity;非熵目标不看熵字段。E1.9 起 entropyBonus 由怀表降熵波(+20pp,7 级共鸣再 +5pp)填充并 clamp 100%。
    • 耐久/卡壳/破刀(§3.3):每次攻击结束主手 durability −durabilityLoss(1, refine);低于阈值时 jamChance 随磨损上升(精修 2 阈值 −5pp);开火帧先判卡壳再掷命中,卡壳扣已装填弹、无弹道/伤害/噪音、置 jammed;X 键在卡壳时走 1.2s 清膛(非战斗姿态、不可开火可移动),完成后 jammed=false;近战耐久 0 → 伤害 ×0.5、噪音 +1。refineLevel 由 E1.5 填。
    • 噪音事件(§3.4):GameEventType::NoiseEmitted + GameEvent::noiseEmitted(...),payload["radius"] = noise×2;挥击/开火各一次,破刀 +1,setWatchOverload(true) 时 +5;卡壳不发。
    • 十字(§2.6/§2.8):HitShape::Cross 不参与体积判定,改由 crossCells/crossCellFor 按落点 5 格结算(中心 cross_center_scale、四臂 cross_arm_scale),远程命中采用开火帧 hitscan(弹道视觉仍飞行)。
    • 一次扳机的伤害/弹药总量:武器表第 38 列 ammo_per_shot 决定扣几发(枪 1 / 点射 3 / 近战 0),伤害按 damage_per_shot × shot_count × 武器 damage_scale(覆盖动作表倍率)一次结算;ranged_shotgun.ammoCost 已由 6 改为 1。
    • R1 溢出:LifeState::damageWithOverflow 只由战斗受击调用(50% 溢出转躯干、只跳一次);环境/状态伤害仍走原 damage,不溢出。
    • 同轮修复两个被暴露的旧 bug:HumanoidCharacter::facingFromDirection 的 y 轴反向(垂直攻击转向反面)与 E1.3 setWeaponStateForSlot 的耐久归一化(破刀不可达)。偏差见 docs/combat-e1-weapon-design.md §2.12。
  • E1.5(精修)已落地:
    • 共用曲线是唯一真值:src/game/combat/RefinementCatalog.{h,cpp} 持有 §3.5 的 9 行曲线(伤害 +4%/级、命中 +2/级、耐久上限 +5%/级、对熵 +2pp/级)、成本 10/15/23/34/51/76/114/171(累计 10/25/48/82/133/209/323/494)、逐级解锁特性与校准槽(3 级 1 槽、6 级 2 槽)。E1.4 的 accuracyChance/jamChance/durabilityLoss 三个钩子改读这张表。
    • 精修生效:DamageQuery.refineLevel 进入伤害管线(基础 ×(1+0.04L),对熵再 +2pp/L);equippedDurabilityMax() 返回精修后的耐久池(整数算术 round(raw×(100+5L)/100)),精修时上限增量加给当前耐久、读档/物化按精修池钳制;卡壳判定用精修池;≥4 级换弹 ×0.70、攻击间隔 ×0.85(后者因动作行 cooldown 短于恢复帧,实战几乎不可感知,见设计 §2.13 #4)。
    • 精修入口:GameState::refineEquippedWeapon() 一次一级、按曲线扣主背包/背包容器里的 weapon_parts,受 refine_max_level、8 级「专属觉醒」门槛(专属角色或原创设计)与身体空闲限制;canRefineEquippedWeapon()/equippedRefineCost()/weaponPartsCount()/equippedCalibrationSlots()/canRepairEquippedWeapon() 供台面 UI(E2)与怀表校准(E1.9)读。
    • 存档兼容:E1.5 本身不升版本(当时仍 v9,refineLevel 字段 v9 已在);E1.9 的怀表校准槽把格式升到 v10,见下方。武器实例新增运行期字段 WeaponInstanceState::materialised(不入存档):0 耐久不再兼任“未物化”哨兵,卸下/存读档不再把坏刀修好(E1.3/E1.4 遗留缺陷,§2.13 #10);E1.9 又给实例追加 calibrationModes[2](进 v10)。
    • 精修台交互、零件来源、修理价格曲线属 E2;怀表校准槽与降熵波见下方 E1.9。偏差见 docs/combat-e1-weapon-design.md §2.13。
  • E1.6(武器模型/材质/icon)已落地:
    • src/graphics/ProceduralWeapons.{h,cpp}:18 个模型族覆盖 21 把武器,程序化生成 albedo + 材质遮罩(R 通道精确写 WeaponMaterial id 11..19,BuildingPart::Count=11 之后追加,建筑遮罩编码零迁移);锚点(muzzle/eject/centre)统一为武器局部 tile(+x 枪口、+y 上、原点握把),buildWeaponIcon 把模型缩到 64×64 作为物品图集格。启动期/测试用 validateWeaponSprite 检查遮罩尺寸、id、锚点顺序与后坐参数。
    • src/game/WeaponMaterialSystem.{h,cpp}:9 行 weaponMaterial() 响应表(复用 BuildingPartMaterial 形状:木哑光/冷锻铁低光泽/发蓝钢中等/亮钢与黄铜高光/皮革软/聚合物中/纸哑光)。遮罩按 NEAREST+无 mip 契约上传(Texture2D::loadNearestFromPixels),W12 的三列 CPU 探针证明 nearest/no-chain 0 非法 id,linear+mip 与 nearest+mip 会制造“第三材质”。
    • ItemIcons:武器 icon 不再是哈希形状,而是 weaponModelSpec → buildWeaponSprite → buildWeaponIcon(64) 的字节同源派生(W13);顺带修了 shapeTints 少一项导致的越界兜底帧。
    • 未做:武器材质遮罩尚未绑进 sprite shader 的材质块(shader 材质表只有建筑 11 行);当前持枪绘制用 albedo + Fabric 响应。材质行进 shader 是 E2/视觉打磨。
  • E1.7(表现接线)已落地:
    • 握持修正层(W14):CharacterRig::gripPoseAdjustment(state, grip) + applyWeaponGripPose() 在 clip 采样与 lean 之后、braid 之前加性改手臂链局部旋转;状态机/clip/相位/事件帧号全不变,只有姿势变。
    • 持枪渲染:game::WeaponPresentation 用当前 pose 的手骨世界变换把武器 sprite 坐到画出来的手上(世界 tile 尺度;recoil 沿自身轴反向 kick),drawHeldWeaponAtClip 由 WorldRenderer 在玩家深度槽内、rig 之后调用;地面武器用 drawGroundWeapon。
    • 枪口/烟/壳/曳光/后坐:CombatFx::spawnWeaponShot 的闪光与弹丸起点改读 WeaponPresentation::muzzlePosition(模型 muzzleOffset,旧 0.7 常量删除,W11),起点取 GameState::weaponMuzzleOrigin()(画出来的手/枪管,而非脚底锚点);烟按 sound_profile、抛壳按模型 ejectsCasing、曳光按档位配色、闪光半径随 noise 微调、weaponRecoil(id) 0.12s 衰减驱动持枪踢回。
    • 射击帧同源(E1-GUN P4 已落地):WeaponPresentation::ShotFrame(grip / gripKicked / muzzle / weaponAngle)是座位、后坐、枪口与枪管角的唯一推导;heldGripWorldPosition() 与 heldMuzzlePosition() 只是它的两个字段。会话侧 GameState::weaponShotFrame() 是唯一出口(public,纯查询),weaponMuzzleOrigin() = grip、weaponDrawnMuzzle() = muzzle,update() 的远程分支只算一次帧就交给 CombatFx——闪光、烟、弹壳、曳光、弹丸不会各算一遍。断言:WeaponPresentationChecks 的 W20g2,WeaponE16E18Verify 的 P4 段 168 条(21 把 × 双向:FX 原点/枪口/闪光三者一致 + 锚长过同一因子)。
    • 握持细节:空仓枪收起,rig 握持切 None 且画面不绘制这把枪(两者读同一个 GameState::weaponDrawn());上弹/清膛时枪重新持出,上弹动作结束后才切回行握法并让握持动作生效(Reload 状态不接管手臂,枪跟手);GripPoseAdjustment.weaponOffsetX/Y 真正参与持枪定位(按手角旋转的 rig 单位偏移)。
    • 持握位分「携行 / 瞄准」两档(E1.7 姿势层,docs/e1-gun-model-solution.md §5.1 与 §8.3):持握位置不再只由握持档决定,还看状态——单手枪携行是低端口(height −0.14、枪口朝前下),射击族抬到胸前(height −0.04、枪口平举);双手与长柄不分档。射击族里单手武器的左手交回姿势层,沿枪轴 0.05 rig 单位托住握枪手(否则射击 clip 的双手前推会让左手单独捣出去,读作挥拳)。断言在 WeaponPresentationChecks 的 W20g。
    • 声音:SoundId 追加 pistol/smg/shotgun/rifle/black_powder/special/mechanism 七档,SoundSynth 逐档独立 ToneSpec,weaponSoundProfile() 读表里的 sound_profile;audio::SoundRequest{id,gain} 把 0.2+0.08×noise 的响度带到 AudioEngine::play(id,bus,gain);DemoScene 只 drain 一次 requests(旧 id drain 保留但二选一)。
    • 未做:材质遮罩进 shader(见上)、NPC/副手持枪、真实弹丸碰撞、录制的武器音效覆盖;持枪 sprite 的 tint/roll/alpha 继承与深度遮挡是 E2 表现细节。
  • E1.8(世界投放与拾取)已落地:
    • src/game/WorldPickup.{h,cpp} + data/Eraphanis/weapons/spawns.tsv:anchor_id × item_id × quantity × faction;锚点 player_start/town_centre 与建筑 token(general_store/saloon/police_station/railway_station/bank/church/barn/safehouse)解析到可行走地面;年代门由武器表 prototype_year 对 kStoryYear=1915 决定(Thompson 1921 不刷、1922 刷);faction 仅 E2 元数据。
    • GameState:attachLevel() 末尾幂等生成(worldPickupsSpawned_),update() 在玩家交互前先试 pickupNearbyGroundItem()(AI.3 起,武器与敌人掉落物一起算,近的先拿),成功才吞掉这一帧 interact(不会同时开门/挥镐);拾取按工厂实例物化(满耐久/空弹匣/未卡壳/materialised);背包满留在地面;nearbyWorldPickup() 供 UI 提示。
    • WorldRenderer 在玩家深度排序前画未拾取的地面武器(跳过 taken 与室内)与敌人掉落物(用物品 icon 图集,单独 sprite batch)。
    • 拾取实例走 Inventory::add(id,qty),因此会像任何新实例一样掷 P3 词缀(独立验收发现的绕过,已修);工厂实例状态(满耐久/空弹匣/未卡壳/materialised)由 initialStateFor 给出。
    • 已知限制:pickup 与 take 状态不进存档(E1.9 升 v10 也未加入拾取段);读档/新会话按表重刷;持久化/势力门槛/掉落品质/拆解与精修台属 E2。偏差见设计 §2.16。
  • E1.9(怀表校准)已落地:
    • 模式表:combat::CalibrationMode(None/EntropyWave/StasisField/Acceleration/Rewind/AnchorReset)带稳定 snake_case id 与显示名,id 进存档所以只追加;E1 只有熵波 implementedInE1,另 4 行是 E2 的占位;kWatchOverloadMax=100 保留为过载钩子。
    • 槽位装配接口:槽数由精修派生(3 级 1、6 级 2);GameState::equippedCalibrationMode/installCalibrationMode/clearCalibrationMode 作用于主手武器,拒绝未开启槽、E2 模式、None、重复模式;模式存 WeaponInstanceState::calibrationModes[2],随实例进背包/装备/存档;清掉熵波槽会取消燃烧中的波。
    • 降熵波:GameState::activateWatchCalibration() 置 3 次攻击;每次攻击开始消耗一次(近战/远程、命中/未命中;十字/霰弹多目标一次扳机只算一次),攻击起手把 +20pp(7 级及以上再 +5pp 共鸣)写进 DamageQuery.entropyBonus,与武器自身 entropy_effectiveness、精修 +2pp/级 相加后 clamp 100%;换手/卸下/读档/清槽取消。怀表物品与按键是 E2,E1 暴露动词。
    • 存档 v10:武器后缀扩展 <refine>,<durability>,<loaded>,<jammed>,<mode0>,<mode1>;v9 旧档照常读、槽位空;坏档按槽数/合法 id/去重归一化。测试 tests/WatchCalibrationChecks.cpp 覆盖模式表、装配、3 次攻击、十字只耗 1 次、共鸣 clamp、v9→v10 与 v10 往返。
    • 其余 4 种模式效果、怀表物品/过载充能、台面装配 UI 属 E2。偏差见设计 §2.17。
  • 第四轮 E1 代码评审已修(全阶段审查,记录见 docs/combat-e1-weapon-design.md §2.18):
    • 抛壳口走同一换算:ejectPortOffset 是模型 tile,新增 WeaponPresentation::worldPerModelTile(modelId, figureHeight) 作为「模型 tile → 世界 tile」的唯一出口,spawnWeaponShot 收 figureHeightRig(默认 0 = 参考身高)。之前它被当世界 tile 用,弹壳从枪身外十倍处飞出。
    • 十字落点钳位:Level::clampedTileCoordAt();边界格算「被挡」,hitscan 落点可能落在界外,tileCenter 会抛异常穿出游戏循环。
    • 连段属于开它的那把武器:CombatActionController::closeChainWindow(),syncEquippedWeapon 在主手 id 变化时调用;否则换手后左键会让枪挥出上一把武器的连段(按枪的伤害/命中/噪音/耐久)。
    • 近战技能不广播枪声:持枪按近战技能时噪音取徒手 1(枪的 5–10 是「枪声」的定义,不是「被挥动」的定义)。
    • 栓动步枪蓄力可达:右键蓄力对射击动作自带 charge group 的武器生效(今天只有 ranged_sniper 一族),蓄力画的是当前那一档(Aim → AimFull)。注意该档的 damageScale 2.20 不乘进伤害(远程伤害只读武器 damage_scale,§2.12 #3 的刻意口径),详见设计 §2.18 末尾的规格冲突。
    • 数据表收紧:物品表 parseIntStrict/parseFloatStrict("12O" 不再变成 1)、同文件重复 id 报错、starters.tsv 坏行带原因打 stderr;武器表补 cross_center_scale > 0、cross_arm_scale ≥ 0、melee_half_angle_deg ∈ [0,180]、silent ⇒ noise ≤ 2(w03/w04 的 silent 已从表里去掉)。
    • HUD:上弹/清膛中不再显示 STOWED(weaponDrawn() 才是「画里有没有枪」的唯一真值)。
    • 记录但未改(需先定规格口径或当前不可达):精修 5 级磨损减免在实机零收益、hasAmmoFor 忽略参数、容器搬运丢实例状态、熵目标上的元素份额。见 §2.18 末尾。
  • 仍未做:HUD 弹匣/换弹/卡壳/精修读数(E1.7);真实弹丸碰撞(E1.7,当前 hitscan);怀表物品与其余 4 种校准、精修台/零件来源与修理(E2)。

FX:战斗 FX 是瞬态的,不写入存档;武器命中可伤害方块完整性,枪械有弹道/命中,爆炸类有 AoE。

6.10 图形、音频、平台

图形

  • 桌面 OpenGL 3.3 core;Android GLES 3.0。
  • 游戏通过 src/graphics/compat/UniscopeGfxAliases.h 使用 uniscope::d2::Renderer2D。
  • libs/uniscope-2d 已删除 Light2D、LightingRenderer、CommandBuffer、FrameRecorder、RenderThread 及其测试;游戏侧阴影仍在 WorldRenderer/BuildingShadowVolume。
  • 纹理质量 4 档;MSAA 0/2/4/8;12 个主题;动态分辨率缩放 0.5–1.0(只作用于世界 pass);自动质量滑动。
  • 世界贴图/细节层的详细契约见 resources/assets/eraphanis/textures/world/README.md。
  • 地面细节必须锚定世界坐标(u_detail_origin),否则相机移动时细节会在屏幕上“爬”。v0.1.8.7 修复过这个问题;当前 GroundFlicker 回归说明该契约又被破坏或部分破坏。
  • 武器模型(E1.6,src/graphics/ProceduralWeapons.*):18 族程序化 albedo + 材质遮罩,锚点(muzzle/eject/centre)是武器局部 tile(+x 枪口、原点握把),muzzleOffset 同时是 W11 的枪口火光契约;遮罩 R=材质 id 11..19、采样 NEAREST 无 mip(Texture2D::loadNearestFromPixels);物品 icon 由模型缩图派生(W13)。材质响应表在 game::weaponMaterial(),尚未绑进 sprite shader 的材质块。
  • 看模型:离线导出接触表(21 把枪 + 4 种弹药的 icon 与 sprite,含三对同族变体并排)——cmake --build build-mingw64-release --target dump_weapon_models,再跑 build-mingw64-release/bin/dump_weapon_models.exe data/Eraphanis/weapons/weapons.tsv <输出目录>,得到 weapon_icons.ppm / weapon_variant_pairs.ppm / ammo_icons.ppm,用 tools/ppm_to_png.py 转 PNG 后肉眼核对。它只链图形层(无 GL、无游戏目录),所以哪台机器都能跑。
  • 持枪与射击表现(E1.7):WeaponPresentation 按当前 pose 的手骨变换画持枪 sprite(世界 tile 尺寸、recoil 沿轴 kick),WorldRenderer 在玩家深度槽内调用;枪口闪光/弹丸起点读模型锚点(W11),烟/抛壳/曳光/后坐由 CombatFx 按武器档位与模型 flags 生成;握持修正层只在 rig 姿势上加性改手臂链,不改帧/事件(W14)。

音频(src/audio/)

  • 接口在引擎侧:uniscope::audio::IAudioDevice / IAudioStreamVoice(libs/uniscope-2d/include/uniscope/audio/AudioDevice.h)。上层拥有混音与音量策略,设备只负责发声;没有声卡时用 NullAudioDevice,调用方不需要判空。
  • 后端:openal/OpenALAudioDevice(首选,OpenAL-soft 1.24.1 + mpg123 1.32.9 + stb_vorbis)、winmm/WinmmAudioDevice(Windows 显式回退)、NullAudioDevice(兜底)。AudioFactory::createAudioDevice() 的顺序是 OpenAL → Null,WinMM 只在 createFallbackAudioDevice() 里用。
  • OpenAL Soft / mpg123 以动态库随包部署(Reverseworks_deploy_audio_runtime),满足 LGPLv2 的“可替换”要求;不再静态链进闭源可执行文件。
  • Ogg 曲目时长由 stb_vorbis 的 granule 读出(MusicMetadata.cpp),剧情音乐时钟对 .ogg 不再读到 0。
  • 程序化合成 SFX;音乐播放桥 MusicPlayerBridge 管理菜单 BGM/剧情音乐。
  • 武器音色(E1.7):SoundId 追加 pistol/smg/shotgun/rifle/black_powder/special/mechanism 七档(SoundSynth 每档独立 ToneSpec,无外部素材);武器表的 sound_profile 经 weaponSoundProfile() 映射;audio::SoundRequest{id,gain} 让每发枪声按 0.2+0.08×noise 的响度进 AudioEngine::play(id,bus,gain)(gain 与 bus 音量相乘、按量化增益缓存)。
  • 设置里可调 master/music/sfx/ui/ambient 音量、静音、失焦静音、输出设备。

平台抽象层(PAL)

  • 接口:libs/uniscope-2d/include/uniscope/pal/(Platform.h、Window.h、Input.h、Assets.h、Paths.h、Display.h、Clock.h、Log.h)。
  • 后端:src/pal/glfw/GlfwPlatform.cpp(桌面)、src/pal/android/AndroidPlatform.cpp(Android)、src/pal/common/(StdClock / StdLogSink / FileSystemAssetSource / StdPathProvider)。
  • 一个进程只有一个平台:createPlatform(PlatformInit, error) 成功后由 currentPlatform() 取用;IPlatform 提供 window / asset source / path provider / display enumerator / url opener。
  • 游戏层的 ResourcePaths / DataPaths / UserDataPaths 已退化为薄包装;平台宏(__ANDROID__ 等)不应再出现在游戏头文件的分支选择里。
  • 用户数据目录:Windows %LOCALAPPDATA%\Eraphanis、Linux XDG_DATA_HOME/Eraphanis、macOS ~/Library/Application Support/Eraphanis、Android 内部存储;旧名 Reverseworks 目录仍被识别为遗留布局。

平台

  • Windows:GLFW + OpenGL 3.3;MSVC /W4 /permissive- /utf-8;MinGW 静态 runtime + DLL 部署。
  • Linux:X11;纯 Wayland 无 XWayland 时窗口创建可能失败(已知限制)。
  • macOS:bundle + echo.icns;OpenGL forward-compat。
  • Android:EGL/GLES3、android_main()、AAssetManager、内部存储、触摸手柄;详细见 docs/android.md。

6.10.1 触摸手柄与自适应布局

核心文件:src/game/TouchGamepadLayout.h/.cpp(纯数值,无 GL/平台头)、src/game/TouchGamepad.*(行为)、tests/TouchGamepadLayoutChecks.cpp、tests/TouchGamepadChecks.cpp。

  • 单位是毫米:控件尺寸按物理尺寸授权,再换算成引擎的 UI-norm(y ∈ [-1,1],x ∈ [-aspect,aspect]),所以手机和平板的手感一致;densityDpi <= 0(桌面)按参考 6.5" 19.5:9 手机(68 mm 高)处理。
  • 两条不变量:所有控件完整在屏内;任意两个控件不重叠。空间不够时退回紧凑布局(compact),而不是让按钮叠在一起。
  • 输入:leftHanded 镜像、userScale(选项里 0.7–1.5,1.0 中性)、8 个按钮 + 摇杆(stickRadius 捕获半径、stickTravel 满倾位移、knobHalf)。
  • 手势分层:虚拟手柄先认领触点 → 左缘内滑是内部返回兜底 → 剩下未认领的手指才做双指缩放或“次要点按”(右键语义)。系统返回键/手势本身已经可用(AKEYCODE_BACK → ESC/Cancel,窗口消费,enableOnBackInvokedCallback=false);内部左缘手势只在 OEM 游戏模式吞掉系统手势时生效。顶缘下拉与右缘内滑已移除。见 docs/android.md §2.1。

6.10.2 菜单里的触屏滚动(替代 PC 滚轮)

核心文件:src/ui/TouchMenuScroll.h/.cpp(纯逻辑,无 GL/平台头)、src/core/Application.cpp(菜单里接管单指指针)、tests/TouchMenuScrollChecks.cpp。

Android 没有滚轮,OPTIONS 这类菜单以前只能靠键盘/手柄方向键滚,触摸只能点。现在菜单里的一根手指先经过手势判别再交给 UI:

手指动作判定结果
按住不动 → 抬起tap(位移 < 屏高 0.75%)抬手时补发一次 press(不是按下沿),所以从某一行开始滑动不会顺手激活那一行
上下滑竖向位移 > 0.75% 且压过横向 1.15×转成滚轮档:每 1.75% 屏高约一行(约为一行高度的一半),向上滑 = 下一行;全程没有任何点击
左右滑横向位移占优立刻补发 press 并保持按住(滑条、文本选择这类控件需要拖动)
按住 > 0.55s长按补发 press

滚轮档由菜单控件按指针位置路由,这就是“智能区域检测”,游戏层不复制任何行几何:

  • 落点是手指按下的那一行,不是当前指尖所在行:一行只有约 3.3% 屏高,而一次滑动要走 2.5% 才判定成滚动,跟随指尖的话目标行早就滑走了,值永远滚不起来。所以滚动档的指针坐标被钉在按下点(TouchMenuScroll 的 startX_/startY_);
  • 指针在列表内容里、不在值控件上 → CxTabGroup / CxMenuListGroup 移动选择,滚动跟随选择(顶/底自然夹住);
  • 指针在某一行上 → 该行先拿到滚轮:下拉框 CxDropdown 改值(展开时滚动选项),滑条行 CxSettingsFieldRow 按 ±1 改值;
  • 指针在面板黑色背景之外 → 没有控件接收,滚动无效。

所以“一般选项滚动”和“单个选项值滚动”是同一套输入的两种落点;桌面鼠标滚轮走的是同一条路径(menuScrollDirection / wheelActiveZone)。

Reverseworks.TouchMenuScroll(无 GL)锁定:tap 只在抬手时 press、滑动全程无 press/release、慢速拖动累计成整档、横滑走拖动、长按 0.55s 补 press、第二根手指让位给捏合。

6.11 游泳

核心文件:src/game/TileMaterial.h(isSwimTile)、src/game/PlayerState.h(MotionState::swimming/swimFast)、src/game/HumanoidCharacter.cpp(moveAlong、updateAnimation、overSwimWater)、src/game/WorldRenderer.cpp(drawSwimWater)、src/graphics/CharacterClips.cpp(Swim/SwimIdle)。

判定:isSwimTile(type) = 液体(liquid)且可通行(walkable)。当前只有 ShallowWater 满足;Water(深水)不可通行,所以它是墙而不是水域。判定取自脚底所在格(position.y - kSolesBelowCentre),不是实体锚点——锚点在脚底以北 0.33 格,在每一条岸线上都会读错一格。

速度:两者都是 walkSpeed 的分数(不是 runSpeed),因此水域永远慢于旁边陆地:

状态系数Deanna 实测(1 秒位移)
陆地走1.002.97 格
陆地跑(Shift)runSpeed_5.56 格
游泳kSwimSpeedFactor = 0.521.54 格
游泳加速(Shift)kSwimFastSpeedFactor = 0.802.38 格

有 static_assert 保证 0.52 < 0.80 < 1.0,即“加速但仍慢于陆地走”是编译期契约。加速游泳消耗耐力更快(kSwimSprintStaminaDrain 0.34),单纯踩水也有一点消耗(kSwimTreadStaminaDrain 0.10,净回复慢于陆地)。游泳时 motion_.running 恒为 false(水里不是在跑),加速体现在 motion_.swimFast。水中不能跳(tryJump() 直接拒绝):水面没有可蹬的东西。

深水:TileType::Water 的 walkable=false,Level::blocksMovementAt 经 TileRegistry::isWalkable 拒绝,所以玩家撞在边界上停下,不会游进深水。Reverseworks.Swimming 断言了这一点。

渲染(WorldRenderer::drawSwimWater + src/game/fx/SwimEffects):不是“蓝方块上站个人”,也不是把贴图和特效堆在人物上。分成“身体怎么画”和“水怎么动”两半。

身体怎么画(drawSwimWater,全部是绘制身高的分数,绘制在玩家自己的深度槽、紧跟 rig 之后):

  1. 人物按绘制身高的 10% 下沉(水面 = 脚底立线,身体在水面之下);
  2. 深度分层浸染:水下身体按 kSwimDepthBands = 3 层画,每层是一枚软 blob,越深越暗(kSwimDepthFalloff = 0.10)、越窄(taper 1.25 → 0.93)。层间 alpha 0.42 重叠到约 0.8——够把身体“沉”进水里,又留得住姿态轮廓;单层不透明会把腿变成剪影(特写下验证过)。颜色按通道压暗(kSwimBodyFillDarkenR/G/B = 0.48/0.70/0.80):水面纹理对 grade 的红通道吃得最狠,均匀压暗会在蓝水里发灰;
  3. 焦散:3 枚亮斑沿水下带漂移,相位取自水面动画(waterPhase),alpha 0.26、正弦淡入淡出。这是“身体在一层被照亮的流动水体下”最便宜的读法;
  4. 透镜条带水纹理:水图集帧画成 8 条水平带 × 2 遍,带宽按透镜轮廓收放、alpha 在顶部 1/3 渐隐到 0(kSwimVeilAlpha 0.40 / 第二遍 0.20)——任何方向都没有矩形边;
  5. 水线碎浪:压扁的软泡沫带贴在水线(kSwimFoamAlpha 0.10,宽 0.72、高 0.035 tile)。数值刻意压低:alpha 上到 0.22 时它在特写下会变成一条横贯身体的白带;
  6. 空心涟漪:两圈半相位错开,每圈泡沫椭圆 + 小一号水色椭圆掏空,从浸染区外缘起扩散(kSwimRingAlpha 0.10,kSwimRingHalfHeightFraction 0.06);
  7. 不提交接地投影和投影阴影。

水怎么动(fx::SwimEffects,见 §6.12)负责浮力起伏、身体倾斜、水位线、入水/出水、尾迹、气泡、水珠与渐干。

视觉自检:tests/SwimShotProbe.cpp(目标 Reverseworks-SwimShotProbe,GL 离屏,不进 ctest)走完“入水 → 稳态 → 特写 → 出水”四张 PPM(swim_enter/shot/closeup/exit.ppm)。注意:它每帧都 render——SwimEffects 在 render 里推进,若探针只渲染截图那几帧,粒子时间会被 clamp 压扁,拍到的是一张“没来得及发生”的效果。调游泳渲染常量后跑它看图,不要凭常量推观感。

6.12 游泳特效(水面那半边)

核心文件:src/game/fx/SwimEffects.h/.cpp。它拥有属于水而不是属于角色的一切:把身体托起来的涌、入水撞出来的水、划水留下的尾迹、气泡,以及离开水后的滴水与渐干。角色剪辑、骨骼、玩法状态都不知道它存在。

输入(SwimSample,每帧由 WorldRenderer 填):是否在游、是否冲刺、速度、世界位置、脚底线位置(surfacePosition = position.y - kSolesBelowCentre,不是实体锚点——锚点比脚底高 0.33 格,在那上面生成粒子会把水面件挂到身体中段)、绘制身高、以及“最近的非水边在哪”(邻格判定,深水也算边)。

输出(SwimReadout):

字段含义用在哪
lift涌把身体抬高多少(世界单位)加进 rigParams.originClipY(kBobRest 0.05 + 随速度 kBobUnderWay 0.04,两个不相干的正弦)
roll身体侧倾(弧度)Rig2D::DrawParams::roll(kSwayRest 0.070 + 行进方向 kLeanFromMotion 0.070,上限 0.145)
waterlineShift水位线偏移(身高的分数)加到 kSwimSurfaceFraction 上,夹在 0.18–0.62
wetness湿身度 1→0角色 tint 的水色混合量与湿光(kSwimWetSheen),离开水后 kDrySeconds = 1.9 秒渐干

roll 是渲染变换,不是姿势:Rig2D::DrawParams::roll 在 Rig2D::draw 里绕 rig 原点旋转每个 quad,并把 roll 与该 quad 自己的角度做复合。剪辑、骨骼、pose 数据一个字节都没变,roll 默认 0 时绘制路径与改动前完全一致。

事件(状态由 wasSwimming_ 与 sample.swimming 比较得出,不依赖任何玩法事件):

  • 入水:entryTimer_ = 0.62s,期间 lift 叠加一条 sin(πu) 的下潜弧(kEntrySink 0.17 世界单位,按入水速度缩放)——先沉下去再浮起来;同时环形飞溅 16 颗(按角度分布,不是左右交替排成一条横杠)+ 6 团泡沫。
  • 出水:8 颗水珠从身体高度落下 + 3 团泡沫;wetness 保持 1 后开始衰减,其间每 0.11s 再滴一颗(kDripInterval),滴到 wetness < 0.25 为止。
  • 岸边:邻格非浅水时,每 0.34s 在那一侧生一团破碎泡沫——这是“这是一条岸”而不是“这是一片蓝”的唯一线索。

持续件:

件生成寿命alpha说明
尾迹 Wake每走 0.22 世界单位一枚(按距离,不是按时间)0.72s0.40沿行进方向拉长的椭圆,间距小于长度所以连成带
泡沫 Foam入水/出水/岸边0.95s0.26扁椭圆,随 alpha 淡出
气泡 Bubble每 0.44s(静止时 ×1.7 间隔)1.15s0.50从水面下 0.05–0.21 上升,sin(π·age) 淡入淡出——到水面时必须是零

层级:全部绘制在 drawSwimWater 之后。因为水面是合成在身体之上的(那是本项目表达“人在水里”的方式),任何画在人物之前的粒子都会被那层合成埋掉——存在但永远看不见(第一版就是这么写的,尾迹和涟漪一个都看不到)。身体不被遮挡靠的是位置与权重,不是绘制顺序:粒子只在轮廓外生成、尺寸小、单个 alpha 不超过 0.5。

预算:kMaxParticles = 88,池子在第一次 update 时 reserve,运行期不再分配。全部使用同一张软 blob 贴图,整组零额外纹理绑定。

推进时机:WorldRenderer::render 里调用 update(sample, clock.gameSecondsOfDay())——传的是日钟读数,因为水面瓦片就是从它出相位的,两者必须同源。但 update 内部不直接拿它积分:

step(game s) = secondsNow - lastSeconds                      // 日钟差值
if step < 0  → step += kGameSecondsPerDay                    // 跨午夜是回绕,不是倒退
if step <= 0 → return                                        // 同一瞬间重读:不推进
step = min(step, kMaxStepRealSeconds × kGameSecondsPerRealSecond)
delta(real s) = step / kGameSecondsPerRealSecond             // ← 之后全部用真实秒
waveSeconds_ += delta

三条都是为了同一件事:这个系统里所有「每秒」都必须是真实秒。

  • 日钟跑 12× 真实时间(kGameSecondsPerRealSecond = 86400/7200)。任何写成「每秒 N 次」却乘日钟读数的频率,屏幕上是 12N 次——游泳晃动的抽搐就是这么来的。
  • 钳位写在游戏秒里等于没有钳位。60 FPS 一帧就是 0.2 游戏秒,远超 0.05 的钳位值,于是每帧都被钳成同一个常数:模拟变成「每帧固定步长」,速度随帧率线性变化(30 FPS 1.5×、60 FPS 3.0×、144 FPS 7.2×)。钳位现在写在真实秒里,再乘回游戏秒去比较。
  • 同一瞬间重读必须直接 return。渲染器一帧可能被调用多次(多视图、离屏探针),所以「step 是否恰好为 0」要单独判断,不能让回绕补偿把它变成一整天。

晃动节奏(与水面周期的关系):见 §6.12.1。

6.12.1 晃动节奏与时间基准

游泳的起伏(lift)和倾斜(roll)是两个正弦,频率不是独立设定的,而是从水面周期派生:

// EnvironmentAnimation.h —— 水面周期的唯一来源
constexpr float kWaterCyclesPerGameSecond = 0.15f;
// SwimEffects.cpp
constexpr float kWaterCyclesPerRealSecond =
    kWaterCyclesPerGameSecond * WorldClock::kGameSecondsPerRealSecond;  // 1.8 Hz
constexpr float kBobCyclesPerWave  = 3.0f;   // 每三个浪抬头一次
constexpr float kSwayCyclesPerWave = 5.0f;   // 每五个浪侧倾一次
constexpr float kBobFrequency  = kWaterCyclesPerRealSecond / kBobCyclesPerWave;   // 0.60 Hz
constexpr float kSwayFrequency = kWaterCyclesPerRealSecond / kSwayCyclesPerWave;  // 0.36 Hz

为什么是 1/3 与 1/5:游泳的人比波浪大得多,不会跟着每一道碎浪抖——它响应的是涌。3 与 5 互质,两个正弦要 15 个水面周期(约 8 秒)才重新对齐,肉眼看不到循环;同时两者都是水面周期的精确有理分数,所以永远不会与水面失步。

修复前修复后
起伏频率0.41(按游戏秒)→ 屏幕 4.92 Hz(周期 0.203 s)0.60 Hz(周期 1.67 s)
倾斜频率0.29(按游戏秒)→ 屏幕 3.48 Hz(周期 0.287 s)0.36 Hz(周期 2.78 s)
与水面比快 2.7×(水面 1.8 Hz)慢 3×/5×,锁定
起伏幅度0.050 / 走起来 +0.0400.075 / +0.055
倾斜幅度0.070 rad0.080 rad
粒子推进每帧固定 0.05 游戏秒 → 60 FPS 下 3× 速、144 FPS 下 7.2× 速真实秒,帧率无关

幅度是跟着频率一起改的:同样的 0.05 世界单位,在 4.92 Hz 下是抖动,在 0.60 Hz 下是几乎看不见的漂移。慢下来的同时要略微加大幅度,才读得出「浮在水上」而不是「画面在轻微呼吸」。

Reverseworks.SwimTempo(tests/SwimTempoChecks.cpp,无 GL)直接驱动 SwimEffects,用插值过零点测频率,断言:bob 落在 0.3–0.95 Hz、bob = 水面/3(±0.25)、bob < 水面、30/60/144 fps 下频率差 < 2%、跨午夜只前进一帧、同一瞬间重读不推进。


7. 数据与存档格式

7.1 数据包布局

data/
└─ Eraphanis/
   ├─ items/
   │  ├─ items.tsv          ← 物品/穿戴/消耗/医疗/背包/槽位加成
   │  └─ starters.tsv       ← 新存档初始装备与背包授予
   ├─ weapons/
   │  ├─ weapons.tsv        ← 武器定义(E1.1/E1.4/E1.16):21 把 × 39 列,战斗数值/模型/材质/变体/能力/动作接线
   │  └─ spawns.tsv         ← 世界投放(E1.8):anchor_id × item_id × quantity × faction,年代门读武器表 prototype_year
   └─ world/
      ├─ _template/
      │  ├─ dialogue/       ← 每个存档首次创建时复制的 .cdlg
      │  └─ pv/             ← 每个存档首次创建时复制的 .pv
      └─ <save>/            ← 存档本地覆盖(用户目录)
         ├─ dialogue/
         └─ pv/

data/ 是运行时包根。C++ 系统应优先从这里读可调内容,编译期默认值只作 fallback。

例外:weapons.tsv 没有编译期 fallback。 21 行表在 C++ 里再抄一份就是两个真相;表缺失 = 空目录 + 启动期校验报错(src/game/weapons/WeaponCatalog.cpp 的 validateWeaponCatalog())。

7.2 items.tsv

当前表头(20 列,fields[0..19]):

id  display_name  hud_abbrev  category  use_kind  effect_kind  stack_limit
hydration_relief  cold_relief  wet_cold_relief  heat_relief  heal_amount
armor_rating  insulation  armor_coverage  inventory_slots  block_tile  affixes
icon  model_id

示例行:

wool_cap  Wool Cap  CAP  headwear  wearable  none  1  0 0 0 0 0 0  head:0.95,neck:0.72   1
stone_block_item  Stone Block  STB  material  block  none  32  0 0 0 0 0 0  0  stone_block  0
w01_colt_saa  柯尔特单动军用左轮  SAA  weapon  weapon  none  1  0 0 0 0 0 0       1     2
ammo_handgun  手枪弹  AMMO  material  ammunition  none  120  0 0 0 0 0 0       0     0

解析注意:

  • insulation / armor_coverage 是逗号分隔的 bodyPart:value 映射。
  • block 物品用 block_tile 列(fields[16])写 blockTileId,必须能解析到 TileType,否则在目录加载时就报错。
  • affixes(fields[17])是词缀 roll 的数量占位;实际词缀随物品实例走,不写回 TSV。
  • icon(fields[18])是手绘图标帧名,留空 = 与物品 id 同名(程序化图集默认按物品 id 生成帧)。
  • model_id(fields[19])写这件物品的模型 id:目前只有弹药用它(cartridge_pistol/shell_shotgun/cartridge_rifle/round_special)。留空 = 该物品没有模型,背包图标走形状回落。
  • category=material 目前会落到 ItemCategory::Headwear(见 §10 不变量 7);弹药也走 material + use_kind=ammunition。
  • category=weapon + use_kind=weapon 的行必须在 weapons.tsv 里有同名 id,否则目录加载时整行被丢弃(src/game/EquipmentCatalog.cpp 的 parseItemRow)。
  • 当前 39 行物品:6 件穿戴、1 个背包、4 个消耗/医疗、3 个方块、21 把武器、4 种弹药、1 种零件。
  • 4 种弹药各有一个模型:弹药不再是同一张哈希色「子弹盒」图,而是四张由模型派生的图标(E1.16,见 §6.5 的 E1.16 条目)。
  • 新物品行加入后,若测试报“缺内容”,先检查部署副本是否更新(见 §3.5)。

7.3 starters.tsv

# type  slot_or_item  item_id  quantity
equip     headwear      wool_cap  1
equip     top           padded_coat  1
equip     pants         insulated_pants  1
equip     boots         fur_boots  1
equip     mainhand      w01_colt_saa  1
inventory canteen       canteen  3
inventory field_dressing field_dressing  2
inventory warming_pack  warming_pack  1
inventory ammo_handgun  ammo_handgun  24
inventory ammo_shotgun  ammo_shotgun  12
inventory ammo_rifle    ammo_rifle  20

type=equip 时第二列是 EquipSlot 名称;type=inventory 时第二列是占位/描述,实际物品由 item_id 决定。

7.4 存档格式

SaveGameData::kFormatVersion == 11,kStreamingFormatVersion == 5。

存档是 INI 风格文本,路径:

%LOCALAPPDATA%/Eraphanis/archive/<id>.save

Linux/macOS/Android 由 PAL 的 IPathProvider::userDataDir 决定(见 §6.10)。

主要 section:

Section内容
[meta]version、displayName、savedAtTicks、起始季节/天气、自动存档、consoleEnabled
[world]gameSecondsOfDay、dayIndex、worldSeed、worldTemplate
[environment]天气、季节、温度、湿度、云量、降水、风、能见度、地表湿度、太阳辐照、阶段
[player]placed、x、y
[life]生命状态、消化/免疫、8 个身体部位的生命/温度、状态效果
[diseases]疾病数量与每条疾病
[equipment]8 个装备槽的物品 id(含 mainhand/offhand);每个非空装备槽的独立背包快照;物品行末尾可带实例词缀后缀与武器实例状态后缀
inventory.*玩家背包快照
[hotbar]selected= 选中索引、armed= 哪一格是手上那把枪的视图(v11)、其余每个非空快捷栏槽的快照(armed 格不写物品行)
[survival]cold、heat、dehydration、wetCold、frostbite
[objectives]doneN=<objective id>
[scenes]sceneN=<pv id>,已播放场景
[terrain]chunkCount + 每个 chunk/tile/layer 的方块编辑

版本要点:

  • v11 增加快捷栏 armed 槽:[hotbar] 写 armed=<0..2|-1>,指向“手上那把枪在快捷栏里的视图格”;该格不写 hotbar.slotN 物品行(内容由 [equipment] 主手派生,同一实例不存两份)。读 v11 档时用装备段重建视图;读 v10 及更早时按“装着正好是手上那件(同 id+词缀+武器状态)”的格认领,整排空的老档在选中格播种视图并保留 selected。旧构建读 v11 不报错(loader 只升不降),但会忽略 armed、该格读成空。用例见 tests/InventoryChecks.cpp、tests/SaveSystemChecks.cpp、tests/CombatWeaponChecks.cpp;语义见 docs/e1-fix-1.md §6。
  • v10 增加怀表校准槽:武器后缀在同一逗号段末尾追加 <mode0>,<mode1>(combat::CalibrationMode id,0=空);normalizeLoadedWeaponState 把未知 id、未开启槽、重复模式归一化掉。v9 的四字段后缀照常读、槽位为空。E1.9 落地。
  • v9 增加武器实例状态:物品行/装备行在词缀后缀之后追加第二段 <refine>,<durability>,<loaded>,<jammed>(未物化的实例不写,所以非武器行与 v8 逐字节相同;E1.5 起已物化的 0 耐久——合法坏刀——一定写后缀)。旧档由 normalizeLoadedWeaponState 物化为出厂值(refineLevel=0、durability=durability_max、loadedAmmo=0、jammed=false),并把 refineLevel/loadedAmmo 钳到武器行上界。E1.5 起 refineLevel 会非零:耐久上限按 RefinementCatalog 的精修曲线派生(round(durability_max×(100+5L)/100)),钳制读这个派生上限。
  • v8 增加每物品实例的词缀:词缀以竖线后缀写在物品行末尾(<itemId>|<affix:value>,...),没有词缀的实例不写后缀;旧档按“没有词缀的装备”读入。
  • v7 增加流式地形方块编辑。
  • v6 增加快捷栏。
  • v5 增加 worldSeed 和 worldTemplate。
  • v5 之前的存档没有世界 seed/template,旧固定地图已不存在,因此会落到默认模板的新世界。
  • 读档时 fileVersion 在 clamp 前保留,用于迁移判断;data.version 会被提升到当前版本。

存档不变量:

  • 世界只保存 WorldSpec + 地形编辑,不保存整张地图。
  • 在室内保存时保存室外门口位置,不保存室内坐标。
  • hasPlayerPosition 决定坐标是否有效;没有时回退到生成器出生点。
  • completedObjectives 中未知 id 读档时丢弃;playedScenes 防止剧情重复。
  • consoleEnabled 是每个 Echo 的属性,不是全局设置。
  • 词缀属于物品实例:读档后同一件物品的词缀不能因为“定义相同”而被合并。

8. 操作与调试

8.1 默认键位

动作默认键可重绑定
移动W A S D是
跑 / 游泳加速Left Shift是
跳Space是
确认Enter / Space否(固定)
取消Esc否(固定)
交互E是
循环快捷物品Q是
使用快捷物品R是
快捷栏 1/2/31 2 3是
打开背包I是
攻击(轻击)F是
换弹 / 清膛(E1.3/E1.4)X是(InputAction::Reload):主手卡壳时清膛 1.2s,否则换弹
翻滚C是
格挡(按住)V是
技能栏4 5 6 7 8 9否(固定:Basic 4、Core1-4 5–8、Ultimate 9)
放大/缩小Page Up / Page Down固定 fallback(不进重绑定表)
滚轮缩放鼠标滚轮固定
碰撞覆盖层F3固定
开发者控制台` ``(反引号)固定,且仅桌面
提交控制台行Enter固定
剧情跳过Esc固定

鼠标左键在游戏中可朝光标攻击(如果已装备武器);Ctrl+左键 是快移到打开容器(当前容器为空)。Shift+左键 是背包/快捷栏整叠快移。两指轻点等价右键(重击蓄力 / 装备、使用、取消携带),见 docs/android.md §2.1。

8.2 开发者控制台

  • 只有存档 [meta] consoleEnabled=1 的 Echo 才能打开。
  • 桌面反引号开合;开壳时整帧吞输入,世界暂停;Esc 关壳。
  • 当前命令为 time 与 suicide。新增命令应在 PlayingConsolePanel::dispatch() 注册,不要改壳的输入逻辑。

8.3 调试钩子

  • F3:碰撞/调试覆盖层。
  • ERAPHANIS_TRACE_WORLD=1:DemoScene::finalizeWorldLoad() 会向 stderr 打印世界模板、seed、chunk 数、玩家坐标/tile、是否 standable。
  • FrameProfiler:每帧分段(Update、Render、Swap、WorldUpdate、PlayerUpdate、WorldStream、SaveIO 等),定期打印报告。
  • UiShotProbe:离屏渲染 UI 到 PPM,支持 plain / console / rename 等模式。
  • SwimShotProbe:离屏渲染四张游泳帧(PPM),调游泳渲染常量时看图。
  • 排查类截图/脚本目录(tmp/、tmp_frames/)已于 2026-11-03 清理,需要时重新生成,不要再提交。

9. 测试地图与当前结果

9.1 运行

# 构建全部目标(含测试)
cmake --preset mingw64-release
cmake --build build-mingw64-release -j 8

# 跑全部
ctest --test-dir build-mingw64-release --output-on-failure

# 只跑一个
ctest --test-dir build-mingw64-release -R Reverseworks.GroundFlicker --output-on-failure

MSVC 多配置对应 cmake --build build-msvc-release-x64 --config Release -j 8 与 ctest --test-dir build-msvc-release-x64 -C Release。

9.2 当前结果

2026-11-08 完整运行(MinGW Release,build-mingw64-release):

75 tests total
75 passed
0 failed

其中三条是 P1 通用 AI 的:Reverseworks.AiPathfinding(A\*、两条射线、路径拉紧、 代理节流)与 Reverseworks.AiBrain(特征表解析与校验、名册与 traits 的双向约束、 通用状态机的迁移)用手写网格当世界,不建关卡、不开窗口,各 0.01 s; Reverseworks.AiInteraction(AI.3)多一层:软分离求解器、听觉半径取更远的那个、 掉落表的解析与校验、会话里「死了会掉、掉了能捡」。

本轮同时修掉了两个“假失败”:

  • SaveSystem / SurvivalSystem / Character 曾因为 ctest 工作目录不在部署目录、读不到 data/ 与 resources/ 而失败;现在统一由 Reverseworks_register_test() 设定工作目录(见 §3.5)。
  • AudioParser 曾引用 audio/bgm/rainnight.mp3——该文件早已不存在(实际发布的是 Rain Night.ogg)。命名 cue 表与自检已指向真实文件,并补上了 ogg 时长读取(MusicMetadata.cpp)。

一键验收(含基线对照表,仅跑建筑相关子集 + 全量回归判定):

python tools/building_acceptance.py            # 默认找 build-mingw64-release
python tools/building_acceptance.py --build-dir build-msvc-release-x64

2026-11-08 修过这个脚本的回归判定:ctest 全绿时打印的是 100% tests passed out of N(没有 , N tests failed),而脚本只认后一种 格式,于是 total 读到 0、报成「0/0 通过、无回归」——门禁恰好在全套绿的时候 是瞎的。现在两种摘要都能读,读不到摘要时门禁直接判失败而不是默许。 失败豁免名单(KNOWN_FAILURES)已清空:GroundFlicker 与 AudioParser 修好之后,任何失败都是回归。

GroundFlicker 曾经失败,2026-11-08 已修复(修复前):

field  mipmap+shadow shift1 mean = 0.00044  (limit 0.00005)
field  mipmap+shadow stride8 mean = 0.00097  (limit 0.00040)
8 configuration(s) flickered

根因不是渲染精度,而是锚点从未接线:细节场是从精灵的裁剪空间位置采样的, u_detail_origin 用来把它换算成世界位置,而 Renderer2D::setDetailOrigin() 从 v0.1.8.7 引入起就没有任何调用点——shader 有 uniform、渲染器有接口, 游戏侧从没告诉渲染器「世界原点在屏幕的哪里」,于是细节场一直锚在相机上, 相机一动地面就从它底下滑过去。

修法(src/game/WorldRenderer.cpp):

  • 世界批锚到世界原点的裁剪位置(camera.worldToViewport({0,0}),x 要按 帧缓冲宽高比归一化,与渲染器内部的裁剪空间一致);
  • 角色批锚到角色自身原点(rigParams.originClip),衣料细节跟着身体走, 角色画完后恢复世界锚点。

修复后实测(与 v0.1.8.7 文档记录的“修复后基线”一致):

field  mipmap+shadow shift1 mean = 0.000007  (limit 0.00005)
field  mipmap+shadow stride8 mean = 0.000053  (limit 0.00040)
forest mipmap+shadow shift1 mean = 0.000011  (limit 0.0005)

9.3 项目测试

测试锁定的契约
Reverseworks.RigSpec骨骼比例、骨架装配、几何报告、无头 rig 构建
Reverseworks.RigEvent动画事件:footstep/land/use_item 的 clip time
Reverseworks.Climate气候/季节/环境采样
Reverseworks.Settings设置读写、键位、默认值、损坏回退
Reverseworks.HotbarInput快捷栏按键路由:按键→格子、改键跟随、技能数字键不误选,端到端进会话
Reverseworks.TouchGamepadLayout触摸手柄自适应布局:毫米物理尺寸、屏内/不重叠不变量、手机/平板/窄窗口 × 左右手 × 用户缩放的紧凑回退
Reverseworks.TouchGamepad手柄行为:摇杆捕获与转向、RUN 锁存、按钮路由、指针回退、左手镜像
Reverseworks.TouchMenuScroll菜单触屏滚动:tap 只在抬手 press、滑动全程无点击、滚轮档累计、落点钉在按下行、横滑走拖动、长按 0.55s 补 press、第二指让位给捏合
Reverseworks.AudioParser音频格式/解析;命名 cue 指向真实发布文件(含 ogg 时长)
Reverseworks.CDLG.cdlg 解析与会话
Reverseworks.SaveSystem存档 v11(实例词缀 + 武器实例状态 + 怀表校准槽 + 快捷栏 armed 槽)、v8/v9/v10 旧档迁移、字段往返、存档目录可写性探针、删除结果结构化、世界包、Echo 操作
Reverseworks.Console控制台壳、收字、命令分发、time、suicide、开关闸门与死亡流程
Reverseworks.Inventory背包/快捷栏/堆叠/装备/快移原语
Reverseworks.CxInventoryPanel物品栏面板布局/交互
Reverseworks.MainMenu主菜单、档案编辑、改名/复制/删除/控制台开关
Reverseworks.ArchiveDelete档案删除路径:编辑菜单的 DELETE 行、确认屏、仓库调用;键盘与指针两条入口都驱动,断言「被指的 Echo 没了、别的还在、列表也这么说」
Reverseworks.SurvivalSystem身体部位、体温、风险、疾病
Reverseworks.Character角色 rig、脚底/相机锚点、动画状态
Reverseworks.Swimming游泳:水面判定(脚底所在格)、慢于陆地且 Shift 加速、深水是边界、水中不可跳、两个泳姿三视角剪辑存在且在动
Reverseworks.SwimTempo游泳晃动节奏:bob 落在 0.3–0.95 Hz、恰好是水面周期的 1/3、30/60/144 fps 下同速、跨午夜只前进一帧、同一瞬间重读不推进
Reverseworks.GpuProfileGPU 能力探测/降级
Reverseworks.RenderSmoke真实 GL 渲染冒烟,角色像素非空
Reverseworks.NightBrightness昼夜地面亮度探针
Reverseworks.ThemeColor主题色参与富文本布局缓存键
Reverseworks.Tips浮动提示/键盘选择状态机
Reverseworks.NarrativeFont内容驱动字体图集,CJK 字符覆盖
Reverseworks.Loading加载屏幕完成、字体/内容准备
Reverseworks.Pv.pv 解析/时间线
Reverseworks.PvSystemPV 绑定:相机、时钟、letterbox、文字卡
Reverseworks.Dialogue对话框文字上屏
Reverseworks.Opening开场雾场/字幕淡变/白光序列
Reverseworks.GroundFlicker相机移动 1px 时地面细节稳定性(2026-11-08 修复,当前通过)
Reverseworks.TextureContract世界图集帧表/UV/朝向契约
Reverseworks.EdgeMask地面边缘/邻接遮罩
Reverseworks.WorldGen世界生成确定性、出生点、模板、城镇
Reverseworks.CombatFx武器/弹道/命中/方块伤害
Reverseworks.CombatAction动作框架:动作表完整性、三段状态机、连招窗口、蓄力、命中几何、软锁、部位映射
Reverseworks.CombatP2P2:翻滚无敌帧(有效窗口第二帧起)、冲刺、耐力门槛、格挡与 4 帧完美格挡、技能冷却、瞄准辅助、弹药
Reverseworks.AiPathfindingAI 寻路:绕墙不穿墙、不切角、没路与搜超时是两回事、视线与走线是两个问题、路径拉紧、代理的重规划节流
Reverseworks.AiBrainAI 特征与心智:特征表是数据(坏行报错)、名册与 traits 双向约束、迁移的八个数字未变、先注意后决定、够不着就靠近、丢目标→查看→回岗、牵引、崩胆
Reverseworks.AiInteractionAI 交互(AI.3):软身体分离(重的少动、单帧不抛人、重合可重放)、听觉接线(范围取更远的那个、噪声→查看)、掉落表是数据(坏行报错、每敌人一行、掷点确定)、死了会掉且能捡进背包
Reverseworks.CombatP3P3:敌人共用动作表、硬直等级、伤害类型与免疫/抗性、实例词缀 roll 与存档 v8
Reverseworks.CombatWeaponE1.1–E1.5 端到端:21 把逐行对齐数值表、派生量、启动期校验、旧 id 别名;装备路径 + 弹匣(主/副手、实例状态、换弹、扣弹与空仓收起/徒手);战斗管线(准确性确定性、熵、耐久卡壳/清膛/破刀、噪音事件、十字 5 格、R1 溢出、一次扳机总伤 = 单发×弹数);精修(花零件逐级升级、成本序列、耐久池增长、伤害 ×1.04^L、命中/对熵、换弹 −30%、校准槽、封顶与 8 级觉醒门槛、存档恢复)
Reverseworks.CombatResolutionE1.4 规则层纯函数:accuracyChance/距离衰减/确定性种子与掷骰分布、jamChance/耐久损耗/耐久比例、resolveDamage 管线顺序(精修乘算、熵跳护甲/护甲/抗性/元素/耐久折损)、crossCells/crossCellFor 5 格与 scale、NoiseEmitted 工厂与 EventBus 订阅
Reverseworks.CombatRefineE1.5 精修规则层纯函数:9 行曲线逐列(伤害/命中/耐久/对熵/成本/累计/特性)、成本公式与字面序列、等级钳制、校准槽 0/1/2、4 级换弹与冷却、7 级共鸣、耐久池整数算术、8 级专属/原创门槛与单步升级拒绝、E1.4 三钩子与曲线一致性
Reverseworks.CombatE15VerifyE1.5 独立验收(独立实现):从 §3.5 设计表重算 9 行曲线与成本/累计、21 行 × 全部等级耐久池、成本/零件经济(主背包+背包容器、忙碌门禁)、W8 专属门槛、会话伤害/命中/对熵、换弹 2.0→1.4 s、校准槽、存档往返(精修 563 手改钳制、v8 工厂值、会话恢复)与 weapon_parts 物品行
Reverseworks.WeaponVisualE1.6 武器模型/材质/icon:21 行→18 族解析、albedo+遮罩尺寸/尺寸合法、遮罩 id 11..19、锚点顺序与muzzle范围、18 族轮廓可区分、ejectsCasing 表、W12 三列采样探针(nearest/no-chain 0 非法)、9 行材质响表、21 把 icon 与模型缩图字节相等、确定性;E1.16 追加 W22 族内变体(变体号合法、三对同族枪模型/icon/长度都不同、比例落在档位带内、越界变体回落默认形状)
Reverseworks.AmmunitionVisualE1.16 弹药模型与 icon:4 个弹药族 id 空间、物品行 model_id→模型、sprite 合法(遮罩 id/锚点有限)、icon 与模型字节同源、四种弹药互不相同、生成确定;51 断言
Reverseworks.WeaponPresentationE1.7 表现:W14 握持修正层(同帧 state/clipPhase/事件帧号不变、仅手臂链姿势变、None 无操作、镜像一致)、W11 枪口==闪光(5 把枪)、烟/壳/曳光/后坐、7 档音色与 weaponSoundProfile、SoundRequest 增益随 noise 单调、机制声
Reverseworks.WeaponSpawnE1.8 世界投放:spawns.tsv 校验、10 锚点、1915/1922 年代门(Thompson)、player_start 必刷、pickup 位置可行走、W16 全链路(拾取→工厂实例→装备→装填→开火→掉血→NoiseEmitted)、背包满留地面、attachLevel 幂等、拾取不进存档
Reverseworks.WeaponVerifyE16E1.6–E1.8 独立验收(独立实现):W11 21 把枪口/弹道锚点、W12 独立重采样、W13 icon 字节同源、W14 24 状态帧序列、W16 多世界全链路;1130 断言
Reverseworks.WatchCalibrationE1.9 怀表校准:模式表/id 钉死、槽位 0/1/2、装配拒绝(未开槽/E2 模式/None/重复)、清除、3 次攻击的降熵波(十字 5 目标只耗 1 次)、7 级共鸣 +5 与 100% clamp、换手/读档取消、v9→v10 迁移与 v10 往返、坏档归一化
Reverseworks.WatchE19VerifyE1.9 独立验收(独立实现):模式表/槽位、装配拒绝、w11 三次攻击 61.6032 vs 第四次 48.2112、十字只耗 1 次、w14+7 级共鸣 25pp/clamp 100%、换手/读档取消、v10 原始后缀与 v8/v9 迁移、非武器行无后缀;185 断言
Reverseworks.WeaponE16E18VerifyE1.6–E1.8 独立验收(第二套实现):W11 21×4 方向、W12 独立 box-mip 采样、W13 21 把字节、W14 12 状态事件帧号、W16 拾取/年代/满包/幂等;2228 断言,另输出 8 项集成审计 finding
Reverseworks.BuildingShadow建筑阴影几何
Reverseworks.BuildingMaterial建筑材质选择
Reverseworks.SunPath太阳路径
Reverseworks.ShadowProbe阴影像素探针
Reverseworks.ChangelogPanel更新日志面板
Reverseworks.TerrainRelief地形起伏/高度
Reverseworks.Registry元素/定义注册表
Reverseworks.Vegetation植被分布/树种/地表植物
Reverseworks.Objective目标链/完成条件/存档恢复
Reverseworks.UiPerfUI 性能/布局预算
Reverseworks.CollisionOffset碰撞盒与视觉贴地线
Reverseworks.EntityVisual元素视觉几何
Reverseworks.VisualGroundLine从图集像素测量地面线并对照碰撞矩形
Reverseworks.CollisionVisualAlign建筑/地面/碰撞端到端对齐,城镇空碰撞扫描
Reverseworks.BuildingFaceAlign建筑三线对齐:停人线/画线/排序线
Reverseworks.BuildingAudit建筑模型基线(优化方案阶段 1):部件遮罩占比、部件 id 合法性、albedo/mask 尺寸一致、底边悬浮、孤岛部件、门/窗/层高/坡度比例、显存与每帧开销(quad/换绑/阴影顶点/互遮对);导出模式出全变体视觉图集
Reverseworks.BuildingProportions建筑比例门禁(优化方案阶段 4 / G4):按族的门高/门宽/窗宽高/坡度阈值带;门格契约(有门格的 typeId 必声明门、Depot 不声明);门叶中心必须落在 buildingDoorTileX 的门格上;逐变体复跑烘焙不变量(G5)。116 变体(29 typeId × timber/brick × 无雪/雪)
Reverseworks.BuildingMaskResample建筑材质遮罩采样正确性(阶段 4 / G10,P6):CPU 侧复现 GPU 的 mip 链 + 三线性/最近邻重采样,统计非规范部件字节。断言「NEAREST 且无 mip 链」= 0,并报告现状值供修复前后对比
Reverseworks.CharacterSimulation无头多线程角色模拟

9.4 可选库测试

当前注册且存在:

  • Uniscope2D.Smoke
  • Uniscope2D.Anomaly

ReverseworksTests.cmake 仍保留 if(TARGET Uniscope2D-Thread) 的注册,但该 target 已在渲染器重构中删除,所以当前不会出现 Uniscope2D.Thread。这不是失败,是可选注册。


10. 不变量与常见坑

按“踩了就很难查”排序。

  1. 1 骨骼单位 = 0.5 格。 角色渲染、碰撞、相机锚点必须用 kRigUnitTiles 和现成 helper;不要手写 foot * visualScale 之类的公式。旧错误会让所有立物看起来“碰撞偏北一格”。
  2. WorldSpec 必须随存档走。 不要从 Level 之外的地方随便构造默认 spec;加载前用 WorldSpec::sanitized(),adopt 前校验 level->worldSpec() == activeWorldSpec_。
  3. hasPlayerPosition 才是坐标有效性。 不要用 (0,0) 判断“没移动”。
  4. 在室内保存要写室外门口坐标。 室内是运行时生成、不持久化的;写室内坐标会导致读档醒在错误空间。
  5. 数据部署不是“构建成功就一定有”。 自检读的是部署到 CMAKE_RUNTIME_OUTPUT_DIRECTORY 的副本;改了 data/ 或 resources/ 却没重建,测试读到的还是旧副本。见 §3.5。
  6. GameState::beginSession() 不是实际游戏路径。 真正会话启动在 DemoScene::beginSession();前者主要服务无头检查(如 RenderSmoke)。改角色美术绑定/加载流程时先看 DemoScene。
  7. ItemCategory 没有 material。 写 category=material 会静默变 Headwear;方块物品靠 useKind=Block,弹药靠 useKind=Ammunition。不要只看类别判断行为。
  8. 战斗规则已就绪,但世界内容仍有缺口。 武器定义已经是数据表的 21 把(E1.1),items.tsv 也有对应的武器物品(E1.2);equipWeapon() 已有正式调用点(快捷栏选中武器按使用键),并且 E1.8 的「世界拾取 → 装备 → 开火 → 伤害 → 噪音」全链路已由 W16 覆盖。但敌人仍只在测试里 spawnEnemy(),敌对 NPC/刷怪/商店/掉落品质属 E2;不要把 CombatP2/CombatP3/CombatWeapon/WeaponSpawn 自检当成世界内容全量完成。
  9. GameState::openContainer() 恒为 nullptr。 Ctrl+左键 快移当前是空操作;世界箱子未实装。
  10. playedScenes 防止剧情重复。 新存档才播 opening_prologue;读档不会重播。不要为了“让开场出现”清空它,除非明确要重播。
  11. 被修改的 chunk 不会被流式丢弃。 地形编辑依赖这个;不要为了省内存清掉 modified chunk。
  12. 对话/门旅行/面板会 early-return。 世界更新不是每帧都发生;新增系统要明确自己应该在暂停时运行还是冻结。
  13. 相机缩放在 early-return 之前。 这样对话/开门淡出时仍可缩放;不要把缩放移到 world_.update 之后。
  14. PUI(PlayingUI)先于 GameState 消费输入。 面板/控制台打开时世界暂停;不要在 GameState 里重复处理同一按键。
  15. 地面细节必须世界锚定。 u_detail_origin 不是可选装饰;GroundFlicker 就是它的回归探针。锚点由 WorldRenderer::render 设置:世界批锚到世界原点、角色批锚到角色自身原点。新增任何自己的精灵批(UI 之外的场景绘制)时,要记得给出锚点,否则细节会锚在相机上(这正是 2026-11-08 修掉的那条失败)。
  16. 项目自有文件头有版权 banner。 旧 developer-guide.md 说“不要加 banner”,与当前工作树相反;不要按旧文档批量删除 Copyright (c) 2026 Leo Jeang... 头。
  17. CMake 源文件是 glob + CONFIGURE_DEPENDS。 新增 .cpp 通常不用改 CMake,但需要重新配置;新增测试必须手动在 cmake/ReverseworksTests.cmake 注册。
  18. build preset 只构建游戏。 测试要单独 cmake --build build-xxx -j 8 或指定 target。
  19. docs/update.txt 是 UTF-8 with BOM。 曾长期以 GBK 保存,2026-11 已是 UTF-8;写入时保留 BOM,别转成 GBK。游戏主菜单的更新日志面板是代码里写死的(src/ui/views/MainMenuSpec.h),不读这个文件。
  20. 工作树是脏的。 不要 reset/checkout/清理不认识的目录;先问或先看 git status。
  21. 材质遮罩的 R 通道是分类数据,不是颜色。 部件 id 一经线性过滤或 mip 平均就变成第三个部件(墙+基座 → 西坡,黄昏面假高光)。实测当前配置在缩小档有 19.42% 的 texel 是非规范部件字节。修法是 NEAREST 且不带 mip 链 —— 只改过滤不够,mip 的每一级本身就是平均值(Reverseworks.BuildingMaskResample 三列对比)。
  22. 门由画家声明,不要从 albedo 颜色反推。 BuildingSpritePixels::doorRect / hasDoor 是契约。颜色行程在两个方向上都错:看不见钢制卷帘门(Industrial 20 例),又能被 Civic 门廊柱子的同色木料带偏(24 例)。
  23. 不是每栋建筑都有门。 buildingHasDoorEntrance(BuildingLook) 是唯一真值,Depot(gas_station_route_20th)为 false。任何假定「门格一定存在」的代码都要先问它。
  24. isOpenFormTypeId() 是开放形态的唯一真值。 WorldGen 与 AssetLibrary 都转发到它;不要再抄子串判断(历史上抄漏过 gas_station)。
  25. 游泳是地面的属性,不是玩家的状态位。 motion_.swimming 每帧由脚下格重算(overSwimWater),没有锁存,所以走出浅滩自动结束。任何“进入/离开水域”事件都不能改成一次性切换,否则玩家会带着游泳状态站在岸上。
  26. 浅水可游、深水是墙。 isSwimTile() = 液体且可通行;Water 不可通行,所以深水永远到不了 swimming。新增液体瓦片时必须明确它属于哪一类。
  27. 游泳速度是 walkSpeed 的分数,不是 runSpeed 的。 且两个系数都 < 1.0(static_assert 保证),否则涉水会变成抄近路。
  28. 起伏与倾斜是渲染变换,不是姿势。 Rig2D::DrawParams::roll 绕 rig 原点旋转已摆好的 quad,剪辑/骨骼/pose 一个字节都不改;roll = 0 时绘制路径与改动前完全一致。任何想改“游泳姿态”的需求都不许借这条通道实现。
  29. 水面件画在水面合成之后,且只在轮廓外生成。 水幕是合成在身体之上的,画在人物之前的粒子会被它埋掉(存在但看不见)。角色不被遮挡靠位置与权重(轮廓外、尺寸小、单个 alpha ≤ 0.5),不靠绘制顺序。
  30. 游泳特效里所有「每秒」都是真实秒。 日钟跑 12× 真实时间,所以乘日钟读数的频率在屏幕上快 12 倍;钳位也必须写成真实秒再乘回游戏秒,否则 60 FPS 下每帧都被钳成同一常数,模拟速度就跟着帧率走(30/60/144 FPS 分别是 1.5×/3.0×/7.2×)。任何新加的振荡、计时器、衰减都接 waveSeconds_,不许直接乘 clock.gameSecondsOfDay()。
  31. 晃动频率从水面周期派生,不是独立数值。 kBobFrequency = 水面周期/3、kSwayFrequency = 水面周期/5,水面周期只在 EnvironmentAnimation.h 的 kWaterCyclesPerGameSecond 定义一次。改水面速度,晃动的频率跟着一起变——这才是「协调」,而不是两个各自调过的数字恰好看起来还行。
  32. 动作表是战斗的唯一真值。 玩家与敌人共用 ActionDef 行;动画剪辑、命中帧、耐力、冷却都由行说明。新招式先加行,再让代码引用 id,不要在新代码里另写一套时长。
  33. 词缀属于物品实例。 roll 随物品走(背包、存档、穿戴),不要按物品 id 重新生成;CombatStats 是唯一的汇总入口,伤害/冷却/耐力都读它。
  34. 平台能力走 PAL,不要在游戏层加平台宏。 需要窗口/输入/路径/资源/显示时用 uniscope::pal 接口;__ANDROID__ 之类的分支留在 libs/uniscope-2d/src/pal/ 里。
  35. 音频层不许出现 OpenAL 类型。 上层只认 uniscope::audio::IAudioDevice / IAudioStreamVoice;alSource*、ALuint 之类只能出现在 src/audio/openal/ 内。
  36. 烘焙出口有自检。 validateBuildingSprite() 在 AssetLibrary 烘焙出口调用,当前是告警不拒绝(一个干净发布周期后升级为硬失败)。新增画法若触发告警,说明部件拓扑出现了非法戳记或孤岛区域。
  37. 武器表是武器的唯一真值。 data/Eraphanis/weapons/weapons.tsv 定义武器;src/game/fx/WeaponCatalog.h 只是运行时门面。分工与不变量 32 相同:帧与动画归动作表,伤害与弹丸数归武器表。不要在 C++ 里再抄一份表,也不要在 WeaponDef 里写死一把武器的数值。
  38. 武器物品与武器行用同一个 id 连接。 items.tsv 的武器行 id 必须等于 weapons.tsv 的行 id,否则物品目录加载时整行被丢弃(parseItemRow 的校验)。反过来也一样:武器表里的 id 没有对应物品行时,那把武器拿不到、也装不上。
  39. 装备槽是主手的唯一真相(E1.3 起)。 GameState::syncEquippedWeapon() 每帧读 EquipSlot::MainHand,equippedWeapon() 也直接读槽;不要再用「背包里有没有这把枪」做装备判定(武器穿上后本来就不在背包里,旧检查会静默收枪,E1.3 已删除)。equipWeapon(id) 的语义是「把随身(主背包或快捷栏)里的该武器移进主手」,不是凭空发枪。武器是穿戴不是使用:useInventorySlot() / useSelectedQuickItem() 必须拒绝 useKind=Weapon,否则会把枪“用掉”。
  40. 武器实例状态属于实例,随件走(E1.3 起)。 refineLevel/durability/loadedAmmo/jammed 挂在 InventoryEntry/ElementStack/装备槽上,跟着背包↔装备↔存档移动;不要按物品 id 重建弹匣或耐久。耐久在进背包/读档时按精修后的有效上限(effectiveDurabilityMax = round(durability_max×(100+5L)/100),E1.5 起;精修 0 时即表列 durability_max)物化(未物化 = 补齐;已物化的 0 是合法的坏刀,由运行期字段 WeaponInstanceState::materialised 区分,E1.5/§2.13 #10 起);开火只扣主手实例的 loadedAmmo,换弹才从随身背包搬弹。不要因为 durability == 0 就重新物化:那会把坏刀在卸下或存读档时修好。
  41. 武器表是伤害总量的唯一真值(E1.4 起)。 一次扳机的身体伤害 = damage_per_shot × shot_count × 武器的 damage_scale;武器的 damage_scale 覆盖动作表的 damageScale(21 行全 1.0,连段倍率不再乘 E1 武器),弹匣扣弹量读第 38 列 ammo_per_shot(枪 1 / 点射 3 / 近战 0)。动作表只拥有帧、动画与技能消耗。
  42. 命中概率是确定性的(E1.4 起)。 命中/卡壳掷骰用 xorshift32,种子 = hash(weaponItemId, attackSerial, targetSalt);同一存档重放必须掷出同一串命中与卡壳。不要引入时钟种子或全局随机源;accuracyChance 的输入是数据(武器 accuracy、精修、动作加成、移动惩罚),不是手感常量。
  43. 精修曲线只有一个真值(E1.5 起)。 精修等级/成本/加成/解锁特性/校准槽全部来自 src/game/combat/RefinementCatalog.{h,cpp} 的 9 行表;命中(+2/级)、卡壳(≥2 级阈值 −5pp)、磨损(≥5 级 ×0.8)、伤害(×(1+0.04L))、对熵(+2pp/级)、耐久池(×(1+0.05L),整数算术)、换弹/冷却、槽位都由它派生。不要在 CombatResolution/GameState/UI 里再抄一份数字;refineLevel 存在武器实例上(v9 存档字段),精修后的耐久上限是派生量、不入档。
  44. 武器模型与 icon 同源(E1.6 起)。 src/graphics/ProceduralWeapons.* 是武器像素、材质遮罩和锚点的唯一来源:muzzleOffset 是枪口火光位置的唯一真值(W11,CombatFx 与 WeaponPresentation::muzzlePosition 都读它);遮罩 R 精确写 WeaponMaterial id(11..19,BuildingPart::Count 之后追加,建筑 id 永不重排),采样必须 NEAREST 且无 mip(W12);物品栏武器 icon 必须由 buildWeaponIcon(buildWeaponSprite(spec)) 字节派生(W13),不得另画一张哈希图。

44b. 模型 = 族 + 变体 + 材质(E1.16 起)。 任何地方提到「一把枪的模型」,指的是 (model_id, model_variant, material_id) 三元组:只传 model_id 会让同族的第二把枪画成第一把。WeaponPresentation 的入口(spriteFor/shotFrame/muzzlePosition/drawHeldWeaponAtClip/drawGroundWeapon)、ItemIcons 的 icon 派生与 GameState 的握持档都要带变体;变体进入 modelAssetsFor 的缓存键,否则两把同族枪会共用一张贴图。 44c. 弹药模型是模型层的一半(E1.16 起)。 src/graphics/ProceduralAmmunition.* 画 4 个弹药族(手枪弹/12 号霰弹/步枪弹/特殊弹),与武器共用同一套画笔、同一张材质遮罩(WeaponMaterial id 11..19,NEAREST 无 mip)与同一个 icon 拟合(src/graphics/SpriteIconFit.h);弹药的背包 icon 由 buildAmmunitionIcon(buildAmmunitionSprite(spec)) 字节派生,规则与武器一致(W13 / A4)。弹药物品行用 items.tsv 的 model_id 列指明自己的模型。

  1. 世界拾取是会话内容,不进存档(E1.8 起;E1.9 升 v10 也没加拾取段)。 WorldPickup/taken 不写入存档;读档或新会话在 attachLevel() 按 data/Eraphanis/weapons/spawns.tsv 重刷,worldPickupsSpawned_ 保证幂等。年代门只读武器表的 prototype_year 对 kStoryYear;faction 列是 E2 元数据,不在运行期 gate。要持久化拾取/刷新必须再加存档段并写迁移。
  2. 怀表校准槽与降熵波(E1.9 起)。 槽数由精修派生,不存槽数;实例只存 calibrationModes[2](CalibrationMode id,0=空),随 v10 存档走。装配只收 E1 已实现模式(熵波),两槽是两轴(同一模式不能装两次)。降熵波按攻击消耗:每次攻击开始扣一次(近战/远程、命中/未命中;一次扳机打多个目标只扣一次),攻击起手把 +20pp(7 级及以上再 +5pp 共鸣)写进 DamageQuery.entropyBonus,与基础/精修相加后由管线 clamp 100%;换手/卸下/清档/读档必须取消,不能带进下一场战斗。其余 4 种模式只有数据行(E2),不要给它们编效果。
  3. 快捷栏选中格 = 手持视图(第 2 期起)。 主手 EquipmentLoadout::MainHand 是战斗唯一权威;快捷栏里最多一格(Player::armedHotbarSlot_)是它的视图,内容每帧由主手派生、不另存实例、不进存档物品行。1/2/3、Q、面板右键、拖入统一走 Player::armHotbarSlot() 事务:选中武器格=换手(旧枪回它自己的格,保留弹匣/磨损),选中空格/非武器格=收起;挥击/蓄力/动作锁定中拒绝,失败整体回滚且不动选中。视图格不可被拿起/复制(面板提示 UNEQUIP FIRST),搬回背包=卸下主手。存档 v11 用 [hotbar] armed= 记录该下标。细节与落地记录见 docs/e1-fix-1.md §6。
  4. AI 的依赖方向是单向的:ai/ 不依赖 combat/,combat/ 依赖 ai/。 反向依赖会成环,能力表(将来同时给玩家/敌人/陷阱读)就做不成。src/game/ai/ 里不得出现 EnemyDef/EnemyAttackDef/CombatActionController,也不得直接调 Level 的方法——世界只有 AiWorld 那四个问题(能不能站/挡不挡视线/位置属于哪格/格子中心)。
  5. 特征表没有 C++ 兜底。 data/Eraphanis/ai/traits.tsv 是唯一的性情来源;缺文件、坏行、少一行都在启动期报错(Application::validateAiContent())。EnemyDef::aggroRange/leashRange 已迁出,别再加回来——那会变成双真值。
  6. 视线与走线是两个函数,别合并。 hasLineOfSight 问「看得见吗」(遇挡视线的实体才断),hasClearWalkLine 问「直着走得到吗」(遇不能站的才断)。水能看见、走不过去;墙两者都断。合并成一个就是「AI 穿墙」或「AI 隔墙打人」两种 bug 的共同来源。
  7. 冷却的真值在身体里。 大脑通过 AiAbility::ready 问「现在能不能用」,自己不记时间;一个能力在两处记冷却,就会出现「冷却好了但打不出」或「冷却没好却打了」。
  8. PathAgent 的重规划有节流。 默认 0.25 s 一次,目标漂移 2 格才允许提前重规划;每帧重算路径的敌人群会把整帧都花在寻路上。
  9. 分离是软的,墙优先(AI.3)。 ai/AiSeparation 只回答「该在哪儿」,不产生碰撞:身体被推开而不是变成实体,推力按 pushWeight 分(玩家 4、敌人 1),单帧最多推 0.25 格(对帧初位置量,所以两轮迭代不会叠成两倍)。会话拿到答案后必须再问世界能不能站——站不住就不推。
  10. 听觉范围取更远的那个(AI.3)。 AiSenses::hear 的有效范围是 max(hearing_range, 噪音自带半径),不是相加:相加会让一次枪声传出 20 多格。听见噪声只是进 Investigate(去看),不是 Attack(仇恨)。
  11. 掉落表每敌人一行,掉落不进存档(AI.3)。 data/Eraphanis/ai/drops.tsv 少一行启动就报错(validateAiContent() 反查名册与物品表)。掉落是会话内容(与世界拾取物同规矩),读档带回敌人但不带回它们的战利品。掷点用 xorshift32+FNV,同种子必同结果,不要引入时钟种子。

11. 修改指南:我要改 X,去哪里

11.1 新增物品

  1. 在 data/Eraphanis/items/items.tsv 加一行,遵守当前列顺序。
  2. 如果需要初始授予,在 starters.tsv 加 equip/inventory 行。
  3. 若是方块物品:use_kind=block,block_tile 列写 blockTileId,并确认 TileType 能解析。
  4. 若是弹药:use_kind=ammunition,category 仍是 material。
  5. 图标:ItemIcons 默认按物品 id 生成帧;ItemDefinition::iconId 是覆盖位,但当前 parseItemRow() 不从 TSV 读 icon 列,要覆盖需先加解析。
  6. 跑 Reverseworks.Inventory、Reverseworks.SaveSystem、Reverseworks.CxInventoryPanel。
  7. 注意 category=material 的回落问题;需要材料类别时先扩展 ItemCategory。

11.2 新增元素/建筑装饰

  1. 在 ElementRegistry::registerBuiltins() 或外部注册点定义 ElementDefinition。
  2. 选择组件:CollisionBox、Placeable、Interactable、LifeState、HeatSource、Canopy、Species、Flora。
  3. 在 ElementVisualRegistry 绑定视觉帧/程序化绘制。
  4. 在世界生成/城镇布置处放置(WorldFeatures、Ronesville、TerrainGenerator 等)。
  5. 跑 Reverseworks.Registry、Reverseworks.Vegetation、Reverseworks.EntityVisual、Reverseworks.VisualGroundLine、Reverseworks.CollisionVisualAlign。

11.3 新增世界模板

  1. 在 src/game/WorldGen/WorldTemplate.cpp 写 makeXxxTemplate() 并 templates_.add(...)。
  2. 设置 id、displayName、海平面、湿度/温度、河流/湖泊阈值、森林阈值、树/建筑概率等。
  3. 确认默认模板仍是第一个;未知 id 会回落到它。
  4. 跑 Reverseworks.WorldGen、Reverseworks.TerrainRelief、Reverseworks.Vegetation。
  5. 存档兼容:旧存档的未知模板会被 sanitized() 替换为默认,这是有意行为。

11.4 新增疾病/状态

  1. 在 DiseaseCatalog.cpp 的 diseaseDefinition() 表加定义(严重度、传播、病程、时长)。
  2. 在 DiseaseSystem 的收缩/推进/传播逻辑中接入触发条件。
  3. 若需要新状态效果,扩展 StatusEffectType 并更新 LifeState 的 tick/传播/显示。
  4. 跑 Reverseworks.SurvivalSystem、Reverseworks.SaveSystem。
  5. 存档中的疾病按 diseaseToken() 写文本;新增 kind 要保证 token 稳定。

11.5 新增对话/剧情

  1. .cdlg 放 data/Eraphanis/world/_template/dialogue/,或用 C:\personal\VisualDialoguer 编辑(独立 C#/WinUI3 程序)。
  2. .pv 放 data/Eraphanis/world/_template/pv/。
  3. 新存档首次加载会复制到 %LOCALAPPDATA%/Eraphanis/world/<saveId>/...;已有存档要放自己的目录或清掉旧副本。
  4. 对话入口/信号在 DialogueSystem、ElementRegistry 的 Interactable、PvSystem 触发器里接。
  5. 跑 Reverseworks.CDLG、Reverseworks.Pv、Reverseworks.PvSystem、Reverseworks.Dialogue、Reverseworks.Opening、Reverseworks.NarrativeFont。

11.6 新增目标

  1. 在 src/game/Objective.cpp 的注册表加 ObjectiveDefinition。
  2. 设置前置目标、完成条件(位置、避难所、物品数、天数、生命状态等)。
  3. 跑 Reverseworks.Objective、Reverseworks.SaveSystem。
  4. 目标 id 会写入 completedObjectives;未知 id 读档丢弃,所以 id 要稳定。

11.7 新增存档字段

  1. 改 SaveGameData(SaveSystem.h)并加默认值。
  2. 改写档 SaveRepository::save() 和读档 load();保持旧档可读。
  3. 如果破坏兼容,提升 kFormatVersion,并在读档处写迁移逻辑。
  4. 更新 tests/SaveSystemChecks.cpp 往返断言。
  5. 跑 Reverseworks.SaveSystem,并检查 Reverseworks.Opening/Loading 等依赖存档的测试。

11.8 新增输入动作

  1. 在 src/core/Settings.h 的 InputAction 加枚举。
  2. 在 Settings.cpp 的 kDefaultKeys 加默认键,更新 toString() / keyLabel()。
  3. 在 Application::readSceneInput() 读取边沿/按住状态,填入 GameInput。
  4. 在 DemoScene/GameState/PUI 消费;注意面板打开时输入归属。
  5. 若不该被重绑定,扩展 isInputActionRebindable() 和固定键逻辑。
  6. 跑 Reverseworks.Settings、Reverseworks.MainMenu。

11.9 新增 UI 页签

  1. 在 src/game/playing/PlayingUILayout.h 的 InventoryPageId 加枚举,更新 kInventoryPageCount。
  2. 在 src/game/playing/PlayingUILayout.cpp 加短名/长名。
  3. 在 PlayingHud.cpp / PlayingInventoryPanel.cpp 实现绘制和输入。
  4. 未实现页签保留 NOT IMPLEMENTED YET,不要伪装成可用。
  5. 跑 Reverseworks.CxInventoryPanel、Reverseworks.UiPerf、Reverseworks.MainMenu(如果菜单入口受影响)。

11.10 新增武器(E1.1 后:改表,不改代码)

  1. 在 data/Eraphanis/weapons/weapons.tsv 加一行,遵守当前 39 列顺序(docs/combat-e1-weapon-design.md §2.2 是列契约);model_variant 列写该族里的形状号(默认形状 = 0),族里没有的形状号会在启动期被校验拦下。
  2. 在 items.tsv 加同 id 的武器物品(E1.2 之后已有武器类别:category=weapon、use_kind=weapon、stack_limit=1、槽位 1–3、词缀 1–3);id 与武器表不一致时物品行会被整行丢弃。
  3. 让快捷栏/装备流程调用 GameState::equipWeapon(id);equipWeapon() 存的是规范 id,背包里放的也要是规范 id,否则每帧的背包检查会把枪静默收走(注意:装备到主手槽的武器已经不在背包里,判定要同时看主手槽)。
  4. 跑 Reverseworks.CombatWeapon(表与规则)、Reverseworks.CombatFx、Reverseworks.CombatAction、Reverseworks.Inventory、Reverseworks.SaveSystem。

11.11 新增战斗动作 / 技能 / 敌人

  1. 在 src/game/combat/ActionCatalog.h 的动作表里加 ActionDef 行:剪辑、三段时长、命中几何、伤害类型、耐力/冷却、连招关系。
  2. 若是技能,在 SkillLoadout::loadDefault() 里给出 id;键位由 skillSlotKey() 固定为 4–9。
  3. 若是敌人招式,在 EnemyCatalog.cpp 的攻击表里引用动作 id——启动自检会拒绝不存在的 id。
  4. 新增敌人(P1 起):在 EnemyCatalog.cpp 的名册里加一行(身体:血量/半径/身高/硬直等级/速度/招式),再在 data/Eraphanis/ai/traits.tsv 加同 id 的一行性情——少一行启动就报错(validateEnemyCatalog() 反查)——再在 data/Eraphanis/ai/drops.tsv 加同 id 的一行掉落(少了也不启动报错)。不要在 EnemyDef 里写 aggroRange/leashRange,它们已经迁到 traits 表了。
  5. 新增动画状态见 §11.14;新增伤害类型要同时改 DamageType、kDamageTypeCount、抗性数组与 damageTypeName()。
  6. 跑 Reverseworks.CombatAction、Reverseworks.CombatP2、Reverseworks.CombatP3、Reverseworks.AiBrain、Reverseworks.AiInteraction、Reverseworks.Character、Reverseworks.RigEvent。

11.12 新增测试

  1. 在 tests/ 加 .cpp。
  2. 在 cmake/ReverseworksTests.cmake 用 Reverseworks_add_test(...) 建目标,并用 Reverseworks_register_test(Reverseworks.Xxx Reverseworks-XxxChecks) 注册(它负责设置工作目录,别直接写 add_test)。
  3. 需要数据时确认 Reverseworks_deploy_runtime() 被调用;测试会从部署目录读 data/、resources/。
  4. 重新配置 CMake,构建全部目标,跑 ctest -R Reverseworks.Xxx。
  5. 若是视觉探针,优先复用现有像素采样/断言风格,不要只做“能跑”。

11.13 新增平台后端 / 换平台能力

  1. 在 libs/uniscope-2d/src/pal/<backend>/ 实现 IPlatform(createPlatform 是唯一导出符号)。
  2. 在 libs/uniscope-2d/CMakeLists.txt 里按平台把后端源码加进 UNISCOPE2D_CORE_SOURCES,并处理它的第三方依赖(GLFW / EGL / JNI)。
  3. 游戏层不要为此新增 __XXX__ 分支;用 IPlatform 的接口。
  4. 跑 Reverseworks.Settings、Reverseworks.GpuProfile、Reverseworks.RenderSmoke,并在目标平台上手工启动一次(PAL 的窗口/生命周期没有无头替代)。

11.14 新增动画状态(如 Swim)

  1. 在 src/graphics/CharacterRig.h 的 CharacterAnimState 追加枚举值(在 Count 之前)。
  2. CharacterRig.cpp 的 characterAnimStateName() 补名字。
  3. src/graphics/CharacterStateTuning.h 的 kStateDesc 补一行(淡入时长、clip 速率、辫子弹簧);static_assert 会盯着行数。
  4. src/graphics/CharacterClips.cpp 补 三视角剪辑:buildSideClips 里写侧视,buildFrontBackClips 的前/后循环里写正视与背视(正视不能做俯仰旋转,只能做上下起伏与左右摆动,否则整个身体会歪)。
  5. 若状态由玩法驱动,在 HumanoidCharacter::updateAnimation() 的优先级链里插入分支(当前顺序:死亡 → 受击 → 战斗动作 → 举盾 → 游泳 → 站立 → 用物品 → 腾空 → 走/跑)。
  6. 跑 Reverseworks.Character、Reverseworks.RigEvent、Reverseworks.Swimming(若有相关新自检)。

12. 文档索引与新鲜度

文档状态说明
docs/project-state.md[当前]本文;当前状态/AI 上手入口
docs/update.txt[当前-历史]中文更新日志,v0.1.7.0–v0.1.9.2;UTF-8 with BOM;查“最近改了什么”最有用
docs/README.md[当前]docs 目录索引:每份文档一句话定位,避免从一堆专题里猜入口
docs/platform-abstraction-audit.md[设计+落地记录]PAL 下沉的审计与分阶段方案;正文是 2026-11-02 的现状审计,新增“落地状态”一节对照当前树
docs/maintenance-2026-11-03.md[记录]2026-11-03 的整理/清理/版本 0.1.9.0 记录:删了什么、改了什么、验证结果
docs/superpowers/specs/2026-11-01-touch-gamepad-adaptive-layout-design.md[设计]触摸手柄自适应布局的设计说明(毫米物理尺寸、屏内/不重叠、紧凑回退)
docs/developer-guide.md[部分过时]0.1.6 时代架构/玩法指南;大结构与设计意图仍可读。已知过时:平台/音频章节还按旧文件(GlfwWindow/AndroidWindow/OpenALAudioBackend)描述,存档版本行原为 v7(已改成 v8),并仍说项目代码不该带版权 banner(实际普遍带 proprietary banner)
docs/implementation-guide.md[部分过时]0.1.6 渲染/rig/玩法实现记录;开头“13/13 tests pass”已过时;部分章节自己标注了功能已移除
docs/android.md[较新]Android 构建、EGL/GLES、资源/输入/存储限制;四平台复核
src/graphics/CharacterRig.README.md[当前]骨骼、skin、动画、图集、外部美术、构建诊断的权威说明
resources/assets/eraphanis/textures/world/README.md[当前]世界图集、无缝地面、gutter、细节层
C:\personal\VisualDialoguer\README.md[外部]独立 C#/WinUI3 对话编辑器:架构、快捷键、指令类型、已知限制
docs/combat-system-design.md[设计]战斗系统设计方案(近程/远程/动作,ARPG 对齐)
docs/combat-e1-weapon-design.md[设计+落地记录]战斗扩展 E1 武器系统规格;E1.1–E1.9 已全部落地,§2.9–§2.17 是逐阶段落地记录(偏差/发现)
docs/combat-e2-watch-design.md[设计]战斗扩展 E2 怀表系统规格(怀表物品与槽位、过载/尖啸、迟滞场/加速/回溯/锚点重置、装配面板、v11 存档);未落地
docs/ai-generic-design.md[设计+落地记录]通用 AI 框架设计:特征/寻路/感官/行为/交互/能力五个子系统;AI.1–AI.3 已落地,AI.4–AI.5 未开始
docs/collision-visual-alignment-*.md[专题]碰撞/视觉对齐的审计、修复、分辨率、屋顶悬挑专题
docs/building-model-optimization-plan.md[当前]建筑模型优化的总方案(阶段划分 + D-1~D-9 决策表,决策已全部拍板)
docs/building-audit-baseline.md[基线]阶段 1:29 typeId 的尺寸/部件占比/显存/每帧开销/门连通实测
docs/building-issues.md[当前]阶段 2:22 条问题清单(P0×7 / P1×9 / P2×6)
docs/building-optimization-design.md[设计]阶段 3:S1~S17 逐条主选/备选/取舍/影响面/回退。注意 S14 主选已被阶段 4 的 N4 修正
docs/building-verification-acceptance.md[当前]阶段 4:G1~G11 验收门槛的可执行判据、分策略验收卡、批次追踪、人工评审清单、实机采数模板、N4~N7 新发现
docs/building-proportion-table.md[当前]建筑比例表(S10,按族各一份);与 tests/BuildingProportions.cpp 的 FamilyBand 成对维护
docs/deanna-art-upgrade-plan.md[计划]Deanna 美术升级计划,不是现状
docs/female-body-proportions-plan.md[计划]女性体型比例计划,不是现状
docs/eraphanis.docx / docs/winding.docx[已删除]2026-11-03 删除的旧二进制文档,正文以 md 文档为准

13. 更新本文件的检查清单

每次改完代码/数据/测试后,按这个顺序更新:

  • 更新 §0 的“最后核对日期”“HEAD/分支”“工作树状态”“测试结果”。
  • 如果版本变了,同时更新全部引用点(不止下面四处——2026-11-07 核对时发现 vcpkg.json 与 complexui 的更新日志面板还停在 0.1.8.11,就是因为清单只写了四处):
    • 版本声明:CMakeLists.txt 的 project(VERSION)、vcpkg.json 的 version、android/app/build.gradle 的 versionName + versionCode(+1)、packaging/inno/Eraphanis.iss 的 AppVersion;
    • 更新日志:src/ui/views/MainMenuSpec.h 的 changelogVersion 与 changelogLines(只写 ASCII,中文字形不在字体 atlas 里会是空白)、libs/complexui/src/complexui/components/CxChangelogPanel.h 的默认 versionLabel、docs/update.txt 顶部新条目(保留 UTF-8 BOM);
    • 文档里的“当前版本”:README.md、docs/README.md、docs/developer-guide.md、本文 §0/§2 的版本行。
    • 查漏:git grep -n "0\.1\.[0-9]*\.[0-9]*" 扫一遍,历史记录(docs/maintenance-*.md、旧版更新日志条目)不改。
  • 如果新增/删除系统,更新 §4 目录职责和 §6 功能表。
  • 如果改了帧流程/early-return,更新 §5。
  • 如果改了存档字段/版本,更新 §7 和 §10 的不变量。
  • 如果改了默认键位/控制台命令,更新 §8。
  • 如果新增/删除测试,更新 §9 表格和“当前结果”。
  • 如果修复/新增已知缺口,更新 §1、§9、§10。
  • 如果旧文档仍过时,更新 §12 的状态,不要默默让读者踩坑。
  • 重新跑完整 ctest;如果没跑,明确写“未验证”。

14. 术语速查

术语含义
Echo一个存档/角色周目;档案列表里的条目
Tile / 格世界坐标单位;默认 1.0 世界单位
Chunk32×32 tile 的流式加载块
Element世界中的实体(树、营火、神龛、地表植物等)
Component挂在 Element 上的能力/数据(碰撞、热源、可交互等)
Rig unit骨骼长度单位;1 rig unit = 0.5 tile
CDLG对话脚本格式/库
PV剧情动画时间线格式/库
WorldSpec世界的唯一身份:模板 + seed + 半径
Save v11当前存档格式版本(v11 = 快捷栏 armed 槽;v10 = 怀表校准槽;v9 = 武器实例状态;v8 = 每物品实例的词缀)
PALPlatform Abstraction Layer:uniscope::pal,窗口/输入/生命周期/资源/路径/显示/时钟/日志
ActionDef动作表的一行:剪辑、三段时长、命中几何、伤害/耐力/冷却
Affix装备词缀,属于物品实例,随存档走
GroundFlicker地面细节随相机移动而变化的回归探针;2026-11-08 修复后通过
心智 / 特征src/game/ai/ 里的通用 AI:心智 = 感官 + 状态机 + 一行特征(traits.tsv);身体是它驱动的东西(敌人、同伴、陷阱)
牵引(leash)离岗位多远就放弃追;traits.tsv 的 leash_range
软分离AI.3:身体互相推开而不是互为实体;玩家权重高,只被轻推;墙优先
掉落(drop)AI.3:敌人死后留在地上的东西;drops.tsv 的一行;会话内容,不进存档

本文最后核对:2026-11-08。若你读到的时间明显晚于代码,请先按 §0.1 重新验证。