ERAPHANISECHO
developer-guide.md更新于 3 小时前 · 73 KB

Eraphanis Echo Developer Guide

本文档是面向开发者/AI 的代码架构与实现手册。与 project-state.md(快速索引+契约不变量)互补,聚焦源码组织、模块边界、内部实现机制和扩展配方。

  • 代码 / 测试 / CMake / 数据文件是事实来源;本文档是它们的人工索引。
  • 当本文档与代码冲突时,以代码为准,并顺手更新本文档。
  • 当前版本:0.1.9.3(CMakeLists.txt)。最后核对:2026-11-09。

0. 与 project-state.md 的分工

文档目标读者内容
project-state.md所有人/AI 入门项目是什么、能做什么、快速事实、不变量、已知坑、修改入口(改 X 去哪)
developer-guide.md(本文件)需要深入代码的开发者模块内部结构、类关系、帧循环细节、渲染管线、存档格式、扩展 recipe

建议阅读顺序:先 project-state.md 的 §1–§5 和 §10,再回来按需深挖本文件对应章节。


1. 仓库地图

1.1 顶层目录

Eraphanis/
├─ CMakeLists.txt                    顶层 CMake:定义 Eraphanis 可执行文件 + Reverseworks_Game 静态库
├─ CMakePresets.json                 构建预设:msvc-release-x64 / mingw64-release 等
├─ settings.cfg                      运行时设置(分辨率/音量/键位等默认值)
│
├─ src/                              游戏源码
│  ├─ main.cpp                       main();非 Android 入口
│  ├─ core/                          应用循环、设置、路径、帧分析
│  ├─ platform/                      仅剩平台入口与薄包装(AndroidMain、键码、分辨率、外链、触摸触点);窗口/输入在 libs/uniscope-2d/src/pal/
│  ├─ graphics/                      渲染资产、角色 rig、程序化贴图、字体、Shader
│  ├─ audio/                         音频前端(AudioEngine/AudioFactory、音乐桥)与 openal/、winmm/ 后端
│  ├─ game/                          游戏模拟与呈现(最大目录,约 100 个文件)
│  └─ ui/                            主菜单、加载、Options、Archive
│
├─ libs/                             本地库(独立 CMake 子项目;uniscope-2d 内含 PAL 与音频接口)
│  ├─ uniscope-2d/                   2D 渲染器、骨骼动画、图集、着色器、PAL(include/uniscope/pal)、音频接口
│  ├─ cdlg/                          .cdlg 对话脚本解析与运行时
│  ├─ pv/                            .pv 剧情时间线解析与运行时
│  └─ complexui/                     UI 控件/HUD 绘制
│
├─ tests/                            75 个项目自检 + 2 个库测试(ctest 共 77)
├─ cmake/                            辅助脚本(依赖、音频、部署、测试注册)
├─ data/Eraphanis/                   运行时数据(items TSV、world 模板 dialogue/pv、locale 本地化表)
├─ fonts/                            字体包:fonts.tsv 清单 + en_US/zh_CN/ja_JP 字体文件(与清单一起部署)
├─ resources/                        美术资源(character、world textures、audio、fonts)
├─ tools/                            开发工具(character 美术脚本、建筑验收、PPM→PNG、打包脚本)
├─ 3rdParty/                         第三方源码(GLFW、GLAD、stb、ImGui、mpg123、openal-soft)
├─ android/                          Gradle/NDK 工程
├─ docs/                             本文件与专题文档
├─ evidence/                         探针证据与建筑审计基线(保留,被建筑文档引用)
├─ tmp_frames/                       (2026-11-03 已清理;排查截图不再入库)
└─ build-mingw64-release/            当前已配置的 MinGW Release 构建(测试在这里跑)

1.2 src/ 子目录职责

目录头文件源文件核心职责
src/core/109Application(帧循环)、Settings(键位/质量/音量/语言)、ResourcePaths/DataPaths/UserDataPaths(跨平台路径解析)、Localization(本地化表 + tr())、FrameProfiler、SettingsEditor
src/platform/54平台入口与薄包装:AndroidMain、KeyCodes.h、ResolutionCatalog、SystemBrowser、TouchContacts.h。窗口/输入/生命周期在 libs/uniscope-2d/src/pal/
src/graphics/3923AssetLibrary(图集/程序化建筑缓存)、CharacterRig(骨骼动画)、CharacterModel(角色模型格式:解析器 + 加载器)、CharacterBodyTemplates(female/male/neutral 体型模板)、CharacterPalette(程序化 fallback 配色)、TextureAtlas(世界图集)、ProceduralBuildings/ProceduralWorldTiles(程序化生成)、SdfFont/FontFamily/FontRegistry(字体烘焙与清单,见 3.10.5)、Renderer2D.h(uniscope-2d 兼容别名入口)、Shader/Texture/GpuBuffer 基础设施
src/audio/1513AudioEngine(前端)、AudioFactory(选择后端)、openal/OpenALAudioDevice+OpenALStreamVoice、winmm/WinmmAudioDevice、NullAudioDevice、MusicPlayerBridge(菜单/故事音乐互斥)、SoundSynth(程序化 SFX)、MusicStream+WavReader(音乐解码)、BgmCatalog/AudioCueCatalog
src/game/~70~65游戏层 + PUI(src/game/playing/),详见 §2
src/ui/55MainMenu、MenuAction/MenuContext、CxViewBridge(与 ComplexUI 桥接)
src/ui/views/5—MainMenuSpec、MainMenuView、LoadingWorldView、OptionsView、SaveMenuView
src/ui/loading/2—LoadingCoordinator、LoadingTypes

1.3 src/game/ 子目录

目录职责
src/game/(根)游戏状态、玩家、世界、存档、对话、PV、生存、元素、光照、渲染等(完整列表见 §2)
src/game/playing/PUI(游玩界面):HUD、物品栏面板、开发者控制台。命名空间 Eraphanis::game::playing,与 GUI(src/ui/)分开,见 §5.2
src/game/WorldGen/世界模板注册、地形生成器、城镇布置、噪声函数、区域定义
src/game/fx/战斗 FX、武器定义、天气粒子、环境动画、镜头光晕、粒子渲染
src/game/defs/GameHash.h(注册时哈希)、StaticRegistry.h(静态注册辅助)

2. 运行时架构

2.1 顶层对象图

