106 lines
9.0 KiB
Markdown
106 lines
9.0 KiB
Markdown
# 项目长期记忆
|
||
|
||
## 工程约定
|
||
|
||
- 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层脚本 `player_animator.gd` 直接挂在
|
||
`Body/AnimationPlayer` 上(`animation_player` 留空即自绑自身,所以两者是同一个节点)。
|
||
新增动作状态改 `ANIM_CLIPS`,不要往 `player.gd` 里塞片段名。
|
||
- 组件的可调参数放在组件自己身上(如 Zipline 的速度 / 刹车 / 吸附半径),控制器只读不定义。
|
||
- 头顶 UI(血条、交互提示)是 `player.tscn` 里的 Label3D 节点,不在代码里动态创建。
|
||
- 测试用 `McpTestSuite`(`addons/godot_ai/testing`),脚本放 `res://tests/`。
|
||
- 界面放 `res://ui/`。项目没有 Theme 资源,风格一律用节点上的 `theme_override_*` 内联写;
|
||
给容器(如 VBoxContainer)设 `theme_override_styles/*` 会**向下继承**给子按钮,不用逐个设。
|
||
- 中文字体不用额外配:Godot 默认主题会回退到系统字体,实测中文正常渲染。
|
||
- **入口场景是 `res://ui/main_menu.tscn`**(不再是世界)。流程:开始菜单 →「开始游戏」→ 角色选择 →
|
||
「确定」= `Net.host_game()` → Net 切到 `world/main.tscn`;「联机」= 放出 Net 的创建/加入面板,
|
||
选完同样由 Net 切进世界。**进世界 / 回菜单这两件事都归 Net 管**(`_enter_world` / `leave_game`),
|
||
菜单只管「按了哪个按钮」。
|
||
- **世界是开局之后才加载的**,所以 `MultiplayerSpawner` 的绑定时机跟着变了:
|
||
`Net.bind_spawner()` 里要把已经连上的玩家补生成一遍,否则主机在菜单里就开好服、
|
||
切进世界时一个角色都不会出现。
|
||
- `Net` 是 autoload,它的「联机测试」面板在 `_ready()` 里就 `visible = true`(CanvasLayer layer=20),
|
||
会盖在菜单正中间 —— 菜单调 `Net.set_menu_visible(false)` 收起来。
|
||
|
||
## 动画(AnimationPlayer)
|
||
|
||
- **多 AnimationLibrary 下,片段必须用「库名/片段名」的限定键**。实测(4.7.2):
|
||
`has_animation("idle")` 为假,`has_animation("idle/idle")` 才为真,`get_animation` 同理。
|
||
本项目每个 glb 导入成一个库、库名与片段同名,所以真键形如 `walk/walk`。
|
||
短名播不出来且不报错,是典型的静默失效 —— `PlayerAnimator._ready()` 里扫库解析一次。
|
||
- 导入的 GLB 片段 `loop_mode` 默认是 `NONE`,编辑器里改不了,只能运行时补。
|
||
- **带 `class_name` 的脚本挂在 AnimationPlayer 上没问题,但不能写 `self is AnimationPlayer`**:
|
||
`class_name X extends Node` 之后 `self` 的静态类型就是 X,那句是编译期错误。
|
||
要拿「自己就是播放器」这个事实得绕一圈:遍历 `get_parent().get_children()` 找 `is AnimationPlayer` 的兄弟
|
||
—— 自己就在里面。见 `PlayerAnimator._ready()`。
|
||
- 状态集 `IDLE/WALK/CROUCH/RUN/FALL/SNEAK/JUMP`。签名 `set_motion(velocity, on_floor, sprinting, crouching)`:
|
||
第 4 个参数是「按住蹲键」这个输入事实(`crouch` 动作 = Ctrl),蹲着不动 → CROUCH、蹲着走 → SNEAK,
|
||
这个区分在表现层做,控制器不需要知道两者差别。优先级:空中 > 蹲 > 移动。
|
||
|
||
## 模型:mouse.glb
|
||
|
||
- `body_mesh` 有**两个 surface**:`s0 "lambert1"`(身体,UV 几乎占满 0~1,贴
|
||
`mouse_mouse_lambert1_BaseColor.png`)、`s1 "face"`(面部,独立 UV 孤岛
|
||
u[0.142,0.356] v[0.038,0.238],贴 `mouse_表情测试.png`)。
|
||
**面部是独立材质槽 → 换脸不用动 Blender**,`set_surface_override_material(1, mat)` 即可。
|
||
- `mouse_表情测试.png` 是 1024² **RGBA 但 alpha 全 255**(实心蓝 #89B3FF 底 + 黑线脸),
|
||
不是透明覆盖层,只能当底图,不能当笔画层。
|
||
- 面部孤岛只占 1024² 的 21%×20%,实际脸分辨率约 219×205 —— 要用 shader 把孤岛重映射到 0~1
|
||
再用方形小贴图,有效分辨率能翻一倍多。
|
||
|
||
## 面部系统(character/player/face/ + ui/)
|
||
|
||
- `face_paint.gdshader`:把孤岛 UV 重映射到 0~1,`ALBEDO = mix(base_tex, paint_tex, paint.a)`。
|
||
**两层必须分开**:`base_tex` = 预设脸(切换就换它),`paint_tex` = 玩家笔画层。
|
||
这样橡皮擦掉笔画露出来的是预设脸,而且换预设不会丢手绘。
|
||
- `FaceRig`(挂在 Player 下):建一张 512² 的笔画 Image + ImageTexture,`set_surface_override_material(1, mat)`。
|
||
存 `user://face_preset.json`(选了哪张预设)+ `user://face_paint.png`(透明底笔画层),
|
||
玩家出生时 `apply_saved()` 读回来。预览和玩家是**两套 FaceRig、共用一份存档**。
|
||
- `FacePainter`:**不用 `Control._draw()`,直接改 RGBA8 字节缓冲**。画 = source-over alpha 混合,
|
||
擦 = 把目标 alpha 乘掉;一笔画完 `_sync()` 一次(512² 一次 1MB,别每盖一个印章就同步)。
|
||
- 界面链路:`ui/main_menu.tscn` →「开始游戏」→ `ui/character_select.tscn`(3D 预览 + 预设缩略图 + 自己画)
|
||
→ `ui/paint_panel.tscn`。**画布要「预设 TextureRect + 笔画 TextureRect」叠着显示** ——
|
||
笔画层是透明底的,只显示它等于让玩家对着空气画。
|
||
- **「自己画」= 从空白脸起手**(`presets/face_00_blank.png`,纯色底),不是在预设上补笔。
|
||
空白由面板来落(`open(rig, blank_face)`)—— 它必须**先拍快照再改**,否则点「取消」还原回来的
|
||
是已经清空的状态。取消要同时还原**底图和笔画**,只还原笔画会留下一张白脸。
|
||
|
||
## Godot 4 踩坑
|
||
|
||
- **canvas 没有「擦除」混合模式**:`CanvasItemMaterial.BlendMode` 只有 mix/add/sub/mul/premult,
|
||
`CanvasItem.draw_set_blend_mode()` 在 4.x 已移除。橡皮只能自己管像素。
|
||
- `Image.set_data()` 在 4.7 签名变了(要 5 个参数);改用
|
||
`Image.create_from_data(w, h, mipmaps, format, data)`。
|
||
- **导出的节点引用解析不到**:`@export var mesh: MeshInstance3D` 在 .tscn 里存成 `NodePath`,
|
||
实测场景实例化时拿到的是 null。改成 `@export var mesh_path: NodePath` + `_ready()` 里 `get_node_or_null`。
|
||
- shader 里 `source_color` 只能用在 vec3 / vec4 / sampler 上,`vec2` 用了会编译失败。
|
||
- 给容器(VBoxContainer 等)设 `theme_override_styles/*` 会**向下继承**给子按钮,不用逐个设。
|
||
|
||
## Godot MCP 踩坑
|
||
|
||
- **数值标量经 MCP 会被字符串化**:`node_set_property` 传 `0.15` 能写进去,但回显是 `"0.15"`;
|
||
而 `batch_execute` 里包 `{"command": "set_property", "params": {...}}` 直接报
|
||
`INTERNAL_ERROR … handler returned malformed result`(和旧版本文档里写的正好相反,以实测为准)。
|
||
改被编辑器占用的 `.tscn` / `.tres` 时别绕这个弯 —— 直接写文本更省事:`.tscn` 可写,
|
||
`.tres` 报 EBUSY 就改用 `filesystem_manage(op="write_text")`。
|
||
- **`node_get_properties` 读不到脚本的 @export 属性**(会列进 `unknown_fields`),但 `node_set_property` 能写。
|
||
改完场景文件要 `scene_open(force_reload=true)` 让编辑器重读盘,否则节点上还挂着旧脚本实例。
|
||
- **@tool 脚本现造的节点不会写进 .tscn**:`add_child` 后不给 `owner`,
|
||
`PackedScene::pack()` 就会跳过它。缆绳网格因此可以纯由锚点推导,不存在「文件和锚点对不上」的脏数据。
|
||
- **新 `class_name` 要跑一次 `filesystem_manage(op="scan")`** 才进全局类表,
|
||
否则别的脚本把它当类型引用会解析不到。
|
||
- `game_manage(op="suspend")` 期间**输入事件不送达**,`next_frame` 也拦不住物理帧;
|
||
想抓「运行中途某一瞬间」的画面不可靠,只能靠日志 / 属性断言验证。
|
||
- 测试跑在编辑器进程里,`@tool` 节点必须真的 `add_child` 进编辑中场景才会跑 `_ready()`;
|
||
用完在 `teardown()` 摘掉,节点名以 `_McpTest` 开头可被测试运行器兜底清理。
|
||
- **非 `@tool` 脚本在编辑器里是「placeholder instance」**:`load(path).can_instantiate()` 返回 false,
|
||
`.new()` 报 `Nonexistent function 'new' in base 'GDScript'`,`set_script` 后调方法报
|
||
`Attempt to call a method on a placeholder instance`(`player.gd` 也一样,不是脚本本身的问题)。
|
||
想在 `test_run` 里验非 @tool 脚本,就临时生成一个「去掉 class_name、加上 @tool」的副本喂给测试,跑完删掉。
|
||
- **`test_run` 的 GDScript 编译缓存是按路径的**:删掉再重建同名脚本,跑到的可能还是旧版本
|
||
(现象:逻辑明明改了,测试却按旧逻辑失败)。要验新代码就换个文件名,或者重启编辑器。
|
||
- **编辑器进程的 `InputMap` 只有内建动作**(`ui_accept` 有,`interact` / `jump` 没有)——
|
||
项目动作要等游戏进程启动时才 load 进去。所以断言 `InputMap.has_action(...)` 的测试在编辑器里必然假红,
|
||
先调一次 `InputMap.load_from_project_settings()`。
|
||
- 改输入动作走 `input_map_manage(op="ensure_binding", params={action, event_type="key", keycode="Ctrl"})`,
|
||
幂等且直接落 `project.godot`,比手改文件稳。
|