163 lines
6.2 KiB
GDScript
163 lines
6.2 KiB
GDScript
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)
|