main()  [src/main.cpp]
 └─ core::Application  [src/core/Application.h]
     │
     ├─ uniscope::pal::IWindow(PAL;GLFW / Android 后端)  窗口 + 输入 + 生命周期
     ├─ graphics::Renderer2D                        2D 渲染器(= uniscope::d2::Renderer2D 别名)
     ├─ game::DemoScene                             场景模式切换(菜单/加载/PV/游玩)
     │   ├─ ui::MainMenu + ui::views/*              主菜单、档案、选项
     │   ├─ ui::loading/LoadingCoordinator          世界/对话/PV/字体加载
     │   ├─ game::playing::PlayingUI + PlayingHud   PUI:HUD + 物品栏 + 控制台
     │   ├─ game::DialogueView + NarrationBox       对话/叙事 UI
     │   └─ game::GameState                         游戏会话状态
     │       ├─ game::World                          Level + WorldClock + EnvironmentSystem + LightingSystem
     │       ├─ game::Player                         玩家角色(FemaleCharacter 子类)
     │       ├─ game::Camera2D                       2D 跟随相机
     │       ├─ game::EventBus                       中央事件总线
     │       ├─ game::DialogueSystem                 .cdlg 会话管理
     │       ├─ game::PvSystem                       .pv 时间线绑定
     │       ├─ game::Objective                       目标链
     │       ├─ game::DoorTravel                     门/室内外切换
     │       └─ game::fx/CombatFx                   战斗/弹道/方块破坏
     ├─ game::SaveRepository                        存档读写、列表、迁移
     ├─ audio::AudioEngine                          音频前端
     └─ audio::MusicPlayerBridge                    菜单 ↔ 故事音乐互斥

2.2 Application::run() 每帧流程

位置:src/core/Application.cpp。

while (!window_.shouldClose()) {
    // 1. 事件轮询
    window_.pollEvents();

    // 2. 挂起恢复处理(Android 后台、桌面最小化)
    if (suspended_) { ...; continue; }

    // 3. 帧时间
    deltaSeconds = min(actualDelta, kMaxFrameDeltaSeconds);

    // 4. 读取输入 → GameInput
    GameInput input = window_.readInput();

    // 5. 构造 MenuContext(GameInput + 窗口尺寸 + 设置 + 音频 + 渲染器 + 存档)
    ui::MenuContext ctx(input, window_, settings_, audioEngine_, musicBridge_, saveRepo_, renderer_);

    // 6. 更新音频
    audioEngine_.update(deltaSeconds);

    // 7. 更新场景
    FrameProfiler::beginFrame();
    scene_.update(deltaSeconds, ctx);
    FrameProfiler::endFrame();

    // 8. 渲染
    renderer_.beginFrame();
    scene_.render(renderer_, window_.width(), window_.height(), timeOfDay);
    renderer_.endFrame();
    window_.swapBuffers();

    // 9. 限帧 + 窗口标题更新
    limitFrameRate();
    window_.updateTitle(fps);
}

2.3 DemoScene 模式切换

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

2.4 GameState::update() 更新顺序

位置:src/game/GameState.cpp。严格的顺序 + 关键 early-return 语义:

1. 无 Level 直接返回
2. 相机缩放(鼠标滚轮 / PageUp / PageDown)  ← 在所有 early-return 之前
3. 门旅行(DoorTravel 活跃时只推淡出/换关/淡入,冻结世界)
4. 热源点光同步
5. 对话活跃?→ 只更新对话 + 相机,返回 early
6. world_.update(deltaSeconds, events, playerPos)
7. player_.update(...)
8. 消费门旅行请求 consumeDoorTravelRequest()
9. ObjectiveSystem 帧状态填充 + 更新
10. 预算流式生成 streamAroundBudgeted(playerTile, maxNewChunks=2)
11. CombatFx(武器检查、攻击、弹道、方块破坏)
12. PV 触发器轮询 pv_.pollTriggers(*this)
13. camera_.follow(player_.cameraAnchor(), worldBounds, deltaSeconds)

关键 early-return 规则(修改时必须遵守):

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

2.5 DemoScene::beginSession() vs GameState::beginSession()

  • DemoScene::beginSession() 是真正的游戏启动路径:负责存档创建、世界加载、对话/PV 包准备、字体烘焙、建筑/角色图集预热。
  • GameState::beginSession() 是无头测试入口(如 RenderSmoke、CharacterSimulation):构造一个裸 GameState 并应用存档快照。
  • 改加载流程时先看 DemoScene,改游戏状态恢复逻辑时看 GameState。

3. 核心模块详解

3.1 世界系统

3.1.1 World(src/game/World.h)

聚合根,持有:

  • Level level_ — 当前关卡(室外 or 某个建筑的室内)
  • WorldClock clock_ — 时间推进
  • EnvironmentSystem environment_ — 天气/温度/湿度/云量/降水
  • LightingSystem lighting_ — 环境光/点光/方向光/阴影参数

关键成员:

  • update(deltaSeconds, events, playerPos) — 把玩家位置传给环境系统用于采样局部气候
  • advanceGameSeconds(gameSeconds, events) — 显式时间跳跃(PV 脚本、调试命令)
  • resolveMovement(pos, halfExtents, delta, events) — 碰撞解算(分 X/Y 两步、大 delta 子步进防穿墙)
  • adoptLevel(Level&&) — 加载/进入新关卡
  • prepareNewGame(season) / prepareFromSave(spec, season) — 初始化

3.1.2 Level(src/game/Level.h)

关卡抽象,流式世界的核心载体。

核心常量:

kChunkSize = 32;              // 每个 chunk 32×32 tile
kStreamKeepChunks = 4;         // 保留半径(chunk 数)
kStreamGenerateChunks = 4;     // 新生成半径

世界身份由 WorldSpec(templateId, seed) 决定;每个 tile 的内容 = f(template, seed, coordinate)。不保存整张地图,只保存被玩家编辑过的方块。

关键成员:

  • createWorld(spec, initialStreamChunks) — 以原点为中心构建世界
  • streamAround(centerTile, keep, generate) — 围绕中心保留/生成 chunk
  • streamAroundBudgeted(centerTile, maxNewChunksPerFrame) — 每帧限量版本
  • worldSpec() — 返回当前 spec;加载时必须与会话请求的 spec 匹配,否则放弃
  • notifyEntityMoved(entity, from, to) — 更新 tile occupant 并触发 tile 事件
  • blocksMovementAt(tile) — 世界阻挡查询(tile 本身 + occupant + block stack)
  • interactAt(worldPos, actor, events) — 交互派发
  • ensureHeatSourceSnapshots() — 缓存热源用于热模拟

LevelExtent 枚举:Streaming(无限流式)、Fixed(固定大小,室内用)。

被修改的 chunk 永不丢弃(Chunk::modified = true 保护)。地形编辑按 chunk/tile/layer 写进存档的 [terrain](v7 起;TerrainModificationSnapshot),读档时由 finalizeWorldLoad() 的 applyTerrainModifications() 应用——所以玩家放的方块不会消失。

3.1.3 WorldGen/(src/game/WorldGen/)

文件职责
WorldSpec.h(templateId, seed) 世界身份;sanitized() 把未知模板替换为默认
WorldTemplate.h / .cpp世界模板注册表:forest、temperate_wilderness、rugged_highlands;定义海平面、湿度/温度分布、河湖阈值、森林阈值、树/建筑概率等
TerrainGenerator.h / .cpp地形生成:高度图、河流、湖泊、森林、建筑地基
WorldFeatures.h / .cpp特征点:树、花丛、街道装饰、祭坛、营火、路标
WorldNoise.h / .cpp噪声函数封装
WorldRegion.h / .cpp区域定义(城镇、荒野、森林)
Ronesville.h / .cpp唯一固定城镇(96×80);出生点位于广场
RonesvilleBuildings.h约 29 个建筑定义(住宅、仓库、教堂、诊所、酒馆、铁匠铺等)

3.1.4 Tile / BlockStack / TileBehavior

  • Tile 持有:type、variant、state(生命/温度/湿度等)、occupant entities、可选 BlockStack(可堆叠方块,最多 8 层)
  • BlockStack:maxStableHeight 因材质而异(土壤 2、木头 4、石头 8、营火 1);CampfireBlock 是 top-only standalone(不接受堆叠);integrity = 生命值
  • TileDefinition:材质分类、行走性、结构/透明度标志
  • TileMaterial:每种材质的 porosity(吸水率)、wetnessRetention(保湿度)
  • TileBehavior:enter/exit/step/interact 可选钩子;用 TileBehaviorRegistry::dispatch(ctx, handler) 路由

新增 tile/block 类型的标准步骤:

  1. 扩展 TileType 枚举
  2. 添加 TileDefinition + 默认 ElementState
  3. 在 TileMaterial.cpp 加材质数据
  4. 在 TileBehavior.cpp 加行为钩子(如需)
  5. 在 TileVisual.cpp 加视觉映射
  6. 加测试(行走性、kind、默认状态)

3.1.5 材质边界:圆角与拼接

地面瓦块的材质边界完全在 GPU 侧画,CPU 只负责分类。主文档是 resources/assets/eraphanis/textures/world/README.md(那一节写的是原理), 本节只写代码在哪、契约是什么。

两套描述,别搞混(src/game/ConnectedTextures.h):

函数内容去向
edgeCategoryMask原始描述:每边/每对角挨着什么只留在 CPU,用来推导角形状
boundaryTreatmentMask绘制描述:每边这条边界怎么画低字节进顶点属性,shader 读它
cornerShapeMask四个角的形状(None/Spike/Notch/Taper)高字节进顶点属性,shader 读它
worldEdgeMask上面两者打包成 16 bitMaterialParams2D::edgeMask

边界表与顶点表(阶段二,同一小节的数据模型):

结构内容谁在用
BoundaryDesc一条边界的两侧族、joined、处理方式、样式edgeFrame() 产出;boundaryDesc() 单条查询
VertexDesc一个顶点的四个象限:瓦块指针、坐标、是否存在、各象限的角形状、fillQuadrantappendCornerPatches 据此决定谁切角、谁补片
VertexTable一块瓦的四个顶点同上
EdgeFrame一次邻域读取的全部结果:打包 mask + 四条边界 + 四个顶点渲染器每瓦每帧读一次

角归属由顶点统一判定(resolveVertex),不再靠「谁是 spike」的隐式约定: 象限 q 的两侧是 (q+3)%4 与 (q+1)%4,对角是 (q+2)%4;corner i 的顶点上 quadrant i=对角瓦、quadrant (i+1)%4=边 (i+1) 邻居、quadrant (i+2)%4=自身、 quadrant (i+3)%4=边 i 邻居。象限顺序是绝对的(NE/SE/SW/NW),从四块瓦的 任何一块看同一个顶点,四个象限的坐标都相同,所以跨顶点一致性可以逐象限 直接比较——checkVerticesAgreeWithEveryTileAroundThem 与 checkEveryVertexReadsTheSameFromAllFourTiles 就是这么查的。

切角规则(只有这些情形会产生 Spike,其余都是 None/Taper/Notch):

  • 两侧都 joined + 对角存在且异材 → Notch(不切,对角那块才是 Spike)。
  • 两侧都 open + 对角同材 → None(凸角,本来就方)。
  • 两侧都 open + 对角存在且异材 → Spike,由对角象限补片。
  • 两侧都 open + 对角不存在 → None(地图外角,没有第四块瓦可补)。
  • 一侧 joined 一侧 open → 只能是 Taper(resolveVertex 的最后一个分支)。 早先这里还有一条「两侧邻居同材且都与本块不同、且对角也不同 → Spike」的特例, 它让直岸线两端互切,形成四片叠加的风车;现在代码里这个分支只出 Taper, 文档不要再写回 Spike(checkAStraightArmEndDoesNotPinwheel 钉住)。
  • 四向异材(棋盘格):四个象限各自被三比一否决,全部 Spike,各自由对角 补片。这是合法布局,不是 bug。

一条边界只有一个处理方式:boundaryTreatmentMask 取两侧材质中较硬的一个 (按 band 宽度排序:Soil 0.20 < Water 0.24 < Rock 0.28,见 edgeCategoryRank), 两侧算出同一个值,所以同一条接缝的两边画出一模一样的带。早期版本每边描述的是 邻居的类别,于是土侧画暖色带、水侧画冷色带,两条异色带在接缝处对打。改这里 的时候务必保住这个对称性——tests/EdgeMaskChecks.cpp 的 checkBoundaryTreatmentIsSymmetric 会逐条边界检查。

类别表只有一份,在 CPU:edgeStyleFor(src/game/ConnectedTextures.cpp)是 唯一事实来源,edgeStyleTable() 把它打包成 uniscope::d2::EdgeStyleTable, WorldRenderer 每帧 setEdgeStyleTable() 推给库,库在 flush 时上传成 u_edge_style[4] / u_edge_radius[4] / u_edge_tint[4] 三个 uniform 数组。 (不是一张结构体数组:uniform 数组是紧凑排布的,结构体有 padding,上传时的 stride 会算错,所以拆成三个普通 vec3 数组。)shader 的 edgeStyle/edgeTint/naturalRadiusFor 现在只是查表,不再持有任何数字—— 改样式只改 edgeStyleFor 一处。库侧默认表全零(不画带、不切角、色调中性), 所以忘了推表会表现为边界带消失,Reverseworks.RenderSmoke 会抓到。

材质族是瓦块定义的一部分:TileDefinition::edgeFamily (TileEdgeFamily::{Unset,Water,Soil,Rock},数值与 EdgeCategory 对齐)。 Water/ShallowWater/Ice/PackIce→Water;Rock/Blocked/Slate/LichenRock→Rock; 其余地面→Soil;Empty 与四种 block 层→Unset。以前这是个 switch,新增地面类型 忘了加就静默落到 Soil;现在 Reverseworks.EdgeMask 的 checkEveryGroundDeclaresItsBoundaryFamily 会遍历整个枚举,把没声明的报出来。

圆角的两条硬约束:

  • kCornerRadiusMax = 0.37(shader,切角半径上限)必须 < kCornerPatchScale = 0.42(game::kCornerPatchScale,补片覆盖范围)。 否则切口落到背景而不是对角材质上。两者在不同文件里,靠 Reverseworks.EdgeMask 的 checkCornerPatchCoversTheLargestCut 钉住。
  • 只有 Spike 会切角,也只有 Spike 会发补片(WorldRenderer.cpp 的 appendCornerPatches)。Notch 是同一顶点的另一视角,再切一次会在同一个角 上打第二个洞。

补片必须画在地面之前:补片不透明且先入批,瓦块随后带着 alpha 切口画上来, 合成结果正好是 k*tile + (1-k)*patch,没有第三项。渲染器不开深度测试,层次 纯靠提交顺序——把补片挪到瓦块之后会立刻变成两层半透明叠加。

3.1.6 过渡带:跨材质是真的在混合(阶段三)

阶段一、二把「边界怎么画」对称化了,但跨界的 albedo 仍然是硬切的——一块 草地旁边是水,两种材质在接缝处直接换色,只靠色调和唇边掩盖,远处看还是一条 线。阶段三把这条线换成真正的两材质混合。

数据在 CPU,不占顶点掩码位:

位置内容
TransitionStyle一对材质的过渡参数:width(接缝到「完全属于自己」那一侧的宽度,单位是整格的比例)与 jitter(低频扰动幅度)
transitionStyleFor(near, far)无序类别对 → 参数。水/水 0.24、水/土 0.26、水/岩 0.28、土/土 0.20、土/岩 0.28、岩/岩 0.28
BoundaryDesc::transition每条边界带着自己那份;两侧算出来完全一样(checkTheCrossfadeIsTheSameFromBothSides)
TransitionLayer2DNone / Base / FarOnNear / NearOnFar

过渡带不挤进 24 bit 的 edge mask(那边已经满了:4 边处理 + 4 角形状 + 3 个 flag + 5 bit 角种子)。它走自己的顶点属性 location 12(vec4 aTransition): 模式、轴/边掩码、扰动、切向原点。旧的 24 bit 布局一个字没改 (checkTheCrossfadeRidesItsOwnAttribute 钉住)。

画法是两条半带,不是一条:

  • 一条边界由南/西那一侧的瓦块负责生成(行优先扫描保证低侧的邻居已经画过, 所以两侧的成品色调、动画、detail 参数都能直接抄,不用再算一次光照)。
  • 每条边界出两条半带 overlay:
    • FarOnNear:在近侧那半格上画远侧材质(alpha = farWeight);
    • NearOnFar:在远侧那半格上画近侧材质(alpha = 1 - farWeight)。
  • 两条半带用同一个 farWeight 场,所以在接缝上两边的合成结果逐像素相同; 因为权重互补,两种材质、它们的色调、抖动、动画、湿度、detail、光照与雾 一起混合——每块瓦的明暗抖动是按瓦坐标取的,跨界两侧亮度不同,不混就会在 带内留一个台阶。
  • 提交顺序:补片 → 地面瓦块 → 过渡带 → 深度排序物体。过渡带在普通地面 之后、在 player/排序精灵之前;渲染器不开深度测试,层次纯靠提交顺序。

两处几何约束(都在 tests/EdgeMaskChecks.cpp 里钉住):

  • 带的宽度必须小于角预留:shader 的 transitionWidthScale 在离角 0.30 格 处就把混合收干净,两条相邻的带才不会在角上重叠、把远侧材质混两遍。 checkTheCrossfadeStaysClearOfTheCorners 比对表里的宽度和 shader 里的这个点。
  • 引用的图集条带必须是材料自己的边缘:半带只引用源材质那一侧的边缘条带, 而且材料的边缘纹素要落在接缝上。四边各有一种取法(transitionStripUv, 参数含「接缝在本地 +1 还是 -1 边」),搞反了就把材料的内部纹素放到接缝上、 边缘纹素甩到带的外侧——那是接缝的镜像,不是过渡。

还没做的部分(阶段三原提案里的第四项):把 alpha 切口换成顶点位移裁剪, 从而彻底去掉「补片 + alpha 切口 + 绘制顺序」这条耦合。现在仍然是 alpha 切口 + 补片:切角、补片都保留,阶段三只改了直线段。

改这块之前先读:resources/assets/eraphanis/textures/world/README.md 的 「Material boundaries are drawn per side, not baked」与「Corners need the diagonals」两节。

3.2 字符、骨骼与动画

3.2.1 继承链

Entity              [src/game/Entity.h]
 └─ Character       [src/game/Character.h]        名称、速度、碰撞
     └─ HumanoidCharacter [src/game/HumanoidCharacter.h]   人类通用移动/动画逻辑
         ├─ FemaleCharacter  [src/game/FemaleCharacter.h]  女性体型表(玩家默认)
         └─ MaleCharacter    [src/game/MaleCharacter.h]    男性体型表(保留)
             └─ Player       [src/game/Player.h]           玩家角色 + 生存 + 背包 + 装备
  • FemaleNpc / MaleNpc 是 NPC 模板(Deanna.h 定义 Deanna 的 profile)
  • 玩家默认 = FemaleCharacter;体型表本体在 graphics/CharacterBodyTemplates.cpp(female / male / neutral 三张模板),FemaleCharacter/MaleCharacter 只负责传各自的 head/hair 缩放。想改玩家体型:改模板或改模型文件(resources/character/deanna/model.rig 的 [body] 段),不要改 MaleCharacter。

3.2.2 CharacterRig(src/graphics/CharacterRig.h)

角色骨骼、蒙皮、动画剪辑的统一入口。

骨骼列表:20 根结构骨(pelvis、torso、neck、head、clavicle_{L,R}、upperarm_{L,R}、forearm_{L,R}、hand_{L,R}、thigh_{L,R}、shin_{L,R}、foot_{L,R})+ 2 眼骨 + 3 辫节(braid_root/braid_mid/braid_tip)。无辫子的 profile 跳过辫子链。

动画状态 × 视角:

  • 状态:Idle、Walk、Run、JumpRise、JumpFall、Land、UseItem
  • 视角:Front、Side、Back(由 CharacterFacing 决定当前使用哪个)
  • 事件:footstep(Walk/Run 每循环两次)、land、use_item,由 rig 按 clip time 发出,游戏层决定音效

最重要的 rig 常量(不要自己写换算公式):

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

关键 helper(渲染/碰撞必须用这些):

  • drawnOriginLift() — 角色脚底绘制提升(跳跃时改变)
  • cameraAnchor() — 相机跟随锚点(脚底以上)
  • groundContactOffset() — 地面接触偏移(独立于跳跃提升)
  • originOffset() / footOffset() / shadowRadius() — 渲染器消费

美术绑定:

  • 角色是数据:一个角色 = 一个文件夹 = 一个 model.rig(见 §3.2.2.1)
  • 外部美术缺失时回退程序化材质 + 调试骨骼线
  • 构造 Player 必须 GL-free;atlas GPU 上传在 ensureAssets() 时由渲染器触发
  • 动画状态机纯 CPU,headless 测试可以断言 clip 选择

3.2.2.1 角色模型格式(src/graphics/CharacterModel.h)

角色的一切——体型表、配色、美术绑定、开关——都在一个文件里: resources/character/{name}/model.rig。游戏、预览器、将来的制作器读的都是它。

分层原则:模板 + 覆盖。

内容位置理由
体型模板(female / male / neutral 整表)graphics/CharacterBodyTemplates.cpp整表写死,避免默认值漂移;模板带全部硬约束注释
体型逐项覆盖模型 [body] 段只有「偏差」进数据,模板本身不进
骨架骨表、皮肤 quad 表graphics/CharacterBody.cppcoat 的 span/anchor、辫骨长等是推导式不变量,全量外置会把它们变成改一处就穿帮的显式值;改为逐项覆盖钩子
程序化 fallback 配色CharacterPalette(默认在代码,可被 [palette] 覆盖)不画贴图的第三方角色连颜色都改不了
美术绑定模型 [art] 段与模型同文件夹,路径相对模型自身解析,整夹可搬走
动画 clip不进数据clip 由体型参数化生成(buildSideClips / buildFrontBackClips),是共享动画库

格式:分节的 TSV,[section] + key<TAB>value。段落:[model](name/template/height/bulk/braid)、[body]、[skin]、[palette]、[art]、[flag]。

  • 解析器严格:未知段落/未知键/格式错误/超范围数值一律报错并带行号。静默丢一行 = 角色长得不对且查不出原因。
  • 字段名宽松匹配:thigh_width 与 thighWidth 等价;完整名单见 characterBodyParamNames()。
  • 注释 # / ;,空行忽略,UTF-8 BOM 剥离。

关键 API:

  • graphics::loadCharacterModel(path, model, error) — 读文件夹(自动补 model.rig)或文件
  • graphics::applyCharacterModel(rig, model, error) — 应用体型/高度/粗细/开关/皮肤覆盖/配色/美术
  • graphics::characterBodyTemplate(tmpl, headScale, hairScale) — 取模板表
  • CharacterRig::loadShippedModel() — 读 resources/character/deanna/model.rig 并应用
  • CharacterRig::setPalette() / setSkinOverrides() — 逐项入口

预览器:Reverseworks-CharacterPreview(tools/character/character_model_preview.cpp)加载任意模型文件夹,渲染「四朝向 × 多状态」接触表到 PPM(tools/ppm_to_png.py 转 PNG)。

自检:tests/CharacterModelChecks.cpp(ctest 名 Reverseworks.CharacterModel)钉住模板数值、解析器严格性、皮肤覆盖落位、以及「出厂模型 == 出厂角色」。

详细说明:src/graphics/CharacterRig.README.md 的「The character model format」

Deanna 美术管线(tools/character/):

  • generate_deanna.py — 程序化底图(布局基准,也是缺图时的回退)
  • make_deanna.py render — 本地 ComfyUI(Z-Image Turbo)出原始图到 tmp_frames/deanna_raw
  • make_deanna.py bake — 把原始图烘成 128×128 cell(保留底图布局,套用 ACG 上色/描边)
  • check_alpha.py — 门禁:尺寸、居中、朝向色差、深色像素、描边环、外加 manifest.json 哈希比对
  • shoot_deanna.py — 四朝向实渲染,落到 evidence/deanna-art-review/
  • 详细说明与 cell 契约见 tools/character/README.md

详细说明:src/graphics/CharacterRig.README.md

3.2.3 CharacterFacing(src/game/CharacterFacing.h)

"朝哪边看"的唯一权威。三个问题在这里一起回答:

  1. 绘制翻转:drawFlip() / mirrored() — 美术是立姿正脸图,不是旋转体。kCharacterArtFacesPositiveX 声明了默认朝向。
  2. 交互可达:forward()、interactTileOffset()、interactPoint() — 面向的 tile,fallback 到脚下 tile。
  3. 从移动推断朝向:fromMovement() / observeMovement() — 移动方向太小则保持已有朝向。

3.2.4 碰撞几何

Character::collisionBounds() 返回的矩形 = position + collisionCenterOffset。碰撞盒锚定脚底参考线 [-0.39, -0.27](距脚底 0.06 的浅盒)。不要自己构造碰撞矩形。

World::resolveMovement() 把大 delta 分成 bounded substep,每步应用 X-then-Y solver,防止低帧率时跳过细栅栏或穿墙。

碰撞盒 ≠ 深度排序键。 角色的锚点在脚底上方 graphics::kSolesBelowCentre = 0.33 格, 碰撞盒与画出来的脚底都落在锚点下方那条线上。渲染端的深度键必须用接地线 (src/game/DepthSort.h 的 characterContactLine()),不能用锚点: 用锚点的话,角色贴到建筑南墙时锚点已越过墙线,立面会盖住角色 97% 的身体, 看上去就是「人物穿进房子」。详见 docs/building-south-face-offset.md。

src/game/DepthSort.h 是深度排序规则的唯一出处(渲染器与探针共用):

  • depthSortKey(worldY, layer) — 键大者先画(在北、在后),键小者后画(在前)。
  • characterContactLine(anchorY) — 人形角色的接地线。

3.2.6 战斗动作框架(src/game/combat/)

完整设计标准见 docs/combat-system-design.md;本节只讲代码怎么组织。

模块划分

文件职责依赖
ActionTypes.hAttackPhase ActionCategory HitShape AimAssist、60fps 帧栅格常量无
ActionCatalog.hActionDef 结构 + 静态动作表 + validateActionCatalog()→ graphics/CharacterRig.h(只为 animState)
CombatGeometry.h命中体判定(扇形/圆环/矩形)、软锁定、部位映射、护甲公式→ ActionCatalog.h、EventSystem.h
CombatAction.{h,cpp}三段相位机、连段窗口、蓄力档位、命中台账→ ActionCatalog.h、PlayerState.h

分层原则:combat/ 只依赖数据与纯函数,不碰 Level / Player / 渲染。 所以整套框架可以 headless 断言——tests/CombatActionChecks.cpp 就是这么写的。

三段式动作

每个动作固定跑 Startup → Active → Recovery:

  • 判定只在 Active 段生效,且 activeWindowOpen() 是一次性标志: 它在窗口打开的那一帧置位,由调用方消费后清除。挥击弧、方块伤害、音效、 屏震都挂在这个标志上;身体的判定则是每帧检查(Active 段全程有效), 但每个目标每次挥砍只结算一次——台账在 CombatActionController 里。
  • 帧数以 60fps 为栅格,运行时按秒推进(kActionFrameRate 只用于换算), 所以 30Hz 与 144Hz 下动作的墙钟长度一致。
  • 帧号由 frameOf(seconds) 换算,带一个千分之一帧的 epsilon 吸收浮点 累积误差——没有它,60Hz 下第 6 帧会算成 5.9999,窗口就晚一帧开。

clip 与帧表对齐

挥击 clip 由帧表生成,不手工摆关键帧:CharacterClips.cpp 的 buildSwingClip() 把关键帧放在动作自己的帧栅格上,接触姿态落在 Active 段的第一帧。swingTimingFor(state) 是 graphics 层的一份帧表副本, CombatActionChecks 的 testClipTimingsMatchTheTable 把两边钉住。

这是方案稿 §12「帧数据与动画 clip 脱节」那条风险的解法:改了表,clip 跟着 改;手搓关键帧一改数值就脱节,而脱节是最伤手感的 bug。

命中反馈三件套(方案稿 S2)

反馈落点
hitstopMotionState.hitstopTimer;GameState::update 在它 > 0 时跳过世界与角色的推进,相机与特效照常
屏震既有 Camera2D::addTrauma,数值取自 ActionDef.trauma
闪白CharacterRig::setImpactFlash() + impactFlash_,WorldRenderer 把它乘进 body tint

会话侧的命中管线

GameState::resolveCombatTargets() 按方案稿 §6.1 走:

武器伤害 × 招式系数 × 部位倍率 → 护甲减免 → 扣血 → 霸体 → 击退 → 反馈三件套

目标来自 GameState::setCombatTargets()。本作目前没有敌人(方案稿 §0.4 把敌人招式表排除在外),所以目标表暂时是空的——管线已经接好,敌人接入即可, 不需要第二条代码路径。

3.2.7 P2:动作类与远程(src/game/combat/ + 会话侧)

方案稿见 docs/combat-system-design.md §11 的 P2 段。本节讲代码怎么组织, 以及几条不写下来就会被改错的规则。

新增模块

文件职责
src/game/combat/GuardController.{h,cpp}格挡数据表(GuardDef)与状态机:举盾时刻、韧性池、完美窗口判定
src/game/combat/SkillLoadout.{h,cpp}6 格技能栏(SkillSlot)与按动作 id 计时的 CooldownTracker
src/game/playing/PlayingCombatStrip.{h,cpp}技能条 HUD(键位、技能名、冷却遮罩)+ 体力条;PlayingHud.cpp 只加两行调用

ActionDef 新增字段:displacement(位移格数)、invulnerableFrames(无敌帧)、 skillTier(Basic/Core/Ultimate)、ammoCost、lifetimeSeconds、 landingRadius、landingSeconds。

四条容易改错的规则

  1. 一 rig 状态 = 一帧表。 P2 新增的 9 个状态 (Dodge/Dash/Guard/Cast/Shoot/ShootBurst/ShootSpread/Aim/AimFull)各有自己的 帧表,clip 由帧表生成(CharacterClips.cpp 的 buildSwingClip())。 swingTimingFor(state) 是 graphics 层的一份副本, tests/CombatP2Checks.cpp 的 testTheNewClipsMatchTheTable 把两边钉住。 > 共享状态的档位必须对齐帧数;不共享的档位(如蓄力狙击的两档 > ranged_sniper / ranged_sniper_full)必须各有各的状态, > 否则判定帧和画面帧会错开。
  1. 无敌窗口有 2 帧的前置 grace。 HumanoidCharacter::invulnerable() 的免疫 从 Active 段第 3 帧才开始(kInvulnerabilityGraceFrames = 2), 长度由表里的 invulnerableFrames 给。Active 段头两帧仍然吃伤害—— 这是方案稿 A7 的原文「i-frames 起始前 2 帧仍算命中」。 > 写成「Active 段开始即免疫」会让翻滚吞掉玩家本可以躲开的攻击。
  1. 完美格挡的反应时间有参照系。 GuardController::evaluate(attackerActiveAgeSeconds, toAttacker, facing): 反应时间 = 攻击方 Active 窗口开启时刻 − 玩家举盾时刻。
    • 负值 → 早就举了盾 → 普通格挡
    • 0 .. 4 帧 → 完美格挡(反弹 50% + 攻击者硬直 + 玩家一次免费反击)
    • > 4 帧 → 普通格挡(60% 减免,每挡一击扣 0.25 体力)

格挡只挡正面 120°;背后来的攻击一定吃满。

  1. 技能栏是 6 格固定热键:键 4 = 基础(轻击)、5–8 = 4 核心、9 = 终极。 技能键不进重绑定表——重绑定把键挪走,玩家就记不住第几格是什么了。

第五、六条(E1 武器层,评审后补)

  1. 一个模型 tile 不是世界 tile。 武器按角色身高比例绘制,它的锚点 (muzzleOffset / ejectPortOffset / supportOffset / 座位偏移 / 反冲) 全都在模型 tile 里,跨进世界必须过 WeaponPresentation::worldPerModelTile(modelId, figureHeight) (= weaponRigUnitsPerModelTile × kRigUnitTiles × kRigVisualScale)。 > 把模型偏移直接加到世界坐标上,效果会落在画出来的枪之外——抛壳口就是 > 这么错了一整个数量级的(一次模型 tile ≈ 0.075 世界 tile)。 > > 开火一侧不要再自己拼换算:GameState::weaponShotFrame() 一次给出 > 座位、带后坐的座位、画出来的枪口和枪管角,远程分支只调它一次并把 > shot.grip / shot.muzzle 交给 CombatFx。新增特效请从这一帧取点。
  1. 连段与蓄力都属于「开它的那把武器」。
    • 连段窗口跨武器存活会让下一把武器继承上一把的下一段 (枪挥出刀的第二段,按枪的伤害/命中/噪音/耐久扣)—— 换手时必须 CombatActionController::closeChainWindow(),只清后续段, 在飞的挥击不受影响。
    • 右键蓄力对武器自己的 heavy_action_id,以及射击动作自带 chargeGroup 的远程武器都生效(栓动步枪的 ranged_sniper → ranged_sniper_full,×2.2)。蓄力期间的 rig 状态取 chargeTier()->animState,不是写死的 AttackHeavy。

冷却从技能发动瞬间计时,冷却中的技能不排队(按了就没了)。

远程

  • 弹药是背包物品(use_kind=ammunition),开火瞬间从背包扣 ammoCost 发;被打断的开火不扣。
  • 弹尽自动降级为 melee_light_1(枪当棍子,伤害仍是武器的 combatDamage), 不响空枪。
  • 瞄准辅助三档(WeaponDef.aimAssist → combat::AimAssist): 无 / 12° 偏 50% / 6° 偏 100%,只作用于远程。 > 近程走软锁定(60° 锥),两套瞄准规则绝不叠加。

输入

键动作触发方式
C闪避边沿
V格挡保持(是姿态不是动作)
4–9技能栏边沿

闪避只能从后摇第 2 帧起取消正在跑的动作;无输入时向后滚。

3.2.8 P3:敌人与构筑(src/game/combat/ + 物品/存档侧)

方案稿见 docs/combat-system-design.md §11 的 P3 段。本节讲代码怎么组织, 以及几条不写下来就会被改错的规则。

新增模块

文件职责
src/game/combat/EnemyCatalog.{h,cpp}霸体分级表(PoiseTierDef)、EnemyAttackDef、EnemyDef 名册、启动期校验 validateEnemyCatalog()
src/game/combat/EnemyCombatant.{h,cpp}敌人本体:身体(CombatTarget body_)+ 大脑(CombatActionController action_)+ EnemyStrike
src/game/combat/AffixCatalog.{h,cpp}12 种 AffixStat、13 条词条表、xorshift32 确定性抽取 rollAffixes(itemId, seed, count)
src/game/CombatStats.{h,cpp}构筑汇总:把身上装备的词条加总成一份「构筑」,管线只读它

五条容易改错的规则

  1. 敌人的攻击不另起一张表。 EnemyAttackDef::actionId 指向 ActionCatalog 里的共享行(enemy_claw / enemy_slam / enemy_spit), 敌人跑的是同一个 CombatActionController、同一套帧栅格。 > 另起一张 EnemyActionDef 表就等于另起一套状态机——方案稿 §6.6/§8.1 > 明确要求「玩家和敌人的差异只在数据,不在代码」。
  1. 本轮敌人不出现在世界里。 GameState::spawnEnemy() 放进会话, 会话每帧驱动它(updateEnemies),它的身体就是 CombatTarget, 玩家的挥击打的就是它。它不与世界碰撞、不渲染、不掉落。 > 身体与大脑是真的,缺的只是世界那一层(刷怪表 / 掉落表 / 遭遇循环)。 > 做「世界里的敌人」之前不要顺手把碰撞和渲染加进来——那是另一批。 > (P1 的 AI 层起:它看得见墙、会绕路走,因为会话把 Level 交给了它; > 但身体碰撞仍然没有,那是 AI.3。)
  1. 霸体分级不是更大的血条。 PoiseTierDef 三项一起动: 霸体池更大(common 10 / elite 22 / boss 45)、霸体伤害缩放更低 (1.0 / 0.6 / 0.35)、伤害倍率更高(1.0 / 1.35 / 1.8)。 > 只加血条的精英是沙包,不是威胁。缩放系数在 > applyHitToTarget(target, damage, poiseDamage, poiseDamageScale) 里。
  1. 元素进管线,但护甲只管物理。 命中携带 DamageType (Physical/Fire/Cold/Poison),目标持有 DamageAffinity: 抗性是倍率、免疫是归零。护甲走 armorReduction(), 只作用于物理通道。 > 两个公式作用在同一个数上会互相抵消。拆成两个通道后, > 「防火的护甲」和「抗火的体质」才是两件可分别构筑的事。
  1. 词条属于实例,不属于物品定义。 ItemDefinition::affixCount 给的是 「一个实例抽几条」,抽出来的结果存在 InventoryEntry::affixes / ElementStack::affixes / EquipmentLoadout 的每槽词条上。
    • 抽取必须确定性:rollAffixes(itemId, seed, count) 用 xorshift32, 种子来自 affixSeedFor(itemId, counter)。存档读回来要是同一把斧子。
    • 词条不同绝不合并:ElementStack 只合并词条一致的实例, 合并会把其中一把扔掉。
    • 存档格式 7→8:物品行写成 item0=<id>,<qty>|<affixId>:<value>;..., 装备行同样在 | 后带词条。旧档没有 |,读出来就是没有词条—— 这就是迁移,不需要额外的迁移函数。 > 存档版本号与迁移不能省(§12 的硬要求)。旧档必须能读出来。

构筑汇总

CombatStats 是唯一的「玩家现在有多强」:

字段来源词条接到哪里
flatDamageaffix_sharp / affix_heavy命中管线的平伤加成
armorBonusaffix_sturdy命中管线的护甲值
critChanceaffix_keen命中管线(GameState::rollCrit(),确定性流)
staminaRegenBonusaffix_swift会话每帧给体力池回一点
cooldownReductionaffix_hastyCooldownTracker::setDurationScale()(冷却条同步缩放,不然条和钟会打架)
maxHealthBonusaffix_vital生命池(按差值调整,换装才动,不会每帧叠加)
elementalShare[]affix_fiery / affix_frost / affix_venom命中管线:元素的份额骑在每一次命中上,各自独立读目标的亲和
resistance[]affix_fireskin / affix_iceskin / affix_antitoxin玩家受击侧 buildStats_.affinity()

元素的份额而不是「+X% 火焰伤害只在火焰武器上生效」:没有火焰武器的 版本里,后者就是死词条。份额制让火/冰/毒三条线立刻可用。

处决

skill_execute 的 damageScale = 0:伤害不是动作给的,是会话结算的。 GameState::resolveExecution() 找一个「已经残血(≤25% 血量)且在扇形里」的目标, 直接把它结束。用动作的命中台账(markTargetHit)保证一次处决只结束一个目标。

3.2.9 P1:通用 AI 框架(src/game/ai/)

方案稿见 docs/ai-generic-design.md(AI.1–AI.3 已落地,AI.4–AI.5 未开始)。 这一层把「心智」从「敌人」里拆出来:特征是数据,寻路是纯函数,感官与状态机不认识 任何具体敌人。

新增模块

文件职责
src/game/ai/AiTraits.{h,cpp}特征表(11 列):data/Eraphanis/ai/traits.tsv → AiTraits,解析 + 启动期校验 validateAiCatalog()
src/game/ai/AiWorld.{h,cpp}世界接口(能不能站/挡不挡视线/位置属于哪格/格子中心)与 LevelWorld
src/game/ai/Pathfinder.{h,cpp}八邻域 A\*(不切角、八分距、节点预算)、路径拉紧、hasLineOfSight/hasClearWalkLine/steerToward、PathAgent
src/game/ai/AiSenses.{h,cpp}视/听/最后已知位置的记忆
src/game/ai/AiBrain.{h,cpp}通用状态机(10 态)+ 能力选择 + 寻路跟随
src/game/ai/AiSeparation.{h,cpp}身体分离(纯函数):软分离、按权重分推力、单帧最多 0.25 格、重合按 pair 定轴
src/game/ai/AiDrops.{h,cpp}掉落表(data/Eraphanis/ai/drops.tsv):解析 + 校验 + 确定性掷点 rollAiDrop()
data/Eraphanis/ai/traits.tsv每个心智一行性情,与身体表用同一个 id 连接
data/Eraphanis/ai/drops.tsv每个敌人一行掉落,与身体表用同一个 id 连接

七条容易改错的规则

  1. 依赖是单向的:ai/ 不依赖 combat/。 src/game/ai/ 里不得出现 EnemyDef/EnemyAttackDef/CombatActionController, 也不得直接调 Level 的方法——世界只有 AiWorld 那四个问题。 > 反向依赖会成环,将来「玩家/敌人/陷阱共用一张能力表」就做不成。
  1. 特征表没有 C++ 兜底。 traits.tsv 是唯一的性情来源;缺文件、坏行、敌人少一行 traits,都在启动期报错 (Application::validateAiContent())。 > EnemyDef::aggroRange/leashRange 已经迁出(数值未变)。别再加回去——那会变成双真值。
  1. 视线与走线是两个函数。 hasLineOfSight 问「看得见吗」(遇挡视线的实体才断),hasClearWalkLine 问 「直着走得到吗」(遇不能站的才断)。水能看见、走不过去。 > 合成一个就是「AI 穿墙」和「AI 隔墙打人」两种 bug 的共同来源。
  1. 冷却的真值在身体里。 大脑通过 AiAbility::ready 问「现在能不能用」,自己不记时间。 > 一个能力在两处记冷却,就会出现「冷却好了却打不出」或「冷却没好却打了」。
  1. 分离是软的,墙优先(AI.3)。 ai/AiSeparation 只回答「该在哪儿」,不产生碰撞:身体被推开而不是变成实体,推力按 pushWeight 分(玩家 4、敌人 1),单帧最多推 0.25 格(对帧初位置量,两轮迭代不叠成 两倍)。会话拿到答案后必须再问世界能不能站——站不住就不推。 > 墙比分离重要:把敌人推进墙里比让它挤着更糟。
  1. 听觉范围取更远的那个(AI.3)。 AiSenses::hear 的有效范围是 max(hearing_range, 噪音自带半径),不是相加。 > 相加会让一次枪声传出 20 多格。听见噪声只是进 Investigate(去看),不是 Attack(仇恨)。
  1. 掉落表每敌人一行,掉落不进存档(AI.3)。 data/Eraphanis/ai/drops.tsv 少一行启动就报错(Application::validateAiContent() 反查 名册与物品表)。掉落是会话内容(与世界拾取物同规矩)。掷点用 xorshift32+FNV,同种子必同 结果,不要引入时钟种子。 > 新增敌人时记得加这一行,否则启动就报「该敌人没有掉落行」。

3.3 生存系统

3.3.1 LifeState(src/game/LifeState.h)

8 个身体部位:Head、Neck、Chest、Abdomen、LeftArm、RightArm、LeftLeg、RightLeg。

  • 每部位默认 25 HP、37°C
  • 生命状态:Alive / NearDeath / Dead
  • 状态效果:Poison、Burn、Frostbite、Infection、Fever、Trauma
  • 关键方法:damage(part, amt, events)、applyAmbientThermalExchange(env, events)、movementEfficiency()、leftArmEfficiency() / rightArmEfficiency()

3.3.2 DiseaseSystem / DiseaseCatalog(src/game/DiseaseSystem.h / DiseaseCatalog.h)

10 种疾病:Hypothermia、HeatStroke、Dehydration、CommonCold、Influenza、Infection、Pneumonia、FoodPoisoning、Bronchitis、Tuberculosis。

  • contract(kind, origin, severity, events) 创建实例
  • update(deltaSeconds, life, events) 推进阶段、tick 效果、解析、传播
  • DiseaseCatalog::diseaseDefinition(kind) 返回定义(严重度、传播、病程、时长)

3.3.3 生存风险

Player::updateSurvivalRisks() 累积 5 类风险:cold、heat、dehydration、wetCold、frostbite。由环境(温度、湿度、风速、降水)+ 装备保温 + 角色移动(跑步额外消耗)决定。

体温通过 LifeState::applyAmbientThermalExchange() 与环境交换;装备 insulation 按身体部位生效。营火休息(InteractionEffect::CampfireRest)通过热交换恢复,不是简单加血。

3.4 元素/组件系统

3.4.1 Element(src/game/Element.h)

数据驱动的世界实体基类。核心思想:组件即能力。

9 种组件:

组件作用
CollisionBox碰撞半尺寸 + centerOffset(碰撞盒中心相对于元素 position 的偏移)
Ground地面属性
Placeableplaceable + blocksMovement 标志
InteractabledialogueId、enabled、oneShot、interactionEffect
LifeState元素自己的生命/疾病(树、作物可被伤害)
HeatSource基础温度 + 半径(营火:50°C, 5 tiles)
Canopy树冠半径,用于生成间距
Species树种:Great、Pine、Maple、Aspen
Flora地表植物类型

内置元素(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(路标)

碰撞盒与视觉对齐:

  • 树的碰撞盒按树干实际宽度,不是整格;树冠纯视觉
  • CollisionBoxComponent 有独立窄尺寸参数 + centerOffset,不复用视觉 halfExtents
  • aspen 碰撞盒更窄(细长单 quad)
  • flora_* 和 street_* 都是 placeable(false),可穿过
  • shrine 和 campfire 不阻挡移动

3.4.2 ElementRegistry / ElementVisualRegistry

  • ElementRegistry:元素定义工厂层;find(typeId) 返回定义,create(typeId, pos) 创建实例,bind(EventBus&) 注册 narrative signal handler
  • ElementVisualRegistry:视觉帧/程序化绘制绑定;worldBounds(element, visual) 是唯一世界空间矩形入口

新增元素的步骤:

  1. 在 ElementRegistry 注册 ElementDefinition(含组件列表)
  2. 在 ElementVisualRegistry 绑定视觉(帧 index 或程序化绘制)
  3. 在世界生成/城镇布置处放置(WorldFeatures、Ronesville)
  4. 跑 Reverseworks.Registry、Reverseworks.Vegetation、Reverseworks.EntityVisual

3.5 物品、背包、装备

3.5.1 Inventory 基类(src/game/Inventory.h)

玩家背包和装备口袋的公共基类。

  • 15 基础格,装备/属性可加槽
  • 全局堆叠上限 kMaxStackPerSlot = 172
  • 关键方法:add(itemId, qty)、remove(itemId, qty)、count(itemId)、snapshot() / restore()
  • 容量缩小时占用中的 overflow slot 仍保留访问,防止物品静默丢失

3.5.2 EquipmentLoadout(src/game/EquipmentLoadout.h)

6 个装备槽:Headwear、Top、Pants、Boots、Armor、Backpack。

  • 关键方法:equip(itemId) / unequip(slot)、insulationForParts()(按身体部位返回保温值)、averageInsulation()、inventorySlotBonus()(装备提供的额外背包格数)
  • configureInventoryForSlot(ItemDefinition) 自动创建背包/口袋容器

3.5.3 快捷栏

独立于背包,不占容量。

  • 3 格;1/2/3 选择,Q 循环到下一个非空格,R 使用
  • Shift+左键 整叠在背包/快捷栏间快移
  • Ctrl+左键 原意为快移到打开容器,但 GameState::openContainer() 恒为 nullptr,目前是空操作

3.5.4 items.tsv / starters.tsv

位置:data/Eraphanis/items/。

items.tsv 表头:

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
  • 18 列表;insulation / armor_coverage 是逗号分隔 bodyPart:value 映射
  • 方块物品(use_kind=block)在第 17 列 blockTileId,必须能解析到 TileType
  • 已知陷阱:ItemCategory 没有 material,写 category=material 会静默回落到 Headwear;方块物品靠 use_kind=Block 决定行为
  • 武器物品(E1.2):category=weapon、use_kind=weapon、stack_limit=1,id 必须与 data/Eraphanis/weapons/weapons.tsv 的行 id 相同,否则整行在目录加载时被丢弃

starters.tsv:新存档初始装备授予。格式 type(equip|inventory) slot_or_item item_id quantity。

当前 39 行物品,11 条 starter 授予(含 equip mainhand w01_colt_saa 1)。

3.6 事件系统

3.6.1 GameEventType / GameEventCategory(src/game/EventSystem.h)

事件类型覆盖:life、disease、world clock、weather、temperature、inventory、equipment、item use、rest、interactables、dialogue、narrative flags/signals、entity lifecycle、element state、tile/block changes、UI open/close/selection、save/load、session pause/resume。

Category 按大类分组订阅: Life、Disease、World、Environment、Inventory、Equipment、Interaction、Dialogue、Narrative、Entity、Tile、Block、Ui、Persistence、Session。

3.6.2 GameEvent

双轨设计:固定 legacy 字段 + 新扩展 metadata。

legacy 字段(保持旧初始化器兼容):type、part、condition、status、diseaseId、oldValue、newValue、stage、sourceId、dialogueId、flagId、flagValue、narrativeSignalId、narrativeSignalPayload、worldPositionX/Y、interactionEffect、target。

新扩展:sequence(发布时分配)、category(分发前解析)、name(可选稳定名)、actorId、targetId、itemId、slotId、x/y(tile 坐标)、payload(typed key-value)。

Builder helpers(新代码推荐):named(type, name)、withActor(id)、withTarget(id)、withTile(x,y)、withValue(key, value)。

3.6.3 EventBus

关键接口:

  • subscribe(handler) / subscribe(type, handler) / subscribe(category, handler)
  • subscribeOnce(...) — 首次匹配后自动移除
  • unsubscribe(token) / hasSubscriber(...)
  • publish(event) — 同步分发,顺序 = 订阅顺序
  • queue(event) / flushQueued() — 延迟分发
  • setHistoryLimit(n) / history() — 诊断历史

不跨线程设计。handler 在 dispatch 中增删订阅是安全的。

3.7 对话系统(CDLG)

3.7.1 架构

data/Eraphanis/world/_template/dialogue/*.cdlg     ← shipped 模板(每个存档首次复制)
  ↓ 首次加载复制
%LOCALAPPDATA%/Eraphanis/world/<saveId>/dialogue/   ← 存档本地覆盖
  ↓
libs/cdlg/ 解析 + 会话运行时
  ↓
src/game/DialogueSystem  (游戏层绑定:EventBus + 交互触发)
  ↓
src/game/DialogueView / NarrationBox  (UI 绘制)

存档本地目录优先,模板兜底。

3.7.2 DialogueSystem(src/game/DialogueSystem.h)

  • bind(EventBus&) 注册事件监听
  • loadFromDirectory(path) 加载 .cdlg 包
  • update(input, delta) — 推进对话、处理选择输入
  • handleInteractableTriggered(elementId) — 被 Interactable 组件触发
  • isActive() — 对话正在进行
  • chooseIndex(index) — 程序调用(PV 交接等)

3.7.3 DialogueView / NarrationBox(src/game/DialogueView.h)

  • 底部横带:speaker 名 + line + answers
  • plate 用主题最深色 + 顶部细线 accent(不是 acrylic panel,因为要覆盖任何背景)
  • answers 居中在 line 下方;选中项带 accent caret
  • 键盘 + 鼠标都能选;对话时 HUD 隐藏 hotbar
  • 字体来自 内容驱动烘焙(SdfFont::loadForCodepoints),先解析对话/PV 文本再烘字体

3.8 PV / 剧情时间线

3.8.1 架构

PV(Presentation Video)是独立于 CDLG 的时间线格式。CDLG 是选择图,PV 是时间序列。两者在 dialogue <cdlg_id> 指令处交接。

data/Eraphanis/world/_template/pv/*.pv     ← shipped 模板
  ↓
libs/pv/ 解析 + 时间线运行时(纯时间线,不知道相机/渲染)
  ↓
src/game/PvSystem  (绑定层:决定每个事件的游戏语义)
  ↓
DemoScene::Mode::PlayingPv  (渲染/帧循环接管)

3.8.2 PvSystem(src/game/PvSystem.h)

关键规则:

  • PV 运行时世界冻结(GameState::update 不被调用)
  • PV 拥有相机;Camera2D 有独立 beginScriptedMove(duration) 用于持续时间驱动
  • PV 可以设置世界时钟(TimeSet 事件),因为光照从时钟推导
  • PV 字幕卡用主题色 plate(与对话共用)
  • Esc 跳过 PV;finishDialogue() / skipDialogue() 从对话返回

事件映射:

PV 事件游戏语义
Shot脚本化相机移动(位置、缩放、持续时间)
FollowPlayer归还相机给 gameplay follow
TimeSet设置世界时钟(光照跟随)
WeatherSet重写环境系统快照
Letterbox / Fade电影黑边 / 黑色 overlay 动画
CardShown / CardAdvanced升起 / 清除叙事卡
DialogueRequested交接给 .cdlg 对话并等待
FlagChanged设置 / 清除时间线 flag

3.8.3 Opening Prologue

data/Eraphanis/world/_template/pv/opening_prologue.pv:

  • 仅新存档播放(SaveGameData::playedScenes 记录已播放场景)
  • 冷色雾场 + 缓慢尘粒 + 电影黑边 + 淡入淡出字幕 + 白光揭示;抽象背景由 PvSystem 绘制,全黑时不渲染世界
  • 背景用的是专用着色器 cinematic_backdrop(libs/uniscope-2d/shaders/),不是 menu_background:后者带透视网格与扫描线,读起来像把主菜单摆到旁白后面
  • 三个关键指令:caption <seconds>(独立计时的标题卡)、wait_music(等待故事音乐结束)、whiteout <seconds>(从中心向外的光)
  • PvSystem::start() 返回前以 0 秒步长应用首拍事件;新存档的 LoadingReveal 只淡到黑便交接,避免首帧或开场前闪现世界

3.9 存档系统

3.9.1 格式

SaveGameData::kFormatVersion == 10(当前;v10 = 怀表校准槽,v9 = 武器实例状态,v8 = 每物品实例的词缀);kStreamingFormatVersion == 5。

INI 风格文本:<用户数据根>/archive/<id>.save。

用户数据根由 StdPathProvider::initialize()(libs/uniscope-2d/src/pal/common/StdPathProvider.cpp)决定,不是写死的 %LOCALAPPDATA%:exe 所在目录能做写探测时就用它(便携/默认 per-user 安装都满足),否则回退 %LOCALAPPDATA%/<AppName>(旧名 Reverseworks)。archive/、world/、settings.cfg、cache/ 全在这一个根下,world/ 是 archive/ 的兄弟目录。首次用便携根时 adoptUserData() 会把 per-user 旧目录(含改名前的 Reverseworks)搬过来一次:已存在的存档不覆盖,settings.cfg 例外(安装器投递的默认值会被玩家真正的那份替换),cache/ 不搬。所以"存档在哪"要以进程实际选中的根为准,别按平台惯例推断。

仓库根的 settings.cfg 只是默认模板:Reverseworks_deploy_runtime 把它部署到 bin/,游戏运行后那份会被玩家版本替换,而后续构建因为 stamp 记录的是源文件 mtime/size 不会回写——所以 bin/settings.cfg 与仓库那份不同是正常现象,不是漂移。

主要 section:

Section内容
[meta]version、displayName、savedAtTicks、起始季节/天气、自动存档、consoleEnabled
[world]gameSecondsOfDay、dayIndex、worldSeed、worldTemplate
[environment]天气、季节、温度、湿度、云量、降水、风、能见度、地表湿度、太阳辐照、阶段
[player]placed(= hasPlayerPosition 的 INI 名)、x、y
[life]生命状态、8 身体部位生命/温度、状态效果
[diseases]疾病数量与每条
[equipment]6 槽物品 id;每个非空装备槽的独立背包快照
[inventory.*]玩家背包快照
[hotbar]选中索引 + 每个非空槽的快照
[survival]5 类风险值
[objectives]doneN=<objective id>
[scenes]sceneN=<pv id>(已播放)
[terrain]chunkCount + 每个 chunk/tile/layer 的方块编辑(v7 新增流式地形;v8 新增物品实例词缀)

存档不变量(违反会导致静默错误):

  1. WorldSpec 必须随存档走;读档时如果 spec 不匹配,放弃加载回主菜单
  2. 在室内保存 → 保存室外门口坐标,不是室内坐标(室内运行时生成不持久化)
  3. hasPlayerPosition([player] placed)才是坐标有效性判断;不要用 (0,0) 推断
  4. completedObjectives / playedScenes 中未知 id 读档时丢弃
  5. 存档读档时 fileVersion 保留原值用于迁移判断;data.version 被提升到当前版本

3.9.2 SaveRepository(src/game/SaveSystem.h)

关键接口:

  • list() — 返回缓存 summary,最新优先
  • createNew(settings) — 创建新存档对象(还未落盘)
  • load(id, out) — 读存档;处理版本迁移
  • save(data) — 写存档;先 resolve worldSpec 再写
  • remove(id) / rename(id, name) / duplicate(id, out) — 存档管理
  • setConsoleEnabled(id, enabled) — 每个存档独立的开发者控制台开关

版本迁移历史:

  • v8 增加逐实例装备词条(物品行与装备行在 | 后带 affixId:value;...; 旧档没有 |,读出来就是没有词条)
  • v7 增加流式地形方块编辑
  • v6 增加快捷栏
  • v5 增加 worldSeed 和 worldTemplate(之前的存档固定地图已不存在,读档落到默认模板新世界)

3.10 光照、阴影与渲染

3.10.1 LightingSystem(src/game/LightingSystem.h)

负责光照计算与缓存:

  • 点光源管理(营火 HeatSourceComponent 同步为点光)
  • 环境光/方向光计算
  • 可见 tile 缓存(帧级)
  • 环境颜色 + 方向光方向 + 强度

3.10.2 建筑阴影(BuildingShadowVolume.h/.cpp)

建筑阴影几何:

  • 阳光 pass:投影多边形 silhouette + 增量渲染(caster set/frame/buffer 未变时只 append 新 flush)
  • 接触阴影:硬边 boot print 放在阳光阴影之后、blur 之前;characterRig::contactFrame 命名哪个脚
  • 玩家阴影跟随落地那只脚(CharacterRig::groundContactOffset() 从 rig 空间计算,不手动拼)

3.10.3 世界渲染管线

入口:src/game/WorldRenderer.cpp。

渲染流程(每帧):

Renderer2D::beginFrame()
 ├─ 天空 / 背景
 ├─ 世界 pass(tiles + elements + buildings)
 │   ├─ 逐 tile MaterialParams2D 设置
 │   ├─ 地面细节 pass(MaterialKind2D::Foliage/Mineral,FlatSurface flag)
 │   └─ 建筑材质 per-part 响应(BuildingSurface + mask + BuildingMaterialSystem)
 ├─ 角色 rig pass
 ├─ 接触阴影(硬边)
 ├─ HUD pass
 └─ 后处理 / 天空光
Renderer2D::endFrame()

地面细节的硬性契约:

  • 必须锚定世界坐标(shader 的 u_detail_origin uniform),否则相机移动时细节会"爬"
  • Reverseworks.GroundFlicker 测试:把同一世界从相距 1 像素的两个相机位置各渲染一次,比较残差;失败说明细节层又失去世界锚定
  • 该测试 2026-11-08 已恢复全绿:锚点由 WorldRenderer::render 设置(世界批锚世界原点、角色批锚角色自身原点)

3.10.4 程序化建筑材质(ProceduralBuildings.h + BuildingMaterialSystem.h)

建筑 painter 在 albedo 旁边烘一个 material mask(R=part、G=gloss、B=normal lean),shader 端读取后:

  • 4 种屋顶面(近坡/远坡/东西 hip)各自独立 sun path 响应
  • 玻璃有 lamp 亮度(夜晚室内灯光按玻璃 luma 而非天空光)
  • 雨水响应 per-part(masonry 吸水、slate 流走、glass 珠化、awning 湿透、snow 不吸收)
  • DaylightFactor 来自 SunPath::dayLightFactor(不是原始 sun elevation——午夜和日出 elevation 都为 0,但前者是全黑、后者是黎明)

3.10.5 字体烘焙与字体清单

烘什么由内容决定:SDF atlas 2048×2048,SdfFont::loadFromPath(id, path, cjk, texts, report) 只烘给定 texts 里实际出现的字符。 数据源:collectBuiltInTexts()(编译期字符串 + 本地化表全语种文案)+ collectDialogueTexts/PvTexts(当前存档内容)。 烘焙后 bump fontEpoch(),让所有 text measurement cache 失效;SdfFontLoadReport 报告哪些字符没放进 atlas(会渲染为空白)。

一个字体文件 = 一个 face,清单驱动(fonts/fonts.tsv → uniscope::d2::FontRegistry):

概念含义
scriptlatin / chinese / japanese——这份字体服务哪套文字,不是 UI 语言。中文会话里的英文单词仍用中文 face(它的拉丁是贴着汉字画的)
rolebody / emphasis / display / mono / spare。层级槽位而非字重;spare 已注册但没人认领,加一行就能用
order同 (script, role) 内的回退名次。只有主 face 烘全部文案,后面的 face 只烘前面缺的字——这是 bakeFaces() 的适配逻辑
eager1 = 载入时就烘,即使没有链认领它。CJK 字重约 0.7 s + 一张 2048² atlas(≈4 MB),所以「把整棵树都烘上」是逐行花预算,不是默认值
path仓库相对路径:fonts/... 与 resources/... 都按「可执行文件旁 → 向上 6 层 → cwd 及其父目录 → Android APK asset」解析

按脚本烘焙,一次载入全语种:Renderer2D::bakeAllScripts(latin, chinese, japanese) 对三种文字各走一条链; setTextScript() 决定 CJK 走中文还是日文链(Application 用 core::Localization 的语言设置它)。 绘制时 fontForCodepoint() 的选取顺序:当前脚本链(role → body)→ 另一条 CJK 链 → 任意已烘 face → 度量基准 face。 所以「日系字形的思源真黑缺 124 个简体字」不会变成空白——缺的那几个字由链上的思源黑体补,位置不动。

格式硬约束:stb_truetype v1.26 只认 glyf(TrueType)和 CFF (CFF1),不认 CFF2;可变字体只烘默认实例(不解析 fvar/gvar)。 Reverseworks.FontBake 会逐个文件检查表目录,混进 CFF2 或 OTF/CFF2 变量字体就直接失败。

字重陷阱(2026-11-09 实机定位):可变字体烘的是默认实例,而本仓库里非 HW 的思源黑体 VF 在 OS/2 里声明 usWeightClass = 250(ExtraLight),HW 版是 400。 把它当回退字体用时,缺字那 20% 会以极细笔画混在 400 的正文里——看起来像「同一行有两种字体/两个粗细」。修法:回退字体选 400 的构建(本仓库用 SourceHanSansHWSC-VF / SourceHanSansHW-VF),或先用 fonttools instancer 生成静态实例。 顺带一提,字重不一致还会让量宽和绘制对不上(回退字体 advance 偏窄),表现为「中文页签/行值首字被裁」;换成一族同字重后这个现象一起消失。

实测(2026-11-09,本机 MinGW Release):清单 28 个 face(25 glyf + 3 CFF),首次载入烘 4 个 face (en-body / zh-body / zh-body-fallback / ja-body)约 2.3 s(日文 body 换成 35 MB 的 pan-CJK HW 后变慢),中文与日文缺字均为 0; 中文链里 zh-body-fallback 的存在就是被主 face 缺掉的那批字。


4. 音频系统

4.1 后端选择链

OpenALAudioDevice    ← 首选(桌面 + Android 都有,OpenAL-soft + mpg123 + stb_vorbis)
    ↓ 失败
WinmmAudioDevice     ← Windows 显式回退(createFallbackAudioDevice)
    ↓ 失败
NullAudioDevice      ← 终极 fallback(静默,不崩溃)

4.2 架构

src/audio/AudioEngine.h          前端:音效 + 菜单 BGM + 故事音乐 + 音量混合
src/audio/MusicPlayerBridge.h   互斥控制器:菜单音乐 ↔ 故事音乐
src/audio/AudioFactory.h         选择后端;接口在 libs/uniscope-2d/include/uniscope/audio/AudioDevice.h
src/audio/openal/OpenALAudioDevice.*  OpenAL-soft 封装(唯一允许出现 AL 类型的层)
src/audio/MusicStream.h          音乐解码 + 分块调度(mp3 / ogg / wav 一条通道)
src/audio/WavReader.h            WAV 解析:8/16/24/32-bit PCM 与 IEEE float → 16-bit PCM
src/audio/SoundSynth.h           程序化 SFX(合成,不加载文件)
src/audio/BgmCatalog.h           菜单 BGM 列表;src/audio/AudioCueCatalog.h  PV/故事 cue 名 → 实际文件映射

4.3 故事音乐与世界音乐互斥

只有一个音乐通道。故事音乐替换而不是叠加世界音乐:

  • AudioEngine::startStoryMusic(cue) — 停菜单音乐,播放故事音乐(不 loop)
  • AudioEngine::endStoryMusic() — 恢复世界音乐 playlist
  • PV 的 wait_music 等的就是故事音乐(不是世界音乐)

5. UI 系统

5.1 菜单 UI(src/ui/)

基于 libs/complexui 的 CxComponent 组件树。

  • MainMenu — 主菜单 + 档案列表 + 新建/继续/复制/删除/重命名
  • ui/views/ — 把 menu 内容打包成 view spec
  • MenuContext — 贯穿 Application→Scene→UI 的上下文(GameInput + 窗口 + 设置 + 存档 + 渲染器 + 音频)

5.2 游戏内 UI / PUI(src/game/playing/)

GUI 与 PUI 是两套东西,不是一套东西的两个场景。

  • GUI(src/ui/ + libs/complexui):菜单、存档、设置。屏幕上没有世界, 所以它可以是一块不透明的实体漆板。
  • PUI(src/game/playing/):游玩时的一切——HUD、物品栏、控制台。它浮在 会动的世界上,所以它必须比世界更安静:半透明烟灰、没有落影、只有一道顶部 受光棱。把 GUI 的板子直接搬到 PUI 上会读成"贴在屏幕上的招牌",这正是 PUI 单独一套表面的原因。

PUI 的代码全部在 src/game/playing/,命名空间 Eraphanis::game::playing:

文件类型职责
PlayingUI.h/.cppPlayingUI / PlayingUIUpdateResultPUI 的统一入口:HUD + 物品栏 + 控制台
PlayingUILayout.h/.cppPlayingUiLayout / PlayingUiState / PlayRect渲染与命中共用的几何读数(不硬编码坐标)
PlayingHud.h/.cppPlayingHudHUD 绘制(顶栏 / 侧栏生命体征 / 底部快捷栏 / 物品栏 chrome)
PlayingInventoryPanel.h/.cppPlayingInventoryPanel物品栏的 complexui 容器树
PlayingConsolePanel.h/.cppPlayingConsolePanel开发者控制台壳

关键设计:

  • 输入消费优先级:PUI 先于 GameState
  • 打开面板时返回 ConsumedAndResetInput → DemoScene 直接 return → 世界暂停
  • 8 个页签:BAG(实现)、CHAR、LOG、MAP、SKY、TASKS、CLANS、DEEDS(其余显示 NOT IMPLEMENTED YET)
  • 前向声明留在 Eraphanis::game 里:放在 game::playing 里会造出一个同名 空壳,把真正的 GameState / WorldClock 挡住(class game::GameState; 这种 写法也不行——限定名要求类型已经声明过)。

5.3 开发者控制台(src/game/playing/PlayingConsolePanel.h)

  • 每个存档独立开关([meta] consoleEnabled)
  • 桌面反引号 ` `` 开合(不可重绑定,不在 InputAction 表)
  • 壳打开时整帧吞输入,世界暂停;Esc 关壳
  • 当前命令只有 time:
    • time — 报告当前时间
    • time dawn|day|dusk|night|midnight — 锚定到时钟的昼夜关键时刻
    • time HH:MM — 设置具体小时
  • 其他命令回显后输出 UNKNOWN COMMAND
  • 命令派发入口:PlayingConsolePanel::dispatch()

5.4 浮动提示(Tooltip)

复杂 UI 通用机制(libs/complexui/):

  • 每个组件带 setTip("...")
  • 两个独立目标:hover(鼠标)和 focused(键盘);hover 优先
  • 延迟显示:hover 0.40s、focused 0.22s(避免键盘选择时闪烁)
  • 键盘 focus 只有在确实移动过后才开始显示 tooltip

5.5 视觉语言:表面模型(libs/complexui/theme/ + draw/)

拟物化读感不来自贴图,来自一组互相咬合的颜色头。CxSurface 就是这组头:

头作用
body / bodyTop / bodyBottom板身基色与上/下缘(受光 / 沉底)
edgeLight / edgeDark受光棱 / 背光棱(凹槽里两者互换)
outline轮廓发丝线:把实体从背景里切出来
innerShadow凹槽内阴影
sheen顶部柔光
rim焦点 / 选中边
shadow落影

一个主题里带四组表面,GUI 与 PUI 各用各的:

表面用在哪特征
plateGUI 实体板(菜单、面板、按钮)不透明、有落影、有轮廓线
wellGUI 凹槽(输入框、槽位、轨道)上缘暗、下缘亮,读成凿进去的井
hudPlatePUI 安静板(物品栏、控制台)半透明烟灰、无落影、只有顶部一道受光棱
hudWellPUI 凹槽(血条、槽位)同上,反过来用

为什么 PUI 不能用 GUI 的板子:HUD 浮在会动的世界上。一块带落影和重边框的 实心漆板贴在画面上,读成"贴在屏幕上的招牌",而不是"浮在世界里的一层界面"。 所以 PUI 表面刻意去掉落影与四边棱,只留一道顶部受光棱。

绘制原语(draw/CxSurfaceDraw.h):

  • drawCxSurface() — 凸起实体板:落影 → 轮廓 → 板身 → 渐变 → 柔光 → 双棱 → 焦点边
  • drawCxWell() — 凹槽:同一组头反过来用 + 内阴影
  • drawCxHairlineRing() — 发丝边(焦点环)
  • draw/CxPixelRound.h — 像素圆角基元(header-only 内联):圆角半径落在像素网格上

按钮三态(draw/CxPanelDraw.h 的 CxButtonState):Rest(平:只有一道底边发丝线

  • 焦点竖条)、Hover(浮起成实体板 + 焦点边)、Pressed(凹陷成凹槽 + 扩散闪光)。

焦点的主语言是一根竖条,不是一圈发光的边框——竖条在任何背景上都读得清。

5.6 文本:换行、输入框、Action

换行(text/CxTextWrap.h):断行规则认得 CJK——汉字/假名/韩文/全角标点逐字成块, 英文按词成块,空格单独成块。游戏对话是中文,只按空格断行的旧写法会把整段话 挤成一行、冲出面板。HUD 提示与 CxUI 富文本共用这一份实现(wrapTextLines / cachedWrapTextLines),两边不会各断各的。

输入框(components/CxTextField.h):声明式容器树里的普通节点,和按钮一样把 结果交给 Action 系统——输入框自己不决定"提交了之后要干什么",那是宿主的活。

  • 编辑态由宿主掌控(setEditing(true/false))
  • 提交用 submitLine 而不是 confirm:confirm 连空格一起吃,而空格在输入框里 是一个字符,不是"确定"
  • 提交 / 取消走 CxActionKind::SubmitText / CancelText,内容跟着动作走
  • 可嵌进别人的行里(setShowWell(false) + renderIn(rect)),存档改名就是这么用的

Action 系统:CxAction 是 CxUI 唯一的交互出口。组件 postAction(),宿主 consumeAction(),动作里可以带载荷(text、saveIndex)。按钮、页签、复选框、 输入框走的是同一条通道。

5.7 布局:声明式容器与锚点

  • 声明式容器:CxStackContainer(方向 / 间距 / 对齐 / 换行)、CxSlotContainer、 CxGridContainer、CxOverlayContainer、CxScrollContainer。约束写在 CxConstraints 上,引擎照声明排布。
  • 声明式构建 API(containers/CxUi.h):一条链写完一个节点, CxUi::node(...).anchor(...).size(...).grow(...).done()。
  • 锚点系统:CxConstraints::anchor + anchorOffsetX/Y。锚定的孩子不进 flex 流: 既不占 flex 的位置,也不撑父容器的测量尺寸,而是相对父容器内容区按锚点定位 (layout/CxAnchorLayout.cpp 的 anchorChild())。这条契约过去只写在注释里、 引擎没兑现;现在 measureStack / layoutStack 里都实现了它。 CxOverlayContainer 走的是同一套 anchorChild()。

5.8 本地化(src/core/Localization.h + data/Eraphanis/locale/strings.tsv)

一张表,三列语言:strings.tsv 每行一个 key,列头是语言代码 (key<TAB>zh-CN<TAB>en-US<TAB>ja-JP)。zh-CN 是源语言——世界观、对话、物品名都是中文写的, 缺译、空译、找不到 key 时的回退终点都是它。新增语言 = 加一个 Language 枚举项 + 加一列 + languageCode() 补一行,表本身不需要别的改动。 当前 197 个 key × 3 语,覆盖游戏层全部可绘制文案:menu.* options.* settings.*(含取值与主题名) input.* loading.* hud.* inventory.* status.* console.*。LocalizationChecks 断言每行三语齐全、 键唯一、占位符不漂、UTF-8 合法。

格式契约(Localization::loadFromText 已实现,LocalizationChecks 会验):

规则说明
列头必须规范代码zh / en / ja 这类前缀在设置里能解析,在列头里会被拒绝(哪个中文变体不该靠猜)
key 形如 [A-Za-z0-9._-]+命名空间:menu.* options.* settings.* input.* loading.* hud.* inventory.* status.*
空白不做 trim值里的前后空格是内容;布局要的分隔符由布局给,不要写进文案
转义值里不能直接出现 tab:写 \t;\n 换行、\\ 反斜杠
占位符printf 风格的值(如 hud.dayFormat)必须与源语言顺序、种类一致,否则 %d 会读错参数
UTF-8每格都做严格校验;BOM、CRLF 都能吃

查询:core::tr("menu.action.wake") → const std::string&(短 key 落在 SSO 里,逐帧调用不分配)。 回退顺序:当前语言 → 源语言 → key 本身(缺项会在画面上显示成 key,而不是一块空白)。 Localization::epoch() 在换语言/重载表时自增,缓存了换行/字宽测量的调用方据此失效。

语言设置:settings.cfg 的 ui.language 存代码(zh-CN / en-US / ja-JP),不存枚举下标 ——枚举重排不会污染存档。启动时 Application 构造里 loadDefault() + setLanguage(resolveLanguage(...)), 未识别代码回退源语言。

字体烘焙:atlas 是内容驱动的,烘焙按脚本而不是按语言列:LoadingCoordinator 把 「本地化表的中文列 + 编译期字符串 + 对话/PV」交给中文链、日文列交给日文链、英文列交给拉丁链 (Renderer2D::bakeAllScripts)。切语言因此不需要重烘,缺字也不会跨语种串味。 链与回退规则见 3.10.5;实测三链共 4 个 face、约 2.1 s、中/日缺字 0(Reverseworks.NarrativeFont 断言)。

文字路由:Application 用 ui.language 调 Renderer2D::setTextScript(), 中文会话走中文链(英文单词也用中文 face 的拉丁),英文会话走拉丁链。 所以「界面还是英文字面量 + ui.language=zh-CN」时,菜单会以中文字体的拉丁渲染——这是语言先于字体的正确行为。

Options → LANGUAGE:SettingsField::UiLanguage 是普通的选择型设置行(hasChoices + choiceLabels), 下拉/左右键循环三档,值一律用各语言自己的名字(languageNativeName:简体中文 / English / 日本語), 存下来的是规范代码。改动在下一帧生效:Application::applyLanguageSetting() 比对设置与当前语言, 做 setLanguage + setTextScript,紧接着调 Renderer2D::preloadScriptFace()——在这里把新语言的 UI face 烘出来, 而不是等某次绘制才懒加载(Options 是唯一能在第一次载入世界之前切换语言的界面)。 载入时已经把三条链烘好的话,这一次调用是空操作,全程不重烘。 玩家若在载入前就切语言,fontForCodepoint() 的按需分支会用 setUiFallbackTexts()(Application 塞进三个语言名) 按小集合补一张脸,而不是默认的千字切片。

Archives / ECHO 菜单(CxArchives{Browse,Edit,Delete}Panel + CxArchiveToggleRow + SaveMenuView)已全部查表: 标题、回声计数、LATEST、行提示、NEW ECHO、编辑面板的名称/控制台/复制/删除/取消与改名提示、删除面板的确认与三种失败原因, 以及存档行的 第 N 天 · 季节 · 体质 · 病症 与 CONSOLE 标记(SaveSystem::formatSaveDetailLine/formatSaveRowSecondary 用带占位符的整句, 季节与病症词来自 season.* / save.detail.*)。语言切换由 MainMenu 的 epoch 检查调 SaveMenuView::retranslate() → 三个面板各自 retranslate() 重建行。

尚未覆盖:libs/complexui 里仍有少量写死文案——BGM 播放器的 NO BGM / PLAYING、物品栏控件自身 chrome、Tip 气泡; 物品/武器/建筑的显示名仍走各自的内容表(items.tsv 等),不在这一张表里。

已接入 core::tr() 的界面(切语言后整屏换字):

位置取值方式
Options 标题/页签/按键绑定页/返回OptionsView.cpp 直接查表
设置行标签、行取值、下拉选项、主题名SettingsEditor::label/valueText/choiceLabels(枚举→键的映射在 displayModeKey/qualityKey/themeKey)
主菜单铭牌、按钮、提示、更新日志标题与四行MainMenuSpec::classic()
HUD 状态块、体征行、手部/装备、死亡横幅、技能名PlayingHud.cpp / PlayingCombatStrip.cpp
物品栏页签/标题/说明、槽位短标签、底部键位提示、操作提示PlayingUILayout.* / PlayingUI.cpp
载入阶段、开发者控制台、生命状态词汇(部位/状态/症状/风险)LoadingTypes.h / PlayingConsolePanel.cpp / LifeState.cpp / Player::riskSummary()

「整屏生效」怎么做到:组件把字符串按值持有且没有 setter,所以语言切换后重建而不是改字。 MainMenu::update 比对 Localization::epoch(),变了就 MainMenuView::rebuild()(清空子节点后重新 build()) 与 OptionsView::retranslate()(CxTabGroup::rebuild / CxMenuListGroup::rebuild,两者为此新增)。 HUD/物品栏/控制台是每帧查表,天然即时。断言具体文案的自检(Console、CxInventoryPanel)在开头把语言钉成英文。


6. 输入与键位

6.1 默认键位表

动作默认键可重绑定
移动W A S D是
跑Left Shift是
跳Space是
确认Enter / Space否(固定)
取消Esc否(固定)
交互E是
循环快捷物品Q是
使用快捷物品R是
快捷栏 1/2/31 2 3是
打开背包I是
攻击F是
放大/缩小Page Up / Page Down固定 fallback
滚轮缩放鼠标滚轮固定
碰撞覆盖层F3固定
开发者控制台` ``固定,仅桌面
提交控制台行Enter固定
剧情跳过Esc固定

6.2 自定义输入动作

新增步骤:

  1. src/core/Settings.h 的 InputAction 枚举
  2. Settings.cpp 的 kDefaultKeys 默认键 + toString() / keyLabel()
  3. Application::readSceneInput() 读入 GameInput
  4. DemoScene / GameState / PUI 消费
  5. 不可重绑定的动作扩展 isInputActionRebindable()

7. 构建与测试

7.1 CMake 结构

CMakeLists.txt                      顶层:定义 Reverseworks_Game (静态库) + Eraphanis (可执行)
cmake/ReverseworksDependencies.cmake   GLFW / GLAD / stb / 本地库链接
cmake/ReverseworksAudio.cmake           OpenAL / mpg123 / stb_vorbis 链接
cmake/ReverseworksRuntime.cmake         运行时部署(data/ + resources/ 复制)
cmake/ReverseworksRelease.cmake         Release 特殊处理
cmake/ReverseworksTests.cmake           测试目标注册

源文件收集:file(GLOB_RECURSE ... "src/*.cpp" CONFIGURE_DEPENDS) + list(FILTER ... EXCLUDE ...) 排除平台特定文件。新增 .cpp 通常不用改 CMake,但需要重新 configure。

7.2 预设

CMakePresets.json:

  • msvc-release-x64 / msvc-debug-x64
  • mingw64-release / mingw64-debug
  • mingw32-release / mingw32-debug

7.3 构建命令

cmake --preset msvc-release-x64
cmake --build --preset msvc-release-x64        # 只构建 Eraphanis 游戏
cmake --build build-msvc-release-x64 --config Release -j 8  # 构建全部(含测试)

7.4 测试

ctest --test-dir build-msvc-release-x64 -C Release --output-on-failure

当前结果(2026-11-08,MinGW Release):72 个测试全部通过。此前长期失败的 Reverseworks.AudioParser(夜间 BGM 的 cue 指向了不存在的文件名)与 Reverseworks.GroundFlicker(细节层锚在相机上,setDetailOrigin() 从未接线)都已修复。

7.5 多配置 VS 的数据部署陷阱

Visual Studio 多配置生成器:

  • CMAKE_RUNTIME_OUTPUT_DIRECTORY = build-msvc-release-x64/bin
  • 可执行文件实际在 bin/Release/Eraphanis.exe
  • Reverseworks_deploy_runtime 把 data/、resources/ 部署到 bin/
  • 若干测试用字面量 data/... 读取,需要 bin/Release/data/ 存在

workaround:手动 Copy-Item -Recurse bin/data bin/Release/data。重新 configure 后可能需要重做。


8. 代码组织规范

8.1 头文件 vs 源文件

  • 头文件放声明,源文件放实现
  • 大型实现(如 CharacterRig)拆分到多个 .cpp(CharacterRig.cpp、CharacterClips.cpp、CharacterBody.cpp、CharacterBraid.cpp 等),保持头文件精简
  • 头文件顺序:include guard → 前置声明 → 外部 include → 内部 include → 类型定义 → 类声明

8.2 本地库 vs 游戏代码

  • libs/ 下的库必须无游戏层依赖,可独立编译/测试
    • uniscope-2d:不知道什么是 "player" 或 "dialogue"
    • cdlg:不知道什么是 "save" 或 "world"
    • pv:不知道什么是 "camera" 或 "renderer"
    • complexui:不知道什么是 "game state" 或 "inventory"
  • 游戏层通过绑定层连接库(PvSystem 绑定 pv 库,DialogueSystem 绑定 cdlg 库,AssetLibrary 绑定 uniscope-2d)

8.3 注册模式

游戏层大量使用"注册"模式:

ElementRegistry     元素类型注册 + factory
ElementVisualRegistry    元素视觉帧注册
EventBus             事件订阅注册
TileRegistry         Tile 类型注册
DiseaseCatalog       疾病定义注册
EquipmentCatalog     物品定义注册
WorldTemplate        世界模板注册
Objective            目标注册

注册入口统一在各自的 registerBuiltins() 或 setup() 方法中集中调用。

8.4 命名约定

  • 类型:大驼峰 WorldTime、SaveGameData
  • 函数/方法:小驼峰 worldSpec()、advanceGameSeconds()
  • 常量:前缀 k 大驼峰 kChunkSize、kMaxFrameDeltaSeconds
  • 成员变量:_ 后缀 level_、clock_(游戏层);或无后缀(uniscope-2d/complexui 用 snake_case)
  • 命名空间:game::、core::、graphics::、audio::、ui::、platform::

8.5 Headless 可测试性原则

游戏的核心逻辑(角色模拟、存档读写、事件分发、世界生成、rig 动画状态机)必须能在无 OpenGL 上下文下运行和测试。GPU 相关的代码必须在 ensureAssets() 或 GlAvailability::isAvailable() 门控之后才执行。

8.6 文件头

项目自有文件(src/、tests/、cmake/、libs/)当前带有版权 banner(Copyright (c) 2026 Leo Jeang...)。不要按旧文档批量删除。

第三方文件(3rdParty/)保留原始 license 头,不要修改。


9. 扩展 Recipe

9.1 新增物品

  1. data/Eraphanis/items/items.tsv 加一行,遵守当前列顺序
  2. 初始授予 → starters.tsv 加 equip/inventory 行
  3. 方块物品:use_kind=block,最后一列 blockTileId,确认 TileType 能解析
  4. 图标:ItemIcons 默认按 id 生成帧;覆盖需先加 IconId 解析
  5. 跑 Reverseworks.Inventory、Reverseworks.SaveSystem、Reverseworks.CxInventoryPanel
  6. 注意 category=material 会回落 Headwear

9.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

9.3 新增世界模板

  1. src/game/WorldGen/WorldTemplate.cpp 写 makeXxxTemplate() 并 templates_.add(...)
  2. 设置 id、displayName、海平面、湿度/温度、河流湖泊阈值、森林阈值、树/建筑概率等
  3. 默认模板仍是第一个;未知 id 被 sanitized() 静默替换
  4. 跑 Reverseworks.WorldGen、Reverseworks.TerrainRelief、Reverseworks.Vegetation

9.4 新增对话/PV

  1. .cdlg 放 data/Eraphanis/world/_template/dialogue/,或用独立的 C:\personal\VisualDialoguer(C#/WinUI3,不再随本仓库发布)
  2. .pv 放 data/Eraphanis/world/_template/pv/
  3. 首次加载自动复制到存档目录;已有存档需放自己的目录或清掉旧副本
  4. 新内容的 CJK 字符会在字体烘焙时被覆盖——只要内容在对话/PV 文本里,字体就会自动包含
  5. 跑 Reverseworks.CDLG、Reverseworks.Pv、Reverseworks.PvSystem、Reverseworks.Dialogue、Reverseworks.Opening、Reverseworks.NarrativeFont

9.5 新增存档字段

  1. 改 SaveGameData 并加默认值
  2. 改写档 SaveRepository::save() 和读档 load();保持旧档可读
  3. 破坏兼容则提升 kFormatVersion,在读档处写迁移逻辑
  4. 更新 tests/SaveSystemChecks.cpp 往返断言
  5. 跑 Reverseworks.SaveSystem

9.6 新增 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

9.7 新增输入动作

  1. Settings.h 的 InputAction 枚举
  2. Settings.cpp 的 kDefaultKeys、toString()、keyLabel()
  3. Application::readSceneInput() 读入 GameInput
  4. 消费点:DemoScene / GameState / PlayingUI
  5. 不可重绑定动作扩展 isInputActionRebindable()
  6. 跑 Reverseworks.Settings、Reverseworks.MainMenu

9.8 新增事件

  1. EventSystem.h 的 GameEventType 加枚举
  2. eventCategory(...) 加 category 映射
  3. 新发布点优先用 builder helpers(named() + withValue() 等)
  4. 订阅:精确 type(gameplay)或 category(诊断/广播)
  5. 跑受影响的测试(Reverseworks.SurvivalSystem、Reverseworks.SaveSystem 等)

9.9 新增一条本地化文案

  1. data/Eraphanis/locale/strings.tsv 在对应命名空间块里加一行:key<TAB>中文<TAB>English<TAB>日本語
  2. 代码里用 core::tr("key") 取(#include "core/Localization.h"),不要写字面量
  3. 含 %d 这类占位符时,三列必须保持相同顺序(否则 LocalizationChecks 报错)
  4. 跑 Reverseworks.LocalizationChecks(全表逐行校验 + UTF-8 + 占位符 + 回退链)
  5. 新语言:Language 加枚举项 → languageCode() / languageNativeName() 各补一行 → 表加一列 → 跑 Reverseworks.NarrativeFont(确认 atlas 还装得下)

9.10 换字体 / 加一个字重

  1. 把文件放进 fonts/<语言>/<家族>/(或塞进 resources/,两种路径都能解析)
  2. 在 fonts/fonts.tsv 加一行:id / script / role / weight / path / order
    • 同 (script, role) 里 order 小的先烘;只有第一名烘全部文案,后面的只烘缺字
    • 想要「主字体缺字时由另一个家族补」,把补的那个写成同 role、order 更大
    • 想让某个字重载入即用(不被链认领也烘),加 eager=1;否则按需 bakeFaces(script, role, …)
  3. 文件必须是 glyf 或 CFF1 轮廓(stb 不认 CFF2;可变字体只烘默认实例)
  4. 跑 Reverseworks.FontBake(清单解析 + 文件存在 + 轮廓格式 + 链顺序)
  5. 跑 Reverseworks.NarrativeFont(三链真烘焙 + 缺字 0 + CFF 字体能光栅化)

10. 调试工具与钩子

10.1 快捷键

键功能
F3碰撞/调试覆盖层(固定,不进重绑定表)
` ``开发者控制台(需存档开启 consoleEnabled)

10.2 环境变量

$env:ERAPHANIS_TRACE_WORLD=1
# → DemoScene::finalizeWorldLoad() 向 stderr 打印世界模板、seed、chunk 数、玩家坐标/tile、是否 standable

10.3 FrameProfiler

每帧分段计时:Update、Render、Swap、WorldUpdate、PlayerUpdate、WorldStream、SaveIO 等。定期打印报告。

10.4 离屏探针

tests/UiShotProbe.cpp / tests/RenderSmokeTest.cpp 等会离屏渲染到 PPM 文件,供目视检查。

10.5 evidence/(与已清理的 tmp_frames/)

evidence/ 存探针产出物与建筑审计基线,保留(建筑文档引用它)。tmp_frames/、tmp/ 等临时排查材料已于 2026-11-03 清理并入 .gitignore;需要时重新生成,不要再提交。


11. 已知缺口与未完成项

来自 project-state.md 的当前状态,这里补充工程视角的细节:

缺口位置现状
物品栏只实现 BAG 页签PlayingUILayout.cpp / PlayingInventoryPanel.cpp其余 7 页签显示 NOT IMPLEMENTED YET
容器/箱子未实装GameState::openContainer()恒为 nullptr;Ctrl+左键是空操作
战斗系统未接入GameState::equipWeapon() 无调用点;装备槽无 UI武器定义已是数据表的 21 把、items.tsv 已有 21 个武器物品(E1.1/E1.2),但正常游玩仍无法装备武器
敌人不出现在世界里GameState::spawnEnemy() / EnemyCombatant身体、大脑与命中管线都是真的(P3),但没有刷怪表、没有掉落、没有遭遇循环、不渲染、不与世界碰撞
装备词条没有界面EquipmentLoadout / AffixCatalog词条会抽、会存、会进构筑(P3),但装备面板不显示词条行,也没有洗练 / 重 roll
控制台命令很少PlayingConsolePanel::dispatch()目前只有 time、`time dawn\\day\\dusk\\night\\midnight、time HH:MM、suicide;其余回显 UNKNOWN COMMAND`
地形编辑已进存档SaveGameData 的 [terrain](v7)按 chunk/tile/layer 记录方块编辑;读档时 applyTerrainModifications() 应用
GroundFlicker 回归libs/uniscope-2d/shaders/textured2d_batch.frag 的 u_detail_origin已修复(2026-11-08):根因是 Renderer2D::setDetailOrigin() 从未接线,细节场一直锚在相机上;现在世界批锚世界原点、角色批锚角色自身原点
渲染器重构进行中Light2D/LightingRenderer/CommandBuffer/FrameRecorder/RenderThread 已从 libs/uniscope-2d 删除游戏侧阴影仍在 WorldRenderer/BuildingShadowVolume,功能无回退

12. 专题文档索引

文档内容
docs/project-state.md快速事实、帧流程、系统概览、不变量、测试地图、修改入口(AI 上手必读)
docs/android.mdAndroid 平台约束、Gradle/NDK 工程、AAssetManager
docs/collision-visual-alignment-*.md碰撞与视觉对齐的审计/修复/验证记录
docs/deanna-art-upgrade-plan.mdDeanna 角色美术升级计划
docs/female-body-proportions-plan.md女性体型比例改造方案
docs/implementation-guide.md早期实现指南(0.1.x 时期)
src/graphics/CharacterRig.README.md角色骨骼/蒙皮/动画剪辑/美术绑定的完整说明
resources/assets/eraphanis/textures/world/README.md世界 atlas:帧表、无缝地面生成、gutter、exposure、detail map
C:\personal\VisualDialoguer\README.mdCDLG 编辑器(独立程序,已从本仓库剥离)
data/README.md数据包布局
docs/update.txt更新日志(UTF-8 with BOM)

13. 第三方依赖

库位置用途
GLFW 3.43rdParty/glfw-3.4/桌面窗口 + 输入
GLAD3rdParty/GLAD/OpenGL 3.3 / GLES 3.0 加载器
stb_image3rdParty/stb/include/stb/PNG/JPG 解码
stb_truetype同上TTF 字体烘焙(SDF atlas)
stb_vorbis同上OGG 解码
ImGui已移除曾用于 cdlg-editor 工具 UI;编辑器剥离后不再随仓库发布
mpg123 1.33.73rdParty/mpg123/MP3 解码
openal-soft 1.24.13rdParty/openal-soft/OpenAL 音频后端
Windows mm系统Winmm 音频 fallback(src/audio/winmm/)
OpenGL系统桌面图形 API
Android EGL / GLES 3.0系统Android 图形 API

许可证细节见 README.md。

13.1 mpg123 的两种架构

3rdParty/mpg123/ 与 3rdParty/openal-soft/ 都在 .gitignore 里,克隆下来是空的,需要自己放二进制。 mpg123 的官方发行包是按架构分开的(mpg123-1.33.7-x64.zip / mpg123-1.33.7-x86.zip, 来自 <https://mpg123.org/download/>),所以要构建双架构就必须两份都放:

3rdParty/mpg123/
├─ libmpg123-0.dll 等        ← x64 版(惯例位置,根目录)
└─ libs/Win32/               ← x86 版放这里
   ├─ libmpg123-0.dll
   ├─ libsyn123-0.dll
   └─ libout123-0.dll

这个布局不是可选的。 mpg123 只发行 .def + .dll,而 .def 是架构中立的—— x86 构建能顺利链接 64 位的导入库,产出的 exe 却在启动时被加载器拒绝 (libmpg123-0.dll 位数不符),表现为「双击没反应」。所以 cmake/ReverseworksAudio.cmake 在 configure 阶段会读 DLL 的 PE 头核对 machine id,不匹配就不启用 MP3 后端并给出警告, 而不是让一个启动即崩的包流到用户手里。

mpg123 缺失(或架构不符)只损失 MP3:OpenAL 仍然工作,wav/ogg 照常播放。 OpenAL 才是音频设备的必需品,它缺失才会退回静音后端。

14. 发布产物与代码签名

14.1 为什么产物会被 SmartScreen 拦

Windows Defender SmartScreen 对「无法识别的应用」直接拒绝启动,弹「Windows 已保护你的电脑」, 发行者显示「发布者未知」。这个提示有三个成因,缺一不可:

  1. 没有数字签名——没有签名就没有发布者身份,SmartScreen 无从判断,只能按未知应用处理。
  2. 没有 VERSIONINFO——resources/resources.rc 里原本只有一个 ICON,没有版本资源块, 于是资源管理器、shell 和杀软都读不到「谁做的这个程序」。
  3. Release 链接没有 strip——MinGW 的 Release 链接默认保留 COFF 符号表和 .debug_* DWARF 节, 文件末尾多出约 2.8 MB overlay,节表里还多出 /4、/19、/31 这类 COFF 长名节。 启发式引擎把这套特征当作「来路不明的二进制」,配合前两条直接判定为高风险。

压缩包本身报同样的警告是同一个原因:SmartScreen 拦的是被下载的文件, 不是压缩包。zip 里的 exe 没签名,解压出来照样被拦。

14.2 三处改动

改动位置作用
-s(strip)cmake/ReverseworksRelease.cmake去掉符号表与 DWARF,overlay 归零
VERSIONINFOresources/resources.rc.in写入 CompanyName / ProductName / FileVersion
签名tools/sign-artifacts.ps1Authenticode 签名 + RFC 3161 时间戳

resources/resources.rc.in 有个坑:版本资源必须带数字 ID。 写成 VS_VERSION_INFO VERSIONINFO(看起来更标准)会让资源编译器把资源注册在 名字 "VS_VERSION_INFO" 下,而不是 ID 1;Windows 的 GetFileVersionInfo 只按 ID 查找, 结果就是二进制里确实有 "Causefficts Studios" 这个字符串,但所有 API 都读不到版本信息。 正确写法是 1 VERSIONINFO。模板里的版本号由 CMake 用 @APP_VERSION@ 注入, 永远和 project(... VERSION ...) 一致。

1 ICON 的路径必须是绝对路径,因为模板生成到 build 树里,相对路径 echo.ico 解析不到。

14.3 签名

证书在 Cert:\CurrentUser\My(含私钥)+ Cert:\CurrentUser\Root(自签根)时, tools/sign-artifacts.ps1 直接用证书库签名,不需要密码:

tools\sign-artifacts.ps1 -Path build-mingw64-release\bin

只有装了 .pfx 而没装证书的机器才需要密码:

tools\sign-artifacts.ps1 -Path build-mingw64-release\bin -PfxPassword $env:ERAPHANIS_PFX_PASSWORD

签名脚本会:清除 mark of the web → 逐个签名 .exe(--IncludeDlls 才带 dll)→ 用 Get-AuthenticodeSignature 校验每个文件确实变成 Valid → 确认签名者是 Causefficts Studios。 上游的 OpenAL/mpg123 DLL 不签名:它们不是本项目的东西,签名反而破坏其来源信息。

证书是自签名(Subject == Issuer)。它能让 SmartScreen 显示正确的发行者并放行, 但前提是用户机器上也要信任这个根。给终端用户分发时必须同时提供根证书, 否则对方机器上仍会提示未知发布者。

14.6 根证书(信任锚)

tools/release.py 在签名时会顺带导出 dist/release/CauseffictsStudios-Root.cer—— 就是证书的公钥部分(Export-Certificate 永远不会写出私钥), 并把它写进 SHA256SUMS.txt 和 manifest.json(role 为 root-certificate)。

终端用户拿到发行版后,双击这个 .cer →「安装证书」→「受信任的根证书颁发机构」, 再运行游戏,SmartScreen 才会显示 Causefficts Studios 而不是「发布者未知」。

证书的 Subject 与 Issuer 相同、指纹 DAEC31D3140C794D66241783CE26CAE457573390, 所以它同时是「签名用的证书」和「用户要信任的根」——一个文件就够了。

14.4 打包流水线

tools/package-release-all.ps1(双架构 zip + 安装包)和 tools/package-inno.ps1(单架构安装包) 都已经串好签名:

Stage runtime payload → Validate → Sign staged payload → Compile installer → Sign installer → Publish

签名发生在暂存副本上而不是构建产物上,所以 build 目录里保留的是未签名二进制, 可以随意重签。-SkipSigning 可跳过(仅用于本地/CI,不要用于发布)。

zip 产出后会执行 Unblock-File 清掉 mark of the web——我们自己发布的副本是干净的, 终端用户不需要再点一次「解除锁定」。

14.5 私钥

causefficts.pfx 已在 .gitignore 里(连同 *.pfx / *.p12)。 私钥绝不能进仓库:它是「能以 Causefficts Studios 身份签名任何程序」的凭据, 而这正是 SmartScreen 信任的那个身份。

15. 一键发布程序 tools/release.py

15.1 为什么不用 PowerShell

原来的三个打包脚本(package-release-all.ps1 / package-inno.ps1 / sign-artifacts.ps1) 本身是配置:PowerShell 在受管控的 Windows 上意味着执行策略弹窗、 .ps1 需要自己的文件关联才能运行、调用另一个 .ps1 时 $LASTEXITCODE 根本不设置 (于是 & script.ps1 之后判 $LASTEXITCODE -ne 0 会把一次成功读成失败), 以及 Write-Error + exit 1 的失败契约调用方根本观察不到。

tools/release.py 用 Python 标准库重写了同一条流水线,只依赖 Python 本身, 在构建机上怎么跑就怎么发布:

python tools/release.py all

15.2 环节

环节作用
version一次改 6 处版本号 + 插入更新日志条目
build多架构 configure + build
test构建测试目标并跑 ctest
signAuthenticode 签名 + 时间戳 + 导出根证书 .cer
package暂存 payload → 签名 → zip → Inno 安装包
checksums产出 SHA256SUMS.txt 与 manifest.json
publish版本分支 → 提交 → 合并回 echo → 推送 origin

环节可以单独跑,也可以组合,顺序由命令行决定:

python tools/release.py build test --arches x64
python tools/release.py version --level patch --changelog-entry "修复: ..."
python tools/release.py package --skip-signing
python tools/release.py publish --version 0.1.9.3
python tools/release.py all --dry-run

--dry-run 打印将要执行的命令但什么都不做(版本号也不改、不产出文件), 可以在动手前先看清整条流水线的计划。

test 环节会先把 LOCALAPPDATA / TEMP 重定向到构建树里的 testenv/ 再跑 ctest。 这一步不是可有可无的洁癖:测试原本直接往玩家真正的存档目录 (%LOCALAPPDATA%\Reverseworks\archive)里写,两头都出问题—— 一方面未签名的测试程序往用户目录写文件,正好长成杀软行为防护最喜欢的样子, 写入被拦后表现为「测试失败」,但和代码毫无关系;另一方面测试和玩家真正的存档互相踩。 重定向之后测试跑在自己的沙盒档案里,既不会踩存档,也不会被拦。 本机实测:重定向前 65/75,重定向后 75/75 全绿。

15.3 版本号提升

版本号住在 6 个文件里,是迟早会被只改一半的版本: 安装包写着 0.1.9.3、游戏写着 0.1.9.2、changelog 面板什么都不说。 version 环节把它们一起改掉,任何一处匹配不上就整体报错—— 静默跳过比直接失败更糟。

文件位置
CMakeLists.txtproject(Eraphanis VERSION ...)
vcpkg.json"version"
android/app/build.gradleversionName(+ versionCode 自增)
packaging/inno/Eraphanis.iss#define AppVersion
libs/complexui/.../CxChangelogPanel.hversionLabel
src/ui/views/MainMenuSpec.hchangelogVersion

docs/update.txt 不在这个列表里——它是历史记录,只在顶部插入新条目。 该文件带 UTF-8 BOM 且用 CRLF,脚本会原样保留编码与行尾, 不会让每次改动都变成整文件 diff。

15.4 发布只提交本次改动的文件

publish 环节不会用 git add -A。仓库索引里经常压着别人的在途改动, 整体提交会把它们卷进发布提交。

发布提交的内容 = 6 个版本位置 + update.txt + --commit-paths 显式指定的文件, 并且只收确实有改动的那些。中文提交信息通过 UTF-8 无 BOM 的临时文件传给 git commit -F——git commit -m 在 Windows 上按控制台代码页编码参数, 中文会被弄坏。

推送时逐个 ref 推(origin <版本分支>、然后 origin echo): 一次推多个 ref 会和服务端的 ref 锁竞争,被拒的那个 ref 其实已经推上去了。

15.5 已知的取舍

  • 证书是自签名的。它能让 SmartScreen 显示正确的发行者并放行, 但前提是用户机器上也要信任这个根。对外分发时必须同时提供根证书—— release.py 已经把根证书导出成 dist/release/CauseffictsStudios-Root.cer 并写进校验和与清单(见 §14.6),把它一起发出去即可。
  • publish 默认会推送 origin。加 --no-push 可以只做本地提交与合并。
  • 单架构打包时会给出警告:安装包会把这份 payload 同时装到 x64 和 x86 槽位。 发布请用 --arches x64,x86。

15.6 双架构发布

python tools/release.py all --arches x64,x86

产出两份 zip 与一个安装包:

产物说明
Eraphanis-<ver>-win-x64.zip64 位便携包
Eraphanis-<ver>-win-x86.zip32 位便携包
Eraphanis-Setup-<ver>.exe两套 payload 都在里面,按 Is64BitInstallMode 分流

安装包的 [Files] 段用 Check: Is64BitInstallMode / Check: not Is64BitInstallMode 把 x64 与 x86 的条目分开,所以 64 位系统拿到的是 x64 二进制、32 位系统拿到 x86。 体积约为单架构的两倍,这是正常的。

32 位构建的三个坑(都在本仓库踩过并已修好):

  1. mingw32 前缀里没有 make。 MSYS2 按前缀装 make,而 make 是构建工具不是目标工具链, mingw64 里那份驱动 mingw32 编译器毫无问题。release.py 的 find_make() 会在整个 MSYS2 根下找,不会因为 mingw32/bin/mingw32-make.exe 不存在就报错。
  2. 浮点必须统一到 SSE。 32 位 MinGW 默认用 x87 80 位扩展精度,64 位用 SSE2, 同一份源码末位结果不同。顶层 CMakeLists.txt 对 32 位构建统一加 -msse2 -mfpmath=sse,而且是 add_compile_options 给所有 target—— 测试必须和被测代码用同样的舍入方式,否则它证明不了任何事。 修好前 x86 有 4 项失败(WorldGen 噪声确定性、WeaponPresentation 回落、UiPerf 文字度量、 AudioParser),修好后 75/75 全绿。
  3. CMake 命令行传路径要用正斜杠。 CMake 会把 CMAKE_RC_COMPILER 原样写进 CMakeRCCompiler.cmake,反斜杠路径在那里变成非法转义(Invalid character escape '\e'), 全新 configure 直接失败。release.py 的 cmake_path() 统一转正斜杠。