fix(rendering): checkpoint main-character recovery and visual regression work
Handle late main-character data, guarded model construction, bounded retries and map correction. Validate player model sources and add lifecycle and real-asset visual regressions. Include pending material and character-selection changes, updated A1 screenshot, and the detailed rendering repair plan. Hair occlusion, full UI parity and final macOS package acceptance remain unfinished.
This commit is contained in:
@@ -24,6 +24,10 @@
|
|||||||
[`docs/SHINSOO-WORLD-RENDERING.md`](docs/SHINSOO-WORLD-RENDERING.md)(正式移植,Phase 2 bring-up 之后);
|
[`docs/SHINSOO-WORLD-RENDERING.md`](docs/SHINSOO-WORLD-RENDERING.md)(正式移植,Phase 2 bring-up 之后);
|
||||||
距参考图「目视等价」还差哪些保真化工作见 [`docs/PARITY-GAP.md`](docs/PARITY-GAP.md)。
|
距参考图「目视等价」还差哪些保真化工作见 [`docs/PARITY-GAP.md`](docs/PARITY-GAP.md)。
|
||||||
|
|
||||||
|
当前进入游戏、模型/发型、贴地与技能栏问题的分阶段修复步骤,以及结合两个 Godot 项目的
|
||||||
|
对照验证和 macOS 包验收要求,见 [`docs/RENDERING-REPAIR-PLAN.md`](docs/RENDERING-REPAIR-PLAN.md)
|
||||||
|
(2026-09-08,实施计划;不表示其中工作已完成)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 现状
|
## 现状
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# 新客户端渲染修复实施路线
|
||||||
|
|
||||||
|
更新日期:2026-09-08。目标客户端:本仓库 `metin2-client`,第一交付平台为 macOS。
|
||||||
|
|
||||||
|
本文是实施规格,2026-09-08 已开始第一批 R0/R1 修改;阶段结果见文末执行记录。未标明通过的工作仍待执行,不代表整体渲染或最终包完成。
|
||||||
|
|
||||||
|
## 1. 目标、依据与当前状态
|
||||||
|
|
||||||
|
第一交付目标:实际 macOS 客户端进入游戏后显示正确地图和真实人物,身体/发型/装备组合正常,人物在地面上,底部技能栏可用。之后再精细还原 40250 的材质、环境、树木、特效和性能。
|
||||||
|
|
||||||
|
画面真值优先使用用户当前 40250 Windows 客户端的同资源、同场景截图。EnriqueGF 的场景截图是可行性和场景完整性参考,不能直接当作 40250 像素真值;其主角背对镜头,也不能证明正面发型和所有动画正确。原始骨骼数值以固定版本 Granny oracle 的可复现实测为参照。
|
||||||
|
|
||||||
|
| 当前问题/证据 | 状态 | 实施要求 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 用户反馈进入游戏出现胶囊、背景异常 | 待真实客户端复验 | 检查地图、实体、相机与加载时序,不直接归因于模型格式 |
|
||||||
|
| `game_scene.gd` 已加入 `_sync_main_character()` 和 `_main_model_attempted` | 工作区有补丁,尚未完成最终画面验收 | 检查晚到实体、失败重试、角色切换和重连的生命周期 |
|
||||||
|
| 本地 `world_demo.gd` 曾加载真实 A1 地图及模型 | 历史组件证据 | 只能证明这组资源链路可运行,不能证明联机 `.app` 正常 |
|
||||||
|
| 发型挡脸、人物悬空 | 用户反馈,根因仍需隔离 | 分别验证变换、蒙皮、深度、地形高度与动画接触点 |
|
||||||
|
| 底部技能未显示 | 用户反馈,已有 `quickbar.gd` | 检查创建、布局、图标、服务端槽位恢复,不重新发明快捷栏 |
|
||||||
|
| 日志曾出现 `unknown GC header 59 (last: 32,148)` | 历史协议阻塞,是否仍存在待复验 | 若重现,单列协议问题;不得跳过未知包继续解析来伪造联机通过 |
|
||||||
|
| 选角 `flip_winding`、材质及视觉测试已有未提交修改 | 实验状态 | 保留现有改动,记录基线,再逐项验证,不全局翻转或覆盖 |
|
||||||
|
|
||||||
|
历史 README / MIDREVIEW 的阶段完成描述不代表当前最终包通过。现有截图、日志在使用前检查是否存在、属于哪个构建和资源版本。
|
||||||
|
|
||||||
|
## 2. 两个上游项目的使用边界
|
||||||
|
|
||||||
|
本路线依据已经检查的固定提交;上游后续变化需要重新审查:
|
||||||
|
|
||||||
|
- EnriqueGF/metin2-godot:`078d39a41a3ef17c9e09fb9807956cb1a2f16641`。
|
||||||
|
- kcanakdag/metin2-godotime:`6a82d793c1d83e34f3e71200fdfc67a78bcb96d3`。
|
||||||
|
|
||||||
|
| 参考实现 | 本项目的应用方式 | 不应推导的结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| EnriqueGF:`tools/compare_visual_golden.py` | 引入区域、遮罩、误差阈值和差异图方法 | 截图存在或整体平均误差低不代表脸部正确 |
|
||||||
|
| EnriqueGF:`tools/build_character_glb.py`、`godot/client/online/presentation/warrior_actor.gd` | 同一角色离线 GLB 对照;核对身体/发型/武器的组合和动作同步 | 转换包含 TRS 分解、舍弃 shear;不能假定动画无损 |
|
||||||
|
| EnriqueGF:`godot/client/online/presentation/avatar_registry.gd` | 借鉴有效实体数据驱动创建、切地图清理、重复数据处理 | 其快照协议不能直接替代我们的原版协议事件 |
|
||||||
|
| EnriqueGF:`tools/prepare_legacy_ui_assets.py`、`godot/client/online/ui/` | 核对 `.sub` 裁剪、原版布局和按钮状态 | 西班牙语素材、字体、窗口尺寸不能直接替代当前中文配置 |
|
||||||
|
| EnriqueGF:`tools/audit_city_asset_dependencies.py` | 地图资源依赖追踪、缺失分类和报告 | 全部依赖可解析不等于全部已经正确渲染 |
|
||||||
|
| godotime:`tools/export_playable.py` | 实际包资源清单、哈希与加载审计,适配 macOS | Web/Linux 通过不代表 macOS 通过 |
|
||||||
|
| godotime:`content/profiles/p0-warrior-dog.json`、`client/scripts/content/` | 模型、动作、附件和依赖的统一验证清单 | 三个精选模型通过不代表四职业和全量换装通过 |
|
||||||
|
| godotime:`tools/test_browser_accounts.py` | 借鉴双客户端、重连、换装及账户生命周期用例 | 不迁移其 SpacetimeDB 服务端来修复渲染 |
|
||||||
|
|
||||||
|
参考源码:
|
||||||
|
|
||||||
|
- [EnriqueGF 固定版本](https://github.com/EnriqueGF/metin2-godot/tree/078d39a41a3ef17c9e09fb9807956cb1a2f16641)
|
||||||
|
- [EnriqueGF 场景截图](https://github.com/EnriqueGF/metin2-godot/blob/078d39a41a3ef17c9e09fb9807956cb1a2f16641/docs/images/ingame-shinsoo.png)
|
||||||
|
- [godotime 固定版本](https://github.com/kcanakdag/metin2-godotime/tree/6a82d793c1d83e34f3e71200fdfc67a78bcb96d3)
|
||||||
|
|
||||||
|
直接移植 EnriqueGF 原创代码时保留 MIT 声明和提交来源。godotime 在本次检查的提交没有项目级许可证,先借鉴方法;直接复制实现前明确授权。原始游戏资源的来源与使用范围沿用本仓库 [THIRD-PARTY.md](THIRD-PARTY.md),不把上游许可证视为原版资产授权。
|
||||||
|
|
||||||
|
## 3. 技术路线与顺序
|
||||||
|
|
||||||
|
执行顺序:R0 基线 → R1 场景装配 → R2 模型/发型 → R3 材质/环境 → R4 UI → R5 最终包验收 → R6 性能与内容扩展。R4 可在 R1 后独立推进;R5 的资源清单工具可提前建设,最终验收必须等待 R1–R4 完成。
|
||||||
|
|
||||||
|
1. 保留原版网络协议与当前 GR2 主链路,以最小改动修复实际缺陷。
|
||||||
|
2. 引入独立 GLB 对照场景,不先全量转换资源或更换运行时。
|
||||||
|
3. 对照决定具体资源采用哪条路径;结果需要记录,不能预设 GR2 或 GLB 必然更好。
|
||||||
|
4. 角色与静态物件分别决策。静态物件可先试离线 Godot 资源缓存;人物保留完整仿射矩阵的需求单独评估。
|
||||||
|
5. 每项修复必须在真实 GameScene 和导出包中复验。单模型预览只能作为中间证据。
|
||||||
|
|
||||||
|
## 4. R0:建立可重复基线
|
||||||
|
|
||||||
|
涉及现有文件:`test/compare.py`、`project/char_select_visual_test.gd`、`project/world_demo.gd`、`project/game_camera.gd`。
|
||||||
|
|
||||||
|
### 具体步骤
|
||||||
|
|
||||||
|
1. 记录当前 Git 提交、未提交补丁摘要、Godot/导出模板版本、扩展库哈希、macOS/GPU、渲染后端、窗口和实际渲染分辨率。不能仅凭提交号标识脏工作区构建。
|
||||||
|
2. 固定资产来源及哈希:身体、发型、武器、护甲、MSM/MSA、动画 GR2、纹理、地图和环境。两个上游与我们可能使用不同资源版本,必须先核对。
|
||||||
|
3. 为每个场景保存地图名、服务器坐标、地图基点、转换后本地坐标、相机位置/目标/FOV、角色朝向、装备、动作及取样时间。
|
||||||
|
4. 建立三种独立场景:无网络的单角色实验台;真实资产的 GameScene + FakeClient;实际服务器上的导出 `.app`。
|
||||||
|
5. 保存正面、侧面、背面和脚底近景,以及选角界面和游戏全景。待机/跑步/攻击每段至少取开始、中间、结束,过渡动作另测。
|
||||||
|
6. 在现有截图工具基础上增加局部区域和差异图。脸、头发、脚底、技能栏不允许被动态遮罩排除;时间文字、随机粒子等可单列遮罩。
|
||||||
|
7. 区分两类比较:同引擎修复前后用于自动回归;40250 对照用于保真评估。没有匹配的相机和动作时,只能做人工结构检查,不执行严格像素判定。
|
||||||
|
|
||||||
|
拟新增产物(尚未实现):`test/rendering/cases.json`、`test/rendering/README.md`、`tools/rendering/compare_regions.py`。本地日志/截图/报告建议放 `build/rendering/<run-id>/`,不覆盖失败证据。
|
||||||
|
|
||||||
|
阈值:先在不改代码的条件下重复采样三次,测量阴影/抗锯齿噪声,再设置每区域阈值。报告包含 MAE、RMSE、变化像素比例和遮罩范围;不能为了让错误图片通过而放宽阈值。
|
||||||
|
|
||||||
|
完成条件:每条用例可由配置重复启动,并能将截图关联到确定的代码、资源、相机和构建;缺参考图的用例标为待验证。
|
||||||
|
|
||||||
|
## 5. R1:修复主角装配、地图定位与相机
|
||||||
|
|
||||||
|
涉及现有文件:`project/game_scene.gd`、`project/net_world.gd`、`project/net/mapcoord.gd`、`project/ui/player_view.gd`、`project/game_camera.gd`。必要时追踪 `extension/src/net/m2_client.cpp` 的事件顺序及 `extension/src/metin2_world.cpp` 的地图数据。
|
||||||
|
|
||||||
|
### 具体步骤
|
||||||
|
|
||||||
|
1. 增加定点诊断:阶段进入、地图就绪、主角 VID 获得、实体行有效、模型创建、贴地定位、相机开始跟随。每条带角色/场景生命周期编号,避免把上一次登录数据误认为当前数据。
|
||||||
|
2. 复核现有 `_sync_main_character()`:明确“等待数据、加载中、成功、失败”的状态;缺 race 等必要数据时保持等待,重复事件不创建重复人物。
|
||||||
|
3. 检查 `_main_model_attempted` 是否在失败后永久阻止恢复。数据或资源就绪条件变化时允许受控重试;永久资源错误显示明确失败原因,不能每帧重复加载或刷日志。
|
||||||
|
4. 切角色、地图切换、重连时清理旧模型/相机目标/信号并重置对应状态。异步完成结果必须核对是否仍属于当前角色与场景。
|
||||||
|
5. 统一通过 MapCoord 转换:服务器全局厘米 → 地图本地米。现有地图本地 Z 约定与上游不同,禁止直接复制对方轴翻转或地图中心公式。
|
||||||
|
6. 验证坐标往返、合法边界及高度查询位置。`(0,0)` 可是合法地图基点,地图是否就绪应有明确状态,不能仅靠非零基点推断。
|
||||||
|
7. 在地图可查询、角色位置有效后计算地面高度并绑定相机;模型就绪后移除胶囊。资源等待期间提供加载状态,不能把永久占位当正常游戏画面。
|
||||||
|
|
||||||
|
### 必须补充的回归
|
||||||
|
|
||||||
|
- 主角 VID 在 setup 前已有、setup 后出现、实体行再晚一帧、多帧后出现。
|
||||||
|
- 不发 `entity_main_set`,只更新实体存储,最终仍创建主角。
|
||||||
|
- 重复主角事件只产生一个模型;缺 race 时不使用错误职业创建人物。
|
||||||
|
- 加载失败有可见原因;条件恢复后可受控重试;旧加载不会覆盖新角色。
|
||||||
|
- A1/C1 各一个合法出生点;基点为零的合成坐标用例;地图外坐标被明确诊断。
|
||||||
|
- 切角色、重连、切地图后相机跟随当前人物,旧节点与旧状态不残留。
|
||||||
|
|
||||||
|
测试落点:扩展 `project/gamescene_test.gd`、`project/app_flow_lifecycle_test.gd`;新增真实资源的 GameScene 视觉用例。FakeClient 的字段/信号需匹配实际接口。
|
||||||
|
|
||||||
|
完成条件:自动回归通过;真实 GameScene 截图同时出现正确地表与真实主角。协议若中断,保留本地通过结果,但将实际联机验收标为未通过。
|
||||||
|
|
||||||
|
## 6. R2:修复身体、发型、武器与贴地
|
||||||
|
|
||||||
|
涉及现有文件:`extension/src/metin2_model.cpp`、`extension/src/metin2_anim.cpp`、`extension/src/gr2_bridge.cpp`、`project/ui/player_view.gd`、`project/ui/equip_model.gd`、`project/ui/race_spec.gd`、`project/ui/char_select_screen.gd`。
|
||||||
|
|
||||||
|
### 同资源对照与定位顺序
|
||||||
|
|
||||||
|
1. 先选用户出错角色的确切身体/发型/装备;再建立男战士最小对照。GLB 转换必须使用同一组源文件,记录转换器版本与参数。生成文件留在隔离输出目录。
|
||||||
|
2. 在统一实验台逐步加载:身体静态绑定姿势 → 身体动画 → 加发型 → 加武器 → 换护甲。每一步保存矩阵、包围盒、贴图绑定和截图。
|
||||||
|
3. 检查模型初始 placement、单位缩放、轴转换、父子节点变换是否重复应用。分别核查三角形绕序和负缩放,不能以全局 `flip_winding` 代替定位。
|
||||||
|
4. 对身体和发型按骨名核对 mesh binding、骨架索引、inverse bind、当前 world/skin 矩阵。验证发型是否错误重复乘身体变换,是否使用了另一套不兼容的绑定姿势。
|
||||||
|
5. 检查附件共享的动画时间与过渡状态。身体切动作/换装时,发型不能停留在旧姿势。武器验证手部附件空间,避免在 model-local 与 world-space 之间重复转换。
|
||||||
|
6. 先用不受光照影响的诊断材质、线框或深度视图判断几何是否错误;几何正确而发型仍盖脸时进入 R3 检查深度和混合。
|
||||||
|
7. 数值正确后比较 GR2 CPU、GR2 GPU 与 GLB 的同时间画面。对关键骨骼矩阵、选定顶点和完整动画周期采样,保留已有 oracle 精度要求,不只检查 NaN。
|
||||||
|
|
||||||
|
### 贴地专项
|
||||||
|
|
||||||
|
1. 分开记录实体基准高度、地形采样高度、模型局部原点和脚部接触点。
|
||||||
|
2. 平地待机时用足底接触点判断高度;行走/攻击存在抬脚,不能用动画每帧 AABB 最低点强行贴地,否则人物会抖动。
|
||||||
|
3. 测试坡地、台阶或可行走建筑表面,以及地图边界。确认所采样的是人物实际站立表面。
|
||||||
|
4. 阴影检查与几何检查分开:阴影偏差可造成悬空观感,但不能用加深阴影掩盖高度错误。
|
||||||
|
5. 建议初始平地接触误差上限为 2 cm;这是待以资源与 40250 校准的项目目标,不是原版已证实精度。腾空动作不使用该判据。
|
||||||
|
|
||||||
|
### 路线决策
|
||||||
|
|
||||||
|
| 实测结果 | 后续动作 |
|
||||||
|
| --- | --- |
|
||||||
|
| GR2 数值正确,仅材质/装配错误 | 修复现有渲染与组合,不迁移动画 |
|
||||||
|
| GLB 在选定资源上可靠,GR2 缺陷短期难解决 | 为该资源提供可回退的 GLB 适配,保持相同的角色表现接口 |
|
||||||
|
| GLB 丢 shear 或过渡不一致 | 不将该角色纳入迁移范围;保留失败样本 |
|
||||||
|
| 静态 GLB/原生资源缓存画面一致且加载更快 | 单独推进静态物件缓存,与角色动画路线解耦 |
|
||||||
|
|
||||||
|
GLB 扩大范围前必须覆盖四职业、实际使用的性别/护甲/发型变体,以及待机、走、跑、攻击、受击、死亡和选角动作。缺少资产时明确未覆盖,不以一个战士替代全体结论。
|
||||||
|
|
||||||
|
完成条件:出错角色的正面/侧面/背面及关键动作通过;换装、重新选角后仍正确;记录 GR2/GLB 选择依据。清理临时强制绕序、调试打印和 `/tmp` 截图导出时只删除本轮明确归属的诊断代码。
|
||||||
|
|
||||||
|
## 7. R3:材质、地形与环境校准
|
||||||
|
|
||||||
|
涉及现有文件:`extension/src/m2_material.cpp`、`extension/src/static_object.cpp`、`extension/src/terrain_splat.cpp`、`extension/src/environment_builder.cpp`、`extension/src/metin2_world.cpp`、`project/ui/char_shadow.gd`。
|
||||||
|
|
||||||
|
### 具体步骤
|
||||||
|
|
||||||
|
1. 逐 surface 输出源 mesh、tri-group、材质槽、最终纹理和渲染状态。确认 mesh-local 材质索引没有误当成全局索引,贴图回退有明确诊断。
|
||||||
|
2. 身体、脸等不透明面验证深度写入;发丝按资产语义选择 alpha-test;真正半透明面单独混合。不要把所有头发强制成同一模式,也不要对所有表面关闭背面剔除。
|
||||||
|
3. 同贴图下比较标准材质和自定义 shader,逐项打开光照、顶点色、透明、雾。检查 DDS 解码、颜色空间、alpha、mipmap 和双重 gamma。
|
||||||
|
4. 身体、头发、武器分开检查投影与受影。正面、侧面、逆光及脸部重叠处验证深度;根据场景尺寸校准 shadow bias。
|
||||||
|
5. 地形逐层验证 tile/alpha splat 对应、UV 比例、接缝和法线。固定至少一处道路/草地边界作为回归,防止纹理层顺序交换。
|
||||||
|
6. 列出地图物件:应有、找到资源、解析成功、实例化、可见、失败原因。资源 CRC 可解析不等于模型已画出,树木占位必须单独计数。
|
||||||
|
7. 基于原 `.msenv` 对照太阳方向、角色/背景光、环境光、雾距离与天空;逐项记录 Godot 近似规则。EnriqueGF 的固定 filmic/雾参数只作实验参照。
|
||||||
|
8. 完成角色与场景单项校准后再组合全景,避免同时改曝光、贴图与相机导致无法归因。
|
||||||
|
|
||||||
|
完成条件:脸/身体不误透明,发丝边缘和遮挡正确;道路与草地层正确;主要建筑不缺材质;角色高度与阴影分别通过;每项与 40250 的剩余差异有说明。
|
||||||
|
|
||||||
|
## 8. R4:选角界面与技能栏
|
||||||
|
|
||||||
|
涉及现有文件:`project/ui/char_select_screen.gd`、`project/ui/ui_assets.gd`、`project/ui/quickbar.gd`、`project/game_scene.gd`。
|
||||||
|
|
||||||
|
### 具体步骤
|
||||||
|
|
||||||
|
1. 对照当前中文 40250 的 `uiscript`、`.sub`、字体及图集,记录选角背景、角色相机、按钮、任务栏、快捷槽的来源与尺寸。
|
||||||
|
2. 检查 `.sub` 裁剪边界、大小写路径、透明通道与纹理加载失败;输出 UI 依赖清单。
|
||||||
|
3. 从 GameScene 开始检查快捷栏是否创建、挂在正确 CanvasLayer、可见、位于窗口内;核对缩放、锚点、裁剪和其他窗口遮挡。
|
||||||
|
4. 跟踪 `quickslots_changed` 与 `restore_from_server` 的调用时序:初始槽位、晚到数据、重新登录均应恢复。保留现有服务端 36 槽与本地 4 页 × 8 格映射。
|
||||||
|
5. 将空槽、未学习技能、图标缺失、技能不可用、冷却中区分呈现。不得通过凭空填充技能图标让截图看似正常。
|
||||||
|
6. 使用确实已学习并已配置的技能验证图标、拖放、翻页、数字键/F 键和冷却;UI 对比先用 FakeClient,真实施放在适用测试环境下执行。
|
||||||
|
7. 验证背包/聊天打开关闭后输入焦点,避免空格攻击误触 UI 按钮。
|
||||||
|
8. 按 1280×720、1920×1080 和实际 Retina 窗口验证逻辑尺寸/物理像素关系;中文字体宽度单独检查。
|
||||||
|
|
||||||
|
完成条件:选角画面达到已记录的参考布局;底部栏始终可见,槽位状态真实;切角色/重连能恢复;技能冷却和输入行为保持正确。
|
||||||
|
|
||||||
|
## 9. R5:最终 macOS 包与验收证据
|
||||||
|
|
||||||
|
涉及现有文件:`build-macos-client.sh`、`script/live_smoke_test.sh`、`project/live_smoke_test.gd`、`project/asset_pack.gd`、`project/bake_asset_index.gd`。
|
||||||
|
|
||||||
|
### 具体步骤
|
||||||
|
|
||||||
|
1. 扩展构建清单,记录代码版本及脏补丁摘要、构建参数、扩展/模板版本、资源及实际产物哈希。
|
||||||
|
2. 对实际 `.app` 检查身体、发型、装备、动作、地图、UI 图标依赖是否存在且可加载。清单生成后仍要启动包验证,不能用工作区资源替代包内检查。
|
||||||
|
3. 从不依赖开发者资源路径的目录启动最终包;验证资源解析不会偷偷回退到源码 checkout。用受控资源根测试覆盖此情况。
|
||||||
|
4. 执行只读登录冒烟,再运行有画面和输入的 GameScene 验收。现有脚本不是完整视觉测试;需新增或扩展包内测试场景并保存截图。
|
||||||
|
5. 移动、攻击、换装等会改变服务端状态的用例使用适用测试角色/服务器;不要把现有冒烟默认包含的聊天发送当作渲染验收必需动作。
|
||||||
|
6. 两客户端用例验证角色互见、移动朝向、武器/护甲变化、断开移除及重连重建;同时检查本地与远端外观。
|
||||||
|
7. 排除凭据、私有日志、源码资产归档和临时探针出现在普通交付包中。若通过带探针的包定位问题,移除探针后还要对最终普通包重新冒烟。
|
||||||
|
8. 将通过记录绑定到交付 `.app` 的确切构建。此后任何影响画面的代码、资源、导出设置变化,都使相关验收失效,需要重测受影响范围。
|
||||||
|
|
||||||
|
### 现有命令与覆盖边界
|
||||||
|
|
||||||
|
以下命令在仓库根目录运行。执行前检查本机 Godot、扩展和资源配置;这些是已存在的入口,本文没有执行它们或宣称通过。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godot --headless --path project --script gamescene_test.gd
|
||||||
|
godot --headless --path project --script equip_model_test.gd
|
||||||
|
godot --headless --path project --script environment_test.gd
|
||||||
|
godot --path project --script char_select_visual_test.gd
|
||||||
|
./build-macos-client.sh release
|
||||||
|
./script/live_smoke_test.sh --no-build --no-mutations --no-reconnect
|
||||||
|
```
|
||||||
|
|
||||||
|
- Headless 测试仅证明其断言覆盖的逻辑,不能证明实际 GPU 画面正确。
|
||||||
|
- 当前选角视觉脚本有实验性诊断,使用前复核其输出和退出行为;它不是完整 40250 比较器。
|
||||||
|
- `--no-mutations` 不覆盖移动、聊天、重连、换装和攻击;此处同时给出 `--no-reconnect` 以明确首轮只读范围。
|
||||||
|
- 当前脚本包含特定服务器地址与端口,运行前检查是否仍是目标环境;凭据只通过既有本地配置/环境提供,不写进报告。
|
||||||
|
- 现有产物路径为 `build/export/mtgodot-poc.app`;日志在 `build/live-smoke/`。保留每次结果时复制到独立 run-id 归档,避免脚本固定路径覆盖历史。
|
||||||
|
|
||||||
|
### 第一版交付检查表
|
||||||
|
|
||||||
|
- [ ] 首次进入、切角色、重连后出现真实主角,地图和相机正确。
|
||||||
|
- [ ] 用户出错角色的身体/发型/装备在正面及动画中正常。
|
||||||
|
- [ ] 平地和选定坡地测试通过,脚底与阴影分别正确。
|
||||||
|
- [ ] 底部任务栏可见,已配置技能的图标、输入、恢复、冷却正确。
|
||||||
|
- [ ] 材质与地图关键区域通过固定场景比较,差异图有人审阅。
|
||||||
|
- [ ] 最终普通 `.app` 独立启动,关键资源无开发目录回退。
|
||||||
|
- [ ] 协议错误未导致用例提前终止;未完成的联机项明确标注。
|
||||||
|
- [ ] 日志、截图、报告和包哈希对应同一次构建。
|
||||||
|
|
||||||
|
上述为本次内部 macOS 修复版交付标准,不代表全游戏、所有地图或所有平台均已验收。
|
||||||
|
|
||||||
|
## 10. R6:性能、缓存与内容扩展
|
||||||
|
|
||||||
|
在 R1–R5 通过后执行,不以优化替代正确性修复。
|
||||||
|
|
||||||
|
1. 固定场景采集 1/8/20/50 个角色的 CPU/GPU 帧时间、p95/p99、内存、冷加载与重复加载耗时,明确前台/后台和帧率限制。
|
||||||
|
2. 量化当前 CPU 蒙皮逐帧构建网格的成本;GPU 路径先通过数值与画面回归,再扩大启用范围。
|
||||||
|
3. 选择一组代表性静态建筑试缓存,缓存键至少包括源资源、转换器版本、坐标和材质选项;改变任一输入必须失效重建。
|
||||||
|
4. 测量缓存对冷加载、包体和内存的影响,存在收益再扩大导入规模。保留原始资源来源和可重建流程。
|
||||||
|
5. 树木、效果、LOD 和其他地图分别建立缺失清单与回归场景。占位替代物不能计入原版模型已完成数量。
|
||||||
|
6. 最后扩展 Android/iOS 独立验证;macOS 的画面和性能结果不能直接外推。
|
||||||
|
|
||||||
|
## 11. 任务拆分与执行记录
|
||||||
|
|
||||||
|
每项宜形成一个可独立复查的补丁;无需先完成所有工具才修复已定位的问题。已有用户改动应在相邻上下文中保留,不因路线实施而重置。
|
||||||
|
|
||||||
|
| 任务 ID | 交付物 | 前置条件 | 当前状态 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| R0-01 | 固定资源/场景基线与区域截图配置 | 可用参考资源 | 已有 A1/C1 实景入口和部分哈希;区域差异待补 |
|
||||||
|
| R1-01 | 主角晚到、重复事件、重连回归与修复 | R0 最小基线 | 晚到/重入/重试回归通过;真实重连待验 |
|
||||||
|
| R1-02 | 地图坐标、高度、相机就绪检查 | R1-01 | A1/C1 晚到定位实景通过;坡地/边界待验 |
|
||||||
|
| R2-01 | 同战士 GR2/GLB 实验台及对照报告 | R0 | 待执行 |
|
||||||
|
| R2-02 | 发型/身体/附件修复及路线决定 | R2-01 | 待执行 |
|
||||||
|
| R3-01 | 深度/材质/环境与地形校准 | R1、R2 几何稳定 | 材质已有改动,待验证 |
|
||||||
|
| R4-01 | 选角、技能栏装配与数据恢复 | R1 | 待执行 |
|
||||||
|
| R5-01 | 包资源清单、最终 `.app` 冒烟与画面报告 | R1–R4 | 待执行 |
|
||||||
|
| R6-01 | 性能基线与静态资源缓存试验 | R5 | 后续 |
|
||||||
|
|
||||||
|
每项完成记录必须包含:问题复现、确认根因、修改文件、源资源哈希、实际执行命令、断言/截图结果、包或库哈希、未覆盖范围。失败时记录下一步所需证据;不要把“方案合理”“截图已生成”更新成通过。
|
||||||
|
|
||||||
|
与现有文档的关系:本路线聚焦用户当前渲染故障和首个 macOS 修复包;[SHINSOO-WORLD-RENDERING.md](SHINSOO-WORLD-RENDERING.md) 提供世界渲染背景,[PARITY-GAP.md](PARITY-GAP.md) 和 [BACKLOG.md](BACKLOG.md) 保留更广范围。后续只在实测完成时同步这些文档的对应状态。
|
||||||
|
|
||||||
|
### 2026-09-08 第一批实施记录
|
||||||
|
|
||||||
|
- 修改 `game_scene.gd`:setup 完成相关接线后才开始兜底;加载期间阻止网络事件重入;按 VID/race/资源根区分模型;失败间隔 1 秒最多尝试 3 次;丢弃构建期间失效的主角结果;避免重复动画订阅和每帧重置人物位置。
|
||||||
|
- 发现地图路径原来只在 setup 时按已收到的主角位置选择。新增首次有效主角位置到达后的地图重选,复用已有地图重载和相机/移动同步基点定位函数。
|
||||||
|
- 修改 `ui/player_view.gd`:拒绝非法 race、缺失身体文件及解析失败的源文件。模型节点在入树时才实际加载,因此使用源文件预检,不能把入树前为空的 `get_info()` 当成加载失败。
|
||||||
|
- 新增 `main_character_sync_test.gd`:测试失败重试、晚到/无信号实体、重入、重复事件、移动不回滚、过期构建等,已执行得到 `failures=0`。注入失败用例的 warning 属预期。
|
||||||
|
- `gamescene_test.gd` 与 `equip_model_test.gd` 已通过;前者故意使用缺失地图,其预期加载错误不能当成真实地图测试通过。
|
||||||
|
- 新增 `gamescene_visual_test.gd`:Apple M4 / Metal Forward+ / Godot 4.7.1 上已执行 A1/C1;A1 加载 20 区块/601 物件,C1 加载 20 区块/565 物件,身体加发型共 4 个表面,实体定位断言通过。两者均使用 FakeClient,未连接实际服务器。
|
||||||
|
- 截图人工观察:地图和真实人物出现;UI 仍有布局/风格问题,不能标为 R3/R4 完成。选角截图仍复现发型遮脸;单独翻转 winding 的试验未解决,未保留新的绕序修改。
|
||||||
|
- 用例命令、输出位置和验证边界见 [test/rendering/README.md](../test/rendering/README.md)。早期失败截图保留在 `build/rendering/`;后续脚本变化应重新执行,不复用旧报告作为新代码证据。
|
||||||
|
- 尚未完成:GLB 对照、发型根因修复、完整视觉基线与区域阈值、真实服务器重连、最终 macOS 包验收、R6 优化。下一优先项是发型的身体/附件变换与深度隔离。
|
||||||
@@ -183,8 +183,24 @@ Ref<Shader> s_mix_2s;
|
|||||||
Ref<Shader> s_add_2s;
|
Ref<Shader> s_add_2s;
|
||||||
Ref<Shader> s_skin_2s;
|
Ref<Shader> s_skin_2s;
|
||||||
Ref<Shader> s_add_skin_2s;
|
Ref<Shader> s_add_skin_2s;
|
||||||
|
Ref<Shader> s_opaque[4];
|
||||||
|
|
||||||
Ref<Shader> shader_for(bool additive, bool skinned, bool two_sided) {
|
Ref<Shader> shader_for(bool additive, bool skinned, bool two_sided, bool opaque) {
|
||||||
|
if (opaque) {
|
||||||
|
Ref<Shader> &solid = s_opaque[(skinned ? 2 : 0) + (two_sided ? 1 : 0)];
|
||||||
|
if (solid.is_null()) {
|
||||||
|
String code = skinned ? SRC_SKIN : SRC_MIX;
|
||||||
|
// Writing ALPHA puts even alpha=1 into Godot's transparent pass.
|
||||||
|
// Solid faces must write depth before the hair cutout is drawn.
|
||||||
|
code = code.replace("ALPHA = ((mode == 1) ? c.a : 1.0) * lod_fade;",
|
||||||
|
"if (lod_fade < fract(sin(dot(FRAGCOORD.xy, vec2(12.9898, 78.233))) * 43758.5453)) { discard; }");
|
||||||
|
if (two_sided)
|
||||||
|
code = code.replace("cull_back", "cull_disabled");
|
||||||
|
solid.instantiate();
|
||||||
|
solid->set_code(code);
|
||||||
|
}
|
||||||
|
return solid;
|
||||||
|
}
|
||||||
Ref<Shader> &slot = two_sided
|
Ref<Shader> &slot = two_sided
|
||||||
? (additive ? (skinned ? s_add_skin_2s : s_add_2s) : (skinned ? s_skin_2s : s_mix_2s))
|
? (additive ? (skinned ? s_add_skin_2s : s_add_2s) : (skinned ? s_skin_2s : s_mix_2s))
|
||||||
: (additive ? (skinned ? s_add_skin : s_add) : (skinned ? s_skin : s_mix));
|
: (additive ? (skinned ? s_add_skin : s_add) : (skinned ? s_skin : s_mix));
|
||||||
@@ -206,7 +222,8 @@ Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
|
|||||||
|
|
||||||
const bool additive = (d.blend == BlendMode::Add);
|
const bool additive = (d.blend == BlendMode::Add);
|
||||||
const bool skinned = d.skinned;
|
const bool skinned = d.skinned;
|
||||||
m->set_shader(shader_for(additive, skinned, d.two_sided));
|
m->set_shader(shader_for(additive, skinned, d.two_sided,
|
||||||
|
d.blend == BlendMode::Opaque || d.blend == BlendMode::AlphaTest));
|
||||||
if (skinned && d.bones_tex.is_valid()) {
|
if (skinned && d.bones_tex.is_valid()) {
|
||||||
m->set_shader_parameter("bones_tex", d.bones_tex);
|
m->set_shader_parameter("bones_tex", d.bones_tex);
|
||||||
}
|
}
|
||||||
@@ -244,6 +261,8 @@ Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void cleanup_material_shaders() {
|
void cleanup_material_shaders() {
|
||||||
|
for (auto &shader : s_opaque)
|
||||||
|
shader.unref();
|
||||||
s_mix.unref();
|
s_mix.unref();
|
||||||
s_add.unref();
|
s_add.unref();
|
||||||
s_skin.unref();
|
s_skin.unref();
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ func _run() -> void:
|
|||||||
quit(1)
|
quit(1)
|
||||||
return
|
return
|
||||||
print("Selected armor: ", screen._pv.model.get("gr2_path"))
|
print("Selected armor: ", screen._pv.model.get("gr2_path"))
|
||||||
|
print("Hair skin: ", screen._pv.model.get("hair_skin"))
|
||||||
|
var ua = preload("res://ui/ui_assets.gd")
|
||||||
|
ua.load_dds_image(String(screen._pv.model.get("hair_skin"))).save_png("/tmp/metin2-hair.png")
|
||||||
for mi in screen._pv.find_children("*", "MeshInstance3D", true, false):
|
for mi in screen._pv.find_children("*", "MeshInstance3D", true, false):
|
||||||
for surface in mi.mesh.get_surface_count():
|
for surface in mi.mesh.get_surface_count():
|
||||||
print("SURFACE ", surface, " ", mi.mesh.surface_get_name(surface))
|
print("SURFACE ", surface, " ", mi.mesh.surface_get_name(surface))
|
||||||
|
|||||||
+101
-14
@@ -120,6 +120,17 @@ var weather: Node3D # Weather
|
|||||||
var _assets := ""
|
var _assets := ""
|
||||||
var map_path := ""
|
var map_path := ""
|
||||||
var _model_built := false
|
var _model_built := false
|
||||||
|
var _main_model_attempted := false
|
||||||
|
var _main_sync_ready := false
|
||||||
|
var _main_loading := false
|
||||||
|
var _main_generation := 0
|
||||||
|
var _main_view_key := ""
|
||||||
|
var _main_attempts := 0
|
||||||
|
var _main_retry_at := 0
|
||||||
|
var _main_placed_vid := 0
|
||||||
|
var _main_map_vid := 0
|
||||||
|
const MAIN_MODEL_MAX_ATTEMPTS := 3
|
||||||
|
const MAIN_MODEL_RETRY_MS := 1000
|
||||||
var _audio: Node
|
var _audio: Node
|
||||||
var _bgm_director: Node
|
var _bgm_director: Node
|
||||||
var _mount: Node3D
|
var _mount: Node3D
|
||||||
@@ -548,6 +559,7 @@ func setup(m2client: Node, assets_root: String,
|
|||||||
_setup_mobile_ui()
|
_setup_mobile_ui()
|
||||||
|
|
||||||
client.entity_main_set.connect(_on_main_set)
|
client.entity_main_set.connect(_on_main_set)
|
||||||
|
_main_sync_ready = true
|
||||||
if client.get_main_vid() != 0: # 重连 / 已在局内
|
if client.get_main_vid() != 0: # 重连 / 已在局内
|
||||||
# net_play._on_main_set 的信号早在它连上前就发过了 —— 手动补上关键那步:
|
# net_play._on_main_set 的信号早在它连上前就发过了 —— 手动补上关键那步:
|
||||||
# 告诉 net_world 主角 vid 由本地 player 代表,别给它生成节点(否则 catch_up 会
|
# 告诉 net_world 主角 vid 由本地 player 代表,别给它生成节点(否则 catch_up 会
|
||||||
@@ -639,6 +651,13 @@ func _lifecycle_node() -> Node:
|
|||||||
return null
|
return null
|
||||||
|
|
||||||
func _process(_dt: float) -> void:
|
func _process(_dt: float) -> void:
|
||||||
|
# `entered_game` is emitted by the phase transition, while the first
|
||||||
|
# GC_MAIN_CHARACTER / EntityStore change is drained later in the same or a
|
||||||
|
# following frame. The setup-time catch-up below cannot see that packet in
|
||||||
|
# all server timings, so keep the event path and add this idempotent fallback.
|
||||||
|
# Without it the local player stays as the blue CapsuleMesh forever and the
|
||||||
|
# camera follows an ungrounded placeholder, which makes the map look empty.
|
||||||
|
_sync_main_character()
|
||||||
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
|
# MilesLib::CSoundManager::SetPosition/SetDirection + Update. The audio
|
||||||
# node keeps its listener at the origin and rebases active 3D instances.
|
# node keeps its listener at the origin and rebases active 3D instances.
|
||||||
if _audio == null or player == null or not is_instance_valid(player):
|
if _audio == null or player == null or not is_instance_valid(player):
|
||||||
@@ -853,6 +872,14 @@ func _on_warp(pos: Vector3, same_server: bool) -> void:
|
|||||||
_place_player_at_net_pos(pos)
|
_place_player_at_net_pos(pos)
|
||||||
|
|
||||||
func _on_world_reset() -> void:
|
func _on_world_reset() -> void:
|
||||||
|
_main_generation += 1
|
||||||
|
_main_view_key = ""
|
||||||
|
_main_attempts = 0
|
||||||
|
_main_retry_at = 0
|
||||||
|
_main_placed_vid = 0
|
||||||
|
_main_map_vid = 0
|
||||||
|
_model_built = false
|
||||||
|
_main_model_attempted = false
|
||||||
if net_world and net_world.has_method("clear_for_map_change"):
|
if net_world and net_world.has_method("clear_for_map_change"):
|
||||||
net_world.clear_for_map_change()
|
net_world.clear_for_map_change()
|
||||||
if ground_items and ground_items.has_method("clear_for_map_change"):
|
if ground_items and ground_items.has_method("clear_for_map_change"):
|
||||||
@@ -1369,6 +1396,10 @@ func _build_lighting() -> void:
|
|||||||
_env = e
|
_env = e
|
||||||
|
|
||||||
func _on_main_set(vid: int) -> void:
|
func _on_main_set(vid: int) -> void:
|
||||||
|
if not _main_sync_ready or _main_loading or vid <= 0 or client == null:
|
||||||
|
return
|
||||||
|
if vid != int(client.get_main_vid()):
|
||||||
|
return # A queued event may belong to the previous character.
|
||||||
# GC_MAIN_CHARACTER can arrive after GameScene has subscribed. Mark this VID
|
# GC_MAIN_CHARACTER can arrive after GameScene has subscribed. Mark this VID
|
||||||
# as local before building its view so NetWorld removes (and never recreates)
|
# as local before building its view so NetWorld removes (and never recreates)
|
||||||
# the generic remote-player capsule for our own character.
|
# the generic remote-player capsule for our own character.
|
||||||
@@ -1379,11 +1410,45 @@ func _on_main_set(vid: int) -> void:
|
|||||||
var e: Dictionary = client.get_entity(vid)
|
var e: Dictionary = client.get_entity(vid)
|
||||||
if e.is_empty() or player == null:
|
if e.is_empty() or player == null:
|
||||||
return
|
return
|
||||||
|
var net_pos: Variant = e.get("pos", null)
|
||||||
|
if not net_pos is Vector3 or not net_pos.is_finite():
|
||||||
|
return
|
||||||
|
# setup can only choose a map from data already received. Resolve again
|
||||||
|
# once the late authoritative position is available, before placing/viewing.
|
||||||
|
if _main_map_vid != vid:
|
||||||
|
_main_map_vid = vid
|
||||||
|
if _assets != "":
|
||||||
|
var next_map := _resolve_map_path(map_path, Vector2(net_pos.x * 100.0, -net_pos.z * 100.0))
|
||||||
|
if next_map != map_path:
|
||||||
|
_main_loading = true
|
||||||
|
_reload_map_for_warp(net_pos)
|
||||||
|
_main_loading = false
|
||||||
|
var key := "%d:%d:%s" % [vid, int(e.get("race", -1)), _assets]
|
||||||
|
if key != _main_view_key:
|
||||||
|
_main_view_key = key
|
||||||
|
_main_attempts = 0
|
||||||
|
_main_retry_at = 0
|
||||||
|
_main_model_attempted = false
|
||||||
|
_model_built = false
|
||||||
# 首次拿到 race -> 换成真 Metin2Model
|
# 首次拿到 race -> 换成真 Metin2Model
|
||||||
if not _model_built and _assets != "" and int(e.get("race", 0)) >= 0:
|
if not _model_built and _assets != "" and int(e.get("race", -1)) in range(8) \
|
||||||
var pv := PlayerView.new()
|
and _main_attempts < MAIN_MODEL_MAX_ATTEMPTS and Time.get_ticks_msec() >= _main_retry_at:
|
||||||
|
_main_model_attempted = true
|
||||||
|
_main_attempts += 1
|
||||||
|
_main_loading = true
|
||||||
|
var generation := _main_generation
|
||||||
|
var pv = _create_main_view()
|
||||||
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
|
var pump: Callable = client.net_poll if client.has_method("net_poll") else Callable()
|
||||||
if pv.build(_assets, int(e.get("race", 0)), pump):
|
var built: bool = pv.build(_assets, int(e.get("race", 0)), pump)
|
||||||
|
_main_loading = false
|
||||||
|
# build() polls the network. World reset / new main data may arrive
|
||||||
|
# inside it; do not publish a view belonging to an obsolete generation.
|
||||||
|
if generation != _main_generation or is_queued_for_deletion() \
|
||||||
|
or vid != int(client.get_main_vid()) \
|
||||||
|
or int(client.get_entity(vid).get("race", -1)) != int(e.get("race", -1)):
|
||||||
|
pv.free()
|
||||||
|
return
|
||||||
|
if built:
|
||||||
pv.set_audio(_audio)
|
pv.set_audio(_audio)
|
||||||
set_player_model(pv)
|
set_player_model(pv)
|
||||||
_model_built = true
|
_model_built = true
|
||||||
@@ -1391,21 +1456,43 @@ func _on_main_set(vid: int) -> void:
|
|||||||
net_play.player_view = pv
|
net_play.player_view = pv
|
||||||
if pv.anim and pv.anim.has_signal("motion_event"):
|
if pv.anim and pv.anim.has_signal("motion_event"):
|
||||||
pv.anim.motion_event.connect(_on_local_motion_event)
|
pv.anim.motion_event.connect(_on_local_motion_event)
|
||||||
if pc and pc.has_signal("anim_state"):
|
if pc and pc.has_signal("anim_state") and not pc.anim_state.is_connected(_on_main_anim_state):
|
||||||
pc.anim_state.connect(func(s: String):
|
pc.anim_state.connect(_on_main_anim_state)
|
||||||
if is_instance_valid(player) and player.has_method("set_anim_state"):
|
|
||||||
player.set_anim_state(s))
|
|
||||||
if equip_model:
|
if equip_model:
|
||||||
equip_model.set_race(int(e.get("race", 0)))
|
equip_model.set_race(int(e.get("race", 0)))
|
||||||
equip_model.refresh()
|
equip_model.refresh()
|
||||||
else:
|
else:
|
||||||
pv.queue_free()
|
_main_retry_at = Time.get_ticks_msec() + MAIN_MODEL_RETRY_MS
|
||||||
var p: Vector3 = MapCoord.to_world(e.get("pos", Vector3.ZERO))
|
push_warning("GameScene: 主角模型加载失败(vid=%d race=%d attempt=%d/%d)" % [vid, int(e.get("race", 0)), _main_attempts, MAIN_MODEL_MAX_ATTEMPTS])
|
||||||
if world and world.has_method("set_focus_position"):
|
pv.free()
|
||||||
world.call("set_focus_position", p.x, p.z) # 先对准,才建得出脚下的区块
|
# Polling for model readiness must not snap a moving player to the initial
|
||||||
if world and world.has_method("sample_height"):
|
# server position on every frame or on duplicate main-character events.
|
||||||
p.y = float(world.call("sample_height", p.x, p.z))
|
if _main_placed_vid == vid:
|
||||||
player.position = p
|
return
|
||||||
|
_place_player_at_net_pos(net_pos)
|
||||||
|
_main_placed_vid = vid
|
||||||
|
|
||||||
|
func _create_main_view() -> Node3D:
|
||||||
|
return PlayerView.new()
|
||||||
|
|
||||||
|
func _on_main_anim_state(state: String) -> void:
|
||||||
|
if is_instance_valid(player) and player.has_method("set_anim_state"):
|
||||||
|
player.set_anim_state(state)
|
||||||
|
|
||||||
|
func _sync_main_character() -> void:
|
||||||
|
if not _main_sync_ready or _main_loading or player == null or not is_instance_valid(player):
|
||||||
|
return
|
||||||
|
if client == null or not client.has_method("get_main_vid") or not client.has_method("get_entity"):
|
||||||
|
return
|
||||||
|
var vid := int(client.get_main_vid())
|
||||||
|
if vid == 0:
|
||||||
|
return
|
||||||
|
var e: Dictionary = client.get_entity(vid)
|
||||||
|
# Do not mark the attempt until the authoritative character row exists. The
|
||||||
|
# phase packet can precede the row by several frames on a busy connection.
|
||||||
|
if e.is_empty():
|
||||||
|
return
|
||||||
|
_on_main_set(vid)
|
||||||
|
|
||||||
# AppFlow 的唯一生命周期协调者在 GameScene 装配完成后调用,绑定当前场景的音频对象。
|
# AppFlow 的唯一生命周期协调者在 GameScene 装配完成后调用,绑定当前场景的音频对象。
|
||||||
# GameScene 不创建 AppLifecycle,避免切屏时重复监听暂停 / 恢复通知。
|
# GameScene 不创建 AppLifecycle,避免切屏时重复监听暂停 / 恢复通知。
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Real GameScene/assets, delayed authoritative row, no network or account writes.
|
||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const GameScene = preload("res://game_scene.gd")
|
||||||
|
const Fixtures = preload("res://gamescene_test.gd")
|
||||||
|
var failed := false
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("run")
|
||||||
|
|
||||||
|
func run() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
var output := OS.get_environment("MT_RENDER_OUTPUT")
|
||||||
|
if output.is_empty():
|
||||||
|
output = ProjectSettings.globalize_path("res://../build/rendering/gamescene-%d" % Time.get_unix_time_from_system())
|
||||||
|
DirAccess.make_dir_recursive_absolute(output)
|
||||||
|
var fc := Fixtures.FakeClient.new()
|
||||||
|
root.add_child(fc)
|
||||||
|
var scene := GameScene.new()
|
||||||
|
root.add_child(scene)
|
||||||
|
await scene.setup(fc, AssetRoot.path(), "OutdoorA1/metin2_map_a1")
|
||||||
|
# Choose C1 to exercise late map correction too (setup initially had no VID).
|
||||||
|
var use_c1 := "--c1" in OS.get_cmdline_user_args()
|
||||||
|
var server := Vector2(959109, 269267) if use_c1 else Vector2(454600, 934000)
|
||||||
|
fc.main = 1000
|
||||||
|
fc.ents[1000] = {"vid": 1000, "name": "RenderTest", "race": 0,
|
||||||
|
"pos": Vector3(server.x * 0.01, 0, -server.y * 0.01),
|
||||||
|
"pos_cm": server, "is_main": true, "angle_deg": 0.0,
|
||||||
|
"func": 0, "moving": false, "hp": 100, "max_hp": 100, "dead": false}
|
||||||
|
for i in 30: await process_frame
|
||||||
|
var expected := MapCoord.to_world(fc.ents[1000].pos)
|
||||||
|
expected.y = scene.world.sample_height(expected.x, expected.z)
|
||||||
|
failed = not scene._model_built or scene.player.position.distance_to(expected) > 0.02
|
||||||
|
if use_c1: failed = failed or scene.map_path != "OutdoorC1/metin2_map_c1"
|
||||||
|
var surfaces := 0
|
||||||
|
for mi in scene.player.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
if mi.mesh: surfaces += mi.mesh.get_surface_count()
|
||||||
|
failed = failed or surfaces == 0 or not scene._map_loaded()
|
||||||
|
if scene._model_built and scene.player.anim:
|
||||||
|
scene.player.anim.set_time(0.5)
|
||||||
|
scene.player.anim.set_process(false)
|
||||||
|
scene.cam.snap_to_target()
|
||||||
|
if DisplayServer.get_name() != "headless":
|
||||||
|
await RenderingServer.frame_post_draw
|
||||||
|
failed = failed or root.get_texture().get_image().save_png(output.path_join("game.png")) != OK
|
||||||
|
var report := {"passed": not failed, "map": scene.map_path, "server_cm": str(server),
|
||||||
|
"world_position": str(scene.player.position), "expected": str(expected),
|
||||||
|
"surfaces": surfaces, "map_report": scene.world.get_load_report(),
|
||||||
|
"engine": Engine.get_version_info(), "viewport": str(root.size),
|
||||||
|
"camera_transform": str(scene.cam.global_transform), "fov": scene.cam.fov,
|
||||||
|
"assets": AssetRoot.path(), "headless": DisplayServer.get_name() == "headless",
|
||||||
|
"scope": "real GameScene, delayed main row, no main signal; no server/40250 parity"}
|
||||||
|
var hashes := {}
|
||||||
|
for path in ["res://game_scene.gd", "res://ui/player_view.gd", "res://net/mapcoord.gd",
|
||||||
|
"res://gamescene_visual_test.gd", "res://bin/libmtgodot.macos.template_debug.dylib"]:
|
||||||
|
hashes[path] = FileAccess.get_sha256(path)
|
||||||
|
if scene._model_built:
|
||||||
|
for property in ["gr2_path", "hair_gr2", "hair_skin"]:
|
||||||
|
var path := String(scene.player.model.get(property))
|
||||||
|
if not path.is_empty(): hashes[path] = FileAccess.get_sha256(path)
|
||||||
|
var motion := String(scene.player.anim.get("anim_path")) if scene.player.anim else ""
|
||||||
|
if not motion.is_empty(): hashes[motion] = FileAccess.get_sha256(motion)
|
||||||
|
report["sha256"] = hashes
|
||||||
|
report["animation_sample_seconds"] = 0.5
|
||||||
|
var file := FileAccess.open(output.path_join("report.json"), FileAccess.WRITE)
|
||||||
|
file.store_string(JSON.stringify(report, "\t"))
|
||||||
|
file.close()
|
||||||
|
print("GAMESCENE_VISUAL: ", JSON.stringify(report))
|
||||||
|
scene.queue_free()
|
||||||
|
fc.queue_free()
|
||||||
|
await process_frame
|
||||||
|
quit(1 if failed else 0)
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
extends SceneTree
|
||||||
|
|
||||||
|
const Scene = preload("res://game_scene.gd")
|
||||||
|
const View = preload("res://ui/player_view.gd")
|
||||||
|
|
||||||
|
class Client extends Node:
|
||||||
|
var main := 0
|
||||||
|
var rows := {}
|
||||||
|
func get_main_vid() -> int: return main
|
||||||
|
func get_entity(vid: int) -> Dictionary: return rows.get(vid, {})
|
||||||
|
|
||||||
|
class StubView extends Node3D:
|
||||||
|
var anim: Node
|
||||||
|
var succeeds := true
|
||||||
|
var during_build := Callable()
|
||||||
|
func build(_assets: String, _race: int, _pump: Callable) -> bool:
|
||||||
|
if during_build.is_valid(): during_build.call()
|
||||||
|
return succeeds
|
||||||
|
func set_audio(_node: Node) -> void: pass
|
||||||
|
|
||||||
|
class Harness extends Scene:
|
||||||
|
var builds := 0
|
||||||
|
var succeeds := true
|
||||||
|
var during_build := Callable()
|
||||||
|
func _create_main_view() -> Node3D:
|
||||||
|
builds += 1
|
||||||
|
var view := StubView.new()
|
||||||
|
view.succeeds = succeeds
|
||||||
|
view.during_build = during_build
|
||||||
|
return view
|
||||||
|
|
||||||
|
class Controller extends Node:
|
||||||
|
signal anim_state(state: String)
|
||||||
|
var player: Node3D
|
||||||
|
|
||||||
|
class Camera extends Camera3D:
|
||||||
|
var target: Node3D
|
||||||
|
|
||||||
|
var failures := 0
|
||||||
|
func check(value: bool, label: String) -> void:
|
||||||
|
if not value:
|
||||||
|
failures += 1
|
||||||
|
printerr("FAIL: " + label)
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("run")
|
||||||
|
|
||||||
|
func run() -> void:
|
||||||
|
var client := Client.new()
|
||||||
|
var scene := Harness.new()
|
||||||
|
root.add_child(client)
|
||||||
|
root.add_child(scene)
|
||||||
|
scene.set_process(false)
|
||||||
|
scene.client = client
|
||||||
|
scene._assets = "synthetic-assets"
|
||||||
|
scene.player = Node3D.new()
|
||||||
|
scene.add_child(scene.player)
|
||||||
|
scene.pc = Controller.new()
|
||||||
|
scene.add_child(scene.pc)
|
||||||
|
scene.cam = Camera.new()
|
||||||
|
scene.add_child(scene.cam)
|
||||||
|
scene._sync_main_character()
|
||||||
|
check(scene.builds == 0, "setup incomplete does not build")
|
||||||
|
scene._main_sync_ready = true
|
||||||
|
client.main = 10
|
||||||
|
scene._sync_main_character()
|
||||||
|
check(scene.builds == 0, "late entity is awaited")
|
||||||
|
client.rows[10] = {"pos": Vector3(20, 0, -30)}
|
||||||
|
scene._sync_main_character()
|
||||||
|
check(scene.builds == 0, "missing race does not build")
|
||||||
|
check(scene.player.position == Vector3(20, 0, 30), "late row places placeholder")
|
||||||
|
client.rows[10].race = 0
|
||||||
|
scene.during_build = func(): scene._on_main_set(10)
|
||||||
|
scene._sync_main_character()
|
||||||
|
check(scene.builds == 1 and scene._model_built, "reentrant network event creates one view")
|
||||||
|
check(scene.cam.target == scene.player and scene.pc.player == scene.player, "replacement updates consumers")
|
||||||
|
scene.player.position = Vector3(25, 0, 35)
|
||||||
|
for i in 5:
|
||||||
|
scene._sync_main_character()
|
||||||
|
scene._on_main_set(10)
|
||||||
|
check(scene.builds == 1, "duplicate main events are idempotent")
|
||||||
|
check(scene.player.position == Vector3(25, 0, 35), "polling does not undo movement")
|
||||||
|
client.main = 20
|
||||||
|
client.rows[20] = {"race": 1, "pos": Vector3(40, 0, -50)}
|
||||||
|
scene.during_build = Callable()
|
||||||
|
scene.succeeds = false
|
||||||
|
scene._sync_main_character()
|
||||||
|
check(scene.builds == 2 and not scene._model_built, "failed replacement is not marked ready")
|
||||||
|
for i in 5: scene._sync_main_character()
|
||||||
|
check(scene.builds == 2, "failure does not retry every frame")
|
||||||
|
scene._main_retry_at = 0
|
||||||
|
scene.succeeds = true
|
||||||
|
scene._sync_main_character()
|
||||||
|
check(scene.builds == 3 and scene._model_built, "transient failure recovers")
|
||||||
|
check(scene.pc.anim_state.get_connections().size() == 1, "replacement does not duplicate animation subscriptions")
|
||||||
|
var previous = scene.player
|
||||||
|
client.main = 30
|
||||||
|
client.rows[30] = {"race": 0, "pos": Vector3.ZERO}
|
||||||
|
scene.during_build = func(): client.main = 40
|
||||||
|
scene._sync_main_character()
|
||||||
|
check(scene.player == previous, "obsolete build is discarded")
|
||||||
|
client.rows[40] = {"race": 0, "pos": Vector3.ZERO}
|
||||||
|
scene.during_build = Callable()
|
||||||
|
scene.succeeds = false
|
||||||
|
for i in 5:
|
||||||
|
scene._main_retry_at = 0
|
||||||
|
scene._sync_main_character()
|
||||||
|
check(scene._main_attempts == 3, "permanent failure has bounded attempts")
|
||||||
|
var view := View.new()
|
||||||
|
check(not view.build("missing-assets", 8), "unsupported race rejected")
|
||||||
|
view.free()
|
||||||
|
scene.queue_free()
|
||||||
|
client.queue_free()
|
||||||
|
await process_frame
|
||||||
|
print("main_character_sync_test: failures=", failures)
|
||||||
|
quit(1 if failures else 0)
|
||||||
@@ -305,9 +305,8 @@ func _rebuild_model() -> void:
|
|||||||
# Expansion armor textures live beside their GR2, outside the base PC pack.
|
# Expansion armor textures live beside their GR2, outside the base PC pack.
|
||||||
var body_path := String(pv.model.get("gr2_path"))
|
var body_path := String(pv.model.get("gr2_path"))
|
||||||
pv.model.set("texture_dir", body_path.get_base_dir())
|
pv.model.set("texture_dir", body_path.get_base_dir())
|
||||||
# Shape zero uses the head/hair already included in the armor mesh.
|
pv.model.set("lod_enabled", false)
|
||||||
if int(c.get("hair_part", 0)) == 0:
|
pv.model.set("flip_winding", true)
|
||||||
pv.model.set("hair_gr2", "")
|
|
||||||
pv._ground_model()
|
pv._ground_model()
|
||||||
var intro := pv.motion_dir.get_base_dir().path_join("intro/wait.msa")
|
var intro := pv.motion_dir.get_base_dir().path_join("intro/wait.msa")
|
||||||
if pv.anim and FileAccess.file_exists(intro):
|
if pv.anim and FileAccess.file_exists(intro):
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ var _forward := ["weapon_gr2", "shield_gr2", "gr2_path", "hair_gr2", "hair_skin"
|
|||||||
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
|
# 它(= M2Client.net_poll),免得整段阻塞几秒漏 PONG 被服务器踢。
|
||||||
func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
||||||
_assets_root = assets_root
|
_assets_root = assets_root
|
||||||
if not ClassDB.class_exists("Metin2Model"):
|
if race < 0 or race > 7 or not ClassDB.class_exists("Metin2Model"):
|
||||||
return false
|
return false
|
||||||
var cls: String = CLASS_OF[race & 3]
|
var cls: String = CLASS_OF[race & 3]
|
||||||
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls
|
var pc_dir := "pc2/ymir work/pc2/%s" % cls if race in FEMALE_RACES else "PC/ymir work/pc/%s" % cls
|
||||||
@@ -46,8 +46,17 @@ func build(assets_root: String, race: int, pump := Callable()) -> bool:
|
|||||||
model.set("texture_dir", base)
|
model.set("texture_dir", base)
|
||||||
if pump.is_valid(): pump.call()
|
if pump.is_valid(): pump.call()
|
||||||
var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)])
|
var body := _first_existing([base.path_join("%s_novice.gr2" % cls), base.path_join("%s.gr2" % cls)])
|
||||||
if body != "":
|
if body == "":
|
||||||
|
model.free()
|
||||||
|
model = null
|
||||||
|
return false
|
||||||
model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD
|
model.set("gr2_path", body) # 重:解 gr2 + 建网格 + 扫 3 级 LOD
|
||||||
|
# This view is built off-tree: set_gr2_path only stores the path until ready.
|
||||||
|
# Validate the source explicitly rather than reading a not-yet-loaded report.
|
||||||
|
if String(model.call("probe_gr2", body)).contains("FAILED"):
|
||||||
|
model.free()
|
||||||
|
model = null
|
||||||
|
return false
|
||||||
if pump.is_valid(): pump.call()
|
if pump.is_valid(): pump.call()
|
||||||
var hair := _first_existing([base.path_join("hair/hair_1_1.gr2"), base.path_join("hair/hair_01.gr2")])
|
var hair := _first_existing([base.path_join("hair/hair_1_1.gr2"), base.path_join("hair/hair_01.gr2")])
|
||||||
if hair != "":
|
if hair != "":
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 MiB After Width: | Height: | Size: 3.4 MiB |
@@ -0,0 +1,23 @@
|
|||||||
|
# 渲染回归入口
|
||||||
|
|
||||||
|
第一批用例针对 R1 场景装配,2026-09-08 开始执行。未宣称 40250 像素一致、发型正确或最终包通过。
|
||||||
|
|
||||||
|
在仓库根目录运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godot --headless --path project --script main_character_sync_test.gd
|
||||||
|
godot --headless --path project --script gamescene_test.gd
|
||||||
|
godot --path project --script gamescene_visual_test.gd
|
||||||
|
godot --path project --script gamescene_visual_test.gd -- --c1
|
||||||
|
```
|
||||||
|
|
||||||
|
- `main_character_sync_test.gd` 使用可控模型工厂,覆盖晚到实体/缺 race、网络重入、重复事件、移动不被回滚、失败节流与重试上限、VID 变化导致旧构建失效。通过标志是 `failures=0`,不能只看 Godot 退出码。
|
||||||
|
- `gamescene_test.gd` 使用不存在的地图,预期有加载错误日志;它只验证无地图装配,不能当作画面正常证据。
|
||||||
|
- `gamescene_visual_test.gd` 使用真实资产和实际 GameScene,通过 FakeClient 延迟提供 race=0 主角数据,**不发**主角信号。无账号和服务器写入。
|
||||||
|
- 默认先加载 A1;`--c1` 随后提供 C1 服务器位置,用来验证晚到坐标能纠正地图选择。
|
||||||
|
- 视觉用例输出 `build/rendering/gamescene-<timestamp>/game.png` 与 `report.json`。可用环境变量 `MT_RENDER_OUTPUT` 指定独立目录;复用路径会覆盖同名报告,建议每次新目录。
|
||||||
|
- 报告包含位置、相机、地图加载计数、模型表面数、引擎、关键脚本/扩展/模型路径哈希。资产依赖尚未穷举;这只是 R0 部分基线。
|
||||||
|
- 截图尺寸为 1280×720,角色待机固定在 0.5 秒。地形/环境动态因素尚未完全冻结,因此暂不设置像素误差硬阈值。
|
||||||
|
- 测试不配置学习技能,截图中的空快捷槽不代表技能图标验证成功。
|
||||||
|
|
||||||
|
已知后续:脸部/脚底近景和区域差异器、完整资产哈希、四职业 GR2/GLB 对照、技能状态与图标用例、真实联机及普通 macOS `.app` 验收。
|
||||||
Reference in New Issue
Block a user