1
@@ -177,21 +177,130 @@ headless 实例** `--host` / `--join 127.0.0.1` 真连了一次)。
|
||||
(注意那一串带 .exe 的其实是个**目录**)。双开调试命令:
|
||||
`<godot> --headless --path E:/Godot/agame -- --host` / `-- --join 127.0.0.1`。
|
||||
|
||||
## 修 bug:点「自己画」不是空白画布
|
||||
## 修 bug:点击「自己画」不是空白画布 + 透明脸资源落地
|
||||
|
||||
用户报的。根因是**语义理解错了**:我原先把「自己画」做成「在选中的预设上补几笔」,
|
||||
用户要的是**从空白脸起手**。
|
||||
用户上一轮报「选预设后点自己画不是空白」。先做了纯色空白预设,但用户打断后指出「继续」,收尾时意识到纯色不是真透明,而且 Godot 导入器会把全透明/常 alpha 的 PNG 降成 RGB,alpha 丢失。
|
||||
|
||||
- 生成 `presets/face_00_blank.png`(512² 纯色 `#89B3FF`,取自预设贴图里脸的底色)。
|
||||
- `character_select.gd`:「自己画」= 清掉缩略图高亮 + `_paint_panel.open(_rig, BLANK_FACE)`。
|
||||
- `paint_panel.gd`:`open()` 多一个 `blank_face` 参数,**先拍快照再换底图 + 清笔画**。
|
||||
- **顺带修了一个连带 bug**:`_on_cancel` 原来只还原笔画,而「自己画」会把底图换成空白 ——
|
||||
点取消会留下一张白脸。现在底图(`_preset_before`)也一起还原。
|
||||
- 移掉了 `_on_paint_done`(高亮在 `_on_draw` 里已经清了,那个回调成了死代码)。
|
||||
最终做法:
|
||||
- `face_01.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)」,
|
||||
并用 `get_node_info` 确认两个缩略图 modulate 都回到 0.62。`test_run` 28/28。
|
||||
实测:先选择 face_01(角色脸上出现线稿),点「自己画」(绘图面板纯肤色,角色预览脸无预设线稿),点取消(原预设线稿恢复)。`test_run` 29/29。
|
||||
|
||||
**一个排查小教训**:截图里 Preset0 看着比 Preset1 亮,我一度以为高亮没清掉 ——
|
||||
其实两张贴图本身明暗就不同,`modulate` 才是真相。**别靠截图判断颜色,去读属性。**
|
||||
坑:在 `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 工程。
|
||||
|
||||
|
||||
@@ -38,31 +38,32 @@
|
||||
|
||||
## 模型:mouse.glb
|
||||
|
||||
- `body_mesh` 有**两个 surface**:`s0 "lambert1"`(身体,UV 几乎占满 0~1,贴
|
||||
`mouse_mouse_lambert1_BaseColor.png`)、`s1 "face"`(面部,独立 UV 孤岛
|
||||
u[0.142,0.356] v[0.038,0.238],贴 `mouse_表情测试.png`)。
|
||||
**面部是独立材质槽 → 换脸不用动 Blender**,`set_surface_override_material(1, mat)` 即可。
|
||||
- `mouse_表情测试.png` 是 1024² **RGBA 但 alpha 全 255**(实心蓝 #89B3FF 底 + 黑线脸),
|
||||
不是透明覆盖层,只能当底图,不能当笔画层。
|
||||
- 面部孤岛只占 1024² 的 21%×20%,实际脸分辨率约 219×205 —— 要用 shader 把孤岛重映射到 0~1
|
||||
再用方形小贴图,有效分辨率能翻一倍多。
|
||||
- **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`:把孤岛 UV 重映射到 0~1,`ALBEDO = mix(base_tex, paint_tex, paint.a)`。
|
||||
**两层必须分开**:`base_tex` = 预设脸(切换就换它),`paint_tex` = 玩家笔画层。
|
||||
这样橡皮擦掉笔画露出来的是预设脸,而且换预设不会丢手绘。
|
||||
- `FaceRig`(挂在 Player 下):建一张 512² 的笔画 Image + ImageTexture,`set_surface_override_material(1, mat)`。
|
||||
存 `user://face_preset.json`(选了哪张预设)+ `user://face_paint.png`(透明底笔画层),
|
||||
玩家出生时 `apply_saved()` 读回来。预览和玩家是**两套 FaceRig、共用一份存档**。
|
||||
- `FacePainter`:**不用 `Control._draw()`,直接改 RGBA8 字节缓冲**。画 = source-over alpha 混合,
|
||||
擦 = 把目标 alpha 乘掉;一笔画完 `_sync()` 一次(512² 一次 1MB,别每盖一个印章就同步)。
|
||||
- 界面链路:`ui/main_menu.tscn` →「开始游戏」→ `ui/character_select.tscn`(3D 预览 + 预设缩略图 + 自己画)
|
||||
→ `ui/paint_panel.tscn`。**画布要「预设 TextureRect + 笔画 TextureRect」叠着显示** ——
|
||||
笔画层是透明底的,只显示它等于让玩家对着空气画。
|
||||
- **「自己画」= 从空白脸起手**(`presets/face_00_blank.png`,纯色底),不是在预设上补笔。
|
||||
空白由面板来落(`open(rig, blank_face)`)—— 它必须**先拍快照再改**,否则点「取消」还原回来的
|
||||
是已经清空的状态。取消要同时还原**底图和笔画**,只还原笔画会留下一张白脸。
|
||||
- `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 踩坑
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -2,20 +2,20 @@
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://demw0p70x7tf"
|
||||
path.s3tc="res://.godot/imported/mouse_mouse_lambert1_BaseColor.png-df62a96328566f97654d49386d6c6e11.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": "6274bad8370dd70c0704f97985bd23cf"
|
||||
"md5": "d8e805d55d53d74ba4ab8f9a67c62494"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://character/player/assets/mouse_mouse_lambert1_BaseColor.png"
|
||||
dest_files=["res://.godot/imported/mouse_mouse_lambert1_BaseColor.png-df62a96328566f97654d49386d6c6e11.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,20 +2,20 @@
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://dpf8vtls80ko7"
|
||||
path.s3tc="res://.godot/imported/mouse_表情测试.png-d03396f0afb7f56f6567aea06cc6e030.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": "3552dc1475f030f110c45ef4c0c10cfa"
|
||||
"md5": "16475065f6b3024dc12b0a1533211079"
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://character/player/assets/mouse_表情测试.png"
|
||||
dest_files=["res://.godot/imported/mouse_表情测试.png-d03396f0afb7f56f6567aea06cc6e030.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]
|
||||
|
||||
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 58 KiB |
@@ -1,29 +1,51 @@
|
||||
shader_type spatial;
|
||||
// 面部 = 两层贴图的合成:底图(预设脸)+ 笔画层(玩家自绘),后者带 alpha 盖在前面。
|
||||
// 两层都用「面部孤岛重映射后的 UV」采样,所以一张方形小图就能正好铺满整张脸。
|
||||
// 全身 shader:body_mesh 现在只有一个材质 mat_body,所以身体底色照常取它的 BaseColor,
|
||||
// 脸部**不再单独换材质**,而是在同一个 shader 里,只在指定 UV 矩形内把表情和手绘叠上去。
|
||||
// 这样脸和头部走的是同一套材质、同一套光照,不会再出现独立面片的色块边界。
|
||||
//
|
||||
// 为什么不是直接把贴图换掉:画布只装笔画、不装底图,橡皮擦掉笔画后露出来的是预设脸,
|
||||
// 而不是把脸擦成黑洞。这也是它能一边切预设一边保留手绘笔画的原因。
|
||||
// 贴图 alpha 只表示「墨线覆盖率」,绝不写进空间 ALPHA —— body_mesh 的面片必须保持不透明。
|
||||
|
||||
// 面部 UV 孤岛(实测自 mouse.glb 的 body_mesh surface 1 "face"):
|
||||
// u 0.142~0.356,v 0.038~0.238。v 往下外扩到 0.031 是为了取成正方形,避免拉伸。
|
||||
uniform vec2 face_uv_min = vec2(0.142, 0.031);
|
||||
uniform float face_uv_size : hint_range(0.01, 1.0) = 0.214;
|
||||
|
||||
// 预设脸。没接上时给张白图,至少不会一片黑
|
||||
uniform sampler2D base_tex : source_color, filter_linear, repeat_disable,
|
||||
uniform sampler2D body_tex : source_color, filter_linear, repeat_disable,
|
||||
hint_default_white;
|
||||
// 玩家笔画层(一个透明底的 SubViewport)。没接上时给张全透明图,等于没画
|
||||
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() {
|
||||
vec2 uv = (UV - face_uv_min) / face_uv_size;
|
||||
vec4 base = texture(base_tex, uv);
|
||||
vec4 paint = texture(paint_tex, uv);
|
||||
// 全身底色都来自 mat_body 的 BaseColor,表情只是盖在脸上
|
||||
vec3 albedo = texture(body_tex, UV).rgb * body_tint.rgb;
|
||||
|
||||
ALBEDO = mix(base.rgb, paint.rgb, paint.a);
|
||||
// 顶掉 GLB 里 lambert1 那套手感,不然脸会比身体亮一块
|
||||
ROUGHNESS = 1.0;
|
||||
METALLIC = 0.0;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,54 +1,58 @@
|
||||
class_name FaceRig
|
||||
extends Node
|
||||
## 角色的脸:把 mesh 上那个「face」surface 换成自定义材质,
|
||||
## 材质把「预设底图」和「玩家自绘的笔画层」合成起来。
|
||||
##
|
||||
## 笔画层是一张常驻的 Image(不是 SubViewport)—— 这样即使绘画面板没打开,
|
||||
## 游戏内的脸也一直是玩家画的那张。绘画面板只是把 painter.texture 显示出来,
|
||||
## 把鼠标坐标换算好之后喂给 painter。
|
||||
## 角色脸部:body_mesh 现在**只有一个材质**,所以这里不再换材质,
|
||||
## 而是把 mat_body 换成「全身 shader」:身体照常取它的 BaseColor,
|
||||
## 只在脸部的 UV 矩形内叠加表情贴图和玩家手绘。
|
||||
|
||||
const SHADER_PATH := "res://character/player/face/face_paint.gdshader"
|
||||
## 存档:选的是哪张预设
|
||||
const SAVE_PRESET := "user://face_preset.json"
|
||||
## 存档:笔画层(透明底 PNG)
|
||||
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
|
||||
## 第一次要之前先等一会儿。实测:主机生成玩家比客户端早几帧,立刻要会打在一个
|
||||
## 还没建出来的节点上(Godot 报 "Node not found: Main/<id>/FaceRig"),白刷一堆红字。
|
||||
const FACE_SYNC_INITIAL_DELAY := 1.0
|
||||
|
||||
@export_group("接线")
|
||||
## 面部所在的 MeshInstance3D 的路径(相对本节点)。默认就是 mouse.glb 里的 body_mesh。
|
||||
## 存路径而不是节点引用:节点引用是场景实例化时由 Godot 解析的,实测这里解析不到(拿到 null),
|
||||
## 自己在 _ready() 里 get_node_or_null 才稳。
|
||||
@export var mesh_path: NodePath = ^"../Body/mouse/MouseRig/Skeleton3D/body_mesh"
|
||||
## 面部是第几个 surface。mouse.glb 里是 1,名字就叫 face
|
||||
@export var surface_index: int = 1
|
||||
## 没有存档时用哪张脸
|
||||
@export var default_preset: Texture2D
|
||||
## 覆盖第几个 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")
|
||||
## 面部孤岛在贴图上的起点(实测自模型)。模型重导后 UV 变了就改这里
|
||||
@export var face_uv_min: Vector2 = Vector2(0.142, 0.031)
|
||||
## 孤岛的边长(UV 单位)
|
||||
@export var face_uv_size: float = 0.214
|
||||
|
||||
## 当前预设的 res:// 路径。存档靠它记住「你选的是哪张脸」
|
||||
var preset_path: String = ""
|
||||
## 联机时有没有收到过这张脸主人的数据(收到了就不用再重试)
|
||||
var _got_face := false
|
||||
@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:
|
||||
@@ -57,76 +61,221 @@ func _ready() -> void:
|
||||
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()
|
||||
|
||||
# 联机时非本机玩家去要脸。单机(OfflineMultiplayerPeer)不用同步
|
||||
var peer := multiplayer.multiplayer_peer
|
||||
if peer != null and not (peer is OfflineMultiplayerPeer) and not is_multiplayer_authority():
|
||||
_pull_remote_face()
|
||||
|
||||
|
||||
# ---------------- 对外接口 ----------------
|
||||
# ---------------- 表情列表与选择 ----------------
|
||||
|
||||
## 换成某张预设脸。只换底图,玩家画的笔画保留 —— 所以可以边挑预设边留着笔迹。
|
||||
func set_preset(tex: Texture2D, path: String = "") -> void:
|
||||
if material != null:
|
||||
material.set_shader_parameter("base_tex", tex)
|
||||
if not path.is_empty():
|
||||
preset_path = path
|
||||
## 返回内置 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 clear_paint() -> void:
|
||||
if painter != null:
|
||||
## 选择一个内置或本地表情。切换项目时清掉临时笔画,表情项本身是完整表情。
|
||||
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 f := FileAccess.open(SAVE_PRESET, FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_warning("脸部预设存不下来:%s" % SAVE_PRESET)
|
||||
var file := FileAccess.open(LEGACY_PRESET_SAVE, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_warning("脸部选择存不下来:%s" % LEGACY_PRESET_SAVE)
|
||||
return
|
||||
f.store_string(JSON.stringify({"preset": preset_path}))
|
||||
file.store_string(JSON.stringify({"expression_id": selected_expression_id}))
|
||||
file.close()
|
||||
|
||||
|
||||
## 读存档;没有(或存的预设已经不在了)就退回 default_preset
|
||||
func apply_saved() -> void:
|
||||
var preset: Texture2D = default_preset
|
||||
var path := default_preset.resource_path if default_preset != null else ""
|
||||
|
||||
if FileAccess.file_exists(SAVE_PRESET):
|
||||
var f := FileAccess.open(SAVE_PRESET, FileAccess.READ)
|
||||
var data: Variant = JSON.parse_string(f.get_as_text()) if f != null else null
|
||||
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:
|
||||
var saved := str(data.get("preset", ""))
|
||||
if not saved.is_empty() and ResourceLoader.exists(saved):
|
||||
preset = load(saved)
|
||||
path = saved
|
||||
|
||||
set_preset(preset, path)
|
||||
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
|
||||
|
||||
|
||||
# ---------------- 联机同步 ----------------
|
||||
# 用「谁缺谁来要」而不是「出生时推」:各端玩家节点出现的先后顺序不保证,
|
||||
# 推的话很可能推给一个还不存在的节点;而请求总是从已经存在的那一端发出去的。
|
||||
|
||||
## 非本机玩家在 _ready 里调,去要这张脸主人的那一份。带重试,因为对端节点可能还没建好。
|
||||
func _pull_remote_face() -> void:
|
||||
await get_tree().create_timer(FACE_SYNC_INITIAL_DELAY).timeout
|
||||
for i in FACE_SYNC_TRIES:
|
||||
@@ -138,25 +287,39 @@ func _pull_remote_face() -> void:
|
||||
|
||||
@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(),
|
||||
preset_path,
|
||||
selected_expression_id,
|
||||
local_png,
|
||||
painter.to_png_bytes() if painter != null else PackedByteArray(),
|
||||
)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable")
|
||||
func _rpc_send_face(preset: String, png: PackedByteArray) -> void:
|
||||
# 只认这张脸主人的回应;本机自己的脸不接受覆盖
|
||||
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 not preset.is_empty() and ResourceLoader.exists(preset):
|
||||
set_preset(load(preset), preset)
|
||||
if png.size() > 0 and painter != null:
|
||||
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(png) == OK:
|
||||
painter.set_image(img)
|
||||
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)
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 32 KiB |
@@ -2,16 +2,16 @@
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://b2yfeejwww5oi"
|
||||
path="res://.godot/imported/face_00_blank.png-258a9c2a268c4896542abf099fbd9d15.ctex"
|
||||
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_00_blank.png"
|
||||
dest_files=["res://.godot/imported/face_00_blank.png-258a9c2a268c4896542abf099fbd9d15.ctex"]
|
||||
source_file="res://character/player/face/presets/face_atlas_transparent.png"
|
||||
dest_files=["res://.godot/imported/face_atlas_transparent.png-d56481b0045839c14a1d633b682cc418.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
[ext_resource type="AnimationLibrary" uid="uid://dy8a178vlbsof" path="res://character/player/assets/animations/sneak.glb" id="8_jo6ux"]
|
||||
[ext_resource type="AnimationLibrary" uid="uid://db83440o2cyr7" path="res://character/player/assets/animations/walk.glb" id="9_fkbv7"]
|
||||
[ext_resource type="Script" uid="uid://c6l2fl5jgvr1h" path="res://character/player/face/face_rig.gd" id="12_lqp71"]
|
||||
[ext_resource type="Texture2D" path="res://character/player/face/presets/face_01.png" id="13_face01"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_rkbax"]
|
||||
radius = 0.4
|
||||
@@ -70,5 +69,3 @@ outline_size = 8
|
||||
|
||||
[node name="FaceRig" type="Node" parent="." unique_id=792293233]
|
||||
script = ExtResource("12_lqp71")
|
||||
mesh_path = NodePath("../Body/mouse/MouseRig/Skeleton3D/body_mesh")
|
||||
default_preset = ExtResource("13_face01")
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
@tool
|
||||
extends McpTestSuite
|
||||
## 回归测试:面部那套「UV 孤岛 + 预设 + 画笔」的契约。
|
||||
## 回归测试:脸部那套「UV 矩形 + 预设 + 画笔」的契约。
|
||||
##
|
||||
## 脸是 body_mesh 的 surface 1,UV 是一个孤岛;shader 靠 face_uv_min / face_uv_size 把孤岛
|
||||
## 重映射到 0~1,再拿这个 UV 去采样预设和笔画层。**模型重导后 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 里 face_uv_min / face_uv_size 的默认值,必须和 shader 的 uniform 默认值一致
|
||||
const FACE_UV_MIN := Vector2(0.142, 0.031)
|
||||
const FACE_UV_SIZE := 0.214
|
||||
## 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.003
|
||||
const EPS := 0.004
|
||||
|
||||
|
||||
func suite_name() -> String:
|
||||
@@ -39,6 +40,69 @@ func _body_mesh() -> MeshInstance3D:
|
||||
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:
|
||||
@@ -47,60 +111,114 @@ func test_player_has_face_rig_wired() -> void:
|
||||
assert_true(rig != null, "Player 下缺少 FaceRig 节点")
|
||||
if rig == null:
|
||||
return
|
||||
assert_eq(rig.surface_index, 1, "面部是 surface 1(名字叫 face),索引写错就画到身体上了")
|
||||
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_face_surface_exists() -> void:
|
||||
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 m := mi.mesh as ArrayMesh
|
||||
assert_true(m != null, "body_mesh 的 mesh 不是 ArrayMesh")
|
||||
if m == null:
|
||||
var mesh := mi.mesh as ArrayMesh
|
||||
assert_true(mesh != null, "body_mesh 的 mesh 不是 ArrayMesh")
|
||||
if mesh == null:
|
||||
return
|
||||
assert_gt(m.get_surface_count(), 1, "body_mesh 至少要有两个 surface(0 身体 / 1 脸)")
|
||||
if m.get_surface_count() < 2:
|
||||
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(m.surface_get_name(1), "face", "surface 1 不叫 face 了 —— 确认过再改 surface_index")
|
||||
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_island_matches_shader() -> void:
|
||||
var mi := _body_mesh()
|
||||
if mi == null:
|
||||
## 脸部矩形必须整块落在头部孤岛里,否则表情会画到身体别的部位上。
|
||||
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
|
||||
var m := mi.mesh as ArrayMesh
|
||||
if m == null or m.get_surface_count() < 2:
|
||||
return
|
||||
var uv: PackedVector2Array = m.surface_get_arrays(1)[Mesh.ARRAY_TEX_UV]
|
||||
var mn := Vector2(INF, INF)
|
||||
var mx := Vector2(-INF, -INF)
|
||||
for v in uv:
|
||||
mn = mn.min(v)
|
||||
mx = mx.max(v)
|
||||
var far := FACE_UV_MIN + Vector2(FACE_UV_SIZE, FACE_UV_SIZE)
|
||||
# 孤岛必须整个落在 shader 重映射用的那个方框里,否则脸会错位 / 被裁掉
|
||||
assert_true(mn.x >= FACE_UV_MIN.x - EPS and mn.y >= FACE_UV_MIN.y - EPS,
|
||||
"面部 UV 起点 %s 跑出了 face_uv_min %s —— 模型重导后 UV 变了?" % [mn, FACE_UV_MIN])
|
||||
assert_true(mx.x <= far.x + EPS and mx.y <= far.y + EPS,
|
||||
"面部 UV 终点 %s 超出了 face_uv_min + face_uv_size = %s" % [mx, far])
|
||||
# 允许负边长(负数 = 把该轴翻过来),所以先归一化成 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 sh: Shader = load(SHADER_PATH)
|
||||
assert_true(sh != null, "%s 加载失败" % SHADER_PATH)
|
||||
if sh == null:
|
||||
var file := FileAccess.open(SHADER_PATH, FileAccess.READ)
|
||||
assert_true(file != null, "%s 加载失败" % SHADER_PATH)
|
||||
if file == null:
|
||||
return
|
||||
var names: Array[String] = []
|
||||
for p in sh.get_shader_uniform_list():
|
||||
names.append(str(p.get("name")))
|
||||
for required in ["face_uv_min", "face_uv_size", "base_tex", "paint_tex"]:
|
||||
assert_true(names.has(required), "shader 少了 uniform %s,实际为 %s" % [required, names])
|
||||
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 加载失败")
|
||||
@@ -109,8 +227,25 @@ func test_face_sync_methods_exist() -> void:
|
||||
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"]:
|
||||
assert_true(names.has(required), "face_rig.gd 少了 %s,联机时脸不会同步" % required)
|
||||
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:
|
||||
@@ -129,4 +264,14 @@ func test_presets_are_square() -> void:
|
||||
continue
|
||||
var s := tex.get_size()
|
||||
assert_eq(int(s.x), int(s.y), "预设 %s 不是正方形(%s),贴到脸上会拉伸" % [f, s])
|
||||
var image := tex.get_image()
|
||||
var transparent := false
|
||||
var visible_ink := false
|
||||
for y in range(0, image.get_height(), 4):
|
||||
for x in range(0, image.get_width(), 4):
|
||||
var a := image.get_pixel(x, y).a
|
||||
transparent = transparent or a < 0.05
|
||||
visible_ink = visible_ink or a > 0.5
|
||||
assert_true(transparent, "预设 %s 没有透明背景,可能忘了去蓝底" % f)
|
||||
assert_true(visible_ink, "预设 %s 没有可见笔画" % f)
|
||||
assert_gt(found, 0, "%s 里一张预设都没有" % PRESET_DIR)
|
||||
|
||||
@@ -1,90 +1,97 @@
|
||||
extends Control
|
||||
## 角色选择:左边一个 3D 预览(慢慢自转),右边挑角色和脸。
|
||||
##
|
||||
## 预览里这具模型和玩家场景里的模型是**两套独立的 FaceRig**,但共用同一份存档:
|
||||
## 这里点「确定」写进 user://,玩家出生时自己的 FaceRig.apply_saved() 读回来。
|
||||
##
|
||||
## 目前只有一个角色模型(mouse.glb),所以角色那一栏只有一个按钮 ——
|
||||
## 以后加了新模型,往 CharRow 里再放一个按钮就行。
|
||||
## 角色选择:预览模型、内置表情 atlas 与玩家本地表情库。
|
||||
|
||||
signal confirmed
|
||||
## 「返回」按下 —— 这一趟的改动不写存档
|
||||
signal back_pressed
|
||||
|
||||
## 预览里模型的自转速度(弧度/秒)
|
||||
const SPIN_SPEED := 0.5
|
||||
## 「自己画」用的空白脸 —— 纯色底,等于「这张脸上什么都没画」
|
||||
const BLANK_FACE := preload("res://character/player/face/presets/face_00_blank.png")
|
||||
|
||||
@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 _selected_preset: Texture2D
|
||||
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)
|
||||
|
||||
# 预设缩略图就是 PresetRow 里的子节点 —— 加预设 = 往场景里多放一个 TextureButton
|
||||
for child in %PresetRow.get_children():
|
||||
var btn := child as TextureButton
|
||||
if btn == null or btn.texture_normal == null:
|
||||
continue
|
||||
btn.pressed.connect(_on_preset_pressed.bind(btn))
|
||||
# 存档里选的那张,就是当前高亮的
|
||||
if btn.texture_normal.resource_path == _rig.preset_path:
|
||||
_selected_preset = btn.texture_normal
|
||||
_sync_preset_highlight()
|
||||
_paint_panel.finished.connect(_refresh_expression_list)
|
||||
_paint_panel.cancelled.connect(_refresh_expression_list)
|
||||
_refresh_expression_list()
|
||||
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# 转的是外层 Model 而不是模型自己,免得和朝向/动画打架
|
||||
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()
|
||||
|
||||
func _on_preset_pressed(btn: TextureButton) -> void:
|
||||
if _rig == null:
|
||||
return
|
||||
# 只换底图,玩家画的笔画保留 —— 所以可以先挑脸型再补几笔
|
||||
_rig.set_preset(btn.texture_normal, btn.texture_normal.resource_path)
|
||||
_selected_preset = btn.texture_normal
|
||||
_sync_preset_highlight()
|
||||
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 _sync_preset_highlight() -> void:
|
||||
for child in %PresetRow.get_children():
|
||||
var btn := child as TextureButton
|
||||
if btn == null:
|
||||
continue
|
||||
var picked := _selected_preset != null and btn.texture_normal == _selected_preset
|
||||
btn.modulate = Color.WHITE if picked else Color(0.62, 0.62, 0.62)
|
||||
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:
|
||||
_selected_preset = null
|
||||
_sync_preset_highlight()
|
||||
_paint_panel.open(_rig, BLANK_FACE)
|
||||
_paint_panel.open(_rig, true)
|
||||
_refresh_expression_list()
|
||||
|
||||
|
||||
# ---------------- 进出 ----------------
|
||||
|
||||
func _on_confirm() -> void:
|
||||
if _rig != null:
|
||||
_rig.save()
|
||||
_rig.save()
|
||||
confirmed.emit()
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
[gd_scene load_steps=11 format=3]
|
||||
[gd_scene format=3 uid="uid://jd1gw83hfog1"]
|
||||
|
||||
[ext_resource type="Script" path="res://ui/character_select.gd" id="1_select"]
|
||||
[ext_resource type="Script" path="res://character/player/face/face_rig.gd" id="7_face_rig"]
|
||||
[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="Texture2D" path="res://character/player/face/presets/face_01.png" id="5_face01"]
|
||||
[ext_resource type="Texture2D" path="res://character/player/face/presets/face_02.png" id="6_face02"]
|
||||
[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)
|
||||
@@ -25,7 +23,7 @@ sky = ExtResource("4_sky")
|
||||
ambient_light_source = 3
|
||||
reflected_light_source = 2
|
||||
|
||||
[node name="CharacterSelect" type="Control"]
|
||||
[node name="CharacterSelect" type="Control" unique_id=1285412003]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -34,7 +32,7 @@ grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_select")
|
||||
|
||||
[node name="Background" type="TextureRect" parent="."]
|
||||
[node name="Background" type="TextureRect" parent="." unique_id=619181932]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -45,7 +43,7 @@ mouse_filter = 2
|
||||
texture = SubResource("GradientTexture2D_bg")
|
||||
expand_mode = 1
|
||||
|
||||
[node name="Margin" type="MarginContainer" parent="."]
|
||||
[node name="Margin" type="MarginContainer" parent="." unique_id=1895590897]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
@@ -58,54 +56,54 @@ 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"]
|
||||
[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"]
|
||||
[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"]
|
||||
[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"]
|
||||
[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"]
|
||||
[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"]
|
||||
[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"]
|
||||
[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" instance=ExtResource("3_mouse")]
|
||||
[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"]
|
||||
[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_preset = ExtResource("5_face01")
|
||||
default_expression_id = "builtin:face_01"
|
||||
|
||||
[node name="Right" type="VBoxContainer" parent="Margin/Layout"]
|
||||
[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"]
|
||||
[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)
|
||||
@@ -113,21 +111,21 @@ theme_override_constants/outline_size = 10
|
||||
theme_override_font_sizes/font_size = 40
|
||||
text = "角色"
|
||||
|
||||
[node name="CharRow" type="HBoxContainer" parent="Margin/Layout/Right"]
|
||||
[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"]
|
||||
[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"]
|
||||
[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"]
|
||||
[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)
|
||||
@@ -135,53 +133,47 @@ theme_override_constants/outline_size = 8
|
||||
theme_override_font_sizes/font_size = 28
|
||||
text = "脸部"
|
||||
|
||||
[node name="PresetRow" type="HBoxContainer" parent="Margin/Layout/Right"]
|
||||
[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
|
||||
theme_override_constants/separation = 12
|
||||
size_flags_horizontal = 3
|
||||
theme_override_constants/h_separation = 10
|
||||
theme_override_constants/v_separation = 8
|
||||
|
||||
[node name="Preset0" type="TextureButton" parent="Margin/Layout/Right/PresetRow"]
|
||||
custom_minimum_size = Vector2(96, 96)
|
||||
layout_mode = 2
|
||||
texture_normal = ExtResource("5_face01")
|
||||
ignore_texture_size = true
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="Preset1" type="TextureButton" parent="Margin/Layout/Right/PresetRow"]
|
||||
custom_minimum_size = Vector2(96, 96)
|
||||
layout_mode = 2
|
||||
texture_normal = ExtResource("6_face02")
|
||||
ignore_texture_size = true
|
||||
stretch_mode = 5
|
||||
|
||||
[node name="DrawButton" type="Button" parent="Margin/Layout/Right"]
|
||||
[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"]
|
||||
[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"]
|
||||
[node name="Bottom" type="HBoxContainer" parent="Margin/Layout/Right" unique_id=1107875429]
|
||||
layout_mode = 2
|
||||
alignment = 2
|
||||
theme_override_constants/separation = 12
|
||||
alignment = 2
|
||||
|
||||
[node name="BackButton" type="Button" parent="Margin/Layout/Right/Bottom"]
|
||||
[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"]
|
||||
[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="." instance=ExtResource("2_paint")]
|
||||
[node name="PaintPanel" parent="." unique_id=132648199 instance=ExtResource("2_paint")]
|
||||
visible = false
|
||||
layout_mode = 1
|
||||
|
||||
@@ -16,12 +16,13 @@ const VIEW_SIZE := 420
|
||||
@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
|
||||
## 打开面板时用的是哪张预设,取消时一并还原(「自己画」会把底图换成空白)
|
||||
var _preset_before: String = ""
|
||||
## 打开面板时选中的表情 ID,取消时还原
|
||||
var _expression_before: String = ""
|
||||
## 左键是不是按着 —— 拖拽判定用这个,不看 motion 事件里的 button_mask
|
||||
var _drawing := false
|
||||
|
||||
@@ -41,22 +42,20 @@ func _ready() -> void:
|
||||
|
||||
# ---------------- 对外接口 ----------------
|
||||
|
||||
## 打开面板并绑定到某个 FaceRig。
|
||||
## blank_face 传了就从这张空白脸起手(「自己画」用)—— 快照仍然记的是改动之前的状态,
|
||||
## 所以点「取消」能把底图和笔画一起还原。
|
||||
func open(rig: FaceRig, blank_face: Texture2D = null) -> void:
|
||||
## 打开面板并绑定到 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()
|
||||
_preset_before = rig.preset_path
|
||||
if blank_face != null:
|
||||
rig.set_preset(blank_face, blank_face.resource_path)
|
||||
rig.clear_paint()
|
||||
_expression_before = rig.selected_expression_id
|
||||
if start_blank:
|
||||
rig.select_blank(true)
|
||||
_canvas_rect.texture = rig.painter.texture
|
||||
# 画布只显示笔画层,底下得把底图一起摆出来 —— 不然玩家是在对着空气画
|
||||
_preset_rect.texture = rig.material.get_shader_parameter("base_tex") as Texture2D
|
||||
%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
|
||||
@@ -113,18 +112,23 @@ func _on_clear() -> void:
|
||||
|
||||
|
||||
func _on_done() -> void:
|
||||
if _rig != null:
|
||||
_rig.save()
|
||||
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)
|
||||
# 底图也要还原 —— 「自己画」把它换成空白了,只还原笔画会留下一张白脸
|
||||
if not _preset_before.is_empty() and ResourceLoader.exists(_preset_before):
|
||||
_rig.set_preset(load(_preset_before), _preset_before)
|
||||
visible = false
|
||||
cancelled.emit()
|
||||
|
||||
@@ -57,6 +57,17 @@ 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
|
||||
@@ -127,9 +138,15 @@ 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
|
||||
@@ -145,4 +162,4 @@ text = "取消"
|
||||
unique_name_in_owner = true
|
||||
custom_minimum_size = Vector2(120, 40)
|
||||
layout_mode = 2
|
||||
text = "完成"
|
||||
text = "保存到表情列表"
|
||||
|
||||