开始菜单、玩家选择、脸部绘制

This commit is contained in:
2026-10-02 12:06:28 +08:00
parent fbf00cf11f
commit 698cb02c21
26 changed files with 1419 additions and 21 deletions
+74
View File
@@ -121,3 +121,77 @@ SubViewportContainer`,输入天然可达** —— 不是把 SubViewport 当后
**发现**:`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:点「自己画」不是空白画布
用户报的。根因是**语义理解错了**:我原先把「自己画」做成「在选中的预设上补几笔」,
用户要的是**从空白脸起手**。
- 生成 `presets/face_00_blank.png`(512² 纯色 `#89B3FF`,取自预设贴图里脸的底色)。
- `character_select.gd`:「自己画」= 清掉缩略图高亮 + `_paint_panel.open(_rig, BLANK_FACE)`。
- `paint_panel.gd`:`open()` 多一个 `blank_face` 参数,**先拍快照再换底图 + 清笔画**。
- **顺带修了一个连带 bug**:`_on_cancel` 原来只还原笔画,而「自己画」会把底图换成空白 ——
点取消会留下一张白脸。现在底图(`_preset_before`)也一起还原。
- 移掉了 `_on_paint_done`(高亮在 `_on_draw` 里已经清了,那个回调成了死代码)。
验证:实机走了一遍「选预设 → 自己画(画布变纯色、预览里脸也变纯)→ 取消(底图还原回 face_01)」,
并用 `get_node_info` 确认两个缩略图 modulate 都回到 0.62。`test_run` 28/28。
**一个排查小教训**:截图里 Preset0 看着比 Preset1 亮,我一度以为高亮没清掉 ——
其实两张贴图本身明暗就不同,`modulate` 才是真相。**别靠截图判断颜色,去读属性。**
+37 -2
View File
@@ -11,8 +11,15 @@
- 界面放 `res://ui/`。项目没有 Theme 资源,风格一律用节点上的 `theme_override_*` 内联写;
给容器(如 VBoxContainer)设 `theme_override_styles/*` 会**向下继承**给子按钮,不用逐个设。
- 中文字体不用额外配:Godot 默认主题会回退到系统字体,实测中文正常渲染。
- **`Net` 是 autoload,它的「联机测试」面板在 `_ready()` 里就 `visible = true`(CanvasLayer layer=20)**,
会盖在任何场景正中间。做开始菜单时要么让菜单的「联机」按钮接管它,要么在菜单期间把它藏起来。
- **入口场景是 `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)
@@ -40,6 +47,34 @@
- 面部孤岛只占 1024² 的 21%×20%,实际脸分辨率约 219×205 —— 要用 shader 把孤岛重映射到 0~1
再用方形小贴图,有效分辨率能翻一倍多。
## 面部系统(character/player/face/ + ui/)
- `face_paint.gdshader`:把孤岛 UV 重映射到 0~1,`ALBEDO = mix(base_tex, paint_tex, paint.a)`。
**两层必须分开**:`base_tex` = 预设脸(切换就换它),`paint_tex` = 玩家笔画层。
这样橡皮擦掉笔画露出来的是预设脸,而且换预设不会丢手绘。
- `FaceRig`(挂在 Player 下):建一张 512² 的笔画 Image + ImageTexture,`set_surface_override_material(1, mat)`。
存 `user://face_preset.json`(选了哪张预设)+ `user://face_paint.png`(透明底笔画层),
玩家出生时 `apply_saved()` 读回来。预览和玩家是**两套 FaceRig、共用一份存档**。
- `FacePainter`:**不用 `Control._draw()`,直接改 RGBA8 字节缓冲**。画 = source-over alpha 混合,
擦 = 把目标 alpha 乘掉;一笔画完 `_sync()` 一次(512² 一次 1MB,别每盖一个印章就同步)。
- 界面链路:`ui/main_menu.tscn` →「开始游戏」→ `ui/character_select.tscn`(3D 预览 + 预设缩略图 + 自己画)
→ `ui/paint_panel.tscn`。**画布要「预设 TextureRect + 笔画 TextureRect」叠着显示** ——
笔画层是透明底的,只显示它等于让玩家对着空气画。
- **「自己画」= 从空白脸起手**(`presets/face_00_blank.png`,纯色底),不是在预设上补笔。
空白由面板来落(`open(rig, blank_face)`)—— 它必须**先拍快照再改**,否则点「取消」还原回来的
是已经清空的状态。取消要同时还原**底图和笔画**,只还原笔画会留下一张白脸。
## Godot 4 踩坑
- **canvas 没有「擦除」混合模式**:`CanvasItemMaterial.BlendMode` 只有 mix/add/sub/mul/premult,
`CanvasItem.draw_set_blend_mode()` 在 4.x 已移除。橡皮只能自己管像素。
- `Image.set_data()` 在 4.7 签名变了(要 5 个参数);改用
`Image.create_from_data(w, h, mipmaps, format, data)`。
- **导出的节点引用解析不到**:`@export var mesh: MeshInstance3D` 在 .tscn 里存成 `NodePath`,
实测场景实例化时拿到的是 null。改成 `@export var mesh_path: NodePath` + `_ready()` 里 `get_node_or_null`。
- shader 里 `source_color` 只能用在 vec3 / vec4 / sampler 上,`vec2` 用了会编译失败。
- 给容器(VBoxContainer 等)设 `theme_override_styles/*` 会**向下继承**给子按钮,不用逐个设。
## Godot MCP 踩坑
- **数值标量经 MCP 会被字符串化**:`node_set_property` 传 `0.15` 能写进去,但回显是 `"0.15"`;
+36 -5
View File
@@ -18,6 +18,9 @@ const MAX_PLAYERS: int = 8
const DEFAULT_ADDRESS: String = "127.0.0.1"
## 所有玩家节点都会加入这个组,方便整批清场
const PLAYER_GROUP: String = "net_player"
## 开局后要进的世界场景,以及断开后要回的入口场景
const WORLD_SCENE: String = "res://world/main.tscn"
const MENU_SCENE: String = "res://ui/main_menu.tscn"
var _spawner: MultiplayerSpawner = null
@@ -41,8 +44,20 @@ func _ready() -> void:
# ---------------- 对外接口 ----------------
## 世界根节点在 _ready 里调用,把 MultiplayerSpawner 托管给 Net。
##
## 世界是「开局之后」才加载的(入口是开始菜单),所以这里得把已经连上的玩家补生成一遍 ——
## 否则主机在菜单里就开好了,切进世界时一个角色都不会出现。
func bind_spawner(spawner: MultiplayerSpawner) -> void:
_spawner = spawner
if multiplayer.is_server():
call_deferred("_spawn_player", 1)
for id in multiplayer.get_peers():
call_deferred("_spawn_player", id)
## 菜单那边用它开关「联机」面板 —— 面板默认是显示的,会盖住入口菜单。
func set_menu_visible(on: bool) -> void:
_menu.visible = on
## 创建主机。ENet 是客户端-服务器模型,主机同时是服务器与玩家。
@@ -55,8 +70,8 @@ func host_game() -> void:
multiplayer.multiplayer_peer = peer
_show_game_ui()
print("[Net] 主机已启动,端口 %d" % PORT)
# 服务器自己也要一个角色。延后一帧,等各端场景就绪。
call_deferred("_spawn_player", 1)
# 入口是开始菜单,世界这时还没加载 —— 切过去,角色由 bind_spawner 补生成
call_deferred("_enter_world")
## 加入主机。address 为主机在局域网内的内网 IP。
@@ -74,13 +89,28 @@ func join_game(address: String = DEFAULT_ADDRESS) -> void:
print("[Net] 正在连接 %s:%d" % [target, PORT])
## 断开并清场,回到主菜单。
## 断开并清场,回入口菜单。
func leave_game() -> void:
multiplayer.multiplayer_peer = null
_clear_players()
# 世界一卸载 spawner 就跟着没了,留着引用会指向已释放的节点
_spawner = null
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
_show_menu_ui()
_set_status("已断开")
call_deferred("_go_to", MENU_SCENE)
## 切到世界场景。已经在里面就不重复切
func _enter_world() -> void:
_go_to(WORLD_SCENE)
func _go_to(path: String) -> void:
var current := get_tree().current_scene
if current != null and current.scene_file_path == path:
return
get_tree().change_scene_to_file(path)
# ---------------- 网络事件 ----------------
@@ -99,6 +129,7 @@ func _on_peer_disconnected(id: int) -> void:
func _on_connected_to_server() -> void:
_show_game_ui()
print("[Net] 已连接,等待主机生成角色")
call_deferred("_enter_world")
func _on_connection_failed() -> void:
@@ -117,7 +148,7 @@ func _on_server_disconnected() -> void:
# ---------------- 玩家生成与回收 ----------------
func _spawn_player(peer_id: int) -> void:
if _spawner == null or not multiplayer.is_server():
if not is_instance_valid(_spawner) or not multiplayer.is_server():
return
var parent := _spawner.get_node_or_null(_spawner.spawn_path)
if parent == null or parent.has_node(str(peer_id)):
@@ -126,7 +157,7 @@ func _spawn_player(peer_id: int) -> void:
func _despawn_player(peer_id: int) -> void:
if _spawner == null:
if not is_instance_valid(_spawner):
return
var parent := _spawner.get_node_or_null(_spawner.spawn_path)
if parent == null:
+29
View File
@@ -0,0 +1,29 @@
shader_type spatial;
// 面部 = 两层贴图的合成:底图(预设脸)+ 笔画层(玩家自绘),后者带 alpha 盖在前面。
// 两层都用「面部孤岛重映射后的 UV」采样,所以一张方形小图就能正好铺满整张脸。
//
// 为什么不是直接把贴图换掉:画布只装笔画、不装底图,橡皮擦掉笔画后露出来的是预设脸,
// 而不是把脸擦成黑洞。这也是它能一边切预设一边保留手绘笔画的原因。
// 面部 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,
hint_default_white;
// 玩家笔画层(一个透明底的 SubViewport)。没接上时给张全透明图,等于没画
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);
ALBEDO = mix(base.rgb, paint.rgb, paint.a);
// 顶掉 GLB 里 lambert1 那套手感,不然脸会比身体亮一块
ROUGHNESS = 1.0;
METALLIC = 0.0;
}
@@ -0,0 +1 @@
uid://boka801kewxls
+190
View File
@@ -0,0 +1,190 @@
class_name FacePainter
extends Node
## 面部画笔:直接往一张 Image 的字节缓冲上盖笔刷印章。
##
## 为什么不用 Control._draw():Godot 4 的 canvas 已经没有「擦除」混合模式了
## (CanvasItemMaterial 只有 mix / add / sub / mul / premult,也没有 draw_set_blend_mode),
## 橡皮没法靠 blend_mode 实现。自己管像素反而更直接:
## 画 = 标准的 source-over alpha 混合;擦 = 把目标的 alpha 乘掉;存档 = 就是存这张图。
var image: Image
var texture: ImageTexture
var canvas_size: int = 512
## 当前笔刷。绘画面板改这几个值,下一笔生效。
var brush_color: Color = Color.BLACK
var brush_width: float = 12.0
var erasing: bool = false
## 像素缓冲(RGBA8)。所有绘制都改它,一笔画完再一次性推给显卡。
var _buf: PackedByteArray
## 圆形笔刷的覆盖度,边长 _stamp_d
var _cov: PackedFloat32Array
var _stamp_d: int = 0
var _stamp_rgb: Vector3 = Vector3.ZERO
var _last_stamp: Vector2 = Vector2.INF
func _init(size: int = 512) -> void:
canvas_size = size
func _ready() -> void:
_buf = PackedByteArray()
_buf.resize(canvas_size * canvas_size * 4)
_sync()
# ---------------- 画笔接口 ----------------
func begin_stroke(pos: Vector2) -> void:
_rebuild_stamp()
_last_stamp = Vector2.INF
_stamp_at(pos)
_last_stamp = pos
_sync()
func extend_stroke(pos: Vector2) -> void:
if not _last_stamp.is_finite():
_stamp_at(pos)
_last_stamp = pos
_sync()
return
var spacing := maxf(1.0, brush_width * 0.25)
var dist := _last_stamp.distance_to(pos)
if dist < spacing:
return
# 鼠标动得快时两点之间得补章,否则线是一串断点
var steps := int(ceil(dist / spacing))
for i in range(1, steps + 1):
_stamp_at(_last_stamp.lerp(pos, float(i) / float(steps)))
_last_stamp = pos
_sync()
func end_stroke() -> void:
_last_stamp = Vector2.INF
func clear() -> void:
_buf.fill(0)
_last_stamp = Vector2.INF
_sync()
## 拍一张当前笔画层的快照。绘画面板打开时存一份,点「取消」时还原
func snapshot() -> PackedByteArray:
return _buf.duplicate()
## 还原到快照。尺寸对不上就忽略(换过画布大小)
func restore(data: PackedByteArray) -> void:
if data.size() != _buf.size():
return
_buf = data.duplicate()
_last_stamp = Vector2.INF
_sync()
# ---------------- 存档 ----------------
## 笔画层存成一张透明底 PNG
func save_png(path: String) -> void:
_sync()
image.save_png(path)
## 读回笔画层。尺寸对不上就当没读到(换了画布大小的话)
func load_png(path: String) -> void:
if not FileAccess.file_exists(path):
return
set_image(Image.load_from_file(path))
## 从一张 Image 直接载入笔画层 —— 联机收到别人的脸时走这条,不用落盘
func set_image(img: Image) -> void:
if img == null:
return
if img.get_width() != canvas_size or img.get_height() != canvas_size:
push_warning("笔画层尺寸是 %s,画布是 %d,忽略" % [img.get_size(), canvas_size])
return
img.convert(Image.FORMAT_RGBA8)
_buf = img.get_data()
_last_stamp = Vector2.INF
_sync()
## 当前笔画层的 PNG 字节 —— 联机同步用,不落盘
func to_png_bytes() -> PackedByteArray:
_sync()
return image.save_png_to_buffer()
# ---------------- 内部 ----------------
## 把字节缓冲变成 Image 再推给显卡。一笔画完调一次 —— 512² 一次就是 1MB,别每盖一个印章都来
func _sync() -> void:
image = Image.create_from_data(canvas_size, canvas_size, false, Image.FORMAT_RGBA8, _buf)
if texture == null:
texture = ImageTexture.create_from_image(image)
else:
texture.update(image)
## 按当前粗细和颜色重造笔刷。边缘留 1 像素羽化 —— 硬边的线很难看。
func _rebuild_stamp() -> void:
var r := maxi(2, int(round(brush_width * 0.5)))
var d := r * 2
_stamp_d = d
_stamp_rgb = Vector3(brush_color.r, brush_color.g, brush_color.b)
_cov.resize(d * d)
for y in d:
for x in d:
var dx := float(x) - float(r) + 0.5
var dy := float(y) - float(r) + 0.5
var dist := sqrt(dx * dx + dy * dy) / float(r)
_cov[y * d + x] = clampf((1.0 - dist) * float(r), 0.0, 1.0)
## 在 pos 处盖一个印章。越界像素直接跳过,所以画到边上也不会出错。
func _stamp_at(pos: Vector2) -> void:
var d := _stamp_d
var r := d / 2
var cx := int(round(pos.x)) - r
var cy := int(round(pos.y)) - r
var sr := _stamp_rgb.x * 255.0
var sg := _stamp_rgb.y * 255.0
var sb := _stamp_rgb.z * 255.0
for sy in d:
var ty := cy + sy
if ty < 0 or ty >= canvas_size:
continue
var row := ty * canvas_size * 4
var srow := sy * d
for sx in d:
var tx := cx + sx
if tx < 0 or tx >= canvas_size:
continue
var cov := _cov[srow + sx]
if cov <= 0.001:
continue
var o := row + tx * 4
if erasing:
# 擦:把 alpha 按覆盖度乘掉。RGB 留着不管 —— shader 只看 alpha
_buf[o + 3] = int(float(_buf[o + 3]) * (1.0 - cov))
continue
var sa := cov
var da := float(_buf[o + 3]) / 255.0
var na := sa + da * (1.0 - sa)
if na <= 0.0001:
continue
var ws := sa / na
var wd := da * (1.0 - sa) / na
_buf[o] = int(clampf(sr * ws + float(_buf[o]) * wd, 0.0, 255.0))
_buf[o + 1] = int(clampf(sg * ws + float(_buf[o + 1]) * wd, 0.0, 255.0))
_buf[o + 2] = int(clampf(sb * ws + float(_buf[o + 2]) * wd, 0.0, 255.0))
_buf[o + 3] = int(clampf(na * 255.0, 0.0, 255.0))
@@ -0,0 +1 @@
uid://5fbgd46kg2hj
+162
View File
@@ -0,0 +1,162 @@
class_name FaceRig
extends Node
## 角色的脸:把 mesh 上那个「face」surface 换成自定义材质,
## 材质把「预设底图」和「玩家自绘的笔画层」合成起来。
##
## 笔画层是一张常驻的 Image(不是 SubViewport)—— 这样即使绘画面板没打开,
## 游戏内的脸也一直是玩家画的那张。绘画面板只是把 painter.texture 显示出来,
## 把鼠标坐标换算好之后喂给 painter。
const SHADER_PATH := "res://character/player/face/face_paint.gdshader"
## 存档:选的是哪张预设
const SAVE_PRESET := "user://face_preset.json"
## 存档:笔画层(透明底 PNG)
const SAVE_PAINT := "user://face_paint.png"
## 联机同步:非本机玩家主动去要脸的次数与间隔。
## 各端玩家节点出现的先后顺序不保证,头几次请求可能打空,所以要重试。
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
@export_group("画布")
## 画布边长(像素)。玩家画的笔画坐标就在这个空间里
@export var canvas_size: int = 512
@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
var mesh: MeshInstance3D
var material: ShaderMaterial
var painter: FacePainter
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
painter = FacePainter.new(canvas_size)
painter.name = "FacePainter"
add_child(painter)
material = ShaderMaterial.new()
material.shader = load(SHADER_PATH)
material.set_shader_parameter("face_uv_min", face_uv_min)
material.set_shader_parameter("face_uv_size", face_uv_size)
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
func clear_paint() -> void:
if painter != null:
painter.clear()
## 存:预设路径 + 笔画层。下次进游戏自动读回来
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)
return
f.store_string(JSON.stringify({"preset": preset_path}))
## 读存档;没有(或存的预设已经不在了)就退回 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
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)
if painter != null:
painter.load_png(SAVE_PAINT)
# ---------------- 联机同步 ----------------
# 用「谁缺谁来要」而不是「出生时推」:各端玩家节点出现的先后顺序不保证,
# 推的话很可能推给一个还不存在的节点;而请求总是从已经存在的那一端发出去的。
## 非本机玩家在 _ready 里调,去要这张脸主人的那一份。带重试,因为对端节点可能还没建好。
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
_rpc_send_face.rpc_id(
multiplayer.get_remote_sender_id(),
preset_path,
painter.to_png_bytes() if painter != null else PackedByteArray(),
)
@rpc("any_peer", "call_remote", "reliable")
func _rpc_send_face(preset: String, 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:
var img := Image.new()
if img.load_png_from_buffer(png) == OK:
painter.set_image(img)
+1
View File
@@ -0,0 +1 @@
uid://c6l2fl5jgvr1h
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

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

After

Width:  |  Height:  |  Size: 119 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
+7
View File
@@ -11,6 +11,8 @@
[ext_resource type="AnimationLibrary" uid="uid://rqhclfyiingo" path="res://character/player/assets/animations/run.glb" id="7_muf2e"]
[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
@@ -65,3 +67,8 @@ modulate = Color(1, 0.95, 0.6, 1)
text = "按 E 挂上滑索"
font_size = 48
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 -1
View File
@@ -11,7 +11,7 @@ config_version=5
[application]
config/name="agame"
run/main_scene="uid://bp8l1uwgshc86"
run/main_scene="res://ui/main_menu.tscn"
config/features=PackedStringArray("4.7", "Forward Plus")
config/icon="res://icon.svg"
+132
View File
@@ -0,0 +1,132 @@
@tool
extends McpTestSuite
## 回归测试:面部那套「UV 孤岛 + 预设 + 画笔」的契约。
##
## 脸是 body_mesh 的 surface 1,UV 是一个孤岛;shader 靠 face_uv_min / face_uv_size 把孤岛
## 重映射到 0~1,再拿这个 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"
## FaceRig 里 face_uv_min / face_uv_size 的默认值,必须和 shader 的 uniform 默认值一致
const FACE_UV_MIN := Vector2(0.142, 0.031)
const FACE_UV_SIZE := 0.214
## UV 是浮点,两边各写一份,给点容差
const EPS := 0.003
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
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, 1, "面部是 surface 1(名字叫 face),索引写错就画到身体上了")
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:
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:
return
assert_gt(m.get_surface_count(), 1, "body_mesh 至少要有两个 surface(0 身体 / 1 脸)")
if m.get_surface_count() < 2:
return
assert_eq(m.surface_get_name(1), "face", "surface 1 不叫 face 了 —— 确认过再改 surface_index")
func test_face_uv_island_matches_shader() -> void:
var mi := _body_mesh()
if mi == null:
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])
func test_face_shader_has_expected_uniforms() -> void:
var sh: Shader = load(SHADER_PATH)
assert_true(sh != null, "%s 加载失败" % SHADER_PATH)
if sh == 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])
## 联机同步靠这三个方法名,改名了要一起改 —— 改了就不同步,而且不报错
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"]:
assert_true(names.has(required), "face_rig.gd 少了 %s,联机时脸不会同步" % required)
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])
assert_gt(found, 0, "%s 里一张预设都没有" % PRESET_DIR)
+1
View File
@@ -0,0 +1 @@
uid://brw0ntpgcol2o
+15
View File
@@ -187,6 +187,21 @@ 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:
+92
View File
@@ -0,0 +1,92 @@
extends Control
## 角色选择:左边一个 3D 预览(慢慢自转),右边挑角色和脸。
##
## 预览里这具模型和玩家场景里的模型是**两套独立的 FaceRig**,但共用同一份存档:
## 这里点「确定」写进 user://,玩家出生时自己的 FaceRig.apply_saved() 读回来。
##
## 目前只有一个角色模型(mouse.glb),所以角色那一栏只有一个按钮 ——
## 以后加了新模型,往 CharRow 里再放一个按钮就行。
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
var _selected_preset: Texture2D
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()
func _process(delta: float) -> void:
# 转的是外层 Model 而不是模型自己,免得和朝向/动画打架
if _model != null:
_model.rotate_y(SPIN_SPEED * delta)
# ---------------- 选择 ----------------
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()
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_draw() -> void:
_selected_preset = null
_sync_preset_highlight()
_paint_panel.open(_rig, BLANK_FACE)
# ---------------- 进出 ----------------
func _on_confirm() -> void:
if _rig != null:
_rig.save()
confirmed.emit()
func _on_back() -> void:
back_pressed.emit()
+1
View File
@@ -0,0 +1 @@
uid://cwd4rsma685f
+187
View File
@@ -0,0 +1,187 @@
[gd_scene load_steps=11 format=3]
[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="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"]
[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"]
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="."]
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="."]
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"]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 36
[node name="Preview" type="SubViewportContainer" parent="Margin/Layout"]
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"]
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"]
environment = SubResource("Environment_preview")
[node name="Sun" type="DirectionalLight3D" parent="Margin/Layout/Preview/PreviewViewport"]
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_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_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")]
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_name_in_owner = true
script = ExtResource("7_face_rig")
mesh_path = NodePath("../Model/mouse/MouseRig/Skeleton3D/body_mesh")
default_preset = ExtResource("5_face01")
[node name="Right" type="VBoxContainer" parent="Margin/Layout"]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 14
[node name="CharTitle" type="Label" parent="Margin/Layout/Right"]
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"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="CharButton" type="Button" parent="Margin/Layout/Right/CharRow"]
custom_minimum_size = Vector2(160, 46)
layout_mode = 2
text = "小老鼠"
[node name="Spacer" type="Control" parent="Margin/Layout/Right"]
custom_minimum_size = Vector2(0, 12)
layout_mode = 2
mouse_filter = 2
[node name="FaceTitle" type="Label" parent="Margin/Layout/Right"]
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="PresetRow" type="HBoxContainer" parent="Margin/Layout/Right"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 12
[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"]
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"]
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
[node name="Bottom" type="HBoxContainer" parent="Margin/Layout/Right"]
layout_mode = 2
alignment = 2
theme_override_constants/separation = 12
[node name="BackButton" type="Button" parent="Margin/Layout/Right/Bottom"]
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_name_in_owner = true
custom_minimum_size = Vector2(130, 46)
layout_mode = 2
text = "确定"
[node name="PaintPanel" parent="." instance=ExtResource("2_paint")]
visible = false
+53 -13
View File
@@ -1,30 +1,70 @@
extends Control
## 游戏开始菜单 —— 只有界面,没有逻辑。
## 游戏开始菜单 —— 项目的入口场景(project.godot 的 run/main_scene)。
##
## 四个按钮按下去只把对应信号发出去:谁接、接了之后是加载世界、连服务器还是弹设置面板,
## 都不在这个文件里。这样菜单版式可以单独改,主流程也能单独换。
## 「开始游戏」→ 角色选择 → 确定 = 开主机并进世界。这个项目是客户端-服务器模型,
## 单人玩也是本机开服(没有单独的「离线模式」)。
## 「联机」把 Net 的创建/加入面板放出来,那边选完同样会切进世界。
##
## 接线示例(放在外层,比如以后的主场景或一个菜单流程单例):
## menu.start_pressed.connect(_on_start)
## menu.multiplayer_pressed.connect(_on_multiplayer)
## 进世界、回菜单这两件事都归 Net 管(见 net.gd 的 _enter_world / leave_game),
## 菜单只负责「按了哪个按钮」。
##
## 接线示例(放在外层):
## menu.settings_pressed.connect(_on_settings)
## menu.quit_pressed.connect(get_tree().quit)
##
## 用法:把本场景设成 project.godot 的 application/run/main_scene 即可(这一步也没做)。
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)
# 按钮只负责把 pressed 转发成信号,不在这里做任何决定。
%StartButton.pressed.connect(start_pressed.emit)
%MultiplayerButton.pressed.connect(multiplayer_pressed.emit)
%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
+130
View File
@@ -0,0 +1,130 @@
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
var _rig: FaceRig
## 打开面板时的笔画层快照,取消时还原
var _snapshot: PackedByteArray
## 打开面板时用的是哪张预设,取消时一并还原(「自己画」会把底图换成空白)
var _preset_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。
## blank_face 传了就从这张空白脸起手(「自己画」用)—— 快照仍然记的是改动之前的状态,
## 所以点「取消」能把底图和笔画一起还原。
func open(rig: FaceRig, blank_face: Texture2D = null) -> 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()
_canvas_rect.texture = rig.painter.texture
# 画布只显示笔画层,底下得把底图一起摆出来 —— 不然玩家是在对着空气画
_preset_rect.texture = rig.material.get_shader_parameter("base_tex") as Texture2D
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:
_rig.save()
visible = false
finished.emit()
func _on_cancel() -> void:
if _rig != null:
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()
+1
View File
@@ -0,0 +1 @@
uid://cjxxd2m1w00y5
+148
View File
@@ -0,0 +1,148 @@
[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="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="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 = "完成"