This commit is contained in:
2026-10-02 15:42:34 +08:00
parent 698cb02c21
commit faf799837f
22 changed files with 759 additions and 302 deletions
+57 -50
View File
@@ -1,90 +1,97 @@
extends Control
## 角色选择:左边一个 3D 预览(慢慢自转),右边挑角色和脸。
##
## 预览里这具模型和玩家场景里的模型是**两套独立的 FaceRig**,但共用同一份存档:
## 这里点「确定」写进 user://,玩家出生时自己的 FaceRig.apply_saved() 读回来。
##
## 目前只有一个角色模型(mouse.glb),所以角色那一栏只有一个按钮 ——
## 以后加了新模型,往 CharRow 里再放一个按钮就行。
## 角色选择:预览模型、内置表情 atlas 与玩家本地表情库。
signal confirmed
## 「返回」按下 —— 这一趟的改动不写存档
signal back_pressed
## 预览里模型的自转速度(弧度/秒)
const SPIN_SPEED := 0.5
## 「自己画」用的空白脸 —— 纯色底,等于「这张脸上什么都没画」
const BLANK_FACE := preload("res://character/player/face/presets/face_00_blank.png")
@onready var _model: Node3D = %Model
@onready var _camera: Camera3D = %PreviewCamera
@onready var _rig: FaceRig = %FaceRig
@onready var _paint_panel: Control = $PaintPanel
@onready var _expression_row: HFlowContainer = %PresetRow
var _selected_preset: Texture2D
var _expression_buttons: Array[TextureButton] = []
func _ready() -> void:
# 菜单里鼠标要能点
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
# 相机看向模型胸口高度,省得在场景里手算旋转
_camera.look_at(Vector3(0.0, 0.45, 0.0), Vector3.UP)
%ConfirmButton.pressed.connect(_on_confirm)
%BackButton.pressed.connect(_on_back)
%DrawButton.pressed.connect(_on_draw)
# 预设缩略图就是 PresetRow 里的子节点 —— 加预设 = 往场景里多放一个 TextureButton
for child in %PresetRow.get_children():
var btn := child as TextureButton
if btn == null or btn.texture_normal == null:
continue
btn.pressed.connect(_on_preset_pressed.bind(btn))
# 存档里选的那张,就是当前高亮的
if btn.texture_normal.resource_path == _rig.preset_path:
_selected_preset = btn.texture_normal
_sync_preset_highlight()
_paint_panel.finished.connect(_refresh_expression_list)
_paint_panel.cancelled.connect(_refresh_expression_list)
_refresh_expression_list()
func _process(delta: float) -> void:
# 转的是外层 Model 而不是模型自己,免得和朝向/动画打架
if _model != null:
_model.rotate_y(SPIN_SPEED * delta)
# ---------------- 选择 ----------------
## 内置表情用 AtlasTexture 显示对应 UV 小格;本地表情直接显示各自 PNG。
func _refresh_expression_list() -> void:
for child in _expression_row.get_children():
child.free()
_expression_buttons.clear()
func _on_preset_pressed(btn: TextureButton) -> void:
if _rig == null:
return
# 只换底图,玩家画的笔画保留 —— 所以可以先挑脸型再补几笔
_rig.set_preset(btn.texture_normal, btn.texture_normal.resource_path)
_selected_preset = btn.texture_normal
_sync_preset_highlight()
for entry in _rig.get_expression_catalog():
var card := VBoxContainer.new()
card.custom_minimum_size = Vector2(96, 116)
card.add_theme_constant_override("separation", 4)
var skin := ColorRect.new()
skin.color = _rig.skin_color
skin.mouse_filter = Control.MOUSE_FILTER_IGNORE
skin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
var button := TextureButton.new()
button.custom_minimum_size = Vector2(96, 96)
button.texture_normal = entry["thumbnail"] as Texture2D
button.ignore_texture_size = true
button.stretch_mode = TextureButton.STRETCH_KEEP_ASPECT_CENTERED
button.modulate = Color.WHITE if str(entry["id"]) == _rig.selected_expression_id else Color(0.62, 0.62, 0.62)
button.set_meta("expression_id", str(entry["id"]))
button.pressed.connect(_on_expression_pressed.bind(str(entry["id"])))
_expression_buttons.append(button)
var preview := Control.new()
preview.custom_minimum_size = Vector2(96, 96)
preview.add_child(skin)
preview.add_child(button)
var label := Label.new()
label.text = str(entry["name"])
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
label.custom_minimum_size = Vector2(96, 16)
card.add_child(preview)
card.add_child(label)
_expression_row.add_child(card)
func _sync_preset_highlight() -> void:
for child in %PresetRow.get_children():
var btn := child as TextureButton
if btn == null:
continue
var picked := _selected_preset != null and btn.texture_normal == _selected_preset
btn.modulate = Color.WHITE if picked else Color(0.62, 0.62, 0.62)
func _on_expression_pressed(expression_id: String) -> void:
if _rig.select_expression(expression_id):
_refresh_expression_highlight(expression_id)
## 「自己画」= 从一张空白脸起手。空白是在面板里落的 —— 它得先拍快照再改,
## 否则点「取消」还原回来的是已经清空的状态。
func _refresh_expression_highlight(expression_id: String) -> void:
for button in _expression_buttons:
button.modulate = Color.WHITE if str(button.get_meta("expression_id", "")) == expression_id else Color(0.62, 0.62, 0.62)
## 「自己画」从无底图起手。面板会先快照,取消时还原表情 ID 和笔画。
func _on_draw() -> void:
_selected_preset = null
_sync_preset_highlight()
_paint_panel.open(_rig, BLANK_FACE)
_paint_panel.open(_rig, true)
_refresh_expression_list()
# ---------------- 进出 ----------------
func _on_confirm() -> void:
if _rig != null:
_rig.save()
_rig.save()
confirmed.emit()
+39 -47
View File
@@ -1,12 +1,10 @@
[gd_scene load_steps=11 format=3]
[gd_scene format=3 uid="uid://jd1gw83hfog1"]
[ext_resource type="Script" path="res://ui/character_select.gd" id="1_select"]
[ext_resource type="Script" path="res://character/player/face/face_rig.gd" id="7_face_rig"]
[ext_resource type="Script" uid="uid://cwd4rsma685f" path="res://ui/character_select.gd" id="1_select"]
[ext_resource type="PackedScene" path="res://ui/paint_panel.tscn" id="2_paint"]
[ext_resource type="PackedScene" uid="uid://css1vk3u8gt7o" path="res://character/player/assets/mouse.glb" id="3_mouse"]
[ext_resource type="Sky" uid="uid://bi632suyifms5" path="res://world/sky/cartoon_sky.tres" id="4_sky"]
[ext_resource type="Texture2D" path="res://character/player/face/presets/face_01.png" id="5_face01"]
[ext_resource type="Texture2D" path="res://character/player/face/presets/face_02.png" id="6_face02"]
[ext_resource type="Script" uid="uid://c6l2fl5jgvr1h" path="res://character/player/face/face_rig.gd" id="7_face_rig"]
[sub_resource type="Gradient" id="Gradient_bg"]
offsets = PackedFloat32Array(0, 0.55, 1)
@@ -25,7 +23,7 @@ sky = ExtResource("4_sky")
ambient_light_source = 3
reflected_light_source = 2
[node name="CharacterSelect" type="Control"]
[node name="CharacterSelect" type="Control" unique_id=1285412003]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
@@ -34,7 +32,7 @@ grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_select")
[node name="Background" type="TextureRect" parent="."]
[node name="Background" type="TextureRect" parent="." unique_id=619181932]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -45,7 +43,7 @@ mouse_filter = 2
texture = SubResource("GradientTexture2D_bg")
expand_mode = 1
[node name="Margin" type="MarginContainer" parent="."]
[node name="Margin" type="MarginContainer" parent="." unique_id=1895590897]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
@@ -58,54 +56,54 @@ theme_override_constants/margin_top = 40
theme_override_constants/margin_right = 48
theme_override_constants/margin_bottom = 40
[node name="Layout" type="HBoxContainer" parent="Margin"]
[node name="Layout" type="HBoxContainer" parent="Margin" unique_id=1213332253]
layout_mode = 2
mouse_filter = 2
theme_override_constants/separation = 36
[node name="Preview" type="SubViewportContainer" parent="Margin/Layout"]
[node name="Preview" type="SubViewportContainer" parent="Margin/Layout" unique_id=718492618]
custom_minimum_size = Vector2(420, 520)
layout_mode = 2
size_flags_horizontal = 0
size_flags_vertical = 4
stretch = true
[node name="PreviewViewport" type="SubViewport" parent="Margin/Layout/Preview"]
[node name="PreviewViewport" type="SubViewport" parent="Margin/Layout/Preview" unique_id=369907966]
own_world_3d = true
handle_input_locally = false
size = Vector2i(420, 520)
render_target_update_mode = 4
[node name="Environment" type="WorldEnvironment" parent="Margin/Layout/Preview/PreviewViewport"]
[node name="Environment" type="WorldEnvironment" parent="Margin/Layout/Preview/PreviewViewport" unique_id=2065272503]
environment = SubResource("Environment_preview")
[node name="Sun" type="DirectionalLight3D" parent="Margin/Layout/Preview/PreviewViewport"]
[node name="Sun" type="DirectionalLight3D" parent="Margin/Layout/Preview/PreviewViewport" unique_id=1776501976]
transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 3, 0)
light_energy = 1.1
[node name="PreviewCamera" type="Camera3D" parent="Margin/Layout/Preview/PreviewViewport"]
[node name="PreviewCamera" type="Camera3D" parent="Margin/Layout/Preview/PreviewViewport" unique_id=55319451]
unique_name_in_owner = true
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.62, 1.5)
[node name="Model" type="Node3D" parent="Margin/Layout/Preview/PreviewViewport"]
[node name="Model" type="Node3D" parent="Margin/Layout/Preview/PreviewViewport" unique_id=1496447090]
unique_name_in_owner = true
transform = Transform3D(-1, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 0)
[node name="mouse" parent="Margin/Layout/Preview/PreviewViewport/Model" instance=ExtResource("3_mouse")]
[node name="mouse" parent="Margin/Layout/Preview/PreviewViewport/Model" unique_id=1730754830 instance=ExtResource("3_mouse")]
transform = Transform3D(0.18240133, 0, 0, 0, 0.18240133, 0, 0, 0, 0.18240133, 0, 0, 0)
[node name="FaceRig" type="Node" parent="Margin/Layout/Preview/PreviewViewport"]
[node name="FaceRig" type="Node" parent="Margin/Layout/Preview/PreviewViewport" unique_id=304595484]
unique_name_in_owner = true
script = ExtResource("7_face_rig")
mesh_path = NodePath("../Model/mouse/MouseRig/Skeleton3D/body_mesh")
default_preset = ExtResource("5_face01")
default_expression_id = "builtin:face_01"
[node name="Right" type="VBoxContainer" parent="Margin/Layout"]
[node name="Right" type="VBoxContainer" parent="Margin/Layout" unique_id=605730650]
layout_mode = 2
size_flags_horizontal = 3
theme_override_constants/separation = 14
[node name="CharTitle" type="Label" parent="Margin/Layout/Right"]
[node name="CharTitle" type="Label" parent="Margin/Layout/Right" unique_id=584718962]
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_colors/font_outline_color = Color(0.05, 0.1, 0.25, 0.85)
@@ -113,21 +111,21 @@ theme_override_constants/outline_size = 10
theme_override_font_sizes/font_size = 40
text = "角色"
[node name="CharRow" type="HBoxContainer" parent="Margin/Layout/Right"]
[node name="CharRow" type="HBoxContainer" parent="Margin/Layout/Right" unique_id=1751305595]
layout_mode = 2
theme_override_constants/separation = 10
[node name="CharButton" type="Button" parent="Margin/Layout/Right/CharRow"]
[node name="CharButton" type="Button" parent="Margin/Layout/Right/CharRow" unique_id=1541380555]
custom_minimum_size = Vector2(160, 46)
layout_mode = 2
text = "小老鼠"
[node name="Spacer" type="Control" parent="Margin/Layout/Right"]
[node name="Spacer" type="Control" parent="Margin/Layout/Right" unique_id=562037857]
custom_minimum_size = Vector2(0, 12)
layout_mode = 2
mouse_filter = 2
[node name="FaceTitle" type="Label" parent="Margin/Layout/Right"]
[node name="FaceTitle" type="Label" parent="Margin/Layout/Right" unique_id=111752993]
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_colors/font_outline_color = Color(0.05, 0.1, 0.25, 0.85)
@@ -135,53 +133,47 @@ theme_override_constants/outline_size = 8
theme_override_font_sizes/font_size = 28
text = "脸部"
[node name="PresetRow" type="HBoxContainer" parent="Margin/Layout/Right"]
[node name="PresetScroll" type="ScrollContainer" parent="Margin/Layout/Right"]
custom_minimum_size = Vector2(0, 190)
layout_mode = 2
size_flags_vertical = 3
[node name="PresetRow" type="HFlowContainer" parent="Margin/Layout/Right/PresetScroll"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 12
size_flags_horizontal = 3
theme_override_constants/h_separation = 10
theme_override_constants/v_separation = 8
[node name="Preset0" type="TextureButton" parent="Margin/Layout/Right/PresetRow"]
custom_minimum_size = Vector2(96, 96)
layout_mode = 2
texture_normal = ExtResource("5_face01")
ignore_texture_size = true
stretch_mode = 5
[node name="Preset1" type="TextureButton" parent="Margin/Layout/Right/PresetRow"]
custom_minimum_size = Vector2(96, 96)
layout_mode = 2
texture_normal = ExtResource("6_face02")
ignore_texture_size = true
stretch_mode = 5
[node name="DrawButton" type="Button" parent="Margin/Layout/Right"]
[node name="DrawButton" type="Button" parent="Margin/Layout/Right" unique_id=206641737]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 46)
layout_mode = 2
size_flags_horizontal = 0
text = "自己画"
[node name="Grow" type="Control" parent="Margin/Layout/Right"]
[node name="Grow" type="Control" parent="Margin/Layout/Right" unique_id=6282062]
layout_mode = 2
size_flags_vertical = 3
mouse_filter = 2
[node name="Bottom" type="HBoxContainer" parent="Margin/Layout/Right"]
[node name="Bottom" type="HBoxContainer" parent="Margin/Layout/Right" unique_id=1107875429]
layout_mode = 2
alignment = 2
theme_override_constants/separation = 12
alignment = 2
[node name="BackButton" type="Button" parent="Margin/Layout/Right/Bottom"]
[node name="BackButton" type="Button" parent="Margin/Layout/Right/Bottom" unique_id=1760710150]
unique_name_in_owner = true
custom_minimum_size = Vector2(130, 46)
layout_mode = 2
text = "返回"
[node name="ConfirmButton" type="Button" parent="Margin/Layout/Right/Bottom"]
[node name="ConfirmButton" type="Button" parent="Margin/Layout/Right/Bottom" unique_id=814522243]
unique_name_in_owner = true
custom_minimum_size = Vector2(130, 46)
layout_mode = 2
text = "确定"
[node name="PaintPanel" parent="." instance=ExtResource("2_paint")]
[node name="PaintPanel" parent="." unique_id=132648199 instance=ExtResource("2_paint")]
visible = false
layout_mode = 1
+21 -17
View File
@@ -16,12 +16,13 @@ const VIEW_SIZE := 420
@onready var _width_slider: HSlider = %WidthSlider
@onready var _width_label: Label = %WidthLabel
@onready var _eraser_toggle: CheckButton = %EraserToggle
@onready var _name_edit: LineEdit = %NameEdit
var _rig: FaceRig
## 打开面板时的笔画层快照,取消时还原
var _snapshot: PackedByteArray
## 打开面板时用的是哪张预设,取消时一并还原(「自己画」会把底图换成空白)
var _preset_before: String = ""
## 打开面板时选中的表情 ID,取消时还原
var _expression_before: String = ""
## 左键是不是按着 —— 拖拽判定用这个,不看 motion 事件里的 button_mask
var _drawing := false
@@ -41,22 +42,20 @@ func _ready() -> void:
# ---------------- 对外接口 ----------------
## 打开面板并绑定到某个 FaceRig。
## blank_face 传了就从这张空白脸起手(「自己画」用)—— 快照仍然记的是改动之前的状态,
## 所以点「取消」能把底图和笔画一起还原。
func open(rig: FaceRig, blank_face: Texture2D = null) -> void:
## 打开面板并绑定到 FaceRig。start_blank = true 时先快照再清空,取消可完整还原。
func open(rig: FaceRig, start_blank: bool = false) -> void:
if rig == null or rig.painter == null:
push_warning("画脸面板没拿到可用的 FaceRig")
return
_rig = rig
_snapshot = rig.painter.snapshot()
_preset_before = rig.preset_path
if blank_face != null:
rig.set_preset(blank_face, blank_face.resource_path)
rig.clear_paint()
_expression_before = rig.selected_expression_id
if start_blank:
rig.select_blank(true)
_canvas_rect.texture = rig.painter.texture
# 画布只显示笔画层,底下得把底图一起摆出来 —— 不然玩家是在对着空气画
_preset_rect.texture = rig.material.get_shader_parameter("base_tex") as Texture2D
%SkinRect.color = rig.skin_color
_preset_rect.texture = rig.selected_expression_texture
_name_edit.text = ""
rig.painter.brush_color = _color_button.color
rig.painter.brush_width = _width_slider.value
rig.painter.erasing = _eraser_toggle.button_pressed
@@ -113,18 +112,23 @@ func _on_clear() -> void:
func _on_done() -> void:
if _rig != null:
_rig.save()
if _rig == null:
return
var saved := _rig.save_drawing_as_expression(_name_edit.text)
if saved.is_empty():
push_warning("表情保存失败,请检查本地磁盘空间和写入权限")
return
visible = false
finished.emit()
func _on_cancel() -> void:
if _rig != null:
if not _expression_before.is_empty():
_rig.select_expression(_expression_before, false)
else:
_rig.select_blank(false)
if _snapshot.size() > 0:
_rig.painter.restore(_snapshot)
# 底图也要还原 —— 「自己画」把它换成空白了,只还原笔画会留下一张白脸
if not _preset_before.is_empty() and ResourceLoader.exists(_preset_before):
_rig.set_preset(load(_preset_before), _preset_before)
visible = false
cancelled.emit()
+19 -2
View File
@@ -57,6 +57,17 @@ custom_minimum_size = Vector2(420, 420)
layout_mode = 2
mouse_filter = 2
[node name="SkinRect" type="ColorRect" parent="Center/Panel/Margin/Box/Row/CanvasStack"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
color = Color(0.537255, 0.701961, 1, 1)
[node name="PresetRect" type="TextureRect" parent="Center/Panel/Margin/Box/Row/CanvasStack"]
unique_name_in_owner = true
layout_mode = 1
@@ -127,9 +138,15 @@ custom_minimum_size = Vector2(160, 0)
modulate = Color(1, 1, 1, 0.65)
theme_override_font_sizes/font_size = 13
text = "在左边画布上直接画。
橡皮擦掉笔画,露出来的是预设脸。"
橡皮擦掉笔画,露出来的是底下的脸。"
autowrap_mode = 3
[node name="NameEdit" type="LineEdit" parent="Center/Panel/Margin/Box/Row/Tools"]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 36)
layout_mode = 2
placeholder_text = "表情名称(可留空)"
[node name="Bottom" type="HBoxContainer" parent="Center/Panel/Margin/Box"]
layout_mode = 2
alignment = 2
@@ -145,4 +162,4 @@ text = "取消"
unique_name_in_owner = true
custom_minimum_size = Vector2(120, 40)
layout_mode = 2
text = "完成"
text = "保存到表情列表"