diff --git a/.workbuddy-ai/memory/2026-10-02.md b/.workbuddy-ai/memory/2026-10-02.md index ff52d70..2694bf8 100644 --- a/.workbuddy-ai/memory/2026-10-02.md +++ b/.workbuddy-ai/memory/2026-10-02.md @@ -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//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 的其实是个**目录**)。双开调试命令: +` --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` 才是真相。**别靠截图判断颜色,去读属性。** + diff --git a/.workbuddy-ai/memory/MEMORY.md b/.workbuddy-ai/memory/MEMORY.md index bdd642f..4cd5ebb 100644 --- a/.workbuddy-ai/memory/MEMORY.md +++ b/.workbuddy-ai/memory/MEMORY.md @@ -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"`; diff --git a/autoload/net.gd b/autoload/net.gd index e19d51e..8a1a7e5 100644 --- a/autoload/net.gd +++ b/autoload/net.gd @@ -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: diff --git a/character/player/face/face_paint.gdshader b/character/player/face/face_paint.gdshader new file mode 100644 index 0000000..a1d158a --- /dev/null +++ b/character/player/face/face_paint.gdshader @@ -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; +} diff --git a/character/player/face/face_paint.gdshader.uid b/character/player/face/face_paint.gdshader.uid new file mode 100644 index 0000000..e782fcc --- /dev/null +++ b/character/player/face/face_paint.gdshader.uid @@ -0,0 +1 @@ +uid://boka801kewxls diff --git a/character/player/face/face_painter.gd b/character/player/face/face_painter.gd new file mode 100644 index 0000000..7100fbd --- /dev/null +++ b/character/player/face/face_painter.gd @@ -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)) diff --git a/character/player/face/face_painter.gd.uid b/character/player/face/face_painter.gd.uid new file mode 100644 index 0000000..f61f6ae --- /dev/null +++ b/character/player/face/face_painter.gd.uid @@ -0,0 +1 @@ +uid://5fbgd46kg2hj diff --git a/character/player/face/face_rig.gd b/character/player/face/face_rig.gd new file mode 100644 index 0000000..cee2c32 --- /dev/null +++ b/character/player/face/face_rig.gd @@ -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//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) diff --git a/character/player/face/face_rig.gd.uid b/character/player/face/face_rig.gd.uid new file mode 100644 index 0000000..18ff24a --- /dev/null +++ b/character/player/face/face_rig.gd.uid @@ -0,0 +1 @@ +uid://c6l2fl5jgvr1h diff --git a/character/player/face/presets/face_00_blank.png b/character/player/face/presets/face_00_blank.png new file mode 100644 index 0000000..4f29241 Binary files /dev/null and b/character/player/face/presets/face_00_blank.png differ diff --git a/character/player/face/presets/face_00_blank.png.import b/character/player/face/presets/face_00_blank.png.import new file mode 100644 index 0000000..d65adb4 --- /dev/null +++ b/character/player/face/presets/face_00_blank.png.import @@ -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 diff --git a/character/player/face/presets/face_01.png.import b/character/player/face/presets/face_01.png.import new file mode 100644 index 0000000..cc93f30 --- /dev/null +++ b/character/player/face/presets/face_01.png.import @@ -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 diff --git a/character/player/face/presets/face_02.png b/character/player/face/presets/face_02.png new file mode 100644 index 0000000..21006d2 Binary files /dev/null and b/character/player/face/presets/face_02.png differ diff --git a/character/player/face/presets/face_02.png.import b/character/player/face/presets/face_02.png.import new file mode 100644 index 0000000..bc8c622 --- /dev/null +++ b/character/player/face/presets/face_02.png.import @@ -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 diff --git a/character/player/player.tscn b/character/player/player.tscn index fbc1f5f..da41f69 100644 --- a/character/player/player.tscn +++ b/character/player/player.tscn @@ -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") diff --git a/project.godot b/project.godot index 27df965..b815735 100644 --- a/project.godot +++ b/project.godot @@ -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" diff --git a/tests/test_face.gd b/tests/test_face.gd new file mode 100644 index 0000000..779c829 --- /dev/null +++ b/tests/test_face.gd @@ -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) diff --git a/tests/test_face.gd.uid b/tests/test_face.gd.uid new file mode 100644 index 0000000..49d4ce4 --- /dev/null +++ b/tests/test_face.gd.uid @@ -0,0 +1 @@ +uid://brw0ntpgcol2o diff --git a/tests/test_net_replication.gd b/tests/test_net_replication.gd index bbe078d..47b0e82 100644 --- a/tests/test_net_replication.gd +++ b/tests/test_net_replication.gd @@ -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: diff --git a/ui/character_select.gd b/ui/character_select.gd new file mode 100644 index 0000000..33e360a --- /dev/null +++ b/ui/character_select.gd @@ -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() diff --git a/ui/character_select.gd.uid b/ui/character_select.gd.uid new file mode 100644 index 0000000..3cf52a6 --- /dev/null +++ b/ui/character_select.gd.uid @@ -0,0 +1 @@ +uid://cwd4rsma685f diff --git a/ui/character_select.tscn b/ui/character_select.tscn new file mode 100644 index 0000000..41f2fd0 --- /dev/null +++ b/ui/character_select.tscn @@ -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 diff --git a/ui/main_menu.gd b/ui/main_menu.gd index d22b7e7..b45c29d 100644 --- a/ui/main_menu.gd +++ b/ui/main_menu.gd @@ -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 diff --git a/ui/paint_panel.gd b/ui/paint_panel.gd new file mode 100644 index 0000000..92c078e --- /dev/null +++ b/ui/paint_panel.gd @@ -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() diff --git a/ui/paint_panel.gd.uid b/ui/paint_panel.gd.uid new file mode 100644 index 0000000..09830c1 --- /dev/null +++ b/ui/paint_panel.gd.uid @@ -0,0 +1 @@ +uid://cjxxd2m1w00y5 diff --git a/ui/paint_panel.tscn b/ui/paint_panel.tscn new file mode 100644 index 0000000..87c7489 --- /dev/null +++ b/ui/paint_panel.tscn @@ -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 = "完成"