# 2026-10-02 ## 审查:Godot 4.7「SubViewport 面部手绘 + 骨骼驱动」方案 用户贴来一份第三方方案(SubViewport 当可绘制面部贴图 + SkeletonModifier3D 把骨骼 Z 转成 shader 参数),问可行性。只做评审,未改任何代码。 结论:**原理可行**(SubViewport-as-texture + shader 混合是标准做法),但原文代码有 4 处硬伤: 1. shader `render_mode ... skip_vertex_transform` 却没写 vertex() 手动变换 → 模型位置错。删掉该 render_mode。 2. `PaintSurface._input` 在**后台 SubViewport 里收不到鼠标事件**(Viewport 之间不派发输入)→ 必须放进 `SubViewportContainer`,或手动 `push_input`。 3. `SkeletonModifier3D` **没有 `skeleton` 属性**,只有 `get_skeleton()`(已查 4.5 文档确认)。 4. `save_paint` 没 `await RenderingServer.frame_post_draw` → 存到空帧。 标定类问题:`get_bone_pose_position()` 是相对父骨的姿态,Z 轴受父骨朝向影响,GLTF 导入后与 Blender 不一致,z_min/z_max 必须实测标定;`face_index` 只做了亮度乘,没真正切换表情图层。 与本项目相关的判断:角色是 `character-male-e.glb`,需先确认有独立面部材质槽 + 展平 UV;多人下 SubViewport/材质必须每实例一份,远端玩家的画要按 PNG 字节流同步。 ## 重写 player_animator.gd(7 状态) 按新枚举 `{IDLE, WALK, CROUCH, RUN, FALL, SNEAK, JUMP}` 重写 `character/player/player_animator.gd`。 改动:加 `class_name PlayerAnimator`(`player.gd` 用它当类型)、新增 `Stance{STAND,CROUCH,SNEAK}`、 `set_motion(velocity, on_floor, sprinting=false, stance=STAND)`、片段基准速度扩到 walk/run/crouch/sneak。 实测结论(用 @tool 副本跑 McpTestSuite,14 条断言全过): - 短片段名查不到,必须用 `"库名/片段名"`(`walk/walk`);`_ready()` 里扫库解析一次并写入 `_clips`。 - 状态映射、自动绑兄弟 AnimationPlayer、`play("walk/walk")`、loop_mode 补丁、倍率 5/5≈1.0 均正确。 - `class_name` 之后 `self is AnimationPlayer` 是编译期错误 → 脚本不能挂在 AnimationPlayer 节点上。 **未改、留给用户决定的场景问题**(`player.tscn`): 1. 脚本目前挂在 `Body/AnimationPlayer` 上,而 `player.gd`、`player_sync.tres`、`tests/test_net_replication.gd` 都找 `Body/PlayerAnimator` —— 需要新增独立 PlayerAnimator 节点。 2. 该节点的 6 个 float 导出在 .tscn 里被序列化成 `null`(实测运行时是 `Nil`),会覆盖脚本默认值。 3. `tests/test_net_replication.gd` 里的 `REQUIRED_CLIPS` 仍是旧的 `sprint`,需要同步成新状态集。 ## 收尾:按用户决定接线 + 清场景脏数据 用户决定「脚本就挂在 `Body/AnimationPlayer` 上」,据此改完并验证(`test_run` 20/20,320 条断言;实机跑 player.tscn 确认 idle→fall 切换正常)。 - `player_animator.gd`:文档改成「脚本挂在 AnimationPlayer 上、`animation_player` 留空自绑自身」。 - `player.gd`:`$Body/PlayerAnimator` → `$Body/AnimationPlayer`,场景结构注释同步;`_ride_zipline` 里 `tan` 改名 `tan_dir`(消 built-in 遮蔽警告)。 - `player_sync.tres`:同步字段路径改 `Body/AnimationPlayer:anim_state` / `:anim_speed`。 - `player.tscn`:删掉被序列化成 `null` 的 6 个 float 导出(`blend_time` 等)—— 实测运行时确实是 `Nil`,会覆盖脚本默认值。 - `tests/test_net_replication.gd`:路径、`REQUIRED_CLIPS` 全部同步;新增 `_find_clip()` 按库解析(短名断言会假红)。 - `tests/test_zipline.gd`:`test_interact_action_exists` 先调 `InputMap.load_from_project_settings()` —— 编辑器进程的 InputMap 不带项目动作(实测 `interact`/`jump` 均为 false,只有 `ui_accept`),这条一直假红。 - `world/zipline/zipline.gd`:`tan` → `tan_dir`。 **Godot MCP 注意**:`node_get_properties` 读不到脚本的 @export 属性(`unknown_fields`),但 `node_set_property` 能写进去;数值标量经 MCP 会变成字符串(`node_set_property` 返回 `"0.15"`),`batch_execute` 里的 `set_property` 直接报 INTERNAL_ERROR。改 `.tscn` / `.tres` 这类被编辑器占用的文件,直接用文本工具写反而更省事(`.tscn` 可写、`.tres` 报 EBUSY,用 `filesystem_manage(op="write_text")`),写完 `scene_open(force_reload=true)` 让编辑器重新读盘。 ## 接线:Ctrl 蹲下 / Ctrl+移动 潜行 用户定的规则:`crouch` 绑 Ctrl;**蹲着不动 = CROUCH,蹲着走 = SNEAK**。 - `project.godot`:用 `input_map_manage(op="ensure_binding", {action:"crouch", event_type:"key", keycode:"Ctrl"})` 加的动作,落在 keycode 4194326,和已有的 `sprint`(Shift/4194325) 同一套写法。 - `player_animator.gd`:**删掉 `Stance` 枚举**,`set_motion` 第 4 个参数改成 `crouching: bool`。 「蹲 / 潜行」的区分放回表现层(`moving := 水平速度 >= idle_threshold`),控制器只传「按没按 Ctrl」。 优先级改成 空中 > 蹲 > 移动。 - `player.gd`:调用点传 `Input.is_action_pressed("crouch")`;操作说明补上 Ctrl。 - `tests/test_net_replication.gd`:加 `test_crouch_action_exists`(先 `InputMap.load_from_project_settings()`)。 验证:`test_run` 21/21;另用 @tool 副本跑了 4 组状态映射断言(Ctrl+不动→CROUCH、Ctrl+移动→SNEAK、 Ctrl+Shift→仍是 SNEAK、空中→JUMP/FALL 优先于蹲),片段也确认切到了 `crouch/crouch` / `sneak/sneak`。 **踩坑**:`test_run` 的脚本缓存按路径,删掉重建同名文件会跑旧版本 —— 第一次探针就因此假失败 (旧版 `stance: int` 收到 `true` 被当成 `Stance.CROUCH`)。换文件名才拿到真实结果。 **未做**:蹲下目前不改移动速度、也不改碰撞胶囊高度 —— 潜行时用走速(5 m/s)播 sneak 片段, `speed_scale` 会被 clamp 到上限 1.6。要不要减速/压低碰撞盒等用户定。 ## 重做:第三人称 → 第一人称控制器 `player.gd` + `player.tscn` 改造。`test_run` 21/21,实机确认 `CameraRig.position.y=1.6`、`Body.visible=false`。 - **场景**:删掉 `SpringArm`,`Camera` 从 `CameraRig/SpringArm/Camera` 挪到 `CameraRig/Camera`。 `CameraRig` 变成「眼睛」,架在 `eye_height` 高,只转 yaw;pitch 在 `Camera` 自身上原地抬头低头。 `fov` 82.2 → 75(82 是给第三人称的,第一人称太鱼眼)。 - **`player.gd` 新增两个导出**:`eye_height`(1.6,按 1.8 胶囊定,与模型高矮无关)、 `hide_own_body`(true,只藏本机那一份,远端照常显示)。不藏的话相机就在模型里/贴头顶。 - **删掉 `turn_speed`**:第一人称里身体不再朝移动方向转身,改成始终跟视线一致(`_apply_look()` 里 `body.rotation.y = _yaw`),这个平滑参数没用了。 - **新增 `_apply_look()`**(3 处调用:鼠标、滑索、_ready):鼠标一动就落节点,视角跟手不等物理帧。 - pitch 范围从 -80/45 放到 ±89(第一人称要能抬头低头)。滑索的视角对齐沿用原来的 3.0 慢速率。 **已知遗留**:`HealthLabel`(y=2.05) 和 `InteractHint`(y=1.75) 是玩家自己位置的 Label3D, 第一人称下平视看不到(都在相机所在的竖直平面上,超出 FOV),但抬头会看到自己的血条 —— 要干净的话得改成 CanvasLayer 的 HUD(会连带改 `test_zipline` 里断言 `Player/InteractHint` 的那条)。 **MCP 限制**:`game_manage(op="input_mouse")` 注入的 `InputEventMouseMotion` 不带 `relative` 增量, 所以鼠标转视角没法用 MCP 验证(`input_key`/`input_action` 是正常的,用 `H` 键把血条打到 90 验证过)。 ## 方案:面部贴图切换 + 游戏内自绘(只出方案,未写文件) 用户给了 `mouse_表情测试.png`,问怎么在游戏内切面部贴图、并让玩家自己画。 **先探了模型**(临时探针读各 mesh 的 surface/UV/材质,跑完删了):面部早就被拆成了独立 surface —— `body_mesh s1 "face"`,UV 孤岛 u[0.142,0.356] v[0.038,0.238],自带材质和贴图。 所以**不用改 Blender**,直接 `set_surface_override_material(1, ...)`。 给的方案:面部 shader(把孤岛 UV 重映射到 0~1,`ALBEDO = mix(base_tex, paint_tex, paint.a)`) + `base_tex` = 预设方形 PNG(切换就换它)+ `paint_tex` = 一个 512² 透明 SubViewport 的**笔画层**。 画布只装笔画不装底图,所以橡皮擦掉笔画露出来的是预设脸,语义正确。 关键设计点(也是给之前那份第三方方案纠错的地方):**绘画 UI 是可见的 `CanvasLayer + SubViewportContainer`,输入天然可达** —— 不是把 SubViewport 当后台渲染目标(那样 `_input` 收不到事件)。 遗留待用户拍板:现有预设要不要裁成方形(重映射后必须裁,1024² 里裁 x145..365 y32..251); 联机时脸要不要 RPC 同步合成后的 PNG。 ## 新建:游戏开始菜单(只有界面,无逻辑) `ui/main_menu.tscn` + `ui/main_menu.gd`(新目录 `ui/`)。 - 背景用 `GradientTexture2D` 取游戏天空的三段色(zenith 0.09/0.30/0.78 → mid → horizon), 和 `world/sky/cartoon_sky.gdshader` 的调色一致。 - 结构:`MainMenu(Control) → Background(TextureRect) / Center(CenterContainer) → Box → Title + Buttons`, 右下角一个 `Version` Label。 - 四个按钮 开始游戏 / 联机 / 设置 / 退出,**只有 `pressed` → 自定义信号**,没有任何开始/退出/联机实现。 - 按钮样式用 3 个 `StyleBoxFlat`(normal/hover/pressed)**挂在 `Buttons` 这个 VBoxContainer 上**, 靠主题继承一次性作用到四个按钮,不用逐个写。 - 实测(跑起来截图 + `get_ui_elements`):版式正确、中文正常渲染。 **发现**:`Net` autoload 的「联机测试」面板默认可见且在同位置,会把菜单中间的按钮盖住 —— 接线时必须先处理它(隐藏,或让菜单的「联机」按钮接管)。这条已记进 MEMORY.md。 ## 实现:角色选择 + 脸部预设选择 + 脸部绘制 按之前的方案落地了。`test_run` **26/26 通过**(含新增的 face suite 5 条),实机跑通并截图确认。 **新增** - `character/player/face/face_paint.gdshader` — 孤岛 UV 重映射 + `mix(base, paint, paint.a)` - `character/player/face/face_painter.gd` — 笔画层,直接改 RGBA8 字节缓冲 - `character/player/face/face_rig.gd` — 挂 surface 1 的材质、切预设、存读档(`user://face_*.json/png`) - `character/player/face/presets/face_01.png` `face_02.png` — 从 `mouse_表情测试.png` 裁的 512² 方形预设 - `ui/paint_panel.tscn/.gd` — 绘画面板(颜色 / 粗细 / 橡皮 / 清空 / 取消 / 完成) - `ui/character_select.tscn/.gd` — 3D 预览(自转)+ 预设缩略图 + 自己画 - `tests/test_face.gd` — 钉住「surface 1 叫 face、UV 孤岛落在 shader 的框里、预设是正方形」 **改动**:`player.tscn` 加 `FaceRig` 节点;`main_menu.gd`「开始游戏」→ 角色选择,并把 Net 调试面板收起来。 **踩到的坑(都补进 MEMORY.md 了)** 1. Godot 4 的 canvas **没有擦除混合模式**,`draw_set_blend_mode` 也没了 → 橡皮改成自己乘 alpha。 2. `Image.set_data()` 在 4.7 变成 5 个参数 → 改用 `Image.create_from_data`。 3. 导出的**节点引用**在场景实例化时解析成 null → 改成 `mesh_path: NodePath` + `_ready()` 里 `get_node_or_null`。 4. shader 里 `vec2` 不能用 `source_color`(只给 vec3/vec4/sampler)。 5. 画布一开始只显示笔画层,等于让玩家对着空气画 → 底下垫一层预设 TextureRect。 6. 拖拽判定原本看 motion 的 `button_mask`,MCP 注入的 motion 不带这个字段 → 改成自己记 `_drawing`。 7. 排查时一度以为画笔没生效,其实黑点画在脸的黑色线条上被淹没了 —— 换到纯蓝区域才看见。 诊断顺序记一下:先 `print` 确认输入到了(`[PP]`)→ 再确认印章落了(`[FP] stamp`)→ 再看缓冲字节(`probe a=255`)。 ## 收尾:入口换成开始菜单 + 联机脸同步 `test_run` **28/28 通过**。两件事都用实机验证过(入口流程用 MCP 点按钮走通;脸同步用**两个 headless 实例** `--host` / `--join 127.0.0.1` 真连了一次)。 **入口场景** - `project.godot` 的 `run/main_scene` 改成 `res://ui/main_menu.tscn`(用 `project_manage(set_main_scene)` 改的)。 - `net.gd`:加 `WORLD_SCENE` / `MENU_SCENE`、`_enter_world()`、`_go_to()`、`set_menu_visible()`; `host_game()` 和 `_on_connected_to_server()` 之后切进世界;`leave_game()` 切回菜单并把 `_spawner` 置空。 - **关键**:世界是开局后才加载的,`bind_spawner()` 必须补生成已经连上的玩家,否则主机在菜单里 开好服、进世界时没有角色。 - `main_menu.gd`:「确定」→ `Net.host_game()`;用 `Net.set_menu_visible()` 代替原来的节点路径 hack。 - 新增测试 `test_main_scene_is_the_menu`(防有人把入口改回世界)。 **脸同步** - `FaceRig` 加三个方法:`_pull_remote_face` / `_rpc_request_face` / `_rpc_send_face`。 **用「谁缺谁来要」而不是出生时推** —— 各端玩家节点出现的先后顺序不保证,推的话很可能推给一个 还不存在的节点。非本机玩家在 `_ready` 里主动去要,带重试。 - `FacePainter` 加 `to_png_bytes()` / `set_image()`,走内存不落盘。 - **踩到的坑**:第一次跑双实例,客户端刷了一屏 `Node not found: "Main//FaceRig"` / `Failed to get path from RPC` —— 主机生成玩家比客户端早几帧,主机立刻去要时客户端还没建出那个节点。 加 `FACE_SYNC_INITIAL_DELAY = 1.0`(首次要之前先等一秒)后两边都干净了。 - 验证结果:HOST 和 JOIN 都打出「收到 peer X 的脸,png=1930 字节」,且无任何报错。 1930 正好是 `user://face_paint.png` 的大小,说明笔画真的传过去了。 - 新增测试 `test_face_sync_methods_exist`(钉住三个方法名)。 **找到 Godot 可执行文件**:`D:\_yvmou\Godot\Godot_v4.7.2-stable_win64.exe\Godot_v4.7.2-stable_win64_console.exe` (注意那一串带 .exe 的其实是个**目录**)。双开调试命令: ` --headless --path E:/Godot/agame -- --host` / `-- --join 127.0.0.1`。 ## 修 bug:点「自己画」不是空白画布 用户报的。根因是**语义理解错了**:我原先把「自己画」做成「在选中的预设上补几笔」, 用户要的是**从空白脸起手**。 - 生成 `presets/face_00_blank.png`(512² 纯色 `#89B3FF`,取自预设贴图里脸的底色)。 - `character_select.gd`:「自己画」= 清掉缩略图高亮 + `_paint_panel.open(_rig, BLANK_FACE)`。 - `paint_panel.gd`:`open()` 多一个 `blank_face` 参数,**先拍快照再换底图 + 清笔画**。 - **顺带修了一个连带 bug**:`_on_cancel` 原来只还原笔画,而「自己画」会把底图换成空白 —— 点取消会留下一张白脸。现在底图(`_preset_before`)也一起还原。 - 移掉了 `_on_paint_done`(高亮在 `_on_draw` 里已经清了,那个回调成了死代码)。 验证:实机走了一遍「选预设 → 自己画(画布变纯色、预览里脸也变纯)→ 取消(底图还原回 face_01)」, 并用 `get_node_info` 确认两个缩略图 modulate 都回到 0.62。`test_run` 28/28。 **一个排查小教训**:截图里 Preset0 看着比 Preset1 亮,我一度以为高亮没清掉 —— 其实两张贴图本身明暗就不同,`modulate` 才是真相。**别靠截图判断颜色,去读属性。**