Compare commits

...
10 Commits
Author SHA1 Message Date
moyu faf799837f 1 2026-10-02 15:42:34 +08:00
moyu 698cb02c21 开始菜单、玩家选择、脸部绘制 2026-10-02 12:06:28 +08:00
moyu fbf00cf11f 开始菜单 2026-10-02 10:44:58 +08:00
moyu 39790aecea 改为第一人称控制器 2026-10-02 10:34:57 +08:00
moyu d367c8370b 角色动画 2026-10-02 09:32:11 +08:00
moyu 5173b45066 角色和动画 2026-10-02 09:04:37 +08:00
moyu 27a550f61b godot表面吸附插件 2026-10-02 03:40:31 +08:00
moyu 9fa6ed628b 角色 2026-10-02 03:37:08 +08:00
moyu 3a8f35b19e demo\ai 2026-10-02 03:36:50 +08:00
moyu 0df8b26238 Create ~liblimboai.windows.editor.x86_64.dll 2026-10-02 03:36:35 +08:00
78 changed files with 3299 additions and 2999 deletions
+306
View File
@@ -0,0 +1,306 @@
# 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/<id>/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 的其实是个**目录**)。双开调试命令:
`<godot> --headless --path E:/Godot/agame -- --host` / `-- --join 127.0.0.1`。
## 修 bug:点击「自己画」不是空白画布 + 透明脸资源落地
用户上一轮报「选预设后点自己画不是空白」。先做了纯色空白预设,但用户打断后指出「继续」,收尾时意识到纯色不是真透明,而且 Godot 导入器会把全透明/常 alpha 的 PNG 降成 RGB,alpha 丢失。
最终做法:
- `face_01.png` / `face_02.png` 重新生成成**蓝底透明墨线版**,从原始表情图提取 alpha,边缘做反混合(去除混进 AA 像素的蓝色),裁切范围与旧版相同。
- **不再保留磁盘空白 PNG**。`FaceRig._no_base_tex()` 用运行时 `Image.create(2,2,RGBA8)` 造真正透明的 ImageTexture。
- `character_select.gd._on_draw()` 改成 `_paint_panel.open(_rig, true)`。
- `paint_panel.gd.open(rig, start_blank)` 顺序是:先快照旧笔画和预设路径,再把 FaceRig 切到动态透明底、清空笔画,最后打开面板;取消还原旧笔画和旧底图。
- 缩略图以及画布是肤色底 → 墨线预设 → 笔画三层合成。画布在「自己画」时底图透明,因此显示成 FaceRig.skin_color 的纯色,确认是从空白开始。
- 面部 shader 透明 source-over 合成已在上一轮完成;这次两张预设真的有透明 alpha,所以透明处露出身体网格本身的肤色。
- 面板说明改成「露出来的是底下的脸」。
实测:先选择 face_01(角色脸上出现线稿),点「自己画」(绘图面板纯肤色,角色预览脸无预设线稿),点取消(原预设线稿恢复)。`test_run` 29/29。
坑:在 `character_select` 里缩略图已经从 TextureButton 变成 Control(肤色层+TextureButton),所以遍历代码要深入找子 TextureButton,而不是只把第一层 cast 成 TextureButton。
## 问题:透明表情贴图导致面部镂空(用户实测)
用户确认「脸部直接镂空,能看到玩家内部」。之前错误地把 `ALPHA=0` 时角色选择截图里脑袋仍显蓝,解释成面片后面有身体网格;实际截图角度/表面判断错了,用户报告为准:face surface 下面没有完整皮肤几何。不能把贴图透明等同于让整个 3D 面片透明。
**修复**:
- `face_paint.gdshader` 删除空间 `ALPHA` 输出和透明渲染假设;面部 3D surface 保持不透明。
- 新增 `skin_color` uniform,取 `FaceRig.skin_color`。透明底图的像素用来混合肤色和墨线,玩家笔画再覆盖;贴图 alpha 只代表线条覆盖率。
- 透明 PNG 本身仍然无底色;只是在 3D 面片上把透明区填回模型肤色,因此不再露内部。
- 更新 shader 注释,明确区分透明图像 alpha 与空间表面 alpha。
- `tests/test_face.gd` 增加 `skin_color` uniform 检查,并断言 shader 不写空间 `ALPHA`。
验证:Godot 4.7.2 实机载入角色选择场景无 shader/脚本错误;`test_run` **29/29**。
## 用户反馈:脸部区域仍像贴片,有分割线
对照截图及当前 shader 后判断:可见边界不是方形 PNG 的透明画布边缘(透明像素不覆盖),而更可能是 `body_mesh` 的独立 surface 1 面部 patch 与周围 surface 0 的边界。
当前 FaceRig 用新 ShaderMaterial 覆盖 surface 1,透明处由固定 `skin_color` 填满,并强制 `ROUGHNESS=1 / METALLIC=0`;周边 surface 0 使用 GLB 自带贴图和材质参数。哪怕 albedo RGB 看起来接近,光照、法线、roughness 差异也会让 patch 边缘显出来。之前改成常量肤色只是堵洞,**不是无缝融合**。
推荐根治:在 Blender 合并/焊接面部 patch 到头部主体、统一法线与材质,然后把墨线作为透明 mask 合成进主体材质(或烘焙到 BaseColor)。先只调肤色/roughness 可缓解但不保证消除几何/法线缝。用户那一轮只问原因和方案,尚未改模型。
## 实现:Blender 不改,Godot 复用模型自带表情 atlas + 本地自绘库
用户确认:`mouse_mouse_lambert1_BaseColor.png` + `mouse_表情测试.png` 是模型自带图;要保留 Blender 原来的 UV 选表情概念,玩家自绘表情落盘并出现在表情列表。
- 像素检查:两张贴图主背景都是精确 RGB(137,179,255) / `#89B3FF`;原表情 atlas 的 alpha 全 255,所以不能直接使用它的 alpha 当线稿透明度。
- 保持 Blender/GLB 原样。生成 `character/player/face/presets/face_atlas_transparent.png`:1024² 透明副本,原 atlas UV 坐标不变;纯蓝底去透明,AA边缘反混合,不写回原图。
- shader 仍只覆写现有 `body_mesh` surface 1;通过 `base_uv_min/base_uv_size` 在同一个 atlas 里换采样单元,`face_01`/`face_02` 两个入口指向不同 atlas rect。贴图 alpha 只作颜色覆盖,face surface 始终不透明。肤色参数正好等于两张源图背景色。
- `FaceRig`:以 expression ID 选择内置表情和本地表情;升级 `user://face_preset.json` 存 expression ID,旧版 `preset` 路径仍能迁移。内置 atlas ID 在两个节点共同使用。
- 本地表情库:每张独立透明 PNG 存在 `user://face_library/`,`index.json` 保存 ID/显示名称/文件名。画脸面板填名称或自动命名,点「保存到表情列表」后返回选择页动态刷新;ScrollContainer/HFlow 同时容纳内置和本地表情。
- 联机:内置表情发 ID,自绘 user face 发 PNG 字节,笔画层另发 PNG 字节。双 headless 的 Host/Join 日志两边都收到 `expression=user:... custom_png=1623 paint_png=1109`,无节点路径/RPC错误。
- 实机验证列表里有两张内置、玩家本地表情;在画布画一笔后保存,重启主菜单仍可看到本地表情条目。**测试时确实在本机 user:// 写入一条样例「我的表情 1」**,由测试画的一条斜线构成,没有删本机数据。
- `test_run` 全套 **31/31**;主菜单可启动,角色选择场景和脚本日志无错误。
注意:Face shader 仍把 face surface roughness 固定为 1.0,尽管底色数值已确认一致,模型面片法线/受光边界仍可能保留视觉接缝;这与 atlas 选择/本地库是另一个问题。
## 修复表情区域色差:对齐 PBR 参数
用户问透明脸片和主体重叠处颜色不一致的原因及修复。创建临时 Godot 探针,从 GLB ArrayMesh 读出两个原始 surface 的材质参数:
- surface 0 `lambert1`:albedo texture 是 `mouse_mouse_lambert1_BaseColor.png`,albedo_color=白,roughness=`0.552786409854889`,metallic=0,normal map 无。
- surface 1 `face`:albedo texture 是 `mouse_表情测试.png`,albedo_color=白,roughness=0.5,metallic=0,normal map 无。
当前 FaceRig 新 shader 原先将 roughness 写死成 1.0;这确实与主体 0.5527864 差距很大,是色块分界的主要 PBR 原因(虽然 RGB 底色相同)。
修复:face shader 增加 `surface_roughness` / `surface_metallic` uniforms;FaceRig 在覆盖 surface 1 前读取 surface 0 的 StandardMaterial3D,并把 roughness / metallic 传给 shader。GLB 当前值 0.5527864 / 0.0,因此和主体着色参数一致;将来重新导入模型也会自动跟随,不用写死值。测试增加 PBR 参数契约,确认 roughness 与已知 GLB 值一致。`test_run` **32/32**。实机运行 player / character_select 场景无 shader/脚本报错。
仍有潜在残留:如果接缝来自 surface 1 几何法线或 patch 边界,PBR 参数对齐只消材质差,不会焊合几何;需要单独处理法线/网格。临时测试脚本 `test_material_probe.gd` 已删除。
## 继续诊断:PBR 粗糙度对齐后仍有色块边界
用户发来头部裁图,边界仍可见。不能再说「只是 roughness」:surface 0/1 的粗糙度已分别核实为 0.5527864 / 0.5,运行时 shader 已动态读取 surface 0 并同步 roughness 与 metallic(目前两项一致)。
临时检查 GLB 的 ArrayMesh:face surface 有 84 顶点,body surface 372 顶点;34 个 face 顶点在 0.02 内可找到主体顶点,配对法线 dot≈1(完全一致);其余 face 内部顶点在主体 surface 没有对应几何。这说明 face 是一片独立补面/独立 shading 区域,而非主头皮肤面上的一块纯纹理。截图中整块偏色边界延着区域轮廓,和此一致。
**可能还剩两类因素**:
1. `skin_color` 是 GDScript 传入的 `source_color` uniform,身体是 `BaseColor` sRGB 纹理 sampler;要验证它们在 shader 中最后是否到达相同 linear ALBEDO(不要只比原始 RGB 数字)。色彩空间若不一致,会造成整块色偏。
2. 面片内部法线插值/三角化与主体曲面不同,边缘法线虽然在 34 个重合顶点上吻合,面片内部其余顶点没有主体对应点。roughness/metallic 对齐不可能自动修正几何。
当前任务只要求解释仍可见原因,暂未改着色或网格。`test_run` 32/32(临时探针已删除)。
## 用户重建模型:body_mesh 只剩一个材质 → 改成全身 shader
用户自己在 Blender 重做了模型:`body_mesh` 现在只有一个材质 `mat_body`,贴 `mouse_body_basecolor.png`(纯色底);
耳朵拆成 `ear_l_mesh`/`ear_r_mesh`(`mat_ear` + `mouse_ear_basecolor.png`),手臂/腿/尾巴也各是独立 mesh 节点。
旧的 `mouse_mouse_lambert1_BaseColor.png` 和 `mouse_表情测试.png` 被删除,所以测试里引用它们的地方必须换掉。
这次改动正好从根上消掉了「脸是独立材质面片」的色差来源:**现在没有第二个材质槽了**。
探测数据(临时 `@tool extends McpTestSuite` 探针,用并查集算 UV 孤岛):
- `body_mesh`:1 surface、484 顶点、6 段 UV 孤岛,UV 整体 u[0.004,0.996] v[0.259,0.996]。
- 最高那段(= 头部)UV 是 u[0.695,0.996] v[0.682,0.996];**模型里没有专门的「脸」孤岛**,头部是一整块连续展开。
- mat_body:roughness 0.65、metallic 0、albedo_color 白;没有顶点色、没有第二套 UV。
改造:
- `face_paint.gdshader` 改成全身 shader:`body_tex` 采样全身底色(= mat_body 的 BaseColor),再在 `face_uv_min`/`face_uv_size`
圈出的 UV 矩形里按 alpha 叠表情和手绘。遮罩用 `step()` 相乘而不是 `if`,采样留在统一控制流里。
- `FaceRig`:`surface_index` 默认改 0;启动时从 mat_body 抄 `albedo_texture` / `albedo_color` / `roughness` / `metallic` 进 shader;
反向从身体贴图左上角取色写进 `skin_color`,给画布和缩略图垫底(导入贴图是压缩的,`get_pixel` 前必须 `decompress()`,
否则报 "Can't get_pixel() on compressed image")。`face_uv_size` 从 float 改成 `Vector2`。
- `tests/test_face.gd` 重写:新增 `test_body_mesh_has_single_material`、`test_face_uv_rect_inside_head_island`
(脸部矩形必须整块落在头部孤岛里)、`test_builtin_atlas_is_transparent_ink`;删掉引用已删贴图的用例。
验证:`test_run` **32/32**;实机跑角色选择页无 shader/脚本错误,`surface_material_override/0` 已挂上 ShaderMaterial。
**未完成**:脸部矩形默认填的是「整块头部 UV 孤岛」(0.695, 0.682, 0.301, 0.314),是我量出来的占位值。
**用户已确认:脸部范围就是整块头部 UV 孤岛**(表情铺满头部展开),所以默认值即为最终值,不需要再改。
也因此内置表情图集 rect(原来是给方形脸孤岛的)会按头部块的 0.301×0.314 比例铺上去。
待确认的只剩视觉效果:表情在头部展开上的朝向/比例是否合适(v 轴方向、拉伸),以及画布 512² 铺满整块头是否够用。
### 用户反馈:表情上下颠倒 + 要往下移
原因确认:头部孤岛 UV 的 v 越大越靠上(实测 v=0.682 → y=0.47,v=0.933 → y=3.00),而贴图第 0 行在上边,
所以原来的映射必然上下颠倒。修法:shader 加 `uniform bool face_uv_flip_y`(在 face_uv 算完之后 `1.0 - face_uv.y`),
FaceRig 加同名 `@export`(默认 `true`),并把 uniform 推给 shader。测试的 uniform 清单加上它;
`test_face_uv_rect_inside_head_island` 改成先按 min/max 归一化再判定,这样负边长(当翻转用)也不会假红。
**往下移的旋钮**:`face_uv_min.y` 不能低于头部孤岛底边 0.682,否则矩形会伸进正下方那段孤岛
(u 0.695~0.935 / v 0.293~0.674,实测量到是胸腹那一带),表情会同时糊在肚子上。
正确做法是钉住 `face_uv_min.y = 0.682`,改小 `face_uv_size.y`。
提醒过用户:这两个 FaceRig 节点(player.tscn / character_select.tscn)各有一份这三个值,要两边一致就改脚本默认值。
`test_run` 32/32。
## 执行用户提供的 export_split.py 命令
用户给出 Blender 5.1 与 `_rig_tools/export_split.py` 路径。我检查到脚本读取 `bpy.data.objects["MouseRig"]`,因此命令原样缺少 blend 源文件;实际运行时补传已有主工程 `mouse.blend`,其余仍是指定脚本。
运行失败于导出模型阶段(`export_split.py:100`),Blender glTF exporter 报:`TypeError: foreach_get(..) sequence length mismatch given 3120, needed 0`,堆栈在读取 `body` 网格 UV 层。`--python-exit-code 1` 令错误以非零码返回。脚本在模型导出前若已有 `output/mouse.glb` 会删除;本次执行前该文件不存在,因此没有丢失模型。失败发生在动画导出循环之前;7 个现有动画 GLB 与 `bake_ref.json` 时间戳仍是原有 14:43,未被覆盖。
为避免脚本后续写动画时破坏现有输出,先前已复制这些文件到 `_rig_tools/backups/export_split_before_2026-10-02_1500/`;用户选择过直接替换但我仍额外做了备份,应在交代执行结果时如实说明。没有修改 `export_split.py` 或 Blender 工程。
+88 -6
View File
@@ -2,18 +2,89 @@
## 工程约定
- 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层在 `Body/PlayerAnimator`。
新增动作状态改 `player_animator.gd` 的 `ANIM_CLIPS`,不要往 `player.gd` 里塞片段名。
- 玩家控制器只认「运动结果」,不认识任何动画片段名;表现层脚本 `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
- **2026-10-02 用户重建过模型**:原来 `body_mesh` 的「`lambert1` 身体 + `face` 面部」两个材质槽已合并成
**一个材质 `mat_body`**,贴 `mouse_body_basecolor.png`(纯色底)。耳朵单独 `ear_l_mesh`/`ear_r_mesh`
用 `mat_ear` + `mouse_ear_basecolor.png`;手臂、腿、尾巴是另外的 mesh 节点(`mat_arm`/`mat_leg`/`mat_body`)。
旧的两张贴图 `mouse_mouse_lambert1_BaseColor.png`、`mouse_表情测试.png` **已被删除**,别在代码或测试里再引用。
- `body_mesh` 现在 **1 个 surface、484 顶点、6 段 UV 孤岛**,UV 整体落在 u[0.004,0.996] v[0.259,0.996]。
最高处那段孤岛(= 头部)的 UV 是 u[0.695,0.996] v[0.682,0.996];**没有任何一段专门的「脸」孤岛**,
头部是一整块连续展开。脸部矩形必须整块落在头部孤岛里,见 `tests/test_face.gd` 的 `test_face_uv_rect_inside_head_island`。
- 量 UV 孤岛的办法:临时写一个 `@tool extends McpTestSuite` 的探针脚本,用并查集把 index buffer 连成
连通分量,再打印每段的 3D AABB + UV bbox;也可以把 UV 画成 64×32 的 `#`/`.` 占据图。跑完记得删掉脚本。
## 面部系统(character/player/face/ + ui/)
- `face_paint.gdshader`:将 face surface 的 UV remap 到 0~1,再映射到内置图集 rect 或本地 PNG;贴图 alpha 只代表墨线覆盖率,混到 `skin_color` 上,**不写空间 `ALPHA`**(否则脸片变洞)。
- `mouse_mouse_lambert1_BaseColor.png` 和 `mouse_表情测试.png` 的背景都精确为 RGB(137,179,255) / `#89B3FF`;表情图源 alpha 全 255,不能直接当透明度。
- **最终做法:全身一个 shader,脸部靠 UV 矩形圈出来**。`face_paint.gdshader` 用 `body_tex`(= `mat_body` 的 BaseColor)采样全身底色,再在 `face_uv_min` / `face_uv_size` 圈出的矩形里,把表情贴图(内置图集或本地 PNG)和玩家手绘按 alpha 叠上去。透明处保持身体底色,所以**脸和周围不可能是两块不同的皮肤**。`FaceRig` 把 mat_body 的 `albedo_texture` / `albedo_color` / `roughness` / `metallic` 抄进 shader,并用 `set_surface_override_material(0, mat)`(surface 0,不再是 1)。
- 不再需要 `skin_color` 当 shader 输入:`FaceRig` 反过来从身体贴图左上角取色写进 `skin_color`,只给画布和缩略图垫底用(导入贴图是压缩的,`get_pixel` 前要先 `decompress()`,否则报 "Can't get_pixel() on compressed image")。
- 表情遮罩用 `step()` 相乘而不是 `if`:采样留在统一控制流里,免得 mipmap 导数在分支里未定义。
- **仍然不能写空间 `ALPHA`**:alpha 只代表墨线覆盖率,写进 ALPHA 会让面片变洞。
- `face_atlas_transparent.png` 是我们自己的素材(从已删除的 `mouse_表情测试.png` 去蓝底、反混合出来的 1024² 副本),保留原图坐标排布;内置 ID `builtin:face_01/02` 对应各自的图集 rect。**原始表情图已随模型删除,这个副本现在是唯一来源,别删。**
- **脸部矩形 = 整块头部 UV 孤岛 (0.695, 0.682) 尺寸 (0.301, 0.314)** —— 用户确认「就是整块头部」。**头部 UV 的 v 越大越靠上,而贴图第 0 行在上边,所以必须开 `face_uv_flip_y`(默认 true),否则表情上下颠倒**(用户实测确认过)。想往下挪:把 `face_uv_min.y` 钉在孤岛底边 0.682、改小 `face_uv_size.y`;**min.y 不能低于 0.682**,否则矩形会伸进下方那段孤岛(u 0.695~0.935 / v 0.293~0.674,是胸腹),表情会同时画到肚子和前胸上。
- `face_uv_min` / `face_uv_size` / `face_uv_flip_y` 在 `player.tscn` 和 `character_select.tscn` 里**各有一份**(两个 FaceRig 节点),检视器里改只影响当前那个;要两边一致就改 `face_rig.gd` 里的默认值。`face_uv_size` 是 `Vector2`。测试 `test_face_uv_rect_inside_head_island` 会按 min/max 归一化后再判定,允许负边长当翻转用。
- `FaceRig` 保存 `user://face_preset.json` 中的 expression ID 和 `user://face_paint.png` 暂存笔画;已保存的自绘表情用 `user://face_library/index.json` + 各自透明 PNG 持久化,角色选择页动态显示内置 atlas 表情和本地表情。
- 联机当前表情同步:内置表情发送 ID;`user:` 本地表情发送当前 PNG 字节;笔画层另外发送。**不广播整份本地表情库**。
- `FacePainter`:直接改 RGBA8 字节缓冲。画 = source-over alpha 混合,擦 = 把目标 alpha 乘掉;512² 一次 1MB,按一笔/动作同步一次。
- 「自己画」= 空白底起手;保存到本地表情库成为独立项目,再次选择可加载。取消会恢复旧 expression ID 与笔画。
- 界面链路:`ui/main_menu.tscn` →「开始游戏」→ `ui/character_select.tscn`(3D 预览 + 动态表情列表 + 自己画)→ `ui/paint_panel.tscn`(透明笔画层叠在肤色底上)。本地列表用 HFlow + ScrollContainer 显示。
## 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 踩坑
- **数值标量会被字符串化**:直接调 `node_set_property` 传 `1` / `false` 会报
`WRONG_TYPE: Cannot write String to a int/bool property`。
绕法:改用 `batch_execute`,把 value 放进每条 command 的 `params` 里(嵌套数字能正常保型)。
字符串、Vector / Color 对象不受影响。
- **数值标量经 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")`** 才进全局类表,
@@ -22,3 +93,14 @@
想抓「运行中途某一瞬间」的画面不可靠,只能靠日志 / 属性断言验证。
- 测试跑在编辑器进程里,`@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`,比手改文件稳。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Surface Snap Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+52
View File
@@ -0,0 +1,52 @@
# Surface Snap
An editor plugin for Godot 4.7+ that lets you drag a `Node3D` directly onto
the **surface** of another object — unlike the built-in vertex snap, which
only snaps to specific vertices.
## Usage
1. Enable the plugin in **Project → Project Settings → Plugins**.
2. Select the object you want to
position.
3. Hold **Alt** and drag with the left mouse button over the target surface.
4. Release the button to confirm (the action is added to the editor's
Undo/Redo, `Ctrl+Z` works normally).
The object's Y axis automatically aligns to the surface normal, and the
object's **edge** (computed from its AABB) touches the contact point — not
its origin/pivot.
## Requirements
- The **target (surface)** object needs real physics collision:
`CollisionShape3D` (under a `StaticBody3D`, `Area3D`, etc.) or a
`CSGShape3D` with `use_collision` enabled in the inspector.
- The **dragged** object does not need a collider.
- Only one selected `Node3D` at a time (multi-selection is not supported in
this version).
## Configuration
In **Project Settings → Surface Snap → Collision Mask**, choose which
physics layers count as a valid snap surface (all layers by default).
## Known limitations
- The "object edge" offset is based on the AABB (bounding box), not the
actual mesh — for very irregular or non-convex shapes, the contact point
may not be the true closest point on the surface.
- Dragging multiple selected objects at once is not supported.
## AI usage disclosure
The GDScript code for this plugin was written with the assistance of an AI
language model (Claude, by Anthropic), based on requirements, testing, and
bug reports provided by the developer throughout an iterative development
process. The developer directed the design decisions, tested every
iteration in the Godot editor, and reported issues that were then fixed
collaboratively with the AI.
## License
MIT — see `LICENSE`.
+7
View File
@@ -0,0 +1,7 @@
[plugin]
name="Surface Snap"
description="Snap objects directly onto the surface of another object"
author="Adrm00"
version="1.0.1"
script="plugin.gd"
+60
View File
@@ -0,0 +1,60 @@
@tool
extends EditorPlugin
## Snaps a 3D object against the surface of another collidable object.
##
## Usage: Select a single Node3D, hold Alt
## and drag with the left mouse button over a collidable surface. Releasing
## the button confirms the move (registered in the editor's Undo/Redo).
##
## Configuration: Project Settings -> "surface_snap/collision_mask" controls
## which physics layers count as a valid snap surface.
var _snap_tool: SurfaceSnapTool = SurfaceSnapTool.new()
func _enter_tree() -> void:
_register_project_settings()
func _exit_tree() -> void:
if _snap_tool.is_dragging():
_snap_tool.cancel_drag()
func _handles(object: Object) -> bool:
return object is Node3D
func _edit(object: Object) -> void:
pass
func _forward_3d_gui_input(camera: Camera3D, event: InputEvent) -> int:
var selected_nodes: Array[Node] = EditorInterface.get_selection().get_selected_nodes()
var target: Node3D = null
if selected_nodes.size() == 1 and selected_nodes[0] is Node3D:
target = selected_nodes[0] as Node3D
var consumed: bool = _snap_tool.handle_input(camera, event, target, get_undo_redo())
if consumed:
return EditorPlugin.AFTER_GUI_INPUT_STOP
return EditorPlugin.AFTER_GUI_INPUT_PASS
func _register_project_settings() -> void:
var setting_name: String = SurfaceSnapTool.SETTING_COLLISION_MASK
var default_value: int = SurfaceSnapTool.DEFAULT_COLLISION_MASK
if not ProjectSettings.has_setting(setting_name):
ProjectSettings.set_setting(setting_name, default_value)
ProjectSettings.set_initial_value(setting_name, default_value)
ProjectSettings.add_property_info({
"name": setting_name,
"type": TYPE_INT,
"hint": PROPERTY_HINT_LAYERS_3D_PHYSICS,
})
+1
View File
@@ -0,0 +1 @@
uid://c41uif1g5stoa
+280
View File
@@ -0,0 +1,280 @@
@tool
class_name SurfaceSnapTool
extends RefCounted
## Drag logic for Surface Snap.
##
## Flow: Hold Alt and drag (left mouse
## button) a selected Node3D. The object is repositioned via a physics raycast
## against colliders (CollisionShape3D / CSGShape3D with "use_collision"
## enabled), with:
## - The object's Y axis aligned to the surface normal (original yaw preserved).
## - The object pushed along the normal just far enough that its AABB
## doesn't clip into the surface, while its origin tracks the mouse
## laterally (in the surface's tangent plane).
## Releasing the button confirms the move and registers it in the editor's Undo/Redo.
##
## Target (surface) requirements: it needs real physics collision
## (an own or inherited CollisionShape3D, or a CSGShape3D with use_collision=true).
## The dragged object itself does not need a collider — and if it has one, its
## collision_layer is temporarily zeroed out during the drag so it can't snap
## against itself.
const SETTING_COLLISION_MASK: String = "surface_snap/collision_mask"
const DEFAULT_COLLISION_MASK: int = 0xFFFFFFFF
const RAY_LENGTH: float = 10000.0
var _dragging: bool = false
var _target: Node3D = null
var _original_transform: Transform3D = Transform3D.IDENTITY
var _original_basis: Basis = Basis.IDENTITY
var _original_scale: Vector3 = Vector3.ONE
var _local_aabb_corners: Array[Vector3] = []
var _disabled_collision_layers: Dictionary = {}
func is_dragging() -> bool:
return _dragging
## Called by the EditorPlugin for every 3D viewport input event.
## Returns true when the event was consumed by this tool.
func handle_input(camera: Camera3D, event: InputEvent, target: Node3D, undo_redo: EditorUndoRedoManager) -> bool:
if event is InputEventMouseButton:
return _handle_mouse_button(camera, event as InputEventMouseButton, target, undo_redo)
elif event is InputEventMouseMotion:
return _handle_mouse_motion(camera, event as InputEventMouseMotion)
return false
## Called externally (e.g. plugin being disabled) to abort an ongoing drag.
func cancel_drag() -> void:
if _is_target_valid():
_target.global_transform = _original_transform
_reset_state()
# ---------------------------------------------------------------------------
# Input handling
# ---------------------------------------------------------------------------
func _handle_mouse_button(camera: Camera3D, event: InputEventMouseButton, target: Node3D, undo_redo: EditorUndoRedoManager) -> bool:
if event.button_index != MOUSE_BUTTON_LEFT:
return false
if event.pressed and event.alt_pressed and target != null:
_start_drag(camera, event.position, target)
return true
if not event.pressed and _dragging:
_finish_drag(undo_redo)
return true
return false
func _handle_mouse_motion(camera: Camera3D, event: InputEventMouseMotion) -> bool:
if not _dragging:
return false
_update_drag(camera, event.position)
return true
# ---------------------------------------------------------------------------
# Drag lifecycle
# ---------------------------------------------------------------------------
func _start_drag(camera: Camera3D, mouse_pos: Vector2, target: Node3D) -> void:
_target = target
_original_transform = target.global_transform
_original_scale = target.scale
_original_basis = Basis(_original_transform.basis).orthonormalized()
_local_aabb_corners = _compute_local_aabb_corners(target)
_dragging = true
_disable_target_collision(target)
var hit: Dictionary = _raycast_surface(camera, mouse_pos)
if not hit.is_empty():
_apply_hit(hit)
func _update_drag(camera: Camera3D, mouse_pos: Vector2) -> void:
if not _is_target_valid():
_reset_state()
return
var hit: Dictionary = _raycast_surface(camera, mouse_pos)
if hit.is_empty():
return
_apply_hit(hit)
func _apply_hit(hit: Dictionary) -> void:
var hit_position: Vector3 = hit["position"]
var hit_normal: Vector3 = hit["normal"]
var normal_n: Vector3 = hit_normal.normalized()
var new_basis: Basis = _align_up_to_normal(_original_basis, hit_normal)
# Only push the object along the normal, by exactly how far its AABB
# extends past the origin in that direction — this keeps the origin's
# lateral (tangent-plane) position matching the mouse ray, instead of
# pinning a specific AABB corner to the hit point.
var min_projection: float = _find_min_normal_projection(_local_aabb_corners, new_basis, normal_n)
var new_origin: Vector3 = hit_position - min_projection * normal_n
# Re-apply the original scale, which is stripped out by orthonormalization.
var scaled_basis: Basis = Basis(
new_basis.x * _original_scale.x,
new_basis.y * _original_scale.y,
new_basis.z * _original_scale.z
)
_target.global_transform = Transform3D(scaled_basis, new_origin)
func _finish_drag(undo_redo: EditorUndoRedoManager) -> void:
if not _is_target_valid():
_reset_state()
return
var affected_target: Node3D = _target
var final_transform: Transform3D = affected_target.global_transform
var previous_transform: Transform3D = _original_transform
undo_redo.create_action("Surface Snap")
undo_redo.add_do_property(affected_target, "global_transform", final_transform)
undo_redo.add_undo_property(affected_target, "global_transform", previous_transform)
undo_redo.commit_action(false)
_reset_state()
func _reset_state() -> void:
_restore_target_collision()
_dragging = false
_target = null
_original_scale = Vector3.ONE
_local_aabb_corners = []
func _is_target_valid() -> bool:
return _target != null and is_instance_valid(_target) and _target.is_inside_tree()
# ---------------------------------------------------------------------------
# Temporarily disable the dragged object's own collision, so it can't be hit
# by its own raycast (e.g. a CSGShape3D with use_collision enabled).
# ---------------------------------------------------------------------------
func _disable_target_collision(node: Node) -> void:
if node is CollisionObject3D:
var collider := node as CollisionObject3D
_disabled_collision_layers[collider] = collider.collision_layer
collider.collision_layer = 0
elif node is CSGShape3D:
var csg := node as CSGShape3D
_disabled_collision_layers[csg] = csg.collision_layer
csg.collision_layer = 0
for child in node.get_children():
_disable_target_collision(child as Node)
func _restore_target_collision() -> void:
for node: Object in _disabled_collision_layers.keys():
if is_instance_valid(node):
var original_layer: int = _disabled_collision_layers[node]
if node is CollisionObject3D:
(node as CollisionObject3D).collision_layer = original_layer
elif node is CSGShape3D:
(node as CSGShape3D).collision_layer = original_layer
_disabled_collision_layers.clear()
# ---------------------------------------------------------------------------
# Physics raycast
# ---------------------------------------------------------------------------
func _raycast_surface(camera: Camera3D, mouse_pos: Vector2) -> Dictionary:
var world_3d: World3D = camera.get_world_3d()
var space_state: PhysicsDirectSpaceState3D = world_3d.direct_space_state
var from: Vector3 = camera.project_ray_origin(mouse_pos)
var dir: Vector3 = camera.project_ray_normal(mouse_pos)
var to: Vector3 = from + dir * RAY_LENGTH
var collision_mask: int = ProjectSettings.get_setting(SETTING_COLLISION_MASK, DEFAULT_COLLISION_MASK)
var query: PhysicsRayQueryParameters3D = PhysicsRayQueryParameters3D.create(from, to, collision_mask)
return space_state.intersect_ray(query)
# ---------------------------------------------------------------------------
# Rotation alignment (Y axis -> surface normal)
# ---------------------------------------------------------------------------
func _align_up_to_normal(reference_basis: Basis, normal: Vector3) -> Basis:
var normal_n: Vector3 = normal.normalized()
var forward: Vector3 = -reference_basis.z
# Project the reference forward vector onto the surface's tangent plane
# to preserve the object's original yaw as much as possible.
var forward_proj: Vector3 = forward - normal_n * forward.dot(normal_n)
if forward_proj.length() < 0.001:
forward_proj = reference_basis.x - normal_n * reference_basis.x.dot(normal_n)
if forward_proj.length() < 0.001:
forward_proj = Vector3.FORWARD - normal_n * Vector3.FORWARD.dot(normal_n)
forward_proj = forward_proj.normalized()
var right: Vector3 = forward_proj.cross(normal_n).normalized()
var true_forward: Vector3 = normal_n.cross(right).normalized()
var new_basis: Basis = Basis(right, normal_n, -true_forward)
return new_basis.orthonormalized()
# ---------------------------------------------------------------------------
# Grounding offset via local AABB (object edge, not origin, along the normal)
# ---------------------------------------------------------------------------
func _compute_local_aabb_corners(node: Node3D) -> Array[Vector3]:
var aabb: AABB = _get_node_aabb(node)
var corners: Array[Vector3] = []
for i in range(8):
corners.append(aabb.position + Vector3(
aabb.size.x * float(i & 1),
aabb.size.y * float((i >> 1) & 1),
aabb.size.z * float((i >> 2) & 1)
))
return corners
func _get_node_aabb(node: Node3D) -> AABB:
if node is VisualInstance3D:
return (node as VisualInstance3D).get_aabb()
return AABB(Vector3.ZERO, Vector3.ZERO)
func _find_min_normal_projection(corners_local: Array[Vector3], rotation_basis: Basis, normal: Vector3) -> float:
if corners_local.is_empty():
return 0.0
var min_dot: float = INF
for corner_local in corners_local:
var corner_world: Vector3 = rotation_basis * corner_local
var d: float = corner_world.dot(normal)
if d < min_dot:
min_dot = d
return min_dot
@@ -0,0 +1 @@
uid://bq3nx5ukyhb1e
+36 -5
View File
@@ -18,6 +18,9 @@ const MAX_PLAYERS: int = 8
const DEFAULT_ADDRESS: String = "127.0.0.1"
## 所有玩家节点都会加入这个组,方便整批清场
const PLAYER_GROUP: String = "net_player"
## 开局后要进的世界场景,以及断开后要回的入口场景
const WORLD_SCENE: String = "res://world/main.tscn"
const MENU_SCENE: String = "res://ui/main_menu.tscn"
var _spawner: MultiplayerSpawner = null
@@ -41,8 +44,20 @@ func _ready() -> void:
# ---------------- 对外接口 ----------------
## 世界根节点在 _ready 里调用,把 MultiplayerSpawner 托管给 Net。
##
## 世界是「开局之后」才加载的(入口是开始菜单),所以这里得把已经连上的玩家补生成一遍 ——
## 否则主机在菜单里就开好了,切进世界时一个角色都不会出现。
func bind_spawner(spawner: MultiplayerSpawner) -> void:
_spawner = spawner
if multiplayer.is_server():
call_deferred("_spawn_player", 1)
for id in multiplayer.get_peers():
call_deferred("_spawn_player", id)
## 菜单那边用它开关「联机」面板 —— 面板默认是显示的,会盖住入口菜单。
func set_menu_visible(on: bool) -> void:
_menu.visible = on
## 创建主机。ENet 是客户端-服务器模型,主机同时是服务器与玩家。
@@ -55,8 +70,8 @@ func host_game() -> void:
multiplayer.multiplayer_peer = peer
_show_game_ui()
print("[Net] 主机已启动,端口 %d" % PORT)
# 服务器自己也要一个角色。延后一帧,等各端场景就绪。
call_deferred("_spawn_player", 1)
# 入口是开始菜单,世界这时还没加载 —— 切过去,角色由 bind_spawner 补生成
call_deferred("_enter_world")
## 加入主机。address 为主机在局域网内的内网 IP。
@@ -74,13 +89,28 @@ func join_game(address: String = DEFAULT_ADDRESS) -> void:
print("[Net] 正在连接 %s:%d" % [target, PORT])
## 断开并清场,回到主菜单。
## 断开并清场,回入口菜单。
func leave_game() -> void:
multiplayer.multiplayer_peer = null
_clear_players()
# 世界一卸载 spawner 就跟着没了,留着引用会指向已释放的节点
_spawner = null
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_show_menu_ui()
_set_status("已断开")
call_deferred("_go_to", MENU_SCENE)
## 切到世界场景。已经在里面就不重复切
func _enter_world() -> void:
_go_to(WORLD_SCENE)
func _go_to(path: String) -> void:
var current := get_tree().current_scene
if current != null and current.scene_file_path == path:
return
get_tree().change_scene_to_file(path)
# ---------------- 网络事件 ----------------
@@ -99,6 +129,7 @@ func _on_peer_disconnected(id: int) -> void:
func _on_connected_to_server() -> void:
_show_game_ui()
print("[Net] 已连接,等待主机生成角色")
call_deferred("_enter_world")
func _on_connection_failed() -> void:
@@ -117,7 +148,7 @@ func _on_server_disconnected() -> void:
# ---------------- 玩家生成与回收 ----------------
func _spawn_player(peer_id: int) -> void:
if _spawner == null or not multiplayer.is_server():
if not is_instance_valid(_spawner) or not multiplayer.is_server():
return
var parent := _spawner.get_node_or_null(_spawner.spawn_path)
if parent == null or parent.has_node(str(peer_id)):
@@ -126,7 +157,7 @@ func _spawn_player(peer_id: int) -> void:
func _despawn_player(peer_id: int) -> void:
if _spawner == null:
if not is_instance_valid(_spawner):
return
var parent := _spawner.get_node_or_null(_spawner.spawn_path)
if parent == null:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="animation_library"
importer_version=1
type="AnimationLibrary"
uid="uid://stsads1ivf5g"
path="res://.godot/imported/crouch.glb-e5c926449e187b681f5015d7207822d2.res"
[deps]
source_file="res://character/player/assets/animations/crouch.glb"
dest_files=["res://.godot/imported/crouch.glb-e5c926449e187b681f5015d7207822d2.res"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="animation_library"
importer_version=1
type="AnimationLibrary"
uid="uid://ddnmtmswkjx6g"
path="res://.godot/imported/fall.glb-49c9490de76a63bebdee88239a1f0f9e.res"
[deps]
source_file="res://character/player/assets/animations/fall.glb"
dest_files=["res://.godot/imported/fall.glb-49c9490de76a63bebdee88239a1f0f9e.res"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="animation_library"
importer_version=1
type="AnimationLibrary"
uid="uid://ddkl5cqdd5usx"
path="res://.godot/imported/idle.glb-2d0157de17feb1a431c92000d6d714d9.res"
[deps]
source_file="res://character/player/assets/animations/idle.glb"
dest_files=["res://.godot/imported/idle.glb-2d0157de17feb1a431c92000d6d714d9.res"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="animation_library"
importer_version=1
type="AnimationLibrary"
uid="uid://esw60hwb2cty"
path="res://.godot/imported/jump.glb-26f2d5decb10ac3d815d0fc041029ce3.res"
[deps]
source_file="res://character/player/assets/animations/jump.glb"
dest_files=["res://.godot/imported/jump.glb-26f2d5decb10ac3d815d0fc041029ce3.res"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="animation_library"
importer_version=1
type="AnimationLibrary"
uid="uid://rqhclfyiingo"
path="res://.godot/imported/run.glb-cfcc84d875800bb53941cb49d5cb93b3.res"
[deps]
source_file="res://character/player/assets/animations/run.glb"
dest_files=["res://.godot/imported/run.glb-cfcc84d875800bb53941cb49d5cb93b3.res"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="animation_library"
importer_version=1
type="AnimationLibrary"
uid="uid://dy8a178vlbsof"
path="res://.godot/imported/sneak.glb-94e9451c07d186fd632d29f778666d4f.res"
[deps]
source_file="res://character/player/assets/animations/sneak.glb"
dest_files=["res://.godot/imported/sneak.glb-94e9451c07d186fd632d29f778666d4f.res"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
@@ -0,0 +1,45 @@
[remap]
importer="animation_library"
importer_version=1
type="AnimationLibrary"
uid="uid://db83440o2cyr7"
path="res://.godot/imported/walk.glb-0021c25d27c990fe38e60a06db5fd410.res"
[deps]
source_file="res://character/player/assets/animations/walk.glb"
dest_files=["res://.godot/imported/walk.glb-0021c25d27c990fe38e60a06db5fd410.res"]
[params]
nodes/root_type=""
nodes/root_name=""
nodes/root_script=null
mesh_library/use_node_names_as_mesh_names=false
array_mesh/deduplicate_surfaces=true
nodes/apply_root_scale=true
nodes/root_scale=1.0
nodes/import_as_skeleton_bones=false
nodes/use_name_suffixes=true
nodes/use_node_type_suffixes=true
meshes/ensure_tangents=true
meshes/generate_lods=true
meshes/create_shadow_meshes=true
meshes/light_baking=1
meshes/lightmap_texel_size=0.2
meshes/force_disable_compression=false
skins/use_named_skins=true
animation/import=true
animation/fps=30
animation/trimming=false
animation/remove_immutable_tracks=true
animation/import_rest_as_RESET=false
import_script/path=""
materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=1
Binary file not shown.
Binary file not shown.
@@ -3,13 +3,13 @@
importer="scene"
importer_version=1
type="PackedScene"
uid="uid://ccf670c6bkob8"
path="res://.godot/imported/character-male-e.glb-9d9c3cbc43d44547b0be72ff662d6ebd.scn"
uid="uid://css1vk3u8gt7o"
path="res://.godot/imported/mouse.glb-4ea50106b9c7f752fe9b9922707b87db.scn"
[deps]
source_file="res://character/player/assets/character-male-e.glb"
dest_files=["res://.godot/imported/character-male-e.glb-9d9c3cbc43d44547b0be72ff662d6ebd.scn"]
source_file="res://character/player/assets/mouse.glb"
dest_files=["res://.godot/imported/mouse.glb-4ea50106b9c7f752fe9b9922707b87db.scn"]
[params]
@@ -40,6 +40,6 @@ materials/extract=0
materials/extract_format=0
materials/extract_path=""
_subresources={}
gltf/naming_version=1
gltf/naming_version=2
gltf/embedded_image_handling=1
gltf/texture_map_mode=0
gltf/texture_map_mode=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -2,17 +2,20 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://cq3awe0wjqsih"
path.s3tc="res://.godot/imported/colormap.png-4bd9a802f0fbdd1bc388c729ce936ac0.s3tc.ctex"
uid="uid://bstapq3c4s26u"
path.s3tc="res://.godot/imported/mouse_body_basecolor.png-e534f1ddef7d8ba45e0a751e339c550c.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "d8e805d55d53d74ba4ab8f9a67c62494"
}
[deps]
source_file="res://character/player/assets/Textures/colormap.png"
dest_files=["res://.godot/imported/colormap.png-4bd9a802f0fbdd1bc388c729ce936ac0.s3tc.ctex"]
source_file="res://character/player/assets/mouse_body_basecolor.png"
dest_files=["res://.godot/imported/mouse_body_basecolor.png-e534f1ddef7d8ba45e0a751e339c550c.s3tc.ctex"]
[params]
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -2,17 +2,20 @@
importer="texture"
type="CompressedTexture2D"
uid="uid://chawqtd3fcydp"
path.s3tc="res://.godot/imported/texture_01.png-e765476e37c3bd9342318549a3523e3a.s3tc.ctex"
uid="uid://6hseg3y22j6l"
path.s3tc="res://.godot/imported/mouse_ear_basecolor.png-dd004c6f527b3bfbc486f6dd49157842.s3tc.ctex"
metadata={
"imported_formats": ["s3tc_bptc"],
"vram_texture": true
}
generator_parameters={
"md5": "16475065f6b3024dc12b0a1533211079"
}
[deps]
source_file="res://character/player/assets/Textures/texture_01.png"
dest_files=["res://.godot/imported/texture_01.png-e765476e37c3bd9342318549a3523e3a.s3tc.ctex"]
source_file="res://character/player/assets/mouse_ear_basecolor.png"
dest_files=["res://.godot/imported/mouse_ear_basecolor.png-dd004c6f527b3bfbc486f6dd49157842.s3tc.ctex"]
[params]
+51
View File
@@ -0,0 +1,51 @@
shader_type spatial;
// 全身 shader:body_mesh 现在只有一个材质 mat_body,所以身体底色照常取它的 BaseColor,
// 脸部**不再单独换材质**,而是在同一个 shader 里,只在指定 UV 矩形内把表情和手绘叠上去。
// 这样脸和头部走的是同一套材质、同一套光照,不会再出现独立面片的色块边界。
//
// 贴图 alpha 只表示「墨线覆盖率」,绝不写进空间 ALPHA —— body_mesh 的面片必须保持不透明。
uniform sampler2D body_tex : source_color, filter_linear, repeat_disable,
hint_default_white;
uniform vec4 body_tint : source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform float surface_roughness : hint_range(0.0, 1.0) = 0.65;
uniform float surface_metallic : hint_range(0.0, 1.0) = 0.0;
// 脸部在 body_mesh 的 UV 上占的矩形。**模型重导 / 改 UV 之后必须复核这里**:
// 它要整块落在头部那块 UV 孤岛里面,否则表情会画到别的部位上。
uniform vec2 face_uv_min = vec2(0.695, 0.682);
uniform vec2 face_uv_size = vec2(0.301, 0.314);
// 头部 UV 的 v 是「越往上越大」,而贴图第 0 行在上边,所以默认要把 v 翻过来,
// 否则表情上下颠倒。
uniform bool face_uv_flip_y = true;
// 表情底图:内置表情是保留原排布、已去底的透明图集;本地表情是整张透明 PNG。
uniform sampler2D base_tex : source_color, filter_linear, repeat_disable,
hint_default_transparent;
uniform vec2 base_uv_min = vec2(0.0, 0.0);
uniform vec2 base_uv_size = vec2(1.0, 1.0);
// 玩家笔画层(透明底)
uniform sampler2D paint_tex : source_color, filter_linear, repeat_disable,
hint_default_transparent;
void fragment() {
// 全身底色都来自 mat_body 的 BaseColor,表情只是盖在脸上
vec3 albedo = texture(body_tex, UV).rgb * body_tint.rgb;
vec2 face_uv = (UV - face_uv_min) / face_uv_size;
if (face_uv_flip_y) {
face_uv.y = 1.0 - face_uv.y;
}
// 用 step 而不是 if:采样留在统一控制流里,免得 mipmap 的导数在分支里未定义
float mask = step(0.0, face_uv.x) * step(face_uv.x, 1.0)
* step(0.0, face_uv.y) * step(face_uv.y, 1.0);
vec4 base = texture(base_tex, base_uv_min + face_uv * base_uv_size);
vec4 paint = texture(paint_tex, face_uv);
// 透明处保持身体底色 —— 所以脸和周围不可能出现底色差
albedo = mix(albedo, base.rgb, base.a * mask);
albedo = mix(albedo, paint.rgb, paint.a * mask);
ALBEDO = albedo;
ROUGHNESS = surface_roughness;
METALLIC = surface_metallic;
}
@@ -0,0 +1 @@
uid://boka801kewxls
+190
View File
@@ -0,0 +1,190 @@
class_name FacePainter
extends Node
## 面部画笔:直接往一张 Image 的字节缓冲上盖笔刷印章。
##
## 为什么不用 Control._draw():Godot 4 的 canvas 已经没有「擦除」混合模式了
## (CanvasItemMaterial 只有 mix / add / sub / mul / premult,也没有 draw_set_blend_mode),
## 橡皮没法靠 blend_mode 实现。自己管像素反而更直接:
## 画 = 标准的 source-over alpha 混合;擦 = 把目标的 alpha 乘掉;存档 = 就是存这张图。
var image: Image
var texture: ImageTexture
var canvas_size: int = 512
## 当前笔刷。绘画面板改这几个值,下一笔生效。
var brush_color: Color = Color.BLACK
var brush_width: float = 12.0
var erasing: bool = false
## 像素缓冲(RGBA8)。所有绘制都改它,一笔画完再一次性推给显卡。
var _buf: PackedByteArray
## 圆形笔刷的覆盖度,边长 _stamp_d
var _cov: PackedFloat32Array
var _stamp_d: int = 0
var _stamp_rgb: Vector3 = Vector3.ZERO
var _last_stamp: Vector2 = Vector2.INF
func _init(size: int = 512) -> void:
canvas_size = size
func _ready() -> void:
_buf = PackedByteArray()
_buf.resize(canvas_size * canvas_size * 4)
_sync()
# ---------------- 画笔接口 ----------------
func begin_stroke(pos: Vector2) -> void:
_rebuild_stamp()
_last_stamp = Vector2.INF
_stamp_at(pos)
_last_stamp = pos
_sync()
func extend_stroke(pos: Vector2) -> void:
if not _last_stamp.is_finite():
_stamp_at(pos)
_last_stamp = pos
_sync()
return
var spacing := maxf(1.0, brush_width * 0.25)
var dist := _last_stamp.distance_to(pos)
if dist < spacing:
return
# 鼠标动得快时两点之间得补章,否则线是一串断点
var steps := int(ceil(dist / spacing))
for i in range(1, steps + 1):
_stamp_at(_last_stamp.lerp(pos, float(i) / float(steps)))
_last_stamp = pos
_sync()
func end_stroke() -> void:
_last_stamp = Vector2.INF
func clear() -> void:
_buf.fill(0)
_last_stamp = Vector2.INF
_sync()
## 拍一张当前笔画层的快照。绘画面板打开时存一份,点「取消」时还原
func snapshot() -> PackedByteArray:
return _buf.duplicate()
## 还原到快照。尺寸对不上就忽略(换过画布大小)
func restore(data: PackedByteArray) -> void:
if data.size() != _buf.size():
return
_buf = data.duplicate()
_last_stamp = Vector2.INF
_sync()
# ---------------- 存档 ----------------
## 笔画层存成一张透明底 PNG
func save_png(path: String) -> void:
_sync()
image.save_png(path)
## 读回笔画层。尺寸对不上就当没读到(换了画布大小的话)
func load_png(path: String) -> void:
if not FileAccess.file_exists(path):
return
set_image(Image.load_from_file(path))
## 从一张 Image 直接载入笔画层 —— 联机收到别人的脸时走这条,不用落盘
func set_image(img: Image) -> void:
if img == null:
return
if img.get_width() != canvas_size or img.get_height() != canvas_size:
push_warning("笔画层尺寸是 %s,画布是 %d,忽略" % [img.get_size(), canvas_size])
return
img.convert(Image.FORMAT_RGBA8)
_buf = img.get_data()
_last_stamp = Vector2.INF
_sync()
## 当前笔画层的 PNG 字节 —— 联机同步用,不落盘
func to_png_bytes() -> PackedByteArray:
_sync()
return image.save_png_to_buffer()
# ---------------- 内部 ----------------
## 把字节缓冲变成 Image 再推给显卡。一笔画完调一次 —— 512² 一次就是 1MB,别每盖一个印章都来
func _sync() -> void:
image = Image.create_from_data(canvas_size, canvas_size, false, Image.FORMAT_RGBA8, _buf)
if texture == null:
texture = ImageTexture.create_from_image(image)
else:
texture.update(image)
## 按当前粗细和颜色重造笔刷。边缘留 1 像素羽化 —— 硬边的线很难看。
func _rebuild_stamp() -> void:
var r := maxi(2, int(round(brush_width * 0.5)))
var d := r * 2
_stamp_d = d
_stamp_rgb = Vector3(brush_color.r, brush_color.g, brush_color.b)
_cov.resize(d * d)
for y in d:
for x in d:
var dx := float(x) - float(r) + 0.5
var dy := float(y) - float(r) + 0.5
var dist := sqrt(dx * dx + dy * dy) / float(r)
_cov[y * d + x] = clampf((1.0 - dist) * float(r), 0.0, 1.0)
## 在 pos 处盖一个印章。越界像素直接跳过,所以画到边上也不会出错。
func _stamp_at(pos: Vector2) -> void:
var d := _stamp_d
var r := d / 2
var cx := int(round(pos.x)) - r
var cy := int(round(pos.y)) - r
var sr := _stamp_rgb.x * 255.0
var sg := _stamp_rgb.y * 255.0
var sb := _stamp_rgb.z * 255.0
for sy in d:
var ty := cy + sy
if ty < 0 or ty >= canvas_size:
continue
var row := ty * canvas_size * 4
var srow := sy * d
for sx in d:
var tx := cx + sx
if tx < 0 or tx >= canvas_size:
continue
var cov := _cov[srow + sx]
if cov <= 0.001:
continue
var o := row + tx * 4
if erasing:
# 擦:把 alpha 按覆盖度乘掉。RGB 留着不管 —— shader 只看 alpha
_buf[o + 3] = int(float(_buf[o + 3]) * (1.0 - cov))
continue
var sa := cov
var da := float(_buf[o + 3]) / 255.0
var na := sa + da * (1.0 - sa)
if na <= 0.0001:
continue
var ws := sa / na
var wd := da * (1.0 - sa) / na
_buf[o] = int(clampf(sr * ws + float(_buf[o]) * wd, 0.0, 255.0))
_buf[o + 1] = int(clampf(sg * ws + float(_buf[o + 1]) * wd, 0.0, 255.0))
_buf[o + 2] = int(clampf(sb * ws + float(_buf[o + 2]) * wd, 0.0, 255.0))
_buf[o + 3] = int(clampf(na * 255.0, 0.0, 255.0))
@@ -0,0 +1 @@
uid://5fbgd46kg2hj
+325
View File
@@ -0,0 +1,325 @@
class_name FaceRig
extends Node
## 角色脸部:body_mesh 现在**只有一个材质**,所以这里不再换材质,
## 而是把 mat_body 换成「全身 shader」:身体照常取它的 BaseColor,
## 只在脸部的 UV 矩形内叠加表情贴图和玩家手绘。
const SHADER_PATH := "res://character/player/face/face_paint.gdshader"
const ATLAS_PATH := "res://character/player/face/presets/face_atlas_transparent.png"
const LEGACY_PRESET_SAVE := "user://face_preset.json"
const SAVE_PAINT := "user://face_paint.png"
const LIBRARY_DIR := "user://face_library"
const LIBRARY_INDEX := "user://face_library/index.json"
## 图集 rect 来自最初那张 1024² 表情图:face_01=(145,32,219),face_02=(152,290,205)。
## 每个条目用各自的采样矩形,贴图都是项目里的 face_atlas_transparent.png(原图已随模型删掉)。
const BUILTIN_EXPRESSIONS := [
{"id": "builtin:face_01", "name": "预设表情 1", "rect": Rect2(145.0 / 1024.0, 32.0 / 1024.0, 219.0 / 1024.0, 219.0 / 1024.0)},
{"id": "builtin:face_02", "name": "预设表情 2", "rect": Rect2(152.0 / 1024.0, 290.0 / 1024.0, 205.0 / 1024.0, 205.0 / 1024.0)},
]
const FACE_SYNC_TRIES := 5
const FACE_SYNC_INTERVAL := 0.8
const FACE_SYNC_INITIAL_DELAY := 1.0
@export_group("接线")
@export var mesh_path: NodePath = ^"../Body/mouse/MouseRig/Skeleton3D/body_mesh"
## 覆盖第几个 surface。现在 body_mesh 只有一个材质 mat_body,所以是 0。
@export var surface_index: int = 0
@export var default_expression_id: String = "builtin:face_01"
@export_group("画布")
@export var canvas_size: int = 512
## 与两个原始贴图的背景/头部底色完全相同:RGB(137,179,255)。
@export var skin_color: Color = Color(0.537255, 0.701961, 1.0, 1.0)
@export_group("脸部 UV")
## 脸部矩形在 body_mesh 的 UV 上占的范围(实测自当前模型:头部孤岛是 u 0.695~0.996 / v 0.682~0.996)。
## 它必须整块落在头部那块 UV 孤岛里;模型重导后 UV 一变,表情就会画歪或跑到别的部位上。
@export var face_uv_min: Vector2 = Vector2(0.695, 0.682)
## 脸部矩形的 UV 边长
@export var face_uv_size: Vector2 = Vector2(0.301, 0.314)
## 头部 UV 的 v 越往上越大,而贴图第 0 行在上边 —— 不翻就在脸上上下颠倒
@export var face_uv_flip_y: bool = true
var mesh: MeshInstance3D
var material: ShaderMaterial
var painter: FacePainter
var selected_expression_id: String = ""
var selected_expression_name: String = ""
var selected_expression_texture: Texture2D
var _atlas_texture: Texture2D
var _no_base: Texture2D
var _base_uv_min := Vector2.ZERO
var _base_uv_size := Vector2.ONE
var _got_face := false
func _ready() -> void:
mesh = get_node_or_null(mesh_path) as MeshInstance3D
if mesh == null:
push_warning("%s 找不到面部 mesh(%s),脸不会有变化" % [get_path(), mesh_path])
return
_atlas_texture = load(ATLAS_PATH) as Texture2D
painter = FacePainter.new(canvas_size)
painter.name = "FacePainter"
add_child(painter)
material = ShaderMaterial.new()
material.shader = load(SHADER_PATH)
var body_material := mesh.mesh.surface_get_material(surface_index) as StandardMaterial3D
if body_material == null:
push_warning("%s 的 surface %d 不是 StandardMaterial3D,身体底色可能不对" % [mesh_path, surface_index])
else:
# 身体底色、粗糙度、金属度都照抄 mat_body —— 脸和身体才可能看起来是同一块皮肤
var body_tex := body_material.albedo_texture
if body_tex != null:
material.set_shader_parameter("body_tex", body_tex)
# 画布和缩略图垫的底色也跟着身体贴图走,省得和模型对不上。
# 导入的贴图是压缩格式,get_pixel 之前必须先解压。
var body_image := body_tex.get_image()
if body_image != null:
if body_image.is_compressed():
body_image.decompress()
if not body_image.is_empty():
var body_rgb := body_image.get_pixel(0, 0)
skin_color = Color(body_rgb.r, body_rgb.g, body_rgb.b, 1.0)
material.set_shader_parameter("body_tint", body_material.albedo_color)
material.set_shader_parameter("surface_roughness", body_material.roughness)
material.set_shader_parameter("surface_metallic", body_material.metallic)
material.set_shader_parameter("face_uv_min", face_uv_min)
material.set_shader_parameter("face_uv_size", face_uv_size)
material.set_shader_parameter("face_uv_flip_y", face_uv_flip_y)
material.set_shader_parameter("paint_tex", painter.texture)
mesh.set_surface_override_material(surface_index, material)
apply_saved()
var peer := multiplayer.multiplayer_peer
if peer != null and not (peer is OfflineMultiplayerPeer) and not is_multiplayer_authority():
_pull_remote_face()
# ---------------- 表情列表与选择 ----------------
## 返回内置 atlas 表情和本地保存表情,供角色选择页建缩略图。
func get_expression_catalog() -> Array[Dictionary]:
var entries: Array[Dictionary] = []
for builtin in BUILTIN_EXPRESSIONS:
var rect: Rect2 = builtin["rect"]
var thumb := AtlasTexture.new()
thumb.atlas = _atlas_texture
thumb.region = Rect2(rect.position * Vector2(1024.0, 1024.0), rect.size * Vector2(1024.0, 1024.0))
thumb.filter_clip = true
entries.append({
"id": builtin["id"], "name": builtin["name"],
"texture": _atlas_texture, "thumbnail": thumb,
"uv_min": rect.position, "uv_size": rect.size, "is_atlas": true,
})
for row in _read_library_index():
var id := str(row.get("id", ""))
var filename := str(row.get("file", ""))
if id.is_empty() or filename.is_empty():
continue
var image := Image.load_from_file("%s/%s" % [LIBRARY_DIR, filename])
if image == null or image.is_empty():
continue
var tex := ImageTexture.create_from_image(image)
entries.append({
"id": id, "name": str(row.get("name", "我的表情")),
"texture": tex, "thumbnail": tex,
"uv_min": Vector2.ZERO, "uv_size": Vector2.ONE, "is_atlas": false,
})
return entries
## 选择一个内置或本地表情。切换项目时清掉临时笔画,表情项本身是完整表情。
func select_expression(expression_id: String, clear_paint: bool = true) -> bool:
for entry in get_expression_catalog():
if str(entry["id"]) != expression_id:
continue
_apply_expression(entry)
if clear_paint and painter != null:
painter.clear()
return true
return false
## 「自己画」/取消快照需要的透明空白项,不保存为内置表情。
func select_blank(clear_paint: bool = true) -> void:
selected_expression_id = ""
selected_expression_name = "空白"
selected_expression_texture = _no_base_texture()
_base_uv_min = Vector2.ZERO
_base_uv_size = Vector2.ONE
_update_shader_expression()
if clear_paint and painter != null:
painter.clear()
func _apply_expression(entry: Dictionary) -> void:
selected_expression_id = str(entry["id"])
selected_expression_name = str(entry["name"])
selected_expression_texture = entry["texture"] as Texture2D
_base_uv_min = entry["uv_min"] as Vector2
_base_uv_size = entry["uv_size"] as Vector2
_update_shader_expression()
func _update_shader_expression() -> void:
if material == null:
return
material.set_shader_parameter("base_tex", selected_expression_texture if selected_expression_texture != null else _no_base_texture())
material.set_shader_parameter("base_uv_min", _base_uv_min)
material.set_shader_parameter("base_uv_size", _base_uv_size)
func _no_base_texture() -> Texture2D:
if _no_base == null:
_no_base = ImageTexture.create_from_image(Image.create(2, 2, false, Image.FORMAT_RGBA8))
return _no_base
# ---------------- 本地表情库 ----------------
func save_drawing_as_expression(display_name: String) -> Dictionary:
if painter == null:
return {}
var absolute_dir := ProjectSettings.globalize_path(LIBRARY_DIR)
var dir_error := DirAccess.make_dir_recursive_absolute(absolute_dir)
if dir_error != OK and dir_error != ERR_ALREADY_EXISTS:
push_warning("无法创建本地表情目录:%s" % error_string(dir_error))
return {}
var rows := _read_library_index()
var name := display_name.strip_edges()
if name.is_empty():
name = "我的表情 %d" % (rows.size() + 1)
var id := "user:%d" % Time.get_ticks_usec()
var filename := id.replace(":", "_") + ".png"
var file := FileAccess.open("%s/%s" % [LIBRARY_DIR, filename], FileAccess.WRITE)
if file == null:
push_warning("无法保存本地表情:%s" % filename)
return {}
file.store_buffer(painter.to_png_bytes())
file.close()
rows.append({"id": id, "name": name, "file": filename})
if not _write_library_index(rows):
return {}
if not select_expression(id):
return {}
save()
return {"id": id, "name": name}
func _read_library_index() -> Array:
if not FileAccess.file_exists(LIBRARY_INDEX):
return []
var file := FileAccess.open(LIBRARY_INDEX, FileAccess.READ)
if file == null:
return []
var data: Variant = JSON.parse_string(file.get_as_text())
return data if data is Array else []
func _write_library_index(rows: Array) -> bool:
var file := FileAccess.open(LIBRARY_INDEX, FileAccess.WRITE)
if file == null:
push_warning("无法更新本地表情索引:%s" % LIBRARY_INDEX)
return false
file.store_string(JSON.stringify(rows))
file.close()
return true
# ---------------- 存档 ----------------
func save() -> void:
if painter == null:
return
painter.save_png(SAVE_PAINT)
var file := FileAccess.open(LEGACY_PRESET_SAVE, FileAccess.WRITE)
if file == null:
push_warning("脸部选择存不下来:%s" % LEGACY_PRESET_SAVE)
return
file.store_string(JSON.stringify({"expression_id": selected_expression_id}))
file.close()
func apply_saved() -> void:
var expression_id := default_expression_id
if FileAccess.file_exists(LEGACY_PRESET_SAVE):
var file := FileAccess.open(LEGACY_PRESET_SAVE, FileAccess.READ)
var data: Variant = JSON.parse_string(file.get_as_text()) if file != null else null
if data is Dictionary:
expression_id = str(data.get("expression_id", _legacy_expression_id(str(data.get("preset", "")))))
if expression_id.is_empty():
select_blank(false)
elif not select_expression(expression_id, false):
select_expression(default_expression_id, false)
if painter != null:
painter.load_png(SAVE_PAINT)
func _legacy_expression_id(old_path: String) -> String:
if old_path.is_empty():
return ""
if old_path.ends_with("face_02.png"):
return "builtin:face_02"
if old_path.ends_with("face_01.png"):
return "builtin:face_01"
return default_expression_id
# ---------------- 联机同步 ----------------
func _pull_remote_face() -> void:
await get_tree().create_timer(FACE_SYNC_INITIAL_DELAY).timeout
for i in FACE_SYNC_TRIES:
if not is_inside_tree() or _got_face:
return
_rpc_request_face.rpc_id(get_multiplayer_authority())
await get_tree().create_timer(FACE_SYNC_INTERVAL).timeout
@rpc("any_peer", "call_remote", "reliable")
func _rpc_request_face() -> void:
if not is_multiplayer_authority():
return
var local_png := PackedByteArray()
if selected_expression_id.begins_with("user:") and selected_expression_texture != null:
var image := selected_expression_texture.get_image()
local_png = image.save_png_to_buffer()
_rpc_send_face.rpc_id(
multiplayer.get_remote_sender_id(),
selected_expression_id,
local_png,
painter.to_png_bytes() if painter != null else PackedByteArray(),
)
@rpc("any_peer", "call_remote", "reliable")
func _rpc_send_face(expression_id: String, local_png: PackedByteArray, paint_png: PackedByteArray) -> void:
if is_multiplayer_authority() or multiplayer.get_remote_sender_id() != get_multiplayer_authority():
return
_got_face = true
if expression_id.is_empty():
select_blank(false)
elif expression_id.begins_with("user:") and local_png.size() > 0:
var img := Image.new()
if img.load_png_from_buffer(local_png) == OK:
selected_expression_id = expression_id
selected_expression_name = "远端玩家表情"
selected_expression_texture = ImageTexture.create_from_image(img)
_base_uv_min = Vector2.ZERO
_base_uv_size = Vector2.ONE
_update_shader_expression()
else:
select_expression(expression_id, false)
if paint_png.size() > 0 and painter != null:
var paint_image := Image.new()
if paint_image.load_png_from_buffer(paint_png) == OK:
painter.set_image(paint_image)
+1
View File
@@ -0,0 +1 @@
uid://c6l2fl5jgvr1h
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://5wd8ao2i3lcq"
path="res://.godot/imported/face_01.png-cc81c7ed8009dc49f59ab7f3b569ca21.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://character/player/face/presets/face_01.png"
dest_files=["res://.godot/imported/face_01.png-cc81c7ed8009dc49f59ab7f3b569ca21.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bup0pyw24lsft"
path="res://.godot/imported/face_02.png-914865f7dedf5c93ed2fd1b1b037f243.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://character/player/face/presets/face_02.png"
dest_files=["res://.godot/imported/face_02.png-914865f7dedf5c93ed2fd1b1b037f243.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

@@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cg7dharvu5svr"
path="res://.godot/imported/face_atlas_transparent.png-d56481b0045839c14a1d633b682cc418.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://character/player/face/presets/face_atlas_transparent.png"
dest_files=["res://.godot/imported/face_atlas_transparent.png-d56481b0045839c14a1d633b682cc418.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
+60 -45
View File
@@ -1,18 +1,15 @@
extends CharacterBody3D
## 第三人称控制器:只管「怎么动」—— 输入、移动物理、相机、血条、联机分叉。
## 表现层(动画)在 Body/PlayerAnimator 里,本文件不认识任何片段名。
## 第一人称控制器:只管「怎么动」—— 输入、移动物理、视角、血条、联机分叉。
## 表现层(动画)在 Body/AnimationPlayer 上(挂 player_animator.gd),本文件不认识任何片段名。
##
## 场景结构:
## Player (CharacterBody3D) ← 只负责移动与碰撞,自身不旋转
## ├─ Collision (CollisionShape3D, CapsuleShape3D)
## ├─ Body (Node3D) ← 视觉层,朝移动方向转身;换角色模型时挂在它下面
## │ ├─ PlayerAnimator (PlayerAnimator.gd) ← 动作状态机:只读运动、只写动画
## │ └─ character-male-e2 (Node3D)
## │ ├─ character-male-e/Skeleton3D ← 骨骼 + 蒙皮网格(character-male-e.glb)
## │ └─ AnimationPlayer ← 动作库,由 PlayerAnimator 驱动
## ├─ CameraRig (Node3D) ← 承担偏航 yaw,独立于本体旋转
## │ └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障
## │ └─ Camera (Camera3D)
## ├─ CameraRig (Node3D) ← 眼睛,架在 eye_height 高,承担偏航 yaw
## │ └─ Camera (Camera3D) ← 承担俯仰 pitch(第一人称,没有弹簧臂)
## ├─ Body (Node3D) ← 视觉层,始终朝视线方向;换角色模型时挂在它下面
## │ ├─ mouse (Node3D) ← 模型(mouse.glb):MouseRig/Skeleton3D + 各部位 MeshInstance3D
## │ └─ AnimationPlayer ← 动作库 + 表现层(挂 player_animator.gd):只读运动、只写动画
## ├─ Sync (MultiplayerSynchronizer) ← 同步 position、Body.rotation、health、动画状态
## ├─ HealthLabel (Label3D) ← 头顶血条(节点配置,不是代码建的)
## └─ InteractHint (Label3D) ← 靠近滑索时的「按 E」提示,同样在场景里配好
@@ -20,7 +17,7 @@ extends CharacterBody3D
## 滑索:走到缆下按 E 挂上,沿缆滑向低端,缆上按 S 刹车、空格跳离。
## 手感参数(速度/刹车/吸附半径)全在 Zipline 组件上,本文件只读不定义 —— 见文件末尾。
##
## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,E 滑索,Esc 释放/锁定鼠标
## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,Ctrl 蹲下(蹲着走即潜行),空格跳跃,E 滑索,Esc 释放/锁定鼠标
## 跳跃输入缓冲帧数(输入事件在渲染帧到达,物理帧读取,避免漏跳)
const JUMP_BUFFER_FRAMES: int = 6
@@ -32,6 +29,20 @@ const JUMP_BUFFER_FRAMES: int = 6
health = clampi(value, 0, 100)
_refresh_health_label()
@export_group("视角")
## 眼睛高度(米)。相机就架在这个高度上 —— 按碰撞胶囊定,跟模型高矮无关。
@export var eye_height: float = 1.6
## 第一人称下藏掉自己的模型。不藏的话相机要么在模型里、要么贴着头顶,画面糊一片。
## 想做成「看得见自己身体」的第一人称,就把这个关掉、并把 eye_height 降到模型头部高度。
## 只影响本机这一份 —— 远端玩家的模型照常显示。
@export var hide_own_body: bool = true
## 鼠标灵敏度(弧度/像素)
@export var mouse_sensitivity: float = 0.0025
## 最低俯角(向下看)。第一人称得能抬头低头,所以放到接近 ±90°
@export var pitch_min_deg: float = -89.0
## 最高仰角(向上看)
@export var pitch_max_deg: float = 89.0
@export_group("移动")
## 常规移动速度(米/秒)
@export var walk_speed: float = 5.0
@@ -43,27 +54,19 @@ const JUMP_BUFFER_FRAMES: int = 6
@export var air_acceleration: float = 12.0
## 起跳初速度(米/秒)
@export var jump_velocity: float = 5.0
## 视觉层转身平滑速度
@export var turn_speed: float = 10.0
@export_group("相机")
## 鼠标灵敏度(弧度/像素)
@export var mouse_sensitivity: float = 0.0025
## 最低俯角(向下看)
@export var pitch_min_deg: float = -80.0
## 最高仰角(向上看)
@export var pitch_max_deg: float = 45.0
@onready var body: Node3D = $Body
## 眼睛分两层转:CameraRig 绕竖直轴转(yaw),Camera 原地抬头低头(pitch)。
## 分开是为了让 pitch 不掺进移动方向 —— 移动只用 rig 的 basis,低头看地也不会走歪。
@onready var camera_rig: Node3D = $CameraRig
@onready var spring_arm: SpringArm3D = $CameraRig/SpringArm
@onready var camera: Camera3D = $CameraRig/Camera
## 头顶血条是 player.tscn 里的 Label3D 节点(3D 世界里的文字,会跟着角色走)
@onready var _health_label: Label3D = $HealthLabel
## 滑索操作提示,同样在 player.tscn 里配好,默认隐藏
@onready var _hint: Label3D = $InteractHint
## 动画状态机(挂在 Body 下)。只把「运动结果」交给它,也只知道它会自己播动画。
@onready var animator: PlayerAnimator = $Body/PlayerAnimator
## 动画状态机(挂在 Body/AnimationPlayer 上)。只把「运动结果」交给它,也只知道它会自己播动画。
@onready var animator: PlayerAnimator = $Body/AnimationPlayer
var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.8))
var _yaw: float = 0.0
@@ -99,9 +102,12 @@ func _ready() -> void:
set_physics_process(false)
set_process_unhandled_input(false)
return
camera_rig.position.y = eye_height
if hide_own_body:
body.visible = false
_yaw = camera_rig.rotation.y
_pitch = clampf(spring_arm.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg))
spring_arm.rotation.x = _pitch
_pitch = clampf(camera.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg))
_apply_look()
set_mouse_captured(true)
# 同步可能在 _ready() 之前就把 health 写进来了,那时血条还没刷新,这里补一次
_refresh_health_label()
@@ -115,8 +121,7 @@ func _unhandled_input(event: InputEvent) -> void:
deg_to_rad(pitch_min_deg),
deg_to_rad(pitch_max_deg)
)
camera_rig.rotation.y = _yaw
spring_arm.rotation.x = _pitch
_apply_look()
elif event.is_action_pressed("jump"):
# 缆上按跳 = 主动脱手,额外给一个向上的小跳,方便够到旁边的平台
if _zipline != null:
@@ -142,6 +147,14 @@ func set_mouse_captured(captured: bool) -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if captured else Input.MOUSE_MODE_VISIBLE
## 把 _yaw / _pitch 落到节点上。鼠标一动就调一次(渲染帧),所以视角是跟手的、不等物理帧。
## 身体跟着一起转 —— 第一人称里「你看哪,身体就朝哪」,别人看你也是这个朝向。
func _apply_look() -> void:
camera_rig.rotation.y = _yaw
camera.rotation.x = _pitch
body.rotation.y = _yaw
func _physics_process(delta: float) -> void:
if not _is_local:
return
@@ -171,18 +184,17 @@ func _physics_process(delta: float) -> void:
velocity.y = jump_velocity
_jump_buffer = 0
# --- 相对相机的水平输入 ---
# --- 相对视线方向的水平输入 ---
# 只用 rig 的 basis(只有 yaw),低头看地时前进方向不会跟着往下扎。
var input_dir := Input.get_vector("move_left", "move_right", "move_back", "move_forward")
var cam_basis := camera_rig.global_transform.basis
var wish := cam_basis.x * input_dir.x + (-cam_basis.z) * input_dir.y
var look_basis := camera_rig.global_transform.basis
var wish := look_basis.x * input_dir.x + (-look_basis.z) * input_dir.y
wish.y = 0.0
var wants_move := wish.length() > 0.001
if wants_move:
wish = wish.normalized()
# 只转视觉层,不动本体,也不影响相机(Godot 前进方向为 -Z)
var target_yaw := atan2(-wish.x, -wish.z)
body.rotation.y = lerp_angle(body.rotation.y, target_yaw, clampf(turn_speed * delta, 0.0, 1.0))
# 身体朝向由 _apply_look() 跟着视线走 —— 第一人称不再朝移动方向转身
var speed := sprint_speed if Input.is_action_pressed("sprint") else walk_speed
if not wants_move:
@@ -197,7 +209,12 @@ func _physics_process(delta: float) -> void:
move_and_slide()
# 动画只读运动结果。控制器不认识任何片段名,动画器也不认识输入。
animator.set_motion(velocity, is_on_floor(), Input.is_action_pressed("sprint"))
animator.set_motion(
velocity,
is_on_floor(),
Input.is_action_pressed("sprint"),
Input.is_action_pressed("crouch"),
)
# ---------------- 滑索 ----------------
@@ -256,10 +273,10 @@ func _detach_zipline(hop: bool = false) -> void:
## 让物理引擎去推反而会和「贴着曲线走」这个硬约束打架。
## velocity 仍然维护 —— 一是给动画器读,二是脱手后能带着惯性飞出去。
func _ride_zipline(delta: float) -> void:
var tan := _zipline.tangent(_zip_t) * float(_zip_dir)
var tan_dir := _zipline.tangent(_zip_t) * float(_zip_dir)
# 重力沿缆方向的分量:缆越陡加速越猛,平缆上几乎不加速。
# 下坡时 tan.y 是负的,所以前面要加负号。
_zip_speed += -_gravity * _zipline.gravity_scale * tan.y * delta
# 下坡时 tan_dir.y 是负的,所以前面要加负号。
_zip_speed += -_gravity * _zipline.gravity_scale * tan_dir.y * delta
if Input.is_action_pressed("move_back"):
_zip_speed -= _zipline.brake_accel * delta
_zip_speed = clampf(_zip_speed, 0.0, _zipline.max_speed)
@@ -276,13 +293,11 @@ func _ride_zipline(delta: float) -> void:
_zip_t = next_t
_zip_grab_offset = _zip_grab_offset.lerp(Vector3.ZERO, clampf(delta / 0.12, 0.0, 1.0))
global_position = _zipline.ride_point(_zip_t) + _zip_grab_offset
velocity = tan * _zip_speed
var travel_yaw := atan2(-tan.x, -tan.z)
body.rotation.y = lerp_angle(body.rotation.y, travel_yaw, clampf(turn_speed * delta, 0.0, 1.0))
# 相机也跟着摆到行进方向背后 —— 否则人是横着飞的,看不见前面要去哪。
# 速率刻意比转身慢:玩家用鼠标看的视角不会被立刻拽回去。
_yaw = lerp_angle(_yaw, travel_yaw, clampf(3.0 * delta, 0.0, 1.0))
camera_rig.rotation.y = _yaw
velocity = tan_dir * _zip_speed
# 视线跟着摆到行进方向 —— 否则人是横着飞的,看不见前面要去哪。
# 速率刻意放慢:玩家自己用鼠标看的视角不会被一下子拽回去。
_yaw = lerp_angle(_yaw, atan2(-tan_dir.x, -tan_dir.z), clampf(3.0 * delta, 0.0, 1.0))
_apply_look()
# 缆上没有「落地」概念,交给动画器当下落姿态处理(模型里没有专门的滑索片段)
animator.set_motion(velocity, false, false)
File diff suppressed because one or more lines are too long
+82 -51
View File
@@ -1,40 +1,36 @@
extends Node
class_name PlayerAnimator
## 角色动作状态机 —— 只负责「把运动状态翻译成播放哪段动画」,完全不碰移动。
extends Node
## 玩家表现层:把「运动结果」翻译成动画片段 —— 只读运动、只写动画,不碰物理。
##
## 挂在 Player/Body 下,与具体模型解耦:换角色模型只要在检视面板重新指定
## animation_player(或让它自动找),代码里没有任何硬编码的节点路径。
## 控制器每物理帧调一次 set_motion(),本节点据此决定 AnimState 并切片段。
## 片段名集中在 ANIM_CLIPS,换模型 / 加动作只改这张表,控制器不认识任何片段名。
##
## 对外接口只有一个:set_motion()。方向、加速度、重力、输入全归控制器管;
## 本组件只读入一个「运动快照」,输出到 AnimationPlayer。反向不回写任何东西。
##
## 为什么状态与倍率必须是可复制属性:
## 远端玩家物理与输入都关着,手里只有 position 和 Body.rotation,
## 算不出水平速度,也分不清是上升还是下落。所以权威端把结论复制过去,
## 远端只在 setter 里回放。
## 对应的同步条目(Sync.root_path = "..",即 Player 节点):
## Body/PlayerAnimator:anim_state
## Body/PlayerAnimator:anim_speed
## 节点接法:本脚本直接挂在 Body/AnimationPlayer 上 —— 这时 animation_player 留空,
## _ready() 会在父节点的子节点里找 AnimationPlayer,找到的就是它自己。
## 以后想把表现层拆成独立节点也行:挂到一个普通 Node 上、用 animation_player 指过去即可,代码不用改。
## 角色动作状态。权威端每物理帧算一次,再靠 Sync 复制给远端回放。
enum AnimState { IDLE, WALK, SPRINT, JUMP, FALL }
enum AnimState { IDLE, WALK, CROUCH, RUN, FALL, SNEAK, JUMP }
## 状态 → 片段名。取自 character-male-e.glb 的动作库,换动作库改这里。
## 状态 → 片段名。这里写短名就行,_ready() 会把它们解析成 AnimationPlayer 里真正的键。
const ANIM_CLIPS: Dictionary = {
AnimState.IDLE: "idle",
AnimState.WALK: "walk",
AnimState.SPRINT: "sprint",
AnimState.CROUCH: "crouch",
AnimState.RUN: "run",
AnimState.SNEAK: "sneak",
AnimState.JUMP: "jump",
AnimState.FALL: "fall",
}
## 需要循环的状态。glb 里每个片段的 loop_mode 都是 NONE,
## 不补循环的话 idle/walk/sprint 播一遍就定在最后一帧。
const ANIM_LOOPED: Array[int] = [AnimState.IDLE, AnimState.WALK, AnimState.SPRINT]
## 需要循环的状态。其余(jump / fall)播完停在最后一帧。
## crouch / sneak 也是循环的位移动作 —— 漏掉它们,蹲着走一步就会定住。
const ANIM_LOOPED: Array[int] = [
AnimState.IDLE, AnimState.WALK, AnimState.CROUCH, AnimState.RUN, AnimState.SNEAK
]
@export_group("接线")
## 要驱动的 AnimationPlayer。留空则在本节点的兄弟子树里自动找一个。
## 换模型后这里重新指一下即可 —— 这也是它能脱离具体模型的原因。
## 要驱动的 AnimationPlayer。留空则自动找:脚本挂在 AnimationPlayer 上时就是它自己,
## 挂到独立节点上时是父节点下的第一个。换模型后重指一下即可 —— 这也是它能脱离具体模型的原因。
@export var animation_player: AnimationPlayer
@export_group("播放")
@@ -48,11 +44,16 @@ const ANIM_LOOPED: Array[int] = [AnimState.IDLE, AnimState.WALK, AnimState.SPRIN
@export var speed_scale_max: float = 1.6
@export_group("片段基准速度")
## walk 片段在多少米/秒下按 1.0 倍播放。这是片段自身的属性,不是控制器的手感参数:
## 控制器把跑速调到 6 m/s 时,这段动画自然播成 1.2 倍,脚步自动跟上,无需同步改两边。
## 各片段在多少米/秒下按 1.0 倍播放。这是片段自身的属性,不是控制器的手感参数:
## 控制器把跑速调到 6 m/s 时,这段动画自然播成 1.2 倍,脚步自动跟上,两边不用对齐。
@export var walk_ref_speed: float = 5.0
## sprint 片段的基准速度(米/秒)
@export var sprint_ref_speed: float = 8.5
@export var run_ref_speed: float = 8.5
@export var crouch_ref_speed: float = 2.5
@export var sneak_ref_speed: float = 1.6
## 状态 → AnimationPlayer 里真正的片段键(形如 "walk/walk")。_ready() 里解析一次。
## 不直接拿 ANIM_CLIPS 的值去播,是因为多库时短名查不到 —— 见 _ready() 里的说明。
var _clips: Dictionary = {}
## 当前动作状态(AnimState)。权威端每物理帧写一次,Sync 复制给远端。
var anim_state: int = AnimState.IDLE:
@@ -60,7 +61,7 @@ var anim_state: int = AnimState.IDLE:
if anim_state == value:
return
anim_state = value
_play(ANIM_CLIPS.get(anim_state, ""), blend_time)
_play(str(_clips.get(anim_state, "")), blend_time)
## 当前播放倍率(1.0 = 片段原速)。
## 同样要复制:远端只有 position,算不出真实速度,只能照抄权威端的倍率。
@@ -73,23 +74,48 @@ var anim_speed: float = 1.0:
func _ready() -> void:
if animation_player == null:
animation_player = _auto_find_animation_player()
if animation_player == null and get_parent() != null:
# 脚本就挂在 AnimationPlayer 上,所以这里「在父节点的子节点里找」找到的其实是它自己;
# 表现层哪天拆成独立节点,这段也不用改 —— 找到的就是真正的播放器。
# (不能写成 self as AnimationPlayer:加了 class_name 之后 self 的静态类型是本脚本,转不过去。)
for sibling in get_parent().get_children():
if sibling is AnimationPlayer:
animation_player = sibling as AnimationPlayer
break
if animation_player == null:
push_warning("%s 没绑定 AnimationPlayer,角色不会有动画" % get_path())
return
# 片段在 AnimationPlayer 里的键是「库名/片段名」—— 导入时每个 glb 各自成一个库,
# 短名 has_animation() / get_animation() 一律查不到(实测:has_animation("idle") 为假,
# has_animation("idle/idle") 才为真)。所以这里扫一遍库,把状态对应的真键找出来;
# 缺片段直接点名,免得又变成「名字看着对、播出来没反应」的静默失效。
for state in ANIM_CLIPS:
var clip: String = ANIM_CLIPS[state]
for lib_name in animation_player.get_animation_library_list():
var lib := animation_player.get_animation_library(lib_name)
if lib != null and lib.has_animation(clip):
_clips[state] = clip if String(lib_name).is_empty() else "%s/%s" % [lib_name, clip]
break
if not _clips.has(state):
push_warning("PlayerAnimator 找不到片段「%s」,对应状态不会有动画" % clip)
_patch_loop_modes()
# 同步可能在 _ready() 之前就把 anim_state 写进来了 —— 那时 animation_player 还是 null,
# setter 空跑了一趟。所以这里按「当前状态」补播一遍,不能写死 idle。
_play(ANIM_CLIPS.get(anim_state, ""), 0.0)
_play(str(_clips.get(anim_state, "")), 0.0)
# ---------------- 对外接口 ----------------
## 控制器每物理帧调用一次(放在 move_and_slide() 之后)。
## 只读入运动快照,不回写任何物理量 —— 这就是「单向耦合」的边界。
func set_motion(velocity: Vector3, on_floor: bool, sprinting: bool = false) -> void:
var state := _resolve_state(velocity, on_floor, sprinting)
## crouching 就是「按住了蹲键」这个输入事实;蹲着不动算 CROUCH、蹲着走算 SNEAK,
## 这个区分在这里做,控制器不需要知道两个状态有什么不一样。不传就是普通站立。
func set_motion(
velocity: Vector3, on_floor: bool, sprinting: bool = false, crouching: bool = false
) -> void:
var state := _resolve_state(velocity, on_floor, sprinting, crouching)
# 先写倍率再切片段 —— 顺序反了会让新片段用一帧旧倍率。
anim_speed = _speed_scale_for(state, velocity)
anim_state = state # setter 负责切片段
@@ -104,25 +130,38 @@ func current_clip() -> String:
# ---------------- 内部 ----------------
func _resolve_state(velocity: Vector3, on_floor: bool, sprinting: bool) -> int:
## 优先级:空中 > 蹲 > 移动。
## 空中最优先 —— 跳 / 落时不关心脚下是走还是蹲。
## 蹲排在移动前面:只要按着蹲键就播蹲的片段,不会因为没动就弹回站立。
## 蹲着不动是 CROUCH(原地蹲),蹲着走是 SNEAK(潜行)—— 两者只差「有没有在移动」。
func _resolve_state(
velocity: Vector3, on_floor: bool, sprinting: bool, crouching: bool
) -> int:
if not on_floor:
# 上升用 jump、越过顶点用 fall。jump 片段长 0.5s,
# 恰好约等于 jump_velocity 5 / 重力 9.8 的上升时间。
return AnimState.JUMP if velocity.y > 0.0 else AnimState.FALL
if Vector2(velocity.x, velocity.z).length() < idle_threshold:
var moving := Vector2(velocity.x, velocity.z).length() >= idle_threshold
if crouching:
return AnimState.SNEAK if moving else AnimState.CROUCH
if not moving:
return AnimState.IDLE
return AnimState.SPRINT if sprinting else AnimState.WALK
return AnimState.RUN if sprinting else AnimState.WALK
## 播放倍率 = 实际水平速度 / 该片段的基准速度。
## 只有循环的移动动作参与缩放,跳跃/下落/站立一律原速。
## 只有循环的位移动作参与缩放,跳跃 / 下落一律原速(它们不该被速度拉扯)。
func _speed_scale_for(state: int, velocity: Vector3) -> float:
var ref := 0.0
match state:
AnimState.WALK:
ref = walk_ref_speed
AnimState.SPRINT:
ref = sprint_ref_speed
AnimState.RUN:
ref = run_ref_speed
AnimState.CROUCH:
ref = crouch_ref_speed
AnimState.SNEAK:
ref = sneak_ref_speed
_:
return 1.0
if ref <= 0.0:
@@ -133,22 +172,14 @@ func _speed_scale_for(state: int, velocity: Vector3) -> float:
func _play(clip: String, blend: float) -> void:
if animation_player == null or clip.is_empty():
return
if not animation_player.has_animation(clip):
return
animation_player.play(clip, blend)
## 兜底:在同一层视觉节点里找一个 AnimationPlayer。只在没接线时生效。
func _auto_find_animation_player() -> AnimationPlayer:
var parent := get_parent()
if parent == null:
return null
return parent.find_child("AnimationPlayer", true, false) as AnimationPlayer
func _patch_loop_modes() -> void:
# 导入的 GLB 片段 loop_mode 默认是 NONE,播一遍就定在最后一帧;
# 这是导入产物的属性,编辑器里改不了,只能在运行时补。
for state in ANIM_LOOPED:
var clip: String = ANIM_CLIPS[state]
if not animation_player.has_animation(clip):
var clip: String = _clips.get(state, "")
if clip.is_empty():
continue
animation_player.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
+2 -2
View File
@@ -10,9 +10,9 @@ properties/1/replication_mode = 1
properties/2/path = NodePath(".:health")
properties/2/spawn = true
properties/2/replication_mode = 1
properties/3/path = NodePath("Body/PlayerAnimator:anim_state")
properties/3/path = NodePath("Body/AnimationPlayer:anim_state")
properties/3/spawn = true
properties/3/replication_mode = 1
properties/4/path = NodePath("Body/PlayerAnimator:anim_speed")
properties/4/path = NodePath("Body/AnimationPlayer:anim_speed")
properties/4/spawn = true
properties/4/replication_mode = 1
+4 -4
View File
@@ -37,8 +37,8 @@ var/var5/hint = 0
var/var5/hint_string = ""
[sub_resource type="BBNode" id="BBNode_nrd4b"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_qiw21"]
animation_player = SubResource("BBNode_nrd4b")
@@ -54,8 +54,8 @@ custom_name = "Pause before action"
children = [SubResource("BTPlayAnimation_qiw21"), SubResource("BTRandomWait_xlud8")]
[sub_resource type="BBNode" id="BBNode_wpj6d"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_olf37"]
animation_player = SubResource("BBNode_wpj6d")
@@ -70,8 +70,8 @@ group = &"player"
script = ExtResource("2_h5db5")
[sub_resource type="BTTimeLimit" id="BTTimeLimit_xek5v"]
children = [SubResource("BTAction_a4jqi")]
time_limit = 2.0
children = [SubResource("BTAction_a4jqi")]
[sub_resource type="BTSequence" id="BTSequence_1xfnq"]
custom_name = "Pursue player"
@@ -84,8 +84,8 @@ script = ExtResource("3_bpmfp")
duration = 0.1
[sub_resource type="BBNode" id="BBNode_s8evu"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_ppmxd"]
await_completion = 2.0
+5 -5
View File
@@ -19,8 +19,8 @@ var/charge_speed/hint = 1
var/charge_speed/hint_string = "10,1000,10"
[sub_resource type="BBNode" id="BBNode_yrurg"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_ha2ag"]
animation_player = SubResource("BBNode_yrurg")
@@ -45,8 +45,8 @@ range_max = 600
position_var = &"flank_pos"
[sub_resource type="BBNode" id="BBNode_xh3wr"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_h0poo"]
animation_player = SubResource("BBNode_xh3wr")
@@ -58,8 +58,8 @@ script = ExtResource("3_u2ra5")
target_position_var = &"flank_pos"
[sub_resource type="BTTimeLimit" id="BTTimeLimit_is5ag"]
children = [SubResource("BTAction_87mi0")]
time_limit = 3.0
children = [SubResource("BTAction_87mi0")]
[sub_resource type="BTSequence" id="BTSequence_p6pgg"]
custom_name = "Flank target"
@@ -69,8 +69,8 @@ children = [SubResource("BTAction_pp23y"), SubResource("BTAction_pmvd0"), SubRes
script = ExtResource("4_xwjl7")
[sub_resource type="BBNode" id="BBNode_bfijg"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_4okoy"]
await_completion = 2.0
@@ -86,8 +86,8 @@ custom_name = "Anticipation"
children = [SubResource("BTAction_q5g4a"), SubResource("BTPlayAnimation_4okoy"), SubResource("BTWait_ovu3r")]
[sub_resource type="BBNode" id="BBNode_kpp70"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_8s1wq"]
animation_player = SubResource("BBNode_kpp70")
+5 -5
View File
@@ -14,8 +14,8 @@ var/speed/hint = 1
var/speed/hint_string = "10,1000,10"
[sub_resource type="BBNode" id="BBNode_3y70b"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_klk26"]
animation_player = SubResource("BBNode_3y70b")
@@ -37,8 +37,8 @@ children = [SubResource("BTPlayAnimation_klk26"), SubResource("BTAction_chmiy"),
children = [SubResource("BTSequence_k184c")]
[sub_resource type="BBNode" id="BBNode_nrd4b"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_qiw21"]
animation_player = SubResource("BBNode_nrd4b")
@@ -54,8 +54,8 @@ custom_name = "Pause before action"
children = [SubResource("BTPlayAnimation_qiw21"), SubResource("BTRandomWait_xlud8")]
[sub_resource type="BBNode" id="BBNode_wpj6d"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_olf37"]
animation_player = SubResource("BBNode_wpj6d")
@@ -70,8 +70,8 @@ group = &"player"
script = ExtResource("4_jlgat")
[sub_resource type="BTTimeLimit" id="BTTimeLimit_xek5v"]
children = [SubResource("BTAction_a4jqi")]
time_limit = 2.0
children = [SubResource("BTAction_a4jqi")]
[sub_resource type="BTSequence" id="BTSequence_1xfnq"]
custom_name = "Pursue player"
@@ -84,8 +84,8 @@ script = ExtResource("5_o4ggh")
duration = 0.1
[sub_resource type="BBNode" id="BBNode_s8evu"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_ppmxd"]
await_completion = 2.0
+9 -9
View File
@@ -30,8 +30,8 @@ script = ExtResource("1_2883n")
group = &"player"
[sub_resource type="BBNode" id="BBNode_nrd4b"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_qiw21"]
animation_player = SubResource("BBNode_nrd4b")
@@ -54,8 +54,8 @@ script = ExtResource("5_p5dih")
distance_max = 300.0
[sub_resource type="BBNode" id="BBNode_7c0g0"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_sty80"]
animation_player = SubResource("BBNode_7c0g0")
@@ -67,8 +67,8 @@ speed = 1.2
script = ExtResource("2_lpckh")
[sub_resource type="BTTimeLimit" id="BTTimeLimit_lkphr"]
children = [SubResource("BTAction_jryg6")]
time_limit = 1.0
children = [SubResource("BTAction_jryg6")]
[sub_resource type="BTAction" id="BTAction_kidxn"]
script = ExtResource("4_57x51")
@@ -77,8 +77,8 @@ script = ExtResource("4_57x51")
duration = 0.2
[sub_resource type="BBNode" id="BBNode_kcqly"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_hngi6"]
await_completion = 2.0
@@ -93,16 +93,16 @@ custom_name = "Melee attack"
children = [SubResource("BTCondition_uk4dg"), SubResource("BTPlayAnimation_sty80"), SubResource("BTTimeLimit_lkphr"), SubResource("BTAction_kidxn"), SubResource("BTWait_tadkc"), SubResource("BTPlayAnimation_hngi6"), SubResource("BTWait_cm8jy")]
[sub_resource type="BTCooldown" id="BTCooldown_ksvfy"]
children = [SubResource("BTSequence_ww5v2")]
duration = 2.0
children = [SubResource("BTSequence_ww5v2")]
[sub_resource type="BTCondition" id="BTCondition_fpufi"]
script = ExtResource("5_p5dih")
distance_max = 300.0
[sub_resource type="BBNode" id="BBNode_3iqcf"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_ee0ff"]
animation_player = SubResource("BBNode_3iqcf")
@@ -115,15 +115,15 @@ script = ExtResource("6_fkv0k")
speed_var = &"slow_speed"
[sub_resource type="BTTimeLimit" id="BTTimeLimit_cns1i"]
children = [SubResource("BTAction_4ye2y")]
time_limit = 1.5
children = [SubResource("BTAction_4ye2y")]
[sub_resource type="BTAlwaysSucceed" id="BTAlwaysSucceed_nw4ep"]
children = [SubResource("BTTimeLimit_cns1i")]
[sub_resource type="BBNode" id="BBNode_81x7t"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_e61er"]
animation_player = SubResource("BBNode_81x7t")
@@ -137,8 +137,8 @@ custom_name = "Disengage"
children = [SubResource("BTCondition_fpufi"), SubResource("BTPlayAnimation_ee0ff"), SubResource("BTAlwaysSucceed_nw4ep"), SubResource("BTPlayAnimation_e61er"), SubResource("BTRandomWait_jw0cm")]
[sub_resource type="BBNode" id="BBNode_wpj6d"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_olf37"]
animation_player = SubResource("BBNode_wpj6d")
+10 -10
View File
@@ -19,8 +19,8 @@ var/run_speed/hint = 1
var/run_speed/hint_string = "10,1000,10"
[sub_resource type="BBNode" id="BBNode_nrd4b"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_qiw21"]
animation_player = SubResource("BBNode_nrd4b")
@@ -40,8 +40,8 @@ custom_name = "Take a break"
children = [SubResource("BTPlayAnimation_qiw21"), SubResource("BTRandomWait_xlud8"), SubResource("BTAction_c4cxo")]
[sub_resource type="BBNode" id="BBNode_ok0r5"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_unftu"]
animation_player = SubResource("BBNode_ok0r5")
@@ -61,8 +61,8 @@ speed_var = &"run_speed"
avoid_var = &"target"
[sub_resource type="BTTimeLimit" id="BTTimeLimit_gadc6"]
children = [SubResource("BTAction_ycjun")]
time_limit = 7.0
children = [SubResource("BTAction_ycjun")]
[sub_resource type="BTAction" id="BTAction_poqpu"]
script = ExtResource("5_aexyq")
@@ -72,12 +72,12 @@ custom_name = "Change flank"
children = [SubResource("BTPlayAnimation_unftu"), SubResource("BTAction_op6l6"), SubResource("BTTimeLimit_gadc6"), SubResource("BTAction_poqpu")]
[sub_resource type="BTCooldown" id="BTCooldown_2lneu"]
children = [SubResource("BTSequence_0gdqn")]
duration = 7.0
children = [SubResource("BTSequence_0gdqn")]
[sub_resource type="BTProbability" id="BTProbability_6sydk"]
children = [SubResource("BTCooldown_2lneu")]
run_chance = 0.3
children = [SubResource("BTCooldown_2lneu")]
[sub_resource type="BTAction" id="BTAction_kuuw2"]
script = ExtResource("4_53hao")
@@ -86,8 +86,8 @@ range_max = 1000
position_var = &"shoot_pos"
[sub_resource type="BBNode" id="BBNode_kc64r"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_panch"]
animation_player = SubResource("BBNode_kc64r")
@@ -109,8 +109,8 @@ script = ExtResource("2_a8qex")
tolerance = 150.0
[sub_resource type="BBNode" id="BBNode_s6vt4"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_qa8jy"]
animation_player = SubResource("BBNode_s6vt4")
@@ -120,8 +120,8 @@ blend = 0.1
[sub_resource type="BTWait" id="BTWait_gbcyb"]
[sub_resource type="BBNode" id="BBNode_qkfqt"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_0ktds"]
await_completion = 1.0
@@ -130,8 +130,8 @@ animation_name = &"throw"
blend = 0.05
[sub_resource type="BBNode" id="BBNode_1yxc5"]
resource_name = "."
saved_value = NodePath(".")
resource_name = "."
[sub_resource type="BTCallMethod" id="BTCallMethod_yx4fk"]
node = SubResource("BBNode_1yxc5")
@@ -146,8 +146,8 @@ children = [SubResource("BTPlayAnimation_qa8jy"), SubResource("BTWait_gbcyb"), S
metadata/_weight_ = 1.0
[sub_resource type="BTRepeat" id="BTRepeat_g08ia"]
children = [SubResource("BTSequence_rgbq3")]
times = 3
children = [SubResource("BTSequence_rgbq3")]
[sub_resource type="BTSequence" id="BTSequence_lhg7f"]
custom_name = "Get into position"
+9 -9
View File
@@ -19,8 +19,8 @@ var/charge_speed/hint = 0
var/charge_speed/hint_string = ""
[sub_resource type="BBNode" id="BBNode_nrd4b"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_qiw21"]
animation_player = SubResource("BBNode_nrd4b")
@@ -43,8 +43,8 @@ children = [SubResource("BTPlayAnimation_qiw21"), SubResource("BTRandomWait_xlud
script = ExtResource("2_5nwkp")
[sub_resource type="BBNode" id="BBNode_wpj6d"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_olf37"]
animation_player = SubResource("BBNode_wpj6d")
@@ -65,8 +65,8 @@ children = [SubResource("BTAction_85keo"), SubResource("BTPlayAnimation_olf37"),
script = ExtResource("4_hi228")
[sub_resource type="BBNode" id="BBNode_giv5l"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_usdja"]
animation_player = SubResource("BBNode_giv5l")
@@ -77,8 +77,8 @@ blend = 0.1
duration = 0.4
[sub_resource type="BBNode" id="BBNode_s8evu"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_ppmxd"]
await_completion = 2.0
@@ -90,15 +90,15 @@ script = ExtResource("5_au5yc")
speed_var = &"charge_speed"
[sub_resource type="BTParallel" id="BTParallel_ec2e3"]
children = [SubResource("BTPlayAnimation_ppmxd"), SubResource("BTAction_u22bc")]
num_successes_required = 2
children = [SubResource("BTPlayAnimation_ppmxd"), SubResource("BTAction_u22bc")]
[sub_resource type="BTWait" id="BTWait_c0lo0"]
duration = 0.2
[sub_resource type="BBNode" id="BBNode_3oljv"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_q4uis"]
await_completion = 2.0
@@ -110,15 +110,15 @@ script = ExtResource("5_au5yc")
speed_var = &"charge_speed"
[sub_resource type="BTParallel" id="BTParallel_thojy"]
children = [SubResource("BTPlayAnimation_q4uis"), SubResource("BTAction_yuxl3")]
num_successes_required = 2
children = [SubResource("BTPlayAnimation_q4uis"), SubResource("BTAction_yuxl3")]
[sub_resource type="BTWait" id="BTWait_y3pmv"]
duration = 0.2
[sub_resource type="BBNode" id="BBNode_ot40l"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_ye5pt"]
await_completion = 2.0
@@ -130,8 +130,8 @@ script = ExtResource("5_au5yc")
speed_var = &"charge_speed"
[sub_resource type="BTParallel" id="BTParallel_qmdfb"]
children = [SubResource("BTPlayAnimation_ye5pt"), SubResource("BTAction_rwp18")]
num_successes_required = 2
children = [SubResource("BTPlayAnimation_ye5pt"), SubResource("BTAction_rwp18")]
[sub_resource type="BTWait" id="BTWait_nxyko"]
duration = 0.5
+13 -13
View File
@@ -19,8 +19,8 @@ var/flank_speed/hint = 1
var/flank_speed/hint_string = "10,1000,10"
[sub_resource type="BBNode" id="BBNode_nrd4b"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_qiw21"]
animation_player = SubResource("BBNode_nrd4b")
@@ -40,8 +40,8 @@ custom_name = "Pause before action"
children = [SubResource("BTPlayAnimation_qiw21"), SubResource("BTRandomWait_xlud8"), SubResource("BTAction_c4cxo")]
[sub_resource type="BBNode" id="BBNode_kc64r"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_panch"]
animation_player = SubResource("BBNode_kc64r")
@@ -74,13 +74,13 @@ children = [SubResource("BTPlayAnimation_panch"), SubResource("BTAction_6e48s"),
metadata/_weight_ = 1.0
[sub_resource type="BTCooldown" id="BTCooldown_skw41"]
children = [SubResource("BTSequence_lhg7f")]
duration = 6.0
children = [SubResource("BTSequence_lhg7f")]
metadata/_weight_ = 2.0
[sub_resource type="BBNode" id="BBNode_wpj6d"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_olf37"]
animation_player = SubResource("BBNode_wpj6d")
@@ -91,8 +91,8 @@ blend = 0.1
script = ExtResource("5_r1ou0")
[sub_resource type="BTTimeLimit" id="BTTimeLimit_xek5v"]
children = [SubResource("BTAction_a4jqi")]
time_limit = 2.0
children = [SubResource("BTAction_a4jqi")]
[sub_resource type="BTAction" id="BTAction_kidxn"]
script = ExtResource("4_mvsyw")
@@ -101,8 +101,8 @@ script = ExtResource("4_mvsyw")
duration = 0.1
[sub_resource type="BBNode" id="BBNode_g8qww"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_a8yqu"]
await_completion = 2.0
@@ -113,8 +113,8 @@ animation_name = &"attack_2"
duration = 0.1
[sub_resource type="BBNode" id="BBNode_rfop0"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_8oyw6"]
await_completion = 2.0
@@ -132,8 +132,8 @@ range_min = 350
range_max = 600
[sub_resource type="BBNode" id="BBNode_cx111"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_mfbeu"]
animation_player = SubResource("BBNode_cx111")
@@ -144,15 +144,15 @@ blend = 0.1
script = ExtResource("3_svwk8")
[sub_resource type="BTTimeLimit" id="BTTimeLimit_tidwl"]
children = [SubResource("BTAction_6nx58")]
time_limit = 3.0
children = [SubResource("BTAction_6nx58")]
[sub_resource type="BTAction" id="BTAction_8q20y"]
script = ExtResource("4_mvsyw")
[sub_resource type="BBNode" id="BBNode_s6vt4"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_qa8jy"]
animation_player = SubResource("BBNode_s6vt4")
@@ -163,8 +163,8 @@ speed = 0.7
[sub_resource type="BTWait" id="BTWait_gbcyb"]
[sub_resource type="BBNode" id="BBNode_qkfqt"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_0ktds"]
await_completion = 1.0
@@ -173,8 +173,8 @@ animation_name = &"throw"
blend = 0.05
[sub_resource type="BBNode" id="BBNode_1yxc5"]
resource_name = "."
saved_value = NodePath(".")
resource_name = "."
[sub_resource type="BTCallMethod" id="BTCallMethod_yx4fk"]
node = SubResource("BBNode_1yxc5")
@@ -189,8 +189,8 @@ children = [SubResource("BTAction_mf87t"), SubResource("BTPlayAnimation_mfbeu"),
metadata/_weight_ = 2.0
[sub_resource type="BTProbabilitySelector" id="BTProbabilitySelector_rjsiq"]
children = [SubResource("BTCooldown_skw41"), SubResource("BTSequence_1xfnq"), SubResource("BTSequence_rgbq3")]
abort_on_failure = true
children = [SubResource("BTCooldown_skw41"), SubResource("BTSequence_1xfnq"), SubResource("BTSequence_rgbq3")]
[sub_resource type="BTSequence" id="BTSequence_pxl2k"]
custom_name = "Main"
+11 -11
View File
@@ -15,8 +15,8 @@ var/speed/hint = 1
var/speed/hint_string = "10,1000,10"
[sub_resource type="BBNode" id="BBNode_lpri5"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_p3e7a"]
animation_player = SubResource("BBNode_lpri5")
@@ -32,8 +32,8 @@ custom_name = "Chill"
children = [SubResource("BTPlayAnimation_p3e7a"), SubResource("BTRandomWait_j6cjm")]
[sub_resource type="BTCooldown" id="BTCooldown_oms7a"]
children = [SubResource("BTSequence_5thv4")]
trigger_on_failure = true
children = [SubResource("BTSequence_5thv4")]
[sub_resource type="BTAction" id="BTAction_ohfp7"]
script = ExtResource("1_ce4la")
@@ -47,8 +47,8 @@ distance_max = 150.0
script = ExtResource("3_3mw7l")
[sub_resource type="BBNode" id="BBNode_c4nfu"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_yytkn"]
await_completion = 3.0
@@ -57,8 +57,8 @@ animation_name = &"attack_3"
blend = 0.1
[sub_resource type="BTCooldown" id="BTCooldown_ejg6q"]
children = [SubResource("BTPlayAnimation_yytkn")]
duration = 3.0
children = [SubResource("BTPlayAnimation_yytkn")]
[sub_resource type="BTSequence" id="BTSequence_eikr4"]
custom_name = "Melee"
@@ -72,8 +72,8 @@ distance_max = 300.0
script = ExtResource("3_3mw7l")
[sub_resource type="BBNode" id="BBNode_iv62h"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_wnaul"]
animation_player = SubResource("BBNode_iv62h")
@@ -85,8 +85,8 @@ speed = -1.0
script = ExtResource("4_6dr32")
[sub_resource type="BTTimeLimit" id="BTTimeLimit_wm5g2"]
children = [SubResource("BTAction_h2efl")]
time_limit = 1.0
children = [SubResource("BTAction_h2efl")]
[sub_resource type="BTSequence" id="BTSequence_wh4dl"]
custom_name = "Back away"
@@ -96,8 +96,8 @@ children = [SubResource("BTCondition_h75v8"), SubResource("BTAction_wpt7j"), Sub
custom_name = "Get into position"
[sub_resource type="BBNode" id="BBNode_edmui"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_vjtpq"]
animation_player = SubResource("BBNode_edmui")
@@ -121,8 +121,8 @@ custom_name = "Short break before action"
script = ExtResource("3_3mw7l")
[sub_resource type="BBNode" id="BBNode_lh25u"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_vcjeq"]
animation_player = SubResource("BBNode_lh25u")
@@ -136,8 +136,8 @@ duration = 0.2
custom_name = "Spit fire!"
[sub_resource type="BBNode" id="BBNode_cqw71"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_214yn"]
await_completion = 3.0
@@ -146,8 +146,8 @@ animation_name = &"spit"
blend = 0.1
[sub_resource type="BBNode" id="BBNode_dv5b5"]
resource_name = "."
saved_value = NodePath(".")
resource_name = "."
[sub_resource type="BTCallMethod" id="BTCallMethod_lk7i6"]
node = SubResource("BBNode_dv5b5")
@@ -157,8 +157,8 @@ method = &"spit_fire"
custom_name = "Wind down"
[sub_resource type="BBNode" id="BBNode_m68ui"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_m7gyp"]
animation_player = SubResource("BBNode_m68ui")
+14 -14
View File
@@ -16,8 +16,8 @@ var/summon_cooldown/hint = 0
var/summon_cooldown/hint_string = ""
[sub_resource type="BBNode" id="BBNode_b7euf"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_sot6a"]
animation_player = SubResource("BBNode_b7euf")
@@ -34,9 +34,9 @@ children = [SubResource("BTPlayAnimation_sot6a"), SubResource("BTRandomWait_0wc6
children = [SubResource("BTSequence_kwkxc")]
[sub_resource type="BBVariant" id="BBVariant_y3x7n"]
resource_name = "6"
saved_value = 6
type = 2
saved_value = 6
resource_name = "6"
[sub_resource type="BTCheckAgentProperty" id="BTCheckAgentProperty_olmdj"]
property = &"summon_count"
@@ -47,18 +47,18 @@ value = SubResource("BBVariant_y3x7n")
custom_name = "Variable $summon_cooldown is updated by the Cooldown task below"
[sub_resource type="BBVariant" id="BBVariant_hgde2"]
resource_name = "false"
saved_value = false
type = 1
saved_value = false
resource_name = "false"
[sub_resource type="BTCheckVar" id="BTCheckVar_hc3o3"]
children = [SubResource("BTComment_ef6y0")]
variable = &"summon_cooldown"
value = SubResource("BBVariant_hgde2")
children = [SubResource("BTComment_ef6y0")]
[sub_resource type="BBNode" id="BBNode_q8bei"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_6leni"]
animation_player = SubResource("BBNode_q8bei")
@@ -73,14 +73,14 @@ script = ExtResource("1_sgn0p")
position_var = &"minion_pos"
[sub_resource type="BBVariant" id="BBVariant_wfjwy"]
resource_name = "$minion_pos"
type = 5
value_source = 1
variable = &"minion_pos"
type = 5
resource_name = "$minion_pos"
[sub_resource type="BBNode" id="BBNode_v271m"]
resource_name = "."
saved_value = NodePath(".")
resource_name = "."
[sub_resource type="BTCallMethod" id="BTCallMethod_4ath5"]
node = SubResource("BBNode_v271m")
@@ -88,9 +88,9 @@ method = &"summon_minion"
args = Array[BBVariant]([SubResource("BBVariant_wfjwy")])
[sub_resource type="BTCooldown" id="BTCooldown_25f70"]
children = [SubResource("BTCallMethod_4ath5")]
duration = 6.0
cooldown_state_var = &"summon_cooldown"
children = [SubResource("BTCallMethod_4ath5")]
[sub_resource type="BTWait" id="BTWait_tdhfn"]
@@ -103,8 +103,8 @@ script = ExtResource("1_sgn0p")
range_max = 700.0
[sub_resource type="BBNode" id="BBNode_w5fh2"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_qbxm8"]
animation_player = SubResource("BBNode_w5fh2")
@@ -115,12 +115,12 @@ blend = 0.1
script = ExtResource("2_pshl2")
[sub_resource type="BTTimeLimit" id="BTTimeLimit_i05cm"]
children = [SubResource("BTAction_dfifw")]
time_limit = 3.0
children = [SubResource("BTAction_dfifw")]
[sub_resource type="BBNode" id="BBNode_vinxt"]
resource_name = "AnimationPlayer"
saved_value = NodePath("AnimationPlayer")
resource_name = "AnimationPlayer"
[sub_resource type="BTPlayAnimation" id="BTPlayAnimation_t7drr"]
animation_player = SubResource("BBNode_vinxt")
+1 -1
View File
@@ -87,7 +87,7 @@ E:\GD_Projects\agame\
│ ├── player.tscn ← 玩家场景
│ │ Player (CharacterBody3D)
│ │ ├─ Collision
│ │ ├─ CameraRig → SpringArm → Camera ← 远端玩家会被删掉这一支
│ │ ├─ CameraRig → Camera ← 第一人称,远端玩家会被删掉这一支
│ │ ├─ Body → Mesh ← 视觉层,换角色模型挂这里
│ │ ├─ Sync (MultiplayerSynchronizer) ← 负责同步位置和朝向
│ │ └─ HealthLabel (Label3D) ← 头顶血条(3D 世界里的文字,跟着角色走)
+7 -2
View File
@@ -11,7 +11,7 @@ config_version=5
[application]
config/name="agame"
run/main_scene="uid://bp8l1uwgshc86"
run/main_scene="res://ui/main_menu.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
@@ -36,7 +36,7 @@ project/assembly_name="agame"
[editor_plugins]
enabled=PackedStringArray("res://addons/csg_blockout/plugin.cfg", "res://addons/godot_ai/plugin.cfg", "res://addons/godotsteam/plugin.cfg")
enabled=PackedStringArray("res://addons/csg_blockout/plugin.cfg", "res://addons/godot_ai/plugin.cfg", "res://addons/godotsteam/plugin.cfg", "res://addons/surface_snap/plugin.cfg")
[input]
@@ -79,6 +79,11 @@ interact={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":69,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
crouch={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194326,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
[physics]
+277
View File
@@ -0,0 +1,277 @@
@tool
extends McpTestSuite
## 回归测试:脸部那套「UV 矩形 + 预设 + 画笔」的契约。
##
## body_mesh 现在只有一个材质 mat_body,脸部不再单独换材质,而是靠 face_uv_min / face_uv_size
## 在这块 UV 上圈出脸部矩形。**模型重导或改 UV 之后,脸就会画歪、被裁掉、甚至画到别的部位上,
## 而且不报任何错** —— 所以这里把数值钉死,改模型时先让测试红一次。
const PLAYER_SCENE_PATH := "res://character/player/player.tscn"
const SHADER_PATH := "res://character/player/face/face_paint.gdshader"
const PRESET_DIR := "res://character/player/face/presets"
const BODY_MESH_PATH := "Body/mouse/MouseRig/Skeleton3D/body_mesh"
const BODY_MATERIAL_NAME := "mat_body"
## FaceRig / shader 里脸部矩形的默认值;必须和 shader 的 uniform 默认值一致
const FACE_UV_MIN := Vector2(0.695, 0.682)
const FACE_UV_SIZE := Vector2(0.301, 0.314)
## UV 是浮点,两边各写一份,给点容差
const EPS := 0.004
func suite_name() -> String:
return "face"
func _player() -> Node:
var packed := load(PLAYER_SCENE_PATH) as PackedScene
assert_true(packed != null, "%s 未能加载" % PLAYER_SCENE_PATH)
if packed == null:
return null
var player := packed.instantiate()
track(player)
return player
func _body_mesh() -> MeshInstance3D:
var player := _player()
if player == null:
return null
return player.get_node_or_null(BODY_MESH_PATH) as MeshInstance3D
## 找「最高处那块 UV 孤岛」(= 头部)的 UV 范围。脸部矩形必须落在它里面。
func _head_island_uv_rect() -> Rect2:
var mi := _body_mesh()
if mi == null:
return Rect2()
var mesh := mi.mesh as ArrayMesh
if mesh == null or mesh.get_surface_count() == 0:
return Rect2()
var arrays := mesh.surface_get_arrays(0)
var uvs := arrays[Mesh.ARRAY_TEX_UV] as PackedVector2Array
var verts := arrays[Mesh.ARRAY_VERTEX] as PackedVector3Array
var indices := PackedInt32Array()
if arrays[Mesh.ARRAY_INDEX] is PackedInt32Array:
indices = arrays[Mesh.ARRAY_INDEX]
if uvs.size() == 0 or indices.size() == 0:
return Rect2()
var parent := []
parent.resize(uvs.size())
for i in uvs.size():
parent[i] = i
for t in indices.size() / 3:
_union(parent, indices[t * 3], indices[t * 3 + 1])
_union(parent, indices[t * 3], indices[t * 3 + 2])
var groups := {}
for i in uvs.size():
var root := _find(parent, i)
if not groups.has(root):
groups[root] = []
groups[root].append(i)
var best_root := -1
var best_top := -INF
for root in groups:
var members: Array = groups[root]
var top := -INF
for i in members:
top = maxf(top, verts[i].y)
if top > best_top:
best_top = top
best_root = root
var members: Array = groups.get(best_root, [])
var uv_min := Vector2(INF, INF)
var uv_max := Vector2(-INF, -INF)
for i in members:
var uv: Vector2 = uvs[i]
uv_min = Vector2(minf(uv_min.x, uv.x), minf(uv_min.y, uv.y))
uv_max = Vector2(maxf(uv_max.x, uv.x), maxf(uv_max.y, uv.y))
return Rect2(uv_min, uv_max - uv_min)
func _find(parent: Array, index: int) -> int:
while parent[index] != index:
parent[index] = parent[parent[index]]
index = parent[index]
return index
func _union(parent: Array, a: int, b: int) -> void:
var root_a := _find(parent, a)
var root_b := _find(parent, b)
if root_a != root_b:
parent[root_b] = root_a
func test_player_has_face_rig_wired() -> void:
var player := _player()
if player == null:
return
var rig := player.get_node_or_null("FaceRig") as FaceRig
assert_true(rig != null, "Player 下缺少 FaceRig 节点")
if rig == null:
return
assert_eq(rig.surface_index, 0, "body_mesh 只剩一个材质,FaceRig 必须覆盖 surface 0")
assert_true(rig.get_node_or_null(rig.mesh_path) is MeshInstance3D,
"FaceRig.mesh_path(%s)解析不到 MeshInstance3D —— 换模型后路径要跟着改" % rig.mesh_path)
func test_body_mesh_has_single_material() -> void:
var mi := _body_mesh()
assert_true(mi != null, "找不到 %s" % BODY_MESH_PATH)
if mi == null:
return
var mesh := mi.mesh as ArrayMesh
assert_true(mesh != null, "body_mesh 的 mesh 不是 ArrayMesh")
if mesh == null:
return
assert_eq(mesh.get_surface_count(), 1, "body_mesh 应该只有一个材质槽了")
var material := mesh.surface_get_material(0)
assert_true(material != null, "surface 0 没有材质")
if material == null:
return
assert_eq(material.resource_name, BODY_MATERIAL_NAME,
"body_mesh 的材质不再是 %s,FaceRig 抄身体参数会失效" % BODY_MATERIAL_NAME)
var standard := material as StandardMaterial3D
assert_true(standard != null, "%s 不是 StandardMaterial3D" % BODY_MATERIAL_NAME)
if standard != null:
assert_true(standard.albedo_texture != null, "%s 没有 BaseColor 贴图,身体底色会变成白" % BODY_MATERIAL_NAME)
## 脸部矩形必须整块落在头部孤岛里,否则表情会画到身体别的部位上。
func test_face_uv_rect_inside_head_island() -> void:
var head := _head_island_uv_rect()
assert_true(head.size.x > 0.0 and head.size.y > 0.0, "没算出头部 UV 孤岛 —— 模型结构变了?")
if head.size.x <= 0.0:
return
# 允许负边长(负数 = 把该轴翻过来),所以先归一化成 min/max
var rect_min := Vector2(minf(FACE_UV_MIN.x, FACE_UV_MIN.x + FACE_UV_SIZE.x),
minf(FACE_UV_MIN.y, FACE_UV_MIN.y + FACE_UV_SIZE.y))
var rect_max := Vector2(maxf(FACE_UV_MIN.x, FACE_UV_MIN.x + FACE_UV_SIZE.x),
maxf(FACE_UV_MIN.y, FACE_UV_MIN.y + FACE_UV_SIZE.y))
assert_true(rect_min.x >= head.position.x - EPS and rect_min.y >= head.position.y - EPS,
"脸部矩形起点 %s 跑出了头部 UV 孤岛 %s" % [rect_min, head])
assert_true(rect_max.x <= head.end.x + EPS and rect_max.y <= head.end.y + EPS,
"脸部矩形终点 %s 超出了头部 UV 孤岛 %s —— 模型改过 UV?" % [rect_max, head])
func test_face_shader_has_expected_uniforms() -> void:
var file := FileAccess.open(SHADER_PATH, FileAccess.READ)
assert_true(file != null, "%s 加载失败" % SHADER_PATH)
if file == null:
return
var code := file.get_as_text()
for required in [
"body_tex", "body_tint", "surface_roughness", "surface_metallic",
"face_uv_min", "face_uv_size", "face_uv_flip_y",
"base_tex", "base_uv_min", "base_uv_size", "paint_tex",
]:
assert_true(code.contains("uniform ") and code.contains(required), "shader 少了 uniform %s" % required)
assert_true(code.contains("texture(body_tex, UV)"),
"shader 必须用 body_tex 采样全身底色,否则脸和身体不是同一块皮肤")
assert_true(code.contains("ROUGHNESS = surface_roughness"), "shader 必须复用 mat_body 的 roughness")
assert_true(code.contains("METALLIC = surface_metallic"), "shader 必须复用 mat_body 的 metallic")
# alpha 只代表墨线覆盖率,不能写进空间 ALPHA,否则身体面片会被抠出洞
assert_true(not code.contains("ALPHA ="), "面部 shader 写了 ALPHA,会把面片渲染成镂空")
func test_face_shader_copies_body_pbr_values() -> void:
var mesh_node := _body_mesh()
if mesh_node == null:
return
var body_material := mesh_node.mesh.surface_get_material(0) as StandardMaterial3D
assert_true(body_material != null, "surface 0 应有 StandardMaterial3D")
if body_material == null:
return
assert_true(absf(body_material.roughness - 0.65) < 0.001,
"mat_body roughness 改变:当前 %f;FaceRig 会动态读取它,测试值跟着改" % body_material.roughness)
assert_eq(body_material.metallic, 0.0, "mat_body metallic 改变;FaceRig 会动态读取它")
## 内置表情用的是项目里的透明图集副本(原始表情图已随模型一起删掉)。
func test_builtin_atlas_is_transparent_ink() -> void:
var texture: Texture2D = load(FaceRig.ATLAS_PATH)
assert_true(texture != null, "%s 加载失败" % FaceRig.ATLAS_PATH)
if texture == null:
return
var image := texture.get_image()
var transparent := false
var visible_ink := false
for y in range(0, image.get_height(), 8):
for x in range(0, image.get_width(), 8):
var a := image.get_pixel(x, y).a
transparent = transparent or a < 0.05
visible_ink = visible_ink or a > 0.5
assert_true(transparent, "内置表情图集没有透明背景,可能忘了去蓝底")
assert_true(visible_ink, "内置表情图集里没有可见笔画")
func test_builtin_face_uv_tiles_exist() -> void:
assert_eq(FaceRig.BUILTIN_EXPRESSIONS.size(), 2, "内置表情清单应包含两张 atlas 表情")
if FaceRig.BUILTIN_EXPRESSIONS.size() < 2:
return
assert_eq(FaceRig.BUILTIN_EXPRESSIONS[0]["id"], "builtin:face_01", "第一个内置表情 ID 改变")
assert_eq(FaceRig.BUILTIN_EXPRESSIONS[1]["id"], "builtin:face_02", "第二个内置表情 ID 改变")
for entry in FaceRig.BUILTIN_EXPRESSIONS:
var rect: Rect2 = entry["rect"]
assert_true(rect.position.x >= 0.0 and rect.position.y >= 0.0, "表情图集 rect 起点越界")
assert_true(rect.end.x <= 1.0 and rect.end.y <= 1.0, "表情图集 rect 终点越界")
## 联机同步靠这几个方法名,改名了要一起改 —— 改了就不同步,而且不报错
func test_face_sync_methods_exist() -> void:
var script := load("res://character/player/face/face_rig.gd") as GDScript
assert_true(script != null, "face_rig.gd 加载失败")
if script == null:
return
var names: Array[String] = []
for m in script.get_script_method_list():
names.append(str(m.get("name")))
for required in [
"_pull_remote_face", "_rpc_request_face", "_rpc_send_face",
"get_expression_catalog", "select_expression", "save_drawing_as_expression",
]:
assert_true(names.has(required), "face_rig.gd 少了 %s,表情列表/保存/联机可能失效" % required)
## 「没有底图」用运行时创建的 RGBA ImageTexture,而不是磁盘上的全透明 PNG。
## Godot 导入器会把整张同 alpha 的 PNG 优化成 RGB,丢掉 alpha;运行时的 Image 则保留。
func test_runtime_blank_texture_preserves_alpha() -> void:
var image := Image.create(2, 2, false, Image.FORMAT_RGBA8)
var texture := ImageTexture.create_from_image(image)
assert_true(texture != null, "运行时空白 ImageTexture 创建失败")
if texture == null:
return
var loaded := texture.get_image()
assert_eq(loaded.get_format(), Image.FORMAT_RGBA8, "空白贴图被降成了 RGB 格式")
assert_eq(loaded.get_pixel(0, 0).a, 0.0, "空白贴图 alpha 应为 0")
assert_eq(loaded.get_pixel(1, 1).a, 0.0, "空白贴图中心 alpha 应为 0")
func test_presets_are_square() -> void:
var dir := DirAccess.open(PRESET_DIR)
assert_true(dir != null, "打不开预设目录 %s" % PRESET_DIR)
if dir == null:
return
var found := 0
for f in dir.get_files():
if not f.ends_with(".png"):
continue
found += 1
var tex: Texture2D = load(PRESET_DIR + "/" + f)
assert_true(tex != null, "预设 %s 加载失败" % f)
if tex == null:
continue
var s := tex.get_size()
assert_eq(int(s.x), int(s.y), "预设 %s 不是正方形(%s),贴到脸上会拉伸" % [f, s])
var image := tex.get_image()
var transparent := false
var visible_ink := false
for y in range(0, image.get_height(), 4):
for x in range(0, image.get_width(), 4):
var a := image.get_pixel(x, y).a
transparent = transparent or a < 0.05
visible_ink = visible_ink or a > 0.5
assert_true(transparent, "预设 %s 没有透明背景,可能忘了去蓝底" % f)
assert_true(visible_ink, "预设 %s 没有可见笔画" % f)
assert_gt(found, 0, "%s 里一张预设都没有" % PRESET_DIR)
+1
View File
@@ -0,0 +1 @@
uid://brw0ntpgcol2o
+54 -11
View File
@@ -5,28 +5,32 @@ extends McpTestSuite
## 三处典型的「静默失效」——都表现为「人站着不动」且不打任何错误:
## 1. player_sync.tres 键名写错 / replication_mode 落到 NEVER → 远端收不到字段;
## 2. track 路径带一层模型目录名前缀,换模型/改层级后整组失效;
## 3. glb 片段的 loop_mode 默认是 NONE,idle/walk/sprint 播一遍就定在最后一帧。
## 3. glb 片段的 loop_mode 默认是 NONE,idle/walk/crouch/run/sneak 播一遍就定在最后一帧。
## (loop 由 PlayerAnimator._ready() 在运行时补,编辑器测试覆盖不到,靠实机验证。)
const CONFIG_PATH := "res://character/player/player_sync.tres"
const PLAYER_SCENE_PATH := "res://character/player/player.tscn"
## 从 player.gd 抽出来的动画状态机。控制器只通过它间接驱动动画。
const ANIMATOR_PATH := "Body/PlayerAnimator"
## 表现层脚本就直接挂在 AnimationPlayer 上,所以这两条路径指向同一个节点。
const ANIMATOR_PATH := "Body/AnimationPlayer"
const ANIMATOR_SCRIPT := "res://character/player/player_animator.gd"
## 真正干活的动画器。PlayerAnimator 没接线时按这个名字在自己兄弟子树里找它。
const ANIM_PLAYER_PATH := "Body/character-male-e2/AnimationPlayer"
## 真正干活的动画器。表现层脚本挂在它身上。
const ANIM_PLAYER_PATH := "Body/AnimationPlayer"
## player_animator.gd 的 ANIM_CLIPS 写死了这几个名字,少一个对应状态就会静默不动。
const REQUIRED_CLIPS: Array[String] = ["idle", "walk", "sprint", "jump", "fall"]
const REQUIRED_CLIPS: Array[String] = ["idle", "walk", "crouch", "run", "sneak", "jump", "fall"]
## CROUCH / SNEAK 两个状态唯一的入口 —— 控制器读的就是这个动作。
const CROUCH_ACTION := "crouch"
## 必须被复制到远端的字段。远端物理与输入都关着,全靠这几个值回放。
const REQUIRED_SYNCED: Array[String] = [
".:position",
"Body:rotation",
".:health",
"Body/PlayerAnimator:anim_state",
"Body/PlayerAnimator:anim_speed",
"Body/AnimationPlayer:anim_state",
"Body/AnimationPlayer:anim_speed",
]
@@ -80,7 +84,7 @@ func test_player_scene_wires_synchronizer() -> void:
)
## 组件边界:动画状态机必须是挂在 Body 下的独立节点,而不是塞回 player.gd。
## 组件边界:动画状态机必须是挂在 Body 下的节点,而不是塞回 player.gd。
## 有人把 anim_state / anim_speed 挪回控制器时,这条会失败。
func test_player_scene_has_animator_component() -> void:
var player := _instantiate_player()
@@ -104,7 +108,7 @@ func test_player_scene_has_animator_component() -> void:
assert_eq(script.resource_path, ANIMATOR_SCRIPT, "%s 挂的不是动画状态机脚本" % ANIMATOR_PATH)
## PlayerAnimator 没接线时按名字在自己兄弟子树里找 AnimationPlayer。
## PlayerAnimator 没接线时在父节点的子节点里按类型找 AnimationPlayer。
## 编辑器里跑不了它的 _ready()(非 @tool 脚本),所以这里退一步、用同样的规则找一遍:
## 改名或挪位置会让这条失败。
func test_animator_auto_find_target_exists() -> void:
@@ -129,11 +133,25 @@ func test_required_animation_clips_exist() -> void:
return
for clip in REQUIRED_CLIPS:
assert_true(
anim_player.has_animation(clip),
_find_clip(anim_player, clip) != null,
"动作库里缺少片段 %s(PlayerAnimator 的 ANIM_CLIPS 会指向不存在的片段)" % clip
)
## crouch 动作是 CROUCH / SNEAK 唯一的入口;动作没了按 Ctrl 没反应,而且不报错。
## 编辑器进程的 InputMap 只带内建动作,得先按引擎启动那套加载一次,否则这条必然假红。
func test_crouch_action_exists() -> void:
InputMap.load_from_project_settings()
assert_true(InputMap.has_action(CROUCH_ACTION), "InputMap 里没有 %s 动作" % CROUCH_ACTION)
if not InputMap.has_action(CROUCH_ACTION):
return
var bound_to_ctrl := false
for event in InputMap.action_get_events(CROUCH_ACTION):
if event is InputEventKey and (event as InputEventKey).keycode == KEY_CTRL:
bound_to_ctrl = true
assert_true(bound_to_ctrl, "%s 没有绑定 Ctrl" % CROUCH_ACTION)
## 片段是从 glb 里拷出来的,track 路径带层级前缀。换模型/改层级后整组 track 会一起失效,
## 而 AnimationPlayer 不报任何错 —— 现象是「人站着不动」。用测试钉住路径真的能解析。
func test_animation_track_paths_resolve() -> void:
@@ -146,7 +164,7 @@ func test_animation_track_paths_resolve() -> void:
return
for clip in REQUIRED_CLIPS:
var anim := anim_player.get_animation(clip)
var anim := _find_clip(anim_player, clip)
if anim == null:
continue
assert_gt(anim.get_track_count(), 0, "%s 片段没有任何 track" % clip)
@@ -169,6 +187,31 @@ func test_animation_track_paths_resolve() -> void:
)
## 入口场景必须是开始菜单 —— 否则启动就直接落在世界里,还顶着 Net 那个调试面板。
## 这条防的是「有人把 run/main_scene 改回世界」。
func test_main_scene_is_the_menu() -> void:
var configured := str(ProjectSettings.get_setting("application/run/main_scene", ""))
assert_true(not configured.is_empty(), "project.godot 没设 application/run/main_scene")
if configured.is_empty():
return
# 存的是 uid 也可能是 res:// 路径,两种都认
var path := configured
if configured.begins_with("uid://"):
path = ResourceUID.get_id_path(ResourceUID.text_to_id(configured))
assert_true(path.ends_with("ui/main_menu.tscn"),
"入口场景是 %s,不是开始菜单 —— 启动会直接进世界" % path)
## 片段在 AnimationPlayer 里按「库名/片段名」存(导入时每个 glb 各自成一个库),
## 短名 has_animation() / get_animation() 一律查不到 —— 拿短名断言会假红,必须按库扫一遍。
func _find_clip(anim_player: AnimationPlayer, clip: String) -> Animation:
for lib_name in anim_player.get_animation_library_list():
var lib := anim_player.get_animation_library(lib_name)
if lib != null and lib.has_animation(clip):
return lib.get_animation(clip)
return null
func _find_animation_player() -> AnimationPlayer:
var player := _instantiate_player()
if player == null:
+3
View File
@@ -92,7 +92,10 @@ func test_player_scene_has_interact_hint() -> void:
## 挂索/脱手都走 InputMap 的 interact 动作;动作没了按 E 没反应,而且不报错。
## 编辑器进程的 InputMap 只带内建动作,项目动作要等游戏进程启动时才 load 进去 ——
## 不先补这一步,这条断言在编辑器测试环境里必然假红。
func test_interact_action_exists() -> void:
InputMap.load_from_project_settings()
assert_true(InputMap.has_action(INTERACT_ACTION), "InputMap 里没有 %s 动作" % INTERACT_ACTION)
if not InputMap.has_action(INTERACT_ACTION):
return
+99
View File
@@ -0,0 +1,99 @@
extends Control
## 角色选择:预览模型、内置表情 atlas 与玩家本地表情库。
signal confirmed
signal back_pressed
const SPIN_SPEED := 0.5
@onready var _model: Node3D = %Model
@onready var _camera: Camera3D = %PreviewCamera
@onready var _rig: FaceRig = %FaceRig
@onready var _paint_panel: Control = $PaintPanel
@onready var _expression_row: HFlowContainer = %PresetRow
var _expression_buttons: Array[TextureButton] = []
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_camera.look_at(Vector3(0.0, 0.45, 0.0), Vector3.UP)
%ConfirmButton.pressed.connect(_on_confirm)
%BackButton.pressed.connect(_on_back)
%DrawButton.pressed.connect(_on_draw)
_paint_panel.finished.connect(_refresh_expression_list)
_paint_panel.cancelled.connect(_refresh_expression_list)
_refresh_expression_list()
func _process(delta: float) -> void:
if _model != null:
_model.rotate_y(SPIN_SPEED * delta)
## 内置表情用 AtlasTexture 显示对应 UV 小格;本地表情直接显示各自 PNG。
func _refresh_expression_list() -> void:
for child in _expression_row.get_children():
child.free()
_expression_buttons.clear()
for entry in _rig.get_expression_catalog():
var card := VBoxContainer.new()
card.custom_minimum_size = Vector2(96, 116)
card.add_theme_constant_override("separation", 4)
var skin := ColorRect.new()
skin.color = _rig.skin_color
skin.mouse_filter = Control.MOUSE_FILTER_IGNORE
skin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var button := TextureButton.new()
button.custom_minimum_size = Vector2(96, 96)
button.texture_normal = entry["thumbnail"] as Texture2D
button.ignore_texture_size = true
button.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
button.modulate = Color.WHITE if str(entry["id"]) == _rig.selected_expression_id else Color(0.62, 0.62, 0.62)
button.set_meta("expression_id", str(entry["id"]))
button.pressed.connect(_on_expression_pressed.bind(str(entry["id"])))
_expression_buttons.append(button)
var preview := Control.new()
preview.custom_minimum_size = Vector2(96, 96)
preview.add_child(skin)
preview.add_child(button)
var label := Label.new()
label.text = str(entry["name"])
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
label.custom_minimum_size = Vector2(96, 16)
card.add_child(preview)
card.add_child(label)
_expression_row.add_child(card)
func _on_expression_pressed(expression_id: String) -> void:
if _rig.select_expression(expression_id):
_refresh_expression_highlight(expression_id)
func _refresh_expression_highlight(expression_id: String) -> void:
for button in _expression_buttons:
button.modulate = Color.WHITE if str(button.get_meta("expression_id", "")) == expression_id else Color(0.62, 0.62, 0.62)
## 「自己画」从无底图起手。面板会先快照,取消时还原表情 ID 和笔画。
func _on_draw() -> void:
_paint_panel.open(_rig, true)
_refresh_expression_list()
func _on_confirm() -> void:
_rig.save()
confirmed.emit()
func _on_back() -> void:
back_pressed.emit()
+1
View File
@@ -0,0 +1 @@
uid://cwd4rsma685f
+179
View File
@@ -0,0 +1,179 @@
[gd_scene format=3 uid="uid://jd1gw83hfog1"]
[ext_resource type="Script" uid="uid://cwd4rsma685f" path="res://ui/character_select.gd" id="1_select"]
[ext_resource type="PackedScene" path="res://ui/paint_panel.tscn" id="2_paint"]
[ext_resource type="PackedScene" uid="uid://css1vk3u8gt7o" path="res://character/player/assets/mouse.glb" id="3_mouse"]
[ext_resource type="Sky" uid="uid://bi632suyifms5" path="res://world/sky/cartoon_sky.tres" id="4_sky"]
[ext_resource type="Script" uid="uid://c6l2fl5jgvr1h" path="res://character/player/face/face_rig.gd" id="7_face_rig"]
[sub_resource type="Gradient" id="Gradient_bg"]
offsets = PackedFloat32Array(0, 0.55, 1)
colors = PackedColorArray(0.09, 0.3, 0.78, 1, 0.28, 0.6, 0.95, 1, 0.78, 0.88, 1, 1)
[sub_resource type="GradientTexture2D" id="GradientTexture2D_bg"]
gradient = SubResource("Gradient_bg")
width = 4
height = 512
fill_from = Vector2(0.5, 0)
fill_to = Vector2(0.5, 1)
[sub_resource type="Environment" id="Environment_preview"]
background_mode = 2
sky = ExtResource("4_sky")
ambient_light_source = 3
reflected_light_source = 2
[node name="CharacterSelect" type="Control" unique_id=1285412003]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_select")
[node name="Background" type="TextureRect" parent="." unique_id=619181932]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
texture = SubResource("GradientTexture2D_bg")
expand_mode = 1
[node name="Margin" type="MarginContainer" parent="." unique_id=1895590897]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
theme_override_constants/margin_left = 48
theme_override_constants/margin_top = 40
theme_override_constants/margin_right = 48
theme_override_constants/margin_bottom = 40
[node name="Layout" type="HBoxContainer" parent="Margin" unique_id=1213332253]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 36
[node name="Preview" type="SubViewportContainer" parent="Margin/Layout" unique_id=718492618]
custom_minimum_size = Vector2(420, 520)
layout_mode = 2
size_flags_horizontal = 0
size_flags_vertical = 4
stretch = true
[node name="PreviewViewport" type="SubViewport" parent="Margin/Layout/Preview" unique_id=369907966]
own_world_3d = true
handle_input_locally = false
size = Vector2i(420, 520)
render_target_update_mode = 4
[node name="Environment" type="WorldEnvironment" parent="Margin/Layout/Preview/PreviewViewport" unique_id=2065272503]
environment = SubResource("Environment_preview")
[node name="Sun" type="DirectionalLight3D" parent="Margin/Layout/Preview/PreviewViewport" unique_id=1776501976]
transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 3, 0)
light_energy = 1.1
[node name="PreviewCamera" type="Camera3D" parent="Margin/Layout/Preview/PreviewViewport" unique_id=55319451]
unique_name_in_owner = true
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.62, 1.5)
[node name="Model" type="Node3D" parent="Margin/Layout/Preview/PreviewViewport" unique_id=1496447090]
unique_name_in_owner = true
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
[node name="mouse" parent="Margin/Layout/Preview/PreviewViewport/Model" unique_id=1730754830 instance=ExtResource("3_mouse")]
transform = Transform3D(0.18240133, 0, 0, 0, 0.18240133, 0, 0, 0, 0.18240133, 0, 0, 0)
[node name="FaceRig" type="Node" parent="Margin/Layout/Preview/PreviewViewport" unique_id=304595484]
unique_name_in_owner = true
script = ExtResource("7_face_rig")
mesh_path = NodePath("../Model/mouse/MouseRig/Skeleton3D/body_mesh")
default_expression_id = "builtin:face_01"
[node name="Right" type="VBoxContainer" parent="Margin/Layout" unique_id=605730650]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 14
[node name="CharTitle" type="Label" parent="Margin/Layout/Right" unique_id=584718962]
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_colors/font_outline_color = Color(0.05, 0.1, 0.25, 0.85)
theme_override_constants/outline_size = 10
theme_override_font_sizes/font_size = 40
text = "角色"
[node name="CharRow" type="HBoxContainer" parent="Margin/Layout/Right" unique_id=1751305595]
layout_mode = 2
theme_override_constants/separation = 10
[node name="CharButton" type="Button" parent="Margin/Layout/Right/CharRow" unique_id=1541380555]
custom_minimum_size = Vector2(160, 46)
layout_mode = 2
text = "小老鼠"
[node name="Spacer" type="Control" parent="Margin/Layout/Right" unique_id=562037857]
custom_minimum_size = Vector2(0, 12)
layout_mode = 2
mouse_filter = 2
[node name="FaceTitle" type="Label" parent="Margin/Layout/Right" unique_id=111752993]
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_colors/font_outline_color = Color(0.05, 0.1, 0.25, 0.85)
theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 28
text = "脸部"
[node name="PresetScroll" type="ScrollContainer" parent="Margin/Layout/Right"]
custom_minimum_size = Vector2(0, 190)
layout_mode = 2
size_flags_vertical = 3
[node name="PresetRow" type="HFlowContainer" parent="Margin/Layout/Right/PresetScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/h_separation = 10
theme_override_constants/v_separation = 8
[node name="DrawButton" type="Button" parent="Margin/Layout/Right" unique_id=206641737]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 46)
layout_mode = 2
size_flags_horizontal = 0
text = "自己画"
[node name="Grow" type="Control" parent="Margin/Layout/Right" unique_id=6282062]
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
[node name="Bottom" type="HBoxContainer" parent="Margin/Layout/Right" unique_id=1107875429]
layout_mode = 2
theme_override_constants/separation = 12
alignment = 2
[node name="BackButton" type="Button" parent="Margin/Layout/Right/Bottom" unique_id=1760710150]
unique_name_in_owner = true
custom_minimum_size = Vector2(130, 46)
layout_mode = 2
text = "返回"
[node name="ConfirmButton" type="Button" parent="Margin/Layout/Right/Bottom" unique_id=814522243]
unique_name_in_owner = true
custom_minimum_size = Vector2(130, 46)
layout_mode = 2
text = "确定"
[node name="PaintPanel" parent="." unique_id=132648199 instance=ExtResource("2_paint")]
visible = false
layout_mode = 1
+70
View File
@@ -0,0 +1,70 @@
extends Control
## 游戏开始菜单 —— 项目的入口场景(project.godot 的 run/main_scene)。
##
## 「开始游戏」→ 角色选择 → 确定 = 开主机并进世界。这个项目是客户端-服务器模型,
## 单人玩也是本机开服(没有单独的「离线模式」)。
## 「联机」把 Net 的创建/加入面板放出来,那边选完同样会切进世界。
##
## 进世界、回菜单这两件事都归 Net 管(见 net.gd 的 _enter_world / leave_game),
## 菜单只负责「按了哪个按钮」。
##
## 接线示例(放在外层):
## menu.settings_pressed.connect(_on_settings)
## menu.quit_pressed.connect(get_tree().quit)
const CHARACTER_SELECT := preload("res://ui/character_select.tscn")
## 用节点路径而不是全局标识符访问单例:autoload 的全局名只在引擎启动时注册,
## 这样写不依赖注册时机(和 world/main.gd 一致)
const NET_PATH := "/root/Net"
signal start_pressed
signal multiplayer_pressed
signal settings_pressed
signal quit_pressed
var _net: Node
var _character_select: Control
func _ready() -> void:
# 从游戏内退回菜单时鼠标可能还是被捕获的,先放出来 —— 不然菜单点不动
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_net = get_node_or_null(NET_PATH)
# Net 那个「联机测试」面板默认可见(CanvasLayer layer 20),会正好盖在菜单中间
if _net != null:
_net.set_menu_visible(false)
%StartButton.pressed.connect(_on_start_pressed)
%MultiplayerButton.pressed.connect(_on_multiplayer_pressed)
%SettingsButton.pressed.connect(settings_pressed.emit)
%QuitButton.pressed.connect(quit_pressed.emit)
## 「开始游戏」:先把信号发出去(外部想接就接),再把角色选择盖上来
func _on_start_pressed() -> void:
start_pressed.emit()
if _character_select == null:
_character_select = CHARACTER_SELECT.instantiate()
add_child(_character_select)
_character_select.confirmed.connect(_on_character_confirmed)
_character_select.back_pressed.connect(_close_character_select)
_character_select.visible = true
## 角色选择点「确定」:脸这时已经写进 user:// 了,开主机,Net 负责切进世界
func _on_character_confirmed() -> void:
_close_character_select()
if _net != null:
_net.host_game()
func _on_multiplayer_pressed() -> void:
multiplayer_pressed.emit()
# 暂时直接把 Net 那个创建/加入面板放出来顶着 —— 换成正经的联机子页后删掉这行
if _net != null:
_net.set_menu_visible(true)
func _close_character_select() -> void:
if _character_select != null:
_character_select.visible = false
+1
View File
@@ -0,0 +1 @@
uid://bx1ahrgpx5hvg
+162
View File
@@ -0,0 +1,162 @@
[gd_scene load_steps=7 format=3]
[ext_resource type="Script" path="res://ui/main_menu.gd" id="1_menu"]
[sub_resource type="Gradient" id="Gradient_bg"]
offsets = PackedFloat32Array(0, 0.55, 1)
colors = PackedColorArray(0.09, 0.3, 0.78, 1, 0.28, 0.6, 0.95, 1, 0.78, 0.88, 1, 1)
[sub_resource type="GradientTexture2D" id="GradientTexture2D_bg"]
gradient = SubResource("Gradient_bg")
width = 4
height = 512
fill_from = Vector2(0.5, 0)
fill_to = Vector2(0.5, 1)
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_normal"]
content_margin_left = 20.0
content_margin_top = 10.0
content_margin_right = 20.0
content_margin_bottom = 10.0
bg_color = Color(1, 1, 1, 0.14)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 1, 1, 0.5)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_hover"]
content_margin_left = 20.0
content_margin_top = 10.0
content_margin_right = 20.0
content_margin_bottom = 10.0
bg_color = Color(1, 1, 1, 0.32)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 1, 1, 0.85)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[sub_resource type="StyleBoxFlat" id="StyleBoxFlat_pressed"]
content_margin_left = 20.0
content_margin_top = 10.0
content_margin_right = 20.0
content_margin_bottom = 10.0
bg_color = Color(0.05, 0.12, 0.3, 0.35)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 2
border_color = Color(1, 1, 1, 0.95)
corner_radius_top_left = 12
corner_radius_top_right = 12
corner_radius_bottom_right = 12
corner_radius_bottom_left = 12
[node name="MainMenu" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_menu")
[node name="Background" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
texture = SubResource("GradientTexture2D_bg")
expand_mode = 1
[node name="Center" type="CenterContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Box" type="VBoxContainer" parent="Center"]
layout_mode = 2
theme_override_constants/separation = 18
[node name="Title" type="Label" parent="Center/Box"]
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_colors/font_outline_color = Color(0.05, 0.1, 0.25, 0.85)
theme_override_constants/outline_size = 14
theme_override_font_sizes/font_size = 72
text = "AGAME"
horizontal_alignment = 1
[node name="Buttons" type="VBoxContainer" parent="Center/Box"]
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 0.92)
theme_override_colors/font_focus_color = Color(1, 1, 1, 0.92)
theme_override_colors/font_hover_color = Color(1, 1, 1, 1)
theme_override_colors/font_pressed_color = Color(1, 1, 1, 1)
theme_override_constants/separation = 12
theme_override_font_sizes/font_size = 24
theme_override_styles/focus = SubResource("StyleBoxFlat_hover")
theme_override_styles/hover = SubResource("StyleBoxFlat_hover")
theme_override_styles/normal = SubResource("StyleBoxFlat_normal")
theme_override_styles/pressed = SubResource("StyleBoxFlat_pressed")
[node name="StartButton" type="Button" parent="Center/Box/Buttons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(280, 54)
layout_mode = 2
text = "开始游戏"
[node name="MultiplayerButton" type="Button" parent="Center/Box/Buttons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(280, 54)
layout_mode = 2
text = "联机"
[node name="SettingsButton" type="Button" parent="Center/Box/Buttons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(280, 54)
layout_mode = 2
text = "设置"
[node name="QuitButton" type="Button" parent="Center/Box/Buttons"]
unique_name_in_owner = true
custom_minimum_size = Vector2(280, 54)
layout_mode = 2
text = "退出"
[node name="Version" type="Label" parent="."]
layout_mode = 1
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -180.0
offset_top = -40.0
offset_right = -18.0
offset_bottom = -14.0
grow_horizontal = 0
grow_vertical = 0
mouse_filter = 2
theme_override_colors/font_color = Color(1, 1, 1, 0.6)
theme_override_colors/font_outline_color = Color(0.05, 0.1, 0.25, 0.6)
theme_override_constants/outline_size = 4
theme_override_font_sizes/font_size = 14
text = "v0.1"
horizontal_alignment = 2
+134
View File
@@ -0,0 +1,134 @@
extends Control
## 画脸面板:把 FaceRig 的笔画层显示出来,并把鼠标坐标换算成画布坐标喂给画笔。
##
## 面板自己不画任何东西 —— 笔画直接落在 FaceRig 的画笔上,所以关掉面板后角色脸上立刻就是刚画的。
## 打开时拍一张快照,点「取消」还原;点「完成」才写进存档。
signal finished
signal cancelled
## 画布在屏幕上的显示边长。真实画布是 rig.canvas_size 见方,坐标按比例换算
const VIEW_SIZE := 420
@onready var _canvas_rect: TextureRect = %StrokeRect
@onready var _preset_rect: TextureRect = %PresetRect
@onready var _color_button: ColorPickerButton = %ColorButton
@onready var _width_slider: HSlider = %WidthSlider
@onready var _width_label: Label = %WidthLabel
@onready var _eraser_toggle: CheckButton = %EraserToggle
@onready var _name_edit: LineEdit = %NameEdit
var _rig: FaceRig
## 打开面板时的笔画层快照,取消时还原
var _snapshot: PackedByteArray
## 打开面板时选中的表情 ID,取消时还原
var _expression_before: String = ""
## 左键是不是按着 —— 拖拽判定用这个,不看 motion 事件里的 button_mask
var _drawing := false
func _ready() -> void:
visible = false
_canvas_rect.custom_minimum_size = Vector2(VIEW_SIZE, VIEW_SIZE)
_canvas_rect.gui_input.connect(_on_canvas_input)
_color_button.color_changed.connect(_on_color_changed)
_width_slider.value_changed.connect(_on_width_changed)
_eraser_toggle.toggled.connect(_on_eraser_toggled)
%ClearButton.pressed.connect(_on_clear)
%DoneButton.pressed.connect(_on_done)
%CancelButton.pressed.connect(_on_cancel)
_on_width_changed(_width_slider.value)
# ---------------- 对外接口 ----------------
## 打开面板并绑定到 FaceRig。start_blank = true 时先快照再清空,取消可完整还原。
func open(rig: FaceRig, start_blank: bool = false) -> void:
if rig == null or rig.painter == null:
push_warning("画脸面板没拿到可用的 FaceRig")
return
_rig = rig
_snapshot = rig.painter.snapshot()
_expression_before = rig.selected_expression_id
if start_blank:
rig.select_blank(true)
_canvas_rect.texture = rig.painter.texture
%SkinRect.color = rig.skin_color
_preset_rect.texture = rig.selected_expression_texture
_name_edit.text = ""
rig.painter.brush_color = _color_button.color
rig.painter.brush_width = _width_slider.value
rig.painter.erasing = _eraser_toggle.button_pressed
visible = true
# ---------------- 画布输入 ----------------
## 屏幕坐标 → 画布坐标。显示尺寸和画布尺寸不一致时按比例缩
func _canvas_pos(event: InputEventMouse) -> Vector2:
var scale := float(_rig.painter.canvas_size) / maxf(1.0, _canvas_rect.size.x)
return event.position * scale
func _on_canvas_input(event: InputEvent) -> void:
if _rig == null:
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
# 自己记「按没按下」,不靠 motion 事件里的 button_mask —— 那个字段不总是可靠
_drawing = event.pressed
if event.pressed:
_rig.painter.begin_stroke(_canvas_pos(event))
else:
_rig.painter.end_stroke()
elif event is InputEventMouseMotion and _drawing:
_rig.painter.extend_stroke(_canvas_pos(event))
# ---------------- 工具条 ----------------
func _on_color_changed(c: Color) -> void:
if _rig != null:
_rig.painter.brush_color = c
# 选了颜色就顺手退出橡皮 —— 不然「我选了红色怎么还是擦」
_eraser_toggle.set_pressed_no_signal(false)
if _rig != null:
_rig.painter.erasing = false
func _on_width_changed(w: float) -> void:
if _rig != null:
_rig.painter.brush_width = w
_width_label.text = "%d px" % int(w)
func _on_eraser_toggled(on: bool) -> void:
if _rig != null:
_rig.painter.erasing = on
func _on_clear() -> void:
if _rig != null:
_rig.painter.clear()
func _on_done() -> void:
if _rig == null:
return
var saved := _rig.save_drawing_as_expression(_name_edit.text)
if saved.is_empty():
push_warning("表情保存失败,请检查本地磁盘空间和写入权限")
return
visible = false
finished.emit()
func _on_cancel() -> void:
if _rig != null:
if not _expression_before.is_empty():
_rig.select_expression(_expression_before, false)
else:
_rig.select_blank(false)
if _snapshot.size() > 0:
_rig.painter.restore(_snapshot)
visible = false
cancelled.emit()
+1
View File
@@ -0,0 +1 @@
uid://cjxxd2m1w00y5
+165
View File
@@ -0,0 +1,165 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/paint_panel.gd" id="1_paint"]
[node name="PaintPanel" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_paint")
[node name="Dim" type="ColorRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
color = Color(0.03, 0.06, 0.14, 0.72)
[node name="Center" type="CenterContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
[node name="Panel" type="PanelContainer" parent="Center"]
layout_mode = 2
[node name="Margin" type="MarginContainer" parent="Center/Panel"]
layout_mode = 2
theme_override_constants/margin_left = 18
theme_override_constants/margin_top = 14
theme_override_constants/margin_right = 18
theme_override_constants/margin_bottom = 14
[node name="Box" type="VBoxContainer" parent="Center/Panel/Margin"]
layout_mode = 2
theme_override_constants/separation = 12
[node name="Title" type="Label" parent="Center/Panel/Margin/Box"]
layout_mode = 2
theme_override_font_sizes/font_size = 22
text = "画脸"
[node name="Row" type="HBoxContainer" parent="Center/Panel/Margin/Box"]
layout_mode = 2
theme_override_constants/separation = 16
[node name="CanvasStack" type="Control" parent="Center/Panel/Margin/Box/Row"]
custom_minimum_size = Vector2(420, 420)
layout_mode = 2
mouse_filter = 2
[node name="SkinRect" type="ColorRect" parent="Center/Panel/Margin/Box/Row/CanvasStack"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
color = Color(0.537255, 0.701961, 1, 1)
[node name="PresetRect" type="TextureRect" parent="Center/Panel/Margin/Box/Row/CanvasStack"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
expand_mode = 1
[node name="StrokeRect" type="TextureRect" parent="Center/Panel/Margin/Box/Row/CanvasStack"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 0
expand_mode = 1
[node name="Tools" type="VBoxContainer" parent="Center/Panel/Margin/Box/Row"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="ColorButton" type="ColorPickerButton" parent="Center/Panel/Margin/Box/Row/Tools"]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 36)
layout_mode = 2
color = Color(0, 0, 0, 1)
[node name="WidthRow" type="HBoxContainer" parent="Center/Panel/Margin/Box/Row/Tools"]
layout_mode = 2
theme_override_constants/separation = 8
[node name="Label" type="Label" parent="Center/Panel/Margin/Box/Row/Tools/WidthRow"]
layout_mode = 2
text = "粗细"
[node name="WidthSlider" type="HSlider" parent="Center/Panel/Margin/Box/Row/Tools/WidthRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(110, 0)
layout_mode = 2
min_value = 2.0
max_value = 48.0
value = 12.0
[node name="WidthLabel" type="Label" parent="Center/Panel/Margin/Box/Row/Tools/WidthRow"]
unique_name_in_owner = true
custom_minimum_size = Vector2(52, 0)
layout_mode = 2
text = "12 px"
[node name="EraserToggle" type="CheckButton" parent="Center/Panel/Margin/Box/Row/Tools"]
unique_name_in_owner = true
layout_mode = 2
text = "橡皮"
[node name="ClearButton" type="Button" parent="Center/Panel/Margin/Box/Row/Tools"]
unique_name_in_owner = true
layout_mode = 2
text = "清空"
[node name="Hint" type="Label" parent="Center/Panel/Margin/Box/Row/Tools"]
layout_mode = 2
custom_minimum_size = Vector2(160, 0)
modulate = Color(1, 1, 1, 0.65)
theme_override_font_sizes/font_size = 13
text = "在左边画布上直接画。
橡皮擦掉笔画,露出来的是底下的脸。"
autowrap_mode = 3
[node name="NameEdit" type="LineEdit" parent="Center/Panel/Margin/Box/Row/Tools"]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 36)
layout_mode = 2
placeholder_text = "表情名称(可留空)"
[node name="Bottom" type="HBoxContainer" parent="Center/Panel/Margin/Box"]
layout_mode = 2
alignment = 2
theme_override_constants/separation = 10
[node name="CancelButton" type="Button" parent="Center/Panel/Margin/Box/Bottom"]
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 40)
layout_mode = 2
text = "取消"
[node name="DoneButton" type="Button" parent="Center/Panel/Margin/Box/Bottom"]
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 40)
layout_mode = 2
text = "保存到表情列表"
+13
View File
@@ -0,0 +1,13 @@
[gd_scene format=3 uid="uid://44re8f0ce0kc"]
[ext_resource type="Material" path="res://addons/csg_blockout/res/materials/mat_grid_light.tres" id="1_lbw0t"]
[node name="World" type="Node3D" unique_id=332495265]
[node name="CSGBox3D" type="CSGBox3D" parent="." unique_id=768581787]
transform = Transform3D(100, 0, 0, 0, 1, 0, 0, 0, 100, 0, 0, 0)
material = ExtResource("1_lbw0t")
[node name="CSGBox3D2" type="CSGBox3D" parent="." unique_id=198061082]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
material = ExtResource("1_lbw0t")
+4 -4
View File
@@ -156,12 +156,12 @@ func _rebuild_cable() -> void:
prev = p
# 截面参考轴取「切线 × 世界上方向」。切线接近竖直时叉乘会退化成零向量,
# 这时换 RIGHT 兜底,否则 normalize() 会吐 NaN 把整根缆搅烂。
var tan := (_local_sample(minf(t + 0.01, 1.0)) - _local_sample(maxf(t - 0.01, 0.0))).normalized()
var n := tan.cross(Vector3.UP)
var tan_dir := (_local_sample(minf(t + 0.01, 1.0)) - _local_sample(maxf(t - 0.01, 0.0))).normalized()
var n := tan_dir.cross(Vector3.UP)
if n.length_squared() < 0.0001:
n = tan.cross(Vector3.RIGHT)
n = tan_dir.cross(Vector3.RIGHT)
n = n.normalized()
var binormal := n.cross(tan).normalized()
var binormal := n.cross(tan_dir).normalized()
for j in range(RING_SEGMENTS + 1):
var ang := TAU * float(j) / float(RING_SEGMENTS)
var off := n * cos(ang) + binormal * sin(ang)