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/ | 10 | 9 | Application(帧循环)、Settings(键位/质量/音量/语言)、ResourcePaths/DataPaths/UserDataPaths(跨平台路径解析)、Localization(本地化表 + tr())、FrameProfiler、SettingsEditor |
src/platform/ | 5 | 4 | 平台入口与薄包装:AndroidMain、KeyCodes.h、ResolutionCatalog、SystemBrowser、TouchContacts.h。窗口/输入/生命周期在 libs/uniscope-2d/src/pal/ |
src/graphics/ | 39 | 23 | AssetLibrary(图集/程序化建筑缓存)、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/ | 15 | 13 | AudioEngine(前端)、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/ | 5 | 5 | MainMenu、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 | 主菜单;可后台预热最近存档的世界;处理新建/继续/退出 |
LoadingWorld | LoadingCoordinator 生成世界、解析对话/PV、烘焙字体;Esc 取消 |
LoadingReveal | 0.8s 淡黑;新存档直接把黑场交给开场 PV,其他存档再经 0.5s 淡入游戏 |
Playing | 正常游戏;PUI(PlayingUI)先消费输入,再 GameState::update |
PlayingPv | PV 时间线拥有相机;世界冻结;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)— 围绕中心保留/生成 chunkstreamAroundBudgeted(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 类型的标准步骤:
- 扩展
TileType枚举 - 添加
TileDefinition+ 默认ElementState - 在
TileMaterial.cpp加材质数据 - 在
TileBehavior.cpp加行为钩子(如需) - 在
TileVisual.cpp加视觉映射 - 加测试(行走性、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 bit | MaterialParams2D::edgeMask |
边界表与顶点表(阶段二,同一小节的数据模型):
| 结构 | 内容 | 谁在用 |
|---|---|---|
BoundaryDesc | 一条边界的两侧族、joined、处理方式、样式 | edgeFrame() 产出;boundaryDesc() 单条查询 |
VertexDesc | 一个顶点的四个象限:瓦块指针、坐标、是否存在、各象限的角形状、fillQuadrant | appendCornerPatches 据此决定谁切角、谁补片 |
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) |
TransitionLayer2D | None / 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.cpp | coat 的 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_rawmake_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)
"朝哪边看"的唯一权威。三个问题在这里一起回答:
- 绘制翻转:
drawFlip()/mirrored()— 美术是立姿正脸图,不是旋转体。kCharacterArtFacesPositiveX声明了默认朝向。 - 交互可达:
forward()、interactTileOffset()、interactPoint()— 面向的 tile,fallback 到脚下 tile。 - 从移动推断朝向:
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.h | AttackPhase ActionCategory HitShape AimAssist、60fps 帧栅格常量 | 无 |
ActionCatalog.h | ActionDef 结构 + 静态动作表 + 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)
| 反馈 | 落点 |
|---|---|
| hitstop | MotionState.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。
四条容易改错的规则
- 一 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)必须各有各的状态, > 否则判定帧和画面帧会错开。
- 无敌窗口有 2 帧的前置 grace。
HumanoidCharacter::invulnerable()的免疫 从 Active 段第 3 帧才开始(kInvulnerabilityGraceFrames = 2), 长度由表里的invulnerableFrames给。Active 段头两帧仍然吃伤害—— 这是方案稿 A7 的原文「i-frames 起始前 2 帧仍算命中」。 > 写成「Active 段开始即免疫」会让翻滚吞掉玩家本可以躲开的攻击。
- 完美格挡的反应时间有参照系。
GuardController::evaluate(attackerActiveAgeSeconds, toAttacker, facing): 反应时间 = 攻击方 Active 窗口开启时刻 − 玩家举盾时刻。- 负值 → 早就举了盾 → 普通格挡
0 .. 4 帧→ 完美格挡(反弹 50% + 攻击者硬直 + 玩家一次免费反击)> 4 帧→ 普通格挡(60% 减免,每挡一击扣 0.25 体力)
格挡只挡正面 120°;背后来的攻击一定吃满。
- 技能栏是 6 格固定热键:键 4 = 基础(轻击)、5–8 = 4 核心、9 = 终极。 技能键不进重绑定表——重绑定把键挪走,玩家就记不住第几格是什么了。
第五、六条(E1 武器层,评审后补)
- 一个模型 tile 不是世界 tile。 武器按角色身高比例绘制,它的锚点 (
muzzleOffset/ejectPortOffset/supportOffset/ 座位偏移 / 反冲) 全都在模型 tile 里,跨进世界必须过WeaponPresentation::worldPerModelTile(modelId, figureHeight)(=weaponRigUnitsPerModelTile × kRigUnitTiles × kRigVisualScale)。 > 把模型偏移直接加到世界坐标上,效果会落在画出来的枪之外——抛壳口就是 > 这么错了一整个数量级的(一次模型 tile ≈ 0.075 世界 tile)。 > > 开火一侧不要再自己拼换算:GameState::weaponShotFrame()一次给出 > 座位、带后坐的座位、画出来的枪口和枪管角,远程分支只调它一次并把 >shot.grip / shot.muzzle交给CombatFx。新增特效请从这一帧取点。
- 连段与蓄力都属于「开它的那把武器」。
- 连段窗口跨武器存活会让下一把武器继承上一把的下一段 (枪挥出刀的第二段,按枪的伤害/命中/噪音/耐久扣)—— 换手时必须
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} | 构筑汇总:把身上装备的词条加总成一份「构筑」,管线只读它 |
五条容易改错的规则
- 敌人的攻击不另起一张表。
EnemyAttackDef::actionId指向ActionCatalog里的共享行(enemy_claw/enemy_slam/enemy_spit), 敌人跑的是同一个CombatActionController、同一套帧栅格。 > 另起一张EnemyActionDef表就等于另起一套状态机——方案稿 §6.6/§8.1 > 明确要求「玩家和敌人的差异只在数据,不在代码」。
- 本轮敌人不出现在世界里。
GameState::spawnEnemy()放进会话, 会话每帧驱动它(updateEnemies),它的身体就是CombatTarget, 玩家的挥击打的就是它。它不与世界碰撞、不渲染、不掉落。 > 身体与大脑是真的,缺的只是世界那一层(刷怪表 / 掉落表 / 遭遇循环)。 > 做「世界里的敌人」之前不要顺手把碰撞和渲染加进来——那是另一批。 > (P1 的 AI 层起:它看得见墙、会绕路走,因为会话把Level交给了它; > 但身体碰撞仍然没有,那是 AI.3。)
- 霸体分级不是更大的血条。
PoiseTierDef三项一起动: 霸体池更大(common 10 / elite 22 / boss 45)、霸体伤害缩放更低 (1.0 / 0.6 / 0.35)、伤害倍率更高(1.0 / 1.35 / 1.8)。 > 只加血条的精英是沙包,不是威胁。缩放系数在 >applyHitToTarget(target, damage, poiseDamage, poiseDamageScale)里。
- 元素进管线,但护甲只管物理。 命中携带
DamageType(Physical/Fire/Cold/Poison),目标持有DamageAffinity: 抗性是倍率、免疫是归零。护甲走armorReduction(), 只作用于物理通道。 > 两个公式作用在同一个数上会互相抵消。拆成两个通道后, > 「防火的护甲」和「抗火的体质」才是两件可分别构筑的事。
- 词条属于实例,不属于物品定义。
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 是唯一的「玩家现在有多强」:
| 字段 | 来源词条 | 接到哪里 |
|---|---|---|
flatDamage | affix_sharp / affix_heavy | 命中管线的平伤加成 |
armorBonus | affix_sturdy | 命中管线的护甲值 |
critChance | affix_keen | 命中管线(GameState::rollCrit(),确定性流) |
staminaRegenBonus | affix_swift | 会话每帧给体力池回一点 |
cooldownReduction | affix_hasty | CooldownTracker::setDurationScale()(冷却条同步缩放,不然条和钟会打架) |
maxHealthBonus | affix_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 连接 |
七条容易改错的规则
- 依赖是单向的:
ai/不依赖combat/。src/game/ai/里不得出现EnemyDef/EnemyAttackDef/CombatActionController, 也不得直接调Level的方法——世界只有AiWorld那四个问题。 > 反向依赖会成环,将来「玩家/敌人/陷阱共用一张能力表」就做不成。
- 特征表没有 C++ 兜底。
traits.tsv是唯一的性情来源;缺文件、坏行、敌人少一行 traits,都在启动期报错 (Application::validateAiContent())。 >EnemyDef::aggroRange/leashRange已经迁出(数值未变)。别再加回去——那会变成双真值。
- 视线与走线是两个函数。
hasLineOfSight问「看得见吗」(遇挡视线的实体才断),hasClearWalkLine问 「直着走得到吗」(遇不能站的才断)。水能看见、走不过去。 > 合成一个就是「AI 穿墙」和「AI 隔墙打人」两种 bug 的共同来源。
- 冷却的真值在身体里。 大脑通过
AiAbility::ready问「现在能不能用」,自己不记时间。 > 一个能力在两处记冷却,就会出现「冷却好了却打不出」或「冷却没好却打了」。
- 分离是软的,墙优先(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少一行启动就报错(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 | 地面属性 |
Placeable | placeable + blocksMovement 标志 |
Interactable | dialogueId、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,不复用视觉 halfExtentsaspen碰撞盒更窄(细长单 quad)flora_*和street_*都是placeable(false),可穿过shrine和campfire不阻挡移动
3.4.2 ElementRegistry / ElementVisualRegistry
ElementRegistry:元素定义工厂层;find(typeId)返回定义,create(typeId, pos)创建实例,bind(EventBus&)注册 narrative signal handlerElementVisualRegistry:视觉帧/程序化绘制绑定;worldBounds(element, visual)是唯一世界空间矩形入口
新增元素的步骤:
- 在
ElementRegistry注册ElementDefinition(含组件列表) - 在
ElementVisualRegistry绑定视觉(帧 index 或程序化绘制) - 在世界生成/城镇布置处放置(
WorldFeatures、Ronesville) - 跑
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 新增物品实例词缀) |
存档不变量(违反会导致静默错误):
WorldSpec必须随存档走;读档时如果 spec 不匹配,放弃加载回主菜单- 在室内保存 → 保存室外门口坐标,不是室内坐标(室内运行时生成不持久化)
hasPlayerPosition([player] placed)才是坐标有效性判断;不要用(0,0)推断completedObjectives/playedScenes中未知 id 读档时丢弃- 存档读档时
fileVersion保留原值用于迁移判断;data.version被提升到当前版本
3.9.2 SaveRepository(src/game/SaveSystem.h)
关键接口:
list()— 返回缓存 summary,最新优先createNew(settings)— 创建新存档对象(还未落盘)load(id, out)— 读存档;处理版本迁移save(data)— 写存档;先 resolveworldSpec再写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_originuniform),否则相机移动时细节会"爬" 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):
| 概念 | 含义 |
|---|---|
script | latin / chinese / japanese——这份字体服务哪套文字,不是 UI 语言。中文会话里的英文单词仍用中文 face(它的拉丁是贴着汉字画的) |
role | body / emphasis / display / mono / spare。层级槽位而非字重;spare 已注册但没人认领,加一行就能用 |
order | 同 (script, role) 内的回退名次。只有主 face 烘全部文案,后面的 face 只烘前面缺的字——这是 bakeFaces() 的适配逻辑 |
eager | 1 = 载入时就烘,即使没有链认领它。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 specMenuContext— 贯穿 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/.cpp | PlayingUI / PlayingUIUpdateResult | PUI 的统一入口:HUD + 物品栏 + 控制台 |
PlayingUILayout.h/.cpp | PlayingUiLayout / PlayingUiState / PlayRect | 渲染与命中共用的几何读数(不硬编码坐标) |
PlayingHud.h/.cpp | PlayingHud | HUD 绘制(顶栏 / 侧栏生命体征 / 底部快捷栏 / 物品栏 chrome) |
PlayingInventoryPanel.h/.cpp | PlayingInventoryPanel | 物品栏的 complexui 容器树 |
PlayingConsolePanel.h/.cpp | PlayingConsolePanel | 开发者控制台壳 |
关键设计:
- 输入消费优先级: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 各用各的:
| 表面 | 用在哪 | 特征 |
|---|---|---|
plate | GUI 实体板(菜单、面板、按钮) | 不透明、有落影、有轮廓线 |
well | GUI 凹槽(输入框、槽位、轨道) | 上缘暗、下缘亮,读成凿进去的井 |
hudPlate | PUI 安静板(物品栏、控制台) | 半透明烟灰、无落影、只有顶部一道受光棱 |
hudWell | PUI 凹槽(血条、槽位) | 同上,反过来用 |
为什么 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/3 | 1 2 3 | 是 |
| 打开背包 | I | 是 |
| 攻击 | F | 是 |
| 放大/缩小 | Page Up / Page Down | 固定 fallback |
| 滚轮缩放 | 鼠标滚轮 | 固定 |
| 碰撞覆盖层 | F3 | 固定 |
| 开发者控制台 | ` `` | 固定,仅桌面 |
| 提交控制台行 | Enter | 固定 |
| 剧情跳过 | Esc | 固定 |
6.2 自定义输入动作
新增步骤:
src/core/Settings.h的InputAction枚举Settings.cpp的kDefaultKeys默认键 +toString()/keyLabel()Application::readSceneInput()读入GameInputDemoScene/GameState/ PUI 消费- 不可重绑定的动作扩展
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-x64mingw64-release/mingw64-debugmingw32-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 新增物品
data/Eraphanis/items/items.tsv加一行,遵守当前列顺序- 初始授予 →
starters.tsv加equip/inventory行 - 方块物品:
use_kind=block,最后一列blockTileId,确认TileType能解析 - 图标:
ItemIcons默认按 id 生成帧;覆盖需先加IconId解析 - 跑
Reverseworks.Inventory、Reverseworks.SaveSystem、Reverseworks.CxInventoryPanel - 注意
category=material会回落Headwear
9.2 新增元素
ElementRegistry::registerBuiltins()或外部注册点定义ElementDefinition- 选择组件:
CollisionBox、Placeable、Interactable、LifeState、HeatSource、Canopy、Species、Flora ElementVisualRegistry绑定视觉帧/程序化绘制- 在
WorldFeatures、Ronesville、TerrainGenerator等生成处放置 - 跑
Reverseworks.Registry、Reverseworks.Vegetation、Reverseworks.EntityVisual
9.3 新增世界模板
src/game/WorldGen/WorldTemplate.cpp写makeXxxTemplate()并templates_.add(...)- 设置
id、displayName、海平面、湿度/温度、河流湖泊阈值、森林阈值、树/建筑概率等 - 默认模板仍是第一个;未知 id 被
sanitized()静默替换 - 跑
Reverseworks.WorldGen、Reverseworks.TerrainRelief、Reverseworks.Vegetation
9.4 新增对话/PV
.cdlg放data/Eraphanis/world/_template/dialogue/,或用独立的C:\personal\VisualDialoguer(C#/WinUI3,不再随本仓库发布).pv放data/Eraphanis/world/_template/pv/- 首次加载自动复制到存档目录;已有存档需放自己的目录或清掉旧副本
- 新内容的 CJK 字符会在字体烘焙时被覆盖——只要内容在对话/PV 文本里,字体就会自动包含
- 跑
Reverseworks.CDLG、Reverseworks.Pv、Reverseworks.PvSystem、Reverseworks.Dialogue、Reverseworks.Opening、Reverseworks.NarrativeFont
9.5 新增存档字段
- 改
SaveGameData并加默认值 - 改写档
SaveRepository::save()和读档load();保持旧档可读 - 破坏兼容则提升
kFormatVersion,在读档处写迁移逻辑 - 更新
tests/SaveSystemChecks.cpp往返断言 - 跑
Reverseworks.SaveSystem
9.6 新增 UI 页签
src/game/playing/PlayingUILayout.h的InventoryPageId加枚举,更新kInventoryPageCountsrc/game/playing/PlayingUILayout.cpp加短名/长名PlayingHud.cpp/PlayingInventoryPanel.cpp实现绘制和输入- 未实现页签保留
NOT IMPLEMENTED YET,不要伪装 - 跑
Reverseworks.CxInventoryPanel、Reverseworks.UiPerf
9.7 新增输入动作
Settings.h的InputAction枚举Settings.cpp的kDefaultKeys、toString()、keyLabel()Application::readSceneInput()读入GameInput- 消费点:
DemoScene/GameState/PlayingUI - 不可重绑定动作扩展
isInputActionRebindable() - 跑
Reverseworks.Settings、Reverseworks.MainMenu
9.8 新增事件
EventSystem.h的GameEventType加枚举eventCategory(...)加 category 映射- 新发布点优先用 builder helpers(
named()+withValue()等) - 订阅:精确 type(gameplay)或 category(诊断/广播)
- 跑受影响的测试(
Reverseworks.SurvivalSystem、Reverseworks.SaveSystem等)
9.9 新增一条本地化文案
data/Eraphanis/locale/strings.tsv在对应命名空间块里加一行:key<TAB>中文<TAB>English<TAB>日本語- 代码里用
core::tr("key")取(#include "core/Localization.h"),不要写字面量 - 含
%d这类占位符时,三列必须保持相同顺序(否则LocalizationChecks报错) - 跑
Reverseworks.LocalizationChecks(全表逐行校验 + UTF-8 + 占位符 + 回退链) - 新语言:
Language加枚举项 →languageCode()/languageNativeName()各补一行 → 表加一列 → 跑Reverseworks.NarrativeFont(确认 atlas 还装得下)
9.10 换字体 / 加一个字重
- 把文件放进
fonts/<语言>/<家族>/(或塞进resources/,两种路径都能解析) - 在
fonts/fonts.tsv加一行:id/script/role/weight/path/order- 同
(script, role)里order小的先烘;只有第一名烘全部文案,后面的只烘缺字 - 想要「主字体缺字时由另一个家族补」,把补的那个写成同 role、
order更大 - 想让某个字重载入即用(不被链认领也烘),加
eager=1;否则按需bakeFaces(script, role, …)
- 同
- 文件必须是
glyf或CFF1轮廓(stb 不认 CFF2;可变字体只烘默认实例) - 跑
Reverseworks.FontBake(清单解析 + 文件存在 + 轮廓格式 + 链顺序) - 跑
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.md | Android 平台约束、Gradle/NDK 工程、AAssetManager |
docs/collision-visual-alignment-*.md | 碰撞与视觉对齐的审计/修复/验证记录 |
docs/deanna-art-upgrade-plan.md | Deanna 角色美术升级计划 |
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.md | CDLG 编辑器(独立程序,已从本仓库剥离) |
data/README.md | 数据包布局 |
docs/update.txt | 更新日志(UTF-8 with BOM) |
13. 第三方依赖
| 库 | 位置 | 用途 |
|---|---|---|
| GLFW 3.4 | 3rdParty/glfw-3.4/ | 桌面窗口 + 输入 |
| GLAD | 3rdParty/GLAD/ | OpenGL 3.3 / GLES 3.0 加载器 |
| stb_image | 3rdParty/stb/include/stb/ | PNG/JPG 解码 |
| stb_truetype | 同上 | TTF 字体烘焙(SDF atlas) |
| stb_vorbis | 同上 | OGG 解码 |
| ImGui | 已移除 | 曾用于 cdlg-editor 工具 UI;编辑器剥离后不再随仓库发布 |
| mpg123 1.33.7 | 3rdParty/mpg123/ | MP3 解码 |
| openal-soft 1.24.1 | 3rdParty/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 已保护你的电脑」, 发行者显示「发布者未知」。这个提示有三个成因,缺一不可:
- 没有数字签名——没有签名就没有发布者身份,SmartScreen 无从判断,只能按未知应用处理。
- 没有 VERSIONINFO——
resources/resources.rc里原本只有一个 ICON,没有版本资源块, 于是资源管理器、shell 和杀软都读不到「谁做的这个程序」。 - 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 归零 |
| VERSIONINFO | resources/resources.rc.in | 写入 CompanyName / ProductName / FileVersion |
| 签名 | tools/sign-artifacts.ps1 | Authenticode 签名 + 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 |
sign | Authenticode 签名 + 时间戳 + 导出根证书 .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.txt | project(Eraphanis VERSION ...) |
vcpkg.json | "version" |
android/app/build.gradle | versionName(+ versionCode 自增) |
packaging/inno/Eraphanis.iss | #define AppVersion |
libs/complexui/.../CxChangelogPanel.h | versionLabel |
src/ui/views/MainMenuSpec.h | changelogVersion |
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.zip | 64 位便携包 |
Eraphanis-<ver>-win-x86.zip | 32 位便携包 |
Eraphanis-Setup-<ver>.exe | 两套 payload 都在里面,按 Is64BitInstallMode 分流 |
安装包的 [Files] 段用 Check: Is64BitInstallMode / Check: not Is64BitInstallMode 把 x64 与 x86 的条目分开,所以 64 位系统拿到的是 x64 二进制、32 位系统拿到 x86。 体积约为单架构的两倍,这是正常的。
32 位构建的三个坑(都在本仓库踩过并已修好):
mingw32前缀里没有 make。 MSYS2 按前缀装 make,而 make 是构建工具不是目标工具链, mingw64 里那份驱动 mingw32 编译器毫无问题。release.py的find_make()会在整个 MSYS2 根下找,不会因为mingw32/bin/mingw32-make.exe不存在就报错。- 浮点必须统一到 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 全绿。 - CMake 命令行传路径要用正斜杠。 CMake 会把
CMAKE_RC_COMPILER原样写进CMakeRCCompiler.cmake,反斜杠路径在那里变成非法转义(Invalid character escape '\e'), 全新 configure 直接失败。release.py的cmake_path()统一转正斜杠。