Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faf799837f | ||
|
|
698cb02c21 | ||
|
|
fbf00cf11f | ||
|
|
39790aecea | ||
|
|
d367c8370b | ||
|
|
5173b45066 | ||
|
|
27a550f61b | ||
|
|
9fa6ed628b | ||
|
|
3a8f35b19e | ||
|
|
0df8b26238 |
@@ -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 工程。
|
||||
|
||||
@@ -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`,比手改文件稳。
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
uid://c41uif1g5stoa
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
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]
|
||||
|
||||
|
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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c6l2fl5jgvr1h
|
||||
|
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
|
||||
|
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
|
||||
|
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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 世界里的文字,跟着角色走)
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://brw0ntpgcol2o
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cwd4rsma685f
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bx1ahrgpx5hvg
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cjxxd2m1w00y5
|
||||
@@ -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 = "保存到表情列表"
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||