Eraphanis 项目现状与 AI 上手手册
这是
docs/下唯一的“当前状态”入口。目标不是替代所有专题文档,而是让一个没有上下文的 AI/开发者在一轮阅读后知道:项目是什么、现在能做什么、代码从哪里进、哪些契约不能碰、改东西该去哪里、当前哪里坏了。本文件是活文档。代码、测试、CMake 和数据文件是事实来源;本文件是它们的人工索引。发现不一致时,以代码/测试为准,并顺手更新本文件。
0. 文档元信息与维护契约
| 项 | 当前值 |
|---|---|
| 最后核对日期 | 2026-11-08 |
| 核对对象 | 当前工作树(0.1.9.2 版本分支) |
| 项目版本 | 0.1.9.2(CMakeLists.txt;Android versionCode 11) |
| 分支 / HEAD | echo(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 维护规则
- 这是当前状态文档,不是更新日志。 历史演进写到
docs/update.txt;这里只写“现在是什么样”。 - 更新触发条件:版本号、存档格式、构建预设、测试结果、数据表结构、输入映射、核心不变量、已知缺口发生变化时,必须改本文件。
- 先验证再写。 至少运行: ``
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`` 如果测试没跑,明确写“未验证”,不要照抄旧结论。 - 工作树可能很脏。 不要为了“让文档干净”去
git reset、git checkout或删除不认识的evidence/。这些可能是用户正在进行的排查/基线材料。 - 旧文档可能过时。 已知过时点见 §12。遇到冲突时按“代码 > 测试 > CMake/数据 > 本文件 > 旧文档”排序。
- 状态标记:
[当前]表示与工作树一致;[部分过时]表示仍有参考价值但不能直接照做;[历史]表示只用于理解演进。
0.2 AI 推荐阅读顺序
- 本文 §1–§4:知道项目是什么、怎么构建、目录在哪。
- 本文 §5:掌握帧循环和
GameState更新顺序。 - 本文 §10:记住不能破坏的契约。
- 本文 §11:按“我要改 X”查入口。
- 需要细节时再跳到
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,x64mingw64-release/mingw64-debug:MinGW-w64 x64mingw32-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:
window_->pollEvents();处理关闭(shouldClose())。window_->beginInputFrame()。audio_.setWindowPresentation(焦点, 最小化)。- 若
window_->shouldSuspendRendering():释放 GPU 资源、丢弃 atlas GPU 资源、waitEventsTimeout()、清输入边沿、重置帧时钟,continue。 - 从挂起恢复时:恢复 GPU 资源、重新上传缓存图集、重置输入(PAL 的
LifecycleEvent提供 suspended/recovery)。 - 计算
deltaSeconds(window_->time(),上限kMaxFrameDeltaSeconds),更新音频。 - 构造
ui::MenuContext:读取GameInput、framebufferWidth/Height/Aspect、设置、音频、音乐、存档仓库、渲染器、捕获键。 - 应用渲染质量预设和 MSAA 设置。
FrameProfiler::beginFrame();scene_.update(deltaSeconds, context)。- 处理
MenuContext请求:重置输入、预载 CJK 字体、应用显示/音频、打开 URL、键捕获。 - 设置脏则保存。
renderer_.beginFrame()→scene_.render(...)→ 触摸手柄 →renderer_.endFrame()。window_->swapBuffers()。FrameProfiler::endFrame();打印报告;处理退出;限帧;更新窗口标题。- 退出循环后持久化待处理音频设置与当前存档。
5.3 DemoScene 模式
src/game/DemoScene.h 定义:
| 模式 | 行为 |
|---|---|
MainMenu | 主菜单;可后台预热最近存档的世界;处理新建/继续/退出 |
LoadingWorld | LoadingCoordinator 生成世界、解析对话/PV、烘焙字体;Esc 取消 |
LoadingReveal | 0.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:
- 没有
Level直接返回。 - 相机缩放:鼠标滚轮/PageUp/PageDown,先于任何 early-return,所以对话/开门淡出时仍可缩放。
- 门旅行:
DoorTravel活跃时只推进淡出/换关/淡入,冻结世界;黑屏峰值交换Level。 - 热源点光同步:
syncHeatSourcePointLights。 - 对话:若对话活跃,只更新对话与相机,返回。
- 世界更新:
world_.update(deltaSeconds, events_, player_.position()),把玩家位置传给环境系统采样局部气候。 - 玩家更新:
player_.update(...)。 - 消费门请求:
consumeDoorTravelRequest()。 - 目标系统:统计背包物品数,填充
ObjectiveSystem::FrameState,objectives_.update(frame)。 - 预算流式生成:
streamAroundBudgeted(tileCoord, 2),在移动之后、相机跟随之前。 - 战斗/FX:武器检查、攻击、弹道、方块破坏、点光。
- PV 触发:
pv_.pollTriggers(*this)。 - 相机跟随:
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-bitWorldSpec::seed决定;worldRadius是硬安全上限,当前为24000tiles。 - 世界是确定性生成的:同一
(template, seed)生成同一地形;存档只保存WorldSpec和玩家改过的方块,不保存整张地图。 LevelExtent::Streaming:以原点为中心、半径24000tiles 的方形世界;只有玩家附近的 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 快移(容器为空时无操作)。
- 8 个页签:
- 面板/控制台打开时,PUI 消费整帧输入,
DemoScene直接返回,世界暂停。 - 开发者控制台:
- 每个 Echo 单独开关,存在存档
[meta] consoleEnabled。 - 桌面端固定反引号 `
`` 开合,不可重绑定;Android 没有实体反引号,当前只有桌面可触发。 - 当前命令:
timetime dawn|day|dusk|night|midnighttime HH:MMsuicide:通过正常生命状态与事件链立即杀死角色,并关闭控制台。
- 其他命令回显后输出
UNKNOWN COMMAND。
- 每个 Echo 单独开关,存在存档
- 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::complexui | Eraphanis::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)。
- 武器实例状态(设计 E1 §2.3)随实例走:
- 仍未落地:敌人/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.3setWeaponStateForSlot的耐久归一化(破刀不可达)。偏差见docs/combat-e1-weapon-design.md§2.12。
- 准确性掷骰(§3.1):
- 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 通道精确写WeaponMaterialid 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 表现细节。
- 握持修正层(W14):
- 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、LinuxXDG_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.00 | 2.97 格 |
| 陆地跑(Shift) | runSpeed_ | 5.56 格 |
| 游泳 | kSwimSpeedFactor = 0.52 | 1.54 格 |
| 游泳加速(Shift) | kSwimFastSpeedFactor = 0.80 | 2.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 之后):
- 人物按绘制身高的 10% 下沉(水面 = 脚底立线,身体在水面之下);
- 深度分层浸染:水下身体按
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 枚亮斑沿水下带漂移,相位取自水面动画(
waterPhase),alpha 0.26、正弦淡入淡出。这是“身体在一层被照亮的流动水体下”最便宜的读法; - 透镜条带水纹理:水图集帧画成 8 条水平带 × 2 遍,带宽按透镜轮廓收放、alpha 在顶部 1/3 渐隐到 0(
kSwimVeilAlpha 0.40/ 第二遍 0.20)——任何方向都没有矩形边; - 水线碎浪:压扁的软泡沫带贴在水线(
kSwimFoamAlpha 0.10,宽 0.72、高 0.035 tile)。数值刻意压低:alpha 上到 0.22 时它在特写下会变成一条横贯身体的白带; - 空心涟漪:两圈半相位错开,每圈泡沫椭圆 + 小一号水色椭圆掏空,从浸染区外缘起扩散(
kSwimRingAlpha 0.10,kSwimRingHalfHeightFraction 0.06); - 不提交接地投影和投影阴影。
水怎么动(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.72s | 0.40 | 沿行进方向拉长的椭圆,间距小于长度所以连成带 |
泡沫 Foam | 入水/出水/岸边 | 0.95s | 0.26 | 扁椭圆,随 alpha 淡出 |
气泡 Bubble | 每 0.44s(静止时 ×1.7 间隔) | 1.15s | 0.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.040 | 0.075 / +0.055 |
| 倾斜幅度 | 0.070 rad | 0.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::CalibrationModeid,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/3 | 1 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.GpuProfile | GPU 能力探测/降级 | ||
Reverseworks.RenderSmoke | 真实 GL 渲染冒烟,角色像素非空 | ||
Reverseworks.NightBrightness | 昼夜地面亮度探针 | ||
Reverseworks.ThemeColor | 主题色参与富文本布局缓存键 | ||
Reverseworks.Tips | 浮动提示/键盘选择状态机 | ||
Reverseworks.NarrativeFont | 内容驱动字体图集,CJK 字符覆盖 | ||
Reverseworks.Loading | 加载屏幕完成、字体/内容准备 | ||
Reverseworks.Pv | .pv 解析/时间线 | ||
Reverseworks.PvSystem | PV 绑定:相机、时钟、letterbox、文字卡 | ||
Reverseworks.Dialogue | 对话框文字上屏 | ||
Reverseworks.Opening | 开场雾场/字幕淡变/白光序列 | ||
Reverseworks.GroundFlicker | 相机移动 1px 时地面细节稳定性(2026-11-08 修复,当前通过) | ||
Reverseworks.TextureContract | 世界图集帧表/UV/朝向契约 | ||
Reverseworks.EdgeMask | 地面边缘/邻接遮罩 | ||
Reverseworks.WorldGen | 世界生成确定性、出生点、模板、城镇 | ||
Reverseworks.CombatFx | 武器/弹道/命中/方块伤害 | ||
Reverseworks.CombatAction | 动作框架:动作表完整性、三段状态机、连招窗口、蓄力、命中几何、软锁、部位映射 | ||
Reverseworks.CombatP2 | P2:翻滚无敌帧(有效窗口第二帧起)、冲刺、耐力门槛、格挡与 4 帧完美格挡、技能冷却、瞄准辅助、弹药 | ||
Reverseworks.AiPathfinding | AI 寻路:绕墙不穿墙、不切角、没路与搜超时是两回事、视线与走线是两个问题、路径拉紧、代理的重规划节流 | ||
Reverseworks.AiBrain | AI 特征与心智:特征表是数据(坏行报错)、名册与 traits 双向约束、迁移的八个数字未变、先注意后决定、够不着就靠近、丢目标→查看→回岗、牵引、崩胆 | ||
Reverseworks.AiInteraction | AI 交互(AI.3):软身体分离(重的少动、单帧不抛人、重合可重放)、听觉接线(范围取更远的那个、噪声→查看)、掉落表是数据(坏行报错、每敌人一行、掷点确定)、死了会掉且能捡进背包 | ||
Reverseworks.CombatP3 | P3:敌人共用动作表、硬直等级、伤害类型与免疫/抗性、实例词缀 roll 与存档 v8 | ||
Reverseworks.CombatWeapon | E1.1–E1.5 端到端:21 把逐行对齐数值表、派生量、启动期校验、旧 id 别名;装备路径 + 弹匣(主/副手、实例状态、换弹、扣弹与空仓收起/徒手);战斗管线(准确性确定性、熵、耐久卡壳/清膛/破刀、噪音事件、十字 5 格、R1 溢出、一次扳机总伤 = 单发×弹数);精修(花零件逐级升级、成本序列、耐久池增长、伤害 ×1.04^L、命中/对熵、换弹 −30%、校准槽、封顶与 8 级觉醒门槛、存档恢复) | ||
Reverseworks.CombatResolution | E1.4 规则层纯函数:accuracyChance/距离衰减/确定性种子与掷骰分布、jamChance/耐久损耗/耐久比例、resolveDamage 管线顺序(精修乘算、熵跳护甲/护甲/抗性/元素/耐久折损)、crossCells/crossCellFor 5 格与 scale、NoiseEmitted 工厂与 EventBus 订阅 | ||
Reverseworks.CombatRefine | E1.5 精修规则层纯函数:9 行曲线逐列(伤害/命中/耐久/对熵/成本/累计/特性)、成本公式与字面序列、等级钳制、校准槽 0/1/2、4 级换弹与冷却、7 级共鸣、耐久池整数算术、8 级专属/原创门槛与单步升级拒绝、E1.4 三钩子与曲线一致性 | ||
Reverseworks.CombatE15Verify | E1.5 独立验收(独立实现):从 §3.5 设计表重算 9 行曲线与成本/累计、21 行 × 全部等级耐久池、成本/零件经济(主背包+背包容器、忙碌门禁)、W8 专属门槛、会话伤害/命中/对熵、换弹 2.0→1.4 s、校准槽、存档往返(精修 563 手改钳制、v8 工厂值、会话恢复)与 weapon_parts 物品行 | ||
Reverseworks.WeaponVisual | E1.6 武器模型/材质/icon:21 行→18 族解析、albedo+遮罩尺寸/尺寸合法、遮罩 id 11..19、锚点顺序与 | muzzle | 范围、18 族轮廓可区分、ejectsCasing 表、W12 三列采样探针(nearest/no-chain 0 非法)、9 行材质响表、21 把 icon 与模型缩图字节相等、确定性;E1.16 追加 W22 族内变体(变体号合法、三对同族枪模型/icon/长度都不同、比例落在档位带内、越界变体回落默认形状) |
Reverseworks.AmmunitionVisual | E1.16 弹药模型与 icon:4 个弹药族 id 空间、物品行 model_id→模型、sprite 合法(遮罩 id/锚点有限)、icon 与模型字节同源、四种弹药互不相同、生成确定;51 断言 | ||
Reverseworks.WeaponPresentation | E1.7 表现:W14 握持修正层(同帧 state/clipPhase/事件帧号不变、仅手臂链姿势变、None 无操作、镜像一致)、W11 枪口==闪光(5 把枪)、烟/壳/曳光/后坐、7 档音色与 weaponSoundProfile、SoundRequest 增益随 noise 单调、机制声 | ||
Reverseworks.WeaponSpawn | E1.8 世界投放:spawns.tsv 校验、10 锚点、1915/1922 年代门(Thompson)、player_start 必刷、pickup 位置可行走、W16 全链路(拾取→工厂实例→装备→装填→开火→掉血→NoiseEmitted)、背包满留地面、attachLevel 幂等、拾取不进存档 | ||
Reverseworks.WeaponVerifyE16 | E1.6–E1.8 独立验收(独立实现):W11 21 把枪口/弹道锚点、W12 独立重采样、W13 icon 字节同源、W14 24 状态帧序列、W16 多世界全链路;1130 断言 | ||
Reverseworks.WatchCalibration | E1.9 怀表校准:模式表/id 钉死、槽位 0/1/2、装配拒绝(未开槽/E2 模式/None/重复)、清除、3 次攻击的降熵波(十字 5 目标只耗 1 次)、7 级共鸣 +5 与 100% clamp、换手/读档取消、v9→v10 迁移与 v10 往返、坏档归一化 | ||
Reverseworks.WatchE19Verify | E1.9 独立验收(独立实现):模式表/槽位、装配拒绝、w11 三次攻击 61.6032 vs 第四次 48.2112、十字只耗 1 次、w14+7 级共鸣 25pp/clamp 100%、换手/读档取消、v10 原始后缀与 v8/v9 迁移、非武器行无后缀;185 断言 | ||
Reverseworks.WeaponE16E18Verify | E1.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.UiPerf | UI 性能/布局预算 | ||
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.SmokeUniscope2D.Anomaly
ReverseworksTests.cmake 仍保留 if(TARGET Uniscope2D-Thread) 的注册,但该 target 已在渲染器重构中删除,所以当前不会出现 Uniscope2D.Thread。这不是失败,是可选注册。
10. 不变量与常见坑
按“踩了就很难查”排序。
- 1 骨骼单位 = 0.5 格。 角色渲染、碰撞、相机锚点必须用
kRigUnitTiles和现成 helper;不要手写foot * visualScale之类的公式。旧错误会让所有立物看起来“碰撞偏北一格”。 WorldSpec必须随存档走。 不要从Level之外的地方随便构造默认 spec;加载前用WorldSpec::sanitized(),adopt 前校验level->worldSpec() == activeWorldSpec_。hasPlayerPosition才是坐标有效性。 不要用(0,0)判断“没移动”。- 在室内保存要写室外门口坐标。 室内是运行时生成、不持久化的;写室内坐标会导致读档醒在错误空间。
- 数据部署不是“构建成功就一定有”。 自检读的是部署到
CMAKE_RUNTIME_OUTPUT_DIRECTORY的副本;改了data/或resources/却没重建,测试读到的还是旧副本。见 §3.5。 GameState::beginSession()不是实际游戏路径。 真正会话启动在DemoScene::beginSession();前者主要服务无头检查(如 RenderSmoke)。改角色美术绑定/加载流程时先看DemoScene。ItemCategory没有material。 写category=material会静默变Headwear;方块物品靠useKind=Block,弹药靠useKind=Ammunition。不要只看类别判断行为。- 战斗规则已就绪,但世界内容仍有缺口。 武器定义已经是数据表的 21 把(E1.1),
items.tsv也有对应的武器物品(E1.2);equipWeapon()已有正式调用点(快捷栏选中武器按使用键),并且 E1.8 的「世界拾取 → 装备 → 开火 → 伤害 → 噪音」全链路已由 W16 覆盖。但敌人仍只在测试里spawnEnemy(),敌对 NPC/刷怪/商店/掉落品质属 E2;不要把CombatP2/CombatP3/CombatWeapon/WeaponSpawn自检当成世界内容全量完成。 GameState::openContainer()恒为nullptr。Ctrl+左键快移当前是空操作;世界箱子未实装。playedScenes防止剧情重复。 新存档才播opening_prologue;读档不会重播。不要为了“让开场出现”清空它,除非明确要重播。- 被修改的 chunk 不会被流式丢弃。 地形编辑依赖这个;不要为了省内存清掉 modified chunk。
- 对话/门旅行/面板会 early-return。 世界更新不是每帧都发生;新增系统要明确自己应该在暂停时运行还是冻结。
- 相机缩放在 early-return 之前。 这样对话/开门淡出时仍可缩放;不要把缩放移到
world_.update之后。 - PUI(
PlayingUI)先于GameState消费输入。 面板/控制台打开时世界暂停;不要在GameState里重复处理同一按键。 - 地面细节必须世界锚定。
u_detail_origin不是可选装饰;GroundFlicker就是它的回归探针。锚点由WorldRenderer::render设置:世界批锚到世界原点、角色批锚到角色自身原点。新增任何自己的精灵批(UI 之外的场景绘制)时,要记得给出锚点,否则细节会锚在相机上(这正是 2026-11-08 修掉的那条失败)。 - 项目自有文件头有版权 banner。 旧
developer-guide.md说“不要加 banner”,与当前工作树相反;不要按旧文档批量删除Copyright (c) 2026 Leo Jeang...头。 - CMake 源文件是 glob +
CONFIGURE_DEPENDS。 新增.cpp通常不用改 CMake,但需要重新配置;新增测试必须手动在cmake/ReverseworksTests.cmake注册。 build preset只构建游戏。 测试要单独cmake --build build-xxx -j 8或指定 target。docs/update.txt是 UTF-8 with BOM。 曾长期以 GBK 保存,2026-11 已是 UTF-8;写入时保留 BOM,别转成 GBK。游戏主菜单的更新日志面板是代码里写死的(src/ui/views/MainMenuSpec.h),不读这个文件。- 工作树是脏的。 不要 reset/checkout/清理不认识的目录;先问或先看
git status。 - 材质遮罩的 R 通道是分类数据,不是颜色。 部件 id 一经线性过滤或 mip 平均就变成第三个部件(墙+基座 → 西坡,黄昏面假高光)。实测当前配置在缩小档有 19.42% 的 texel 是非规范部件字节。修法是 NEAREST 且不带 mip 链 —— 只改过滤不够,mip 的每一级本身就是平均值(
Reverseworks.BuildingMaskResample三列对比)。 - 门由画家声明,不要从 albedo 颜色反推。
BuildingSpritePixels::doorRect/hasDoor是契约。颜色行程在两个方向上都错:看不见钢制卷帘门(Industrial 20 例),又能被 Civic 门廊柱子的同色木料带偏(24 例)。 - 不是每栋建筑都有门。
buildingHasDoorEntrance(BuildingLook)是唯一真值,Depot(gas_station_route_20th)为 false。任何假定「门格一定存在」的代码都要先问它。 isOpenFormTypeId()是开放形态的唯一真值。 WorldGen 与AssetLibrary都转发到它;不要再抄子串判断(历史上抄漏过gas_station)。- 游泳是地面的属性,不是玩家的状态位。
motion_.swimming每帧由脚下格重算(overSwimWater),没有锁存,所以走出浅滩自动结束。任何“进入/离开水域”事件都不能改成一次性切换,否则玩家会带着游泳状态站在岸上。 - 浅水可游、深水是墙。
isSwimTile()= 液体且可通行;Water不可通行,所以深水永远到不了swimming。新增液体瓦片时必须明确它属于哪一类。 - 游泳速度是 walkSpeed 的分数,不是 runSpeed 的。 且两个系数都 < 1.0(
static_assert保证),否则涉水会变成抄近路。 - 起伏与倾斜是渲染变换,不是姿势。
Rig2D::DrawParams::roll绕 rig 原点旋转已摆好的 quad,剪辑/骨骼/pose 一个字节都不改;roll = 0时绘制路径与改动前完全一致。任何想改“游泳姿态”的需求都不许借这条通道实现。 - 水面件画在水面合成之后,且只在轮廓外生成。 水幕是合成在身体之上的,画在人物之前的粒子会被它埋掉(存在但看不见)。角色不被遮挡靠位置与权重(轮廓外、尺寸小、单个 alpha ≤ 0.5),不靠绘制顺序。
- 游泳特效里所有「每秒」都是真实秒。 日钟跑 12× 真实时间,所以乘日钟读数的频率在屏幕上快 12 倍;钳位也必须写成真实秒再乘回游戏秒,否则 60 FPS 下每帧都被钳成同一常数,模拟速度就跟着帧率走(30/60/144 FPS 分别是 1.5×/3.0×/7.2×)。任何新加的振荡、计时器、衰减都接
waveSeconds_,不许直接乘clock.gameSecondsOfDay()。 - 晃动频率从水面周期派生,不是独立数值。
kBobFrequency = 水面周期/3、kSwayFrequency = 水面周期/5,水面周期只在EnvironmentAnimation.h的kWaterCyclesPerGameSecond定义一次。改水面速度,晃动的频率跟着一起变——这才是「协调」,而不是两个各自调过的数字恰好看起来还行。 - 动作表是战斗的唯一真值。 玩家与敌人共用
ActionDef行;动画剪辑、命中帧、耐力、冷却都由行说明。新招式先加行,再让代码引用 id,不要在新代码里另写一套时长。 - 词缀属于物品实例。 roll 随物品走(背包、存档、穿戴),不要按物品 id 重新生成;
CombatStats是唯一的汇总入口,伤害/冷却/耐力都读它。 - 平台能力走 PAL,不要在游戏层加平台宏。 需要窗口/输入/路径/资源/显示时用
uniscope::pal接口;__ANDROID__之类的分支留在libs/uniscope-2d/src/pal/里。 - 音频层不许出现 OpenAL 类型。 上层只认
uniscope::audio::IAudioDevice/IAudioStreamVoice;alSource*、ALuint之类只能出现在src/audio/openal/内。 - 烘焙出口有自检。
validateBuildingSprite()在AssetLibrary烘焙出口调用,当前是告警不拒绝(一个干净发布周期后升级为硬失败)。新增画法若触发告警,说明部件拓扑出现了非法戳记或孤岛区域。 - 武器表是武器的唯一真值。
data/Eraphanis/weapons/weapons.tsv定义武器;src/game/fx/WeaponCatalog.h只是运行时门面。分工与不变量 32 相同:帧与动画归动作表,伤害与弹丸数归武器表。不要在 C++ 里再抄一份表,也不要在WeaponDef里写死一把武器的数值。 - 武器物品与武器行用同一个 id 连接。
items.tsv的武器行 id 必须等于weapons.tsv的行 id,否则物品目录加载时整行被丢弃(parseItemRow的校验)。反过来也一样:武器表里的 id 没有对应物品行时,那把武器拿不到、也装不上。 - 装备槽是主手的唯一真相(E1.3 起)。
GameState::syncEquippedWeapon()每帧读EquipSlot::MainHand,equippedWeapon()也直接读槽;不要再用「背包里有没有这把枪」做装备判定(武器穿上后本来就不在背包里,旧检查会静默收枪,E1.3 已删除)。equipWeapon(id)的语义是「把随身(主背包或快捷栏)里的该武器移进主手」,不是凭空发枪。武器是穿戴不是使用:useInventorySlot()/useSelectedQuickItem()必须拒绝useKind=Weapon,否则会把枪“用掉”。 - 武器实例状态属于实例,随件走(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就重新物化:那会把坏刀在卸下或存读档时修好。 - 武器表是伤害总量的唯一真值(E1.4 起)。 一次扳机的身体伤害 =
damage_per_shot × shot_count × 武器的 damage_scale;武器的damage_scale覆盖动作表的damageScale(21 行全 1.0,连段倍率不再乘 E1 武器),弹匣扣弹量读第 38 列ammo_per_shot(枪 1 / 点射 3 / 近战 0)。动作表只拥有帧、动画与技能消耗。 - 命中概率是确定性的(E1.4 起)。 命中/卡壳掷骰用 xorshift32,种子 =
hash(weaponItemId, attackSerial, targetSalt);同一存档重放必须掷出同一串命中与卡壳。不要引入时钟种子或全局随机源;accuracyChance的输入是数据(武器 accuracy、精修、动作加成、移动惩罚),不是手感常量。 - 精修曲线只有一个真值(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 存档字段),精修后的耐久上限是派生量、不入档。 - 武器模型与 icon 同源(E1.6 起)。
src/graphics/ProceduralWeapons.*是武器像素、材质遮罩和锚点的唯一来源:muzzleOffset是枪口火光位置的唯一真值(W11,CombatFx与WeaponPresentation::muzzlePosition都读它);遮罩 R 精确写WeaponMaterialid(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 列指明自己的模型。
- 世界拾取是会话内容,不进存档(E1.8 起;E1.9 升 v10 也没加拾取段)。
WorldPickup/taken不写入存档;读档或新会话在attachLevel()按data/Eraphanis/weapons/spawns.tsv重刷,worldPickupsSpawned_保证幂等。年代门只读武器表的prototype_year对kStoryYear;faction列是 E2 元数据,不在运行期 gate。要持久化拾取/刷新必须再加存档段并写迁移。 - 怀表校准槽与降熵波(E1.9 起)。 槽数由精修派生,不存槽数;实例只存
calibrationModes[2](CalibrationModeid,0=空),随 v10 存档走。装配只收 E1 已实现模式(熵波),两槽是两轴(同一模式不能装两次)。降熵波按攻击消耗:每次攻击开始扣一次(近战/远程、命中/未命中;一次扳机打多个目标只扣一次),攻击起手把+20pp(7 级及以上再+5pp共鸣)写进DamageQuery.entropyBonus,与基础/精修相加后由管线 clamp 100%;换手/卸下/清档/读档必须取消,不能带进下一场战斗。其余 4 种模式只有数据行(E2),不要给它们编效果。 - 快捷栏选中格 = 手持视图(第 2 期起)。 主手
EquipmentLoadout::MainHand是战斗唯一权威;快捷栏里最多一格(Player::armedHotbarSlot_)是它的视图,内容每帧由主手派生、不另存实例、不进存档物品行。1/2/3、Q、面板右键、拖入统一走Player::armHotbarSlot()事务:选中武器格=换手(旧枪回它自己的格,保留弹匣/磨损),选中空格/非武器格=收起;挥击/蓄力/动作锁定中拒绝,失败整体回滚且不动选中。视图格不可被拿起/复制(面板提示UNEQUIP FIRST),搬回背包=卸下主手。存档 v11 用[hotbar] armed=记录该下标。细节与落地记录见docs/e1-fix-1.md§6。 - AI 的依赖方向是单向的:
ai/不依赖combat/,combat/依赖ai/。 反向依赖会成环,能力表(将来同时给玩家/敌人/陷阱读)就做不成。src/game/ai/里不得出现EnemyDef/EnemyAttackDef/CombatActionController,也不得直接调Level的方法——世界只有AiWorld那四个问题(能不能站/挡不挡视线/位置属于哪格/格子中心)。 - 特征表没有 C++ 兜底。
data/Eraphanis/ai/traits.tsv是唯一的性情来源;缺文件、坏行、少一行都在启动期报错(Application::validateAiContent())。EnemyDef::aggroRange/leashRange已迁出,别再加回来——那会变成双真值。 - 视线与走线是两个函数,别合并。
hasLineOfSight问「看得见吗」(遇挡视线的实体才断),hasClearWalkLine问「直着走得到吗」(遇不能站的才断)。水能看见、走不过去;墙两者都断。合并成一个就是「AI 穿墙」或「AI 隔墙打人」两种 bug 的共同来源。 - 冷却的真值在身体里。 大脑通过
AiAbility::ready问「现在能不能用」,自己不记时间;一个能力在两处记冷却,就会出现「冷却好了但打不出」或「冷却没好却打了」。 PathAgent的重规划有节流。 默认 0.25 s 一次,目标漂移 2 格才允许提前重规划;每帧重算路径的敌人群会把整帧都花在寻路上。- 分离是软的,墙优先(AI.3)。
ai/AiSeparation只回答「该在哪儿」,不产生碰撞:身体被推开而不是变成实体,推力按pushWeight分(玩家 4、敌人 1),单帧最多推 0.25 格(对帧初位置量,所以两轮迭代不会叠成两倍)。会话拿到答案后必须再问世界能不能站——站不住就不推。 - 听觉范围取更远的那个(AI.3)。
AiSenses::hear的有效范围是max(hearing_range, 噪音自带半径),不是相加:相加会让一次枪声传出 20 多格。听见噪声只是进Investigate(去看),不是Attack(仇恨)。 - 掉落表每敌人一行,掉落不进存档(AI.3)。
data/Eraphanis/ai/drops.tsv少一行启动就报错(validateAiContent()反查名册与物品表)。掉落是会话内容(与世界拾取物同规矩),读档带回敌人但不带回它们的战利品。掷点用 xorshift32+FNV,同种子必同结果,不要引入时钟种子。
11. 修改指南:我要改 X,去哪里
11.1 新增物品
- 在
data/Eraphanis/items/items.tsv加一行,遵守当前列顺序。 - 如果需要初始授予,在
starters.tsv加equip/inventory行。 - 若是方块物品:
use_kind=block,block_tile列写blockTileId,并确认TileType能解析。 - 若是弹药:
use_kind=ammunition,category仍是material。 - 图标:
ItemIcons默认按物品 id 生成帧;ItemDefinition::iconId是覆盖位,但当前parseItemRow()不从 TSV 读icon列,要覆盖需先加解析。 - 跑
Reverseworks.Inventory、Reverseworks.SaveSystem、Reverseworks.CxInventoryPanel。 - 注意
category=material的回落问题;需要材料类别时先扩展ItemCategory。
11.2 新增元素/建筑装饰
- 在
ElementRegistry::registerBuiltins()或外部注册点定义ElementDefinition。 - 选择组件:
CollisionBox、Placeable、Interactable、LifeState、HeatSource、Canopy、Species、Flora。 - 在
ElementVisualRegistry绑定视觉帧/程序化绘制。 - 在世界生成/城镇布置处放置(
WorldFeatures、Ronesville、TerrainGenerator等)。 - 跑
Reverseworks.Registry、Reverseworks.Vegetation、Reverseworks.EntityVisual、Reverseworks.VisualGroundLine、Reverseworks.CollisionVisualAlign。
11.3 新增世界模板
- 在
src/game/WorldGen/WorldTemplate.cpp写makeXxxTemplate()并templates_.add(...)。 - 设置
id、displayName、海平面、湿度/温度、河流/湖泊阈值、森林阈值、树/建筑概率等。 - 确认默认模板仍是第一个;未知 id 会回落到它。
- 跑
Reverseworks.WorldGen、Reverseworks.TerrainRelief、Reverseworks.Vegetation。 - 存档兼容:旧存档的未知模板会被
sanitized()替换为默认,这是有意行为。
11.4 新增疾病/状态
- 在
DiseaseCatalog.cpp的diseaseDefinition()表加定义(严重度、传播、病程、时长)。 - 在
DiseaseSystem的收缩/推进/传播逻辑中接入触发条件。 - 若需要新状态效果,扩展
StatusEffectType并更新LifeState的 tick/传播/显示。 - 跑
Reverseworks.SurvivalSystem、Reverseworks.SaveSystem。 - 存档中的疾病按
diseaseToken()写文本;新增 kind 要保证 token 稳定。
11.5 新增对话/剧情
.cdlg放data/Eraphanis/world/_template/dialogue/,或用C:\personal\VisualDialoguer编辑(独立 C#/WinUI3 程序)。.pv放data/Eraphanis/world/_template/pv/。- 新存档首次加载会复制到
%LOCALAPPDATA%/Eraphanis/world/<saveId>/...;已有存档要放自己的目录或清掉旧副本。 - 对话入口/信号在
DialogueSystem、ElementRegistry的Interactable、PvSystem触发器里接。 - 跑
Reverseworks.CDLG、Reverseworks.Pv、Reverseworks.PvSystem、Reverseworks.Dialogue、Reverseworks.Opening、Reverseworks.NarrativeFont。
11.6 新增目标
- 在
src/game/Objective.cpp的注册表加ObjectiveDefinition。 - 设置前置目标、完成条件(位置、避难所、物品数、天数、生命状态等)。
- 跑
Reverseworks.Objective、Reverseworks.SaveSystem。 - 目标 id 会写入
completedObjectives;未知 id 读档丢弃,所以 id 要稳定。
11.7 新增存档字段
- 改
SaveGameData(SaveSystem.h)并加默认值。 - 改写档
SaveRepository::save()和读档load();保持旧档可读。 - 如果破坏兼容,提升
kFormatVersion,并在读档处写迁移逻辑。 - 更新
tests/SaveSystemChecks.cpp往返断言。 - 跑
Reverseworks.SaveSystem,并检查Reverseworks.Opening/Loading等依赖存档的测试。
11.8 新增输入动作
- 在
src/core/Settings.h的InputAction加枚举。 - 在
Settings.cpp的kDefaultKeys加默认键,更新toString()/keyLabel()。 - 在
Application::readSceneInput()读取边沿/按住状态,填入GameInput。 - 在
DemoScene/GameState/PUI 消费;注意面板打开时输入归属。 - 若不该被重绑定,扩展
isInputActionRebindable()和固定键逻辑。 - 跑
Reverseworks.Settings、Reverseworks.MainMenu。
11.9 新增 UI 页签
- 在
src/game/playing/PlayingUILayout.h的InventoryPageId加枚举,更新kInventoryPageCount。 - 在
src/game/playing/PlayingUILayout.cpp加短名/长名。 - 在
PlayingHud.cpp/PlayingInventoryPanel.cpp实现绘制和输入。 - 未实现页签保留
NOT IMPLEMENTED YET,不要伪装成可用。 - 跑
Reverseworks.CxInventoryPanel、Reverseworks.UiPerf、Reverseworks.MainMenu(如果菜单入口受影响)。
11.10 新增武器(E1.1 后:改表,不改代码)
- 在
data/Eraphanis/weapons/weapons.tsv加一行,遵守当前 39 列顺序(docs/combat-e1-weapon-design.md§2.2 是列契约);model_variant列写该族里的形状号(默认形状 = 0),族里没有的形状号会在启动期被校验拦下。 - 在
items.tsv加同 id 的武器物品(E1.2 之后已有武器类别:category=weapon、use_kind=weapon、stack_limit=1、槽位 1–3、词缀 1–3);id 与武器表不一致时物品行会被整行丢弃。 - 让快捷栏/装备流程调用
GameState::equipWeapon(id);equipWeapon()存的是规范 id,背包里放的也要是规范 id,否则每帧的背包检查会把枪静默收走(注意:装备到主手槽的武器已经不在背包里,判定要同时看主手槽)。 - 跑
Reverseworks.CombatWeapon(表与规则)、Reverseworks.CombatFx、Reverseworks.CombatAction、Reverseworks.Inventory、Reverseworks.SaveSystem。
11.11 新增战斗动作 / 技能 / 敌人
- 在
src/game/combat/ActionCatalog.h的动作表里加ActionDef行:剪辑、三段时长、命中几何、伤害类型、耐力/冷却、连招关系。 - 若是技能,在
SkillLoadout::loadDefault()里给出 id;键位由skillSlotKey()固定为 4–9。 - 若是敌人招式,在
EnemyCatalog.cpp的攻击表里引用动作 id——启动自检会拒绝不存在的 id。 - 新增敌人(P1 起):在
EnemyCatalog.cpp的名册里加一行(身体:血量/半径/身高/硬直等级/速度/招式),再在data/Eraphanis/ai/traits.tsv加同 id 的一行性情——少一行启动就报错(validateEnemyCatalog()反查)——再在data/Eraphanis/ai/drops.tsv加同 id 的一行掉落(少了也不启动报错)。不要在EnemyDef里写aggroRange/leashRange,它们已经迁到 traits 表了。 - 新增动画状态见 §11.14;新增伤害类型要同时改
DamageType、kDamageTypeCount、抗性数组与damageTypeName()。 - 跑
Reverseworks.CombatAction、Reverseworks.CombatP2、Reverseworks.CombatP3、Reverseworks.AiBrain、Reverseworks.AiInteraction、Reverseworks.Character、Reverseworks.RigEvent。
11.12 新增测试
- 在
tests/加.cpp。 - 在
cmake/ReverseworksTests.cmake用Reverseworks_add_test(...)建目标,并用Reverseworks_register_test(Reverseworks.Xxx Reverseworks-XxxChecks)注册(它负责设置工作目录,别直接写add_test)。 - 需要数据时确认
Reverseworks_deploy_runtime()被调用;测试会从部署目录读data/、resources/。 - 重新配置 CMake,构建全部目标,跑
ctest -R Reverseworks.Xxx。 - 若是视觉探针,优先复用现有像素采样/断言风格,不要只做“能跑”。
11.13 新增平台后端 / 换平台能力
- 在
libs/uniscope-2d/src/pal/<backend>/实现IPlatform(createPlatform是唯一导出符号)。 - 在
libs/uniscope-2d/CMakeLists.txt里按平台把后端源码加进UNISCOPE2D_CORE_SOURCES,并处理它的第三方依赖(GLFW / EGL / JNI)。 - 游戏层不要为此新增
__XXX__分支;用IPlatform的接口。 - 跑
Reverseworks.Settings、Reverseworks.GpuProfile、Reverseworks.RenderSmoke,并在目标平台上手工启动一次(PAL 的窗口/生命周期没有无头替代)。
11.14 新增动画状态(如 Swim)
- 在
src/graphics/CharacterRig.h的CharacterAnimState追加枚举值(在Count之前)。 CharacterRig.cpp的characterAnimStateName()补名字。src/graphics/CharacterStateTuning.h的kStateDesc补一行(淡入时长、clip 速率、辫子弹簧);static_assert会盯着行数。src/graphics/CharacterClips.cpp补 三视角剪辑:buildSideClips里写侧视,buildFrontBackClips的前/后循环里写正视与背视(正视不能做俯仰旋转,只能做上下起伏与左右摆动,否则整个身体会歪)。- 若状态由玩法驱动,在
HumanoidCharacter::updateAnimation()的优先级链里插入分支(当前顺序:死亡 → 受击 → 战斗动作 → 举盾 → 游泳 → 站立 → 用物品 → 腾空 → 走/跑)。 - 跑
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 世界单位 |
| Chunk | 32×32 tile 的流式加载块 |
| Element | 世界中的实体(树、营火、神龛、地表植物等) |
| Component | 挂在 Element 上的能力/数据(碰撞、热源、可交互等) |
| Rig unit | 骨骼长度单位;1 rig unit = 0.5 tile |
| CDLG | 对话脚本格式/库 |
| PV | 剧情动画时间线格式/库 |
| WorldSpec | 世界的唯一身份:模板 + seed + 半径 |
| Save v11 | 当前存档格式版本(v11 = 快捷栏 armed 槽;v10 = 怀表校准槽;v9 = 武器实例状态;v8 = 每物品实例的词缀) |
| PAL | Platform 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 重新验证。