角色动画
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# 2026-09-23
|
||||
|
||||
## 玩家动画(KayKit character-male-e 动作库)
|
||||
|
||||
用户把玩家模型换成 `character/player/assets/character-male-e.glb`,并在
|
||||
`Player/Body/character-male-e2/` 下加了 `AnimationPlayer`(动作库内嵌在 player.tscn 里,
|
||||
共 32 个片段,11 条 track,root_node = `..`)。
|
||||
|
||||
### 关键结构事实(别猜,实测)
|
||||
|
||||
- track 路径形如 `character-male-e/Skeleton3D:root`,比玩家本体多一层
|
||||
`character-male-e/` 前缀 —— 换模型/改层级会让整组 track 静默失效。
|
||||
- glb 里所有片段的 `loop_mode` 全是 `NONE`,`idle/walk/sprint` 必须在运行时补
|
||||
`Animation.LOOP_LINEAR`,否则播一遍就定在最后一帧。
|
||||
- 片段长度:idle 1.333s / walk 0.667s / sprint 0.5s / jump 0.5s / fall 0.333s。
|
||||
jump 时长恰好约等于 `jump_velocity=5 / gravity=9.8` 的上升时间。
|
||||
|
||||
### 实现(`character/player/player.gd`)
|
||||
|
||||
- `enum AnimState { IDLE, WALK, SPRINT, JUMP, FALL }` + `ANIM_CLIPS` / `ANIM_LOOPED` 常量。
|
||||
- `_prepare_animation()` 在 `_ready` 里、**本地/远端分支之前**调用(两端都要跑)。
|
||||
- `_update_animation()` 在 `_physics_process` 末尾调用,只读 `velocity` / `is_on_floor()`。
|
||||
- 两个被同步的新属性:
|
||||
- `anim_state: int` —— setter 里切片段(`play(name, anim_blend_time)`),
|
||||
状态不变时 setter 不触发,所以 `_prepare_animation` 里必须显式 `play("idle")`。
|
||||
- `anim_speed: float` —— 播放倍率 = `水平速度 / 该状态设计速度`(walk→walk_speed,
|
||||
sprint→sprint_speed),clamp 到 `[0.65, 1.6]`。远端只有 position 算不出速度,必须复制。
|
||||
- 远端玩家物理与输入都关着,动画完全靠复制过来的 `anim_state` / `anim_speed` 回放。
|
||||
|
||||
### 同步配置
|
||||
|
||||
`character/player/player_sync.tres` 从 3 项扩到 5 项,新增
|
||||
`.:anim_state` 与 `.:anim_speed`,`replication_mode` 全部保持 1 (ALWAYS)。
|
||||
|
||||
## 踩到的坑(可复用)
|
||||
|
||||
1. **编辑器进程里 `.tres` 有 ResourceCache。** 用 MCP `filesystem_manage(op="write_text")`
|
||||
改完 `player_sync.tres` 后,`filesystem_manage(op="scan")` 也刷新不掉缓存 ——
|
||||
`test_run` 里的 `load()` 仍读到旧的内存副本(只认 3 个字段)。
|
||||
→ 测试里改用 `ResourceLoader.load(path, "", ResourceLoader.CACHE_MODE_REPLACE)`;
|
||||
它同时把正确的实例灌回缓存,之后再 `node_set_property` 赋值就拿到新实例了。
|
||||
2. **`Animation` 的 track 路径含 `:骨骼名`,不能直接 `get_node(track_path)`**
|
||||
(get_node 只认节点段)。要用
|
||||
`root.get_node(NodePath(track_path.get_concatenated_names()))`,
|
||||
骨骼名单独用 `get_concatenated_subnames()` + `Skeleton3D.find_bone()` 校验。
|
||||
3. **`game_eval` 的真实签名是 `{"op": "game_eval", "params": {"code": "..."}}`**,
|
||||
不是顶层 `code`。它支持多语句 + 内部 `await`,所以能在编辑器跑着的游戏里
|
||||
直接 `net.host_game()` → 等物理帧 → 驱动状态机 → 读回结果,是最强的端到端验证手段。
|
||||
4. 编辑器报告的 `play_state/is_playing=playing` 可能是陈旧的(`project_manage(op="stop")`
|
||||
回「was_running: false」即证)。
|
||||
|
||||
## 顺带修的东西
|
||||
|
||||
- `tests/test_net_replication.gd` 之前就坏了:路径还写着 `res://player/`(项目已迁到
|
||||
`res://character/player/`),且断言同步字段数为 2(实际 3)。已修正路径、改为断言
|
||||
5 个必需字段,并新增「片段存在」「5 个片段的每条 track 路径都能解析 + 骨骼名存在」两类测试。
|
||||
现在 5 个测试全过(143 条断言)。
|
||||
|
||||
## 未处理的既有问题
|
||||
|
||||
`res://addons/srcoder_thirdperson_controller/assets/mouse_lock.gd` 与 `res://mouse_lock.tscn`
|
||||
缺失,编辑器日志持续刷 `File not found`(约 60 条)。与本任务无关,未动。
|
||||
|
||||
## 模型朝向修正(按 W 却面朝相机)
|
||||
|
||||
**原因**:`player.gd` 的约定是「`body.rotation.y = 0` 即朝 -Z」(按 W 时
|
||||
`wish=(0,0,-1)` → `target_yaw = atan2(-wish.x,-wish.z) = 0`)。
|
||||
但 KayKit 的 `character-male-e.glb` 模型正面朝 **+Z**,
|
||||
场景里 `Body / character-male-e2 / character-male-e` 三个节点的 rotation 全是 0,
|
||||
没有做任何补偿 → 视觉上倒着走。
|
||||
|
||||
**修法**:给模型包装节点 `Body/character-male-e2` 加 `rotation.y = PI`(已 `scene_save`,
|
||||
磁盘上是 `transform = Transform3D(-2.5, 0, -2.19e-7, 0, 2.5, 0, 2.19e-7, 0, -2.5, 0, 0, 0)`)。
|
||||
|
||||
**为什么不是改代码**:
|
||||
- 往场景里给 `Body` 加 180° 没用 —— `body.rotation.y` 每帧被 `lerp_angle` 覆写。
|
||||
- 往 `target_yaw` 上加 PI 会污染 `Body` 的语义(`Body:rotation` 是要同步的「世界朝向」),
|
||||
而且静止时的初始朝向还得额外补一笔。
|
||||
- 加在模型根节点上:代码里的 -Z 约定保持干净,静止与移动两种状态同时正确。
|
||||
- 动画 track 是 `character-male-e/Skeleton3D:骨骼` 的相对路径,旋转包装节点不会破坏它们。
|
||||
|
||||
**验证**(`game_eval`,实测通过):`Input.action_press("move_forward")` 等 45 物理帧,
|
||||
用左右手臂骨算世界空间的 model forward(`Vector3.UP.cross(右轴)`),结果:
|
||||
|
||||
- `move_dir` = (0, -1)、`model_fwd` = (0.05, -1.0) → `dot = 0.999`
|
||||
- 与相机前向 `dot = 0.999` → 看到的是角色背面 ✅
|
||||
- `body_rot_deg = 0`、位移 3.51 m(0.75s × 5 m/s)✅
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,41 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://cq3awe0wjqsih"
|
||||
path.s3tc="res://.godot/imported/colormap.png-4bd9a802f0fbdd1bc388c729ce936ac0.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://character/player/assets/Textures/colormap.png"
|
||||
dest_files=["res://.godot/imported/colormap.png-4bd9a802f0fbdd1bc388c729ce936ac0.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
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=true
|
||||
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=0
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,41 @@
|
||||
[remap]
|
||||
|
||||
importer="texture"
|
||||
type="CompressedTexture2D"
|
||||
uid="uid://chawqtd3fcydp"
|
||||
path.s3tc="res://.godot/imported/texture_01.png-e765476e37c3bd9342318549a3523e3a.s3tc.ctex"
|
||||
metadata={
|
||||
"imported_formats": ["s3tc_bptc"],
|
||||
"vram_texture": true
|
||||
}
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://character/player/assets/Textures/texture_01.png"
|
||||
dest_files=["res://.godot/imported/texture_01.png-e765476e37c3bd9342318549a3523e3a.s3tc.ctex"]
|
||||
|
||||
[params]
|
||||
|
||||
compress/mode=2
|
||||
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=true
|
||||
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=0
|
||||
Binary file not shown.
@@ -0,0 +1,45 @@
|
||||
[remap]
|
||||
|
||||
importer="scene"
|
||||
importer_version=1
|
||||
type="PackedScene"
|
||||
uid="uid://ccf670c6bkob8"
|
||||
path="res://.godot/imported/character-male-e.glb-9d9c3cbc43d44547b0be72ff662d6ebd.scn"
|
||||
|
||||
[deps]
|
||||
|
||||
source_file="res://character/player/assets/character-male-e.glb"
|
||||
dest_files=["res://.godot/imported/character-male-e.glb-9d9c3cbc43d44547b0be72ff662d6ebd.scn"]
|
||||
|
||||
[params]
|
||||
|
||||
nodes/root_type=""
|
||||
nodes/root_name=""
|
||||
nodes/root_script=null
|
||||
mesh_library/use_node_names_as_mesh_names=false
|
||||
array_mesh/deduplicate_surfaces=true
|
||||
nodes/apply_root_scale=true
|
||||
nodes/root_scale=1.0
|
||||
nodes/import_as_skeleton_bones=false
|
||||
nodes/use_name_suffixes=true
|
||||
nodes/use_node_type_suffixes=true
|
||||
meshes/ensure_tangents=true
|
||||
meshes/generate_lods=true
|
||||
meshes/create_shadow_meshes=true
|
||||
meshes/light_baking=1
|
||||
meshes/lightmap_texel_size=0.2
|
||||
meshes/force_disable_compression=false
|
||||
skins/use_named_skins=true
|
||||
animation/import=true
|
||||
animation/fps=30
|
||||
animation/trimming=false
|
||||
animation/remove_immutable_tracks=true
|
||||
animation/import_rest_as_RESET=false
|
||||
import_script/path=""
|
||||
materials/extract=0
|
||||
materials/extract_format=0
|
||||
materials/extract_path=""
|
||||
_subresources={}
|
||||
gltf/naming_version=1
|
||||
gltf/embedded_image_handling=1
|
||||
gltf/texture_map_mode=0
|
||||
@@ -5,11 +5,13 @@ extends CharacterBody3D
|
||||
## Player (CharacterBody3D) ← 只负责移动与碰撞,自身不旋转
|
||||
## ├─ Collision (CollisionShape3D, CapsuleShape3D)
|
||||
## ├─ Body (Node3D) ← 视觉层,朝移动方向转身;换角色模型时挂在它下面
|
||||
## │ └─ Mesh (MeshInstance3D)
|
||||
## │ └─ character-male-e2 (Node3D)
|
||||
## │ ├─ character-male-e/Skeleton3D ← 骨骼 + 蒙皮网格(character-male-e.glb)
|
||||
## │ └─ AnimationPlayer ← 动作库:idle / walk / sprint / jump / fall
|
||||
## ├─ CameraRig (Node3D) ← 承担偏航 yaw,独立于本体旋转
|
||||
## │ └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障
|
||||
## │ └─ Camera (Camera3D)
|
||||
## ├─ Sync (MultiplayerSynchronizer) ← 同步 position 与 Body.rotation
|
||||
## ├─ Sync (MultiplayerSynchronizer) ← 同步 position、Body.rotation、health、anim_state、anim_speed
|
||||
## └─ HealthLabel (Label3D) ← 头顶血条(节点配置,不是代码建的)
|
||||
##
|
||||
## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,Esc 释放/锁定鼠标
|
||||
@@ -17,6 +19,22 @@ extends CharacterBody3D
|
||||
## 跳跃输入缓冲帧数(输入事件在渲染帧到达,物理帧读取,避免漏跳)
|
||||
const JUMP_BUFFER_FRAMES: int = 6
|
||||
|
||||
## 角色动作状态。权威端每物理帧算一次,再靠 Sync 复制给远端回放。
|
||||
enum AnimState { IDLE, WALK, SPRINT, JUMP, FALL }
|
||||
|
||||
## 状态 → 片段名。名字取自 character-male-e.glb 的动作库,改名要同步改这里。
|
||||
const ANIM_CLIPS: Dictionary = {
|
||||
AnimState.IDLE: "idle",
|
||||
AnimState.WALK: "walk",
|
||||
AnimState.SPRINT: "sprint",
|
||||
AnimState.JUMP: "jump",
|
||||
AnimState.FALL: "fall",
|
||||
}
|
||||
|
||||
## 需要循环的状态。glb 里每个片段的 loop_mode 都是 NONE,
|
||||
## 不补循环的话 idle/walk/sprint 播一遍就定在最后一帧。
|
||||
const ANIM_LOOPED: Array[int] = [AnimState.IDLE, AnimState.WALK, AnimState.SPRINT]
|
||||
|
||||
@export_group("属性")
|
||||
## 血量
|
||||
@export var health: int = 100:
|
||||
@@ -46,12 +64,24 @@ const JUMP_BUFFER_FRAMES: int = 6
|
||||
## 最高仰角(向上看)
|
||||
@export var pitch_max_deg: float = 45.0
|
||||
|
||||
@export_group("动画")
|
||||
## 动作切换的交叉淡入时间(秒)。0 为硬切
|
||||
@export var anim_blend_time: float = 0.15
|
||||
## 低于此水平速度就视为站立(米/秒)
|
||||
@export var anim_idle_threshold: float = 0.2
|
||||
## 播放倍率下限:被墙抵住、起步那一瞬,脚步不该慢到像在滑冰
|
||||
@export var anim_speed_scale_min: float = 0.65
|
||||
## 播放倍率上限:防止速度被外力拉高时脚步快到抽搐
|
||||
@export var anim_speed_scale_max: float = 1.6
|
||||
|
||||
@onready var body: Node3D = $Body
|
||||
@onready var camera_rig: Node3D = $CameraRig
|
||||
@onready var spring_arm: SpringArm3D = $CameraRig/SpringArm
|
||||
## 头顶血条是 player.tscn 里的 Label3D 节点(3D 世界里的文字,会跟着角色走)
|
||||
@onready var _health_label: Label3D = $HealthLabel
|
||||
|
||||
@onready var animation_player: AnimationPlayer = $"Body/character-male-e2/AnimationPlayer"
|
||||
|
||||
var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.8))
|
||||
var _yaw: float = 0.0
|
||||
var _pitch: float = 0.0
|
||||
@@ -59,9 +89,30 @@ var _jump_buffer: int = 0
|
||||
## 本节点是否由本机玩家操控。由世界节点在生成时设置 authority 决定,见 world/main.gd。
|
||||
var _is_local: bool = true
|
||||
|
||||
## 当前动作状态(AnimState)。
|
||||
## 权威端每物理帧写一次,Sync 把它复制给远端 —— 远端物理与输入都关着,
|
||||
## 只有靠这个值才知道该播哪个片段。
|
||||
var anim_state: int = AnimState.IDLE:
|
||||
set(value):
|
||||
if anim_state == value:
|
||||
return
|
||||
anim_state = value
|
||||
_apply_anim_state()
|
||||
|
||||
## 当前播放倍率(1.0 = 片段原速)。
|
||||
## 同样要复制:远端只有 position,算不出真实速度,只能照抄权威端的倍率。
|
||||
var anim_speed: float = 1.0:
|
||||
set(value):
|
||||
value = clampf(value, anim_speed_scale_min, anim_speed_scale_max)
|
||||
anim_speed = value
|
||||
if animation_player != null:
|
||||
animation_player.speed_scale = value
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_report_identity()
|
||||
# 本地端与远端都要跑:本地端播自己的动作,远端靠复制来的 anim_state 回放
|
||||
_prepare_animation()
|
||||
_is_local = is_multiplayer_authority()
|
||||
if not _is_local:
|
||||
# 远端玩家只是一具「回放身体」:
|
||||
@@ -149,6 +200,8 @@ func _physics_process(delta: float) -> void:
|
||||
|
||||
move_and_slide()
|
||||
|
||||
_update_animation()
|
||||
|
||||
|
||||
func _refresh_health_label() -> void:
|
||||
# 血条节点在 player.tscn 里(Hud/HealthLabel)。
|
||||
@@ -158,6 +211,63 @@ func _refresh_health_label() -> void:
|
||||
_health_label.text = "HP %d / 100" % health
|
||||
|
||||
|
||||
## 准备动作库。两个端点都要跑,且必须在本地/远端分支之前。
|
||||
func _prepare_animation() -> void:
|
||||
if animation_player == null:
|
||||
push_warning("%s 下没有 AnimationPlayer,角色不会有动画" % name)
|
||||
return
|
||||
for state in ANIM_LOOPED:
|
||||
var clip: String = ANIM_CLIPS[state]
|
||||
if not animation_player.has_animation(clip):
|
||||
continue
|
||||
animation_player.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
|
||||
# 先站在 idle。之后 state 不变时 setter 不会重进去,所以这一句不能省。
|
||||
animation_player.play(ANIM_CLIPS[AnimState.IDLE], 0.0)
|
||||
|
||||
|
||||
## 权威端每物理帧调用:从本帧的真实运动推出动作状态与播放倍率。
|
||||
## 只读 velocity / is_on_floor(),不写任何物理量。
|
||||
func _update_animation() -> void:
|
||||
var h_speed := Vector2(velocity.x, velocity.z).length()
|
||||
|
||||
var next_state: int
|
||||
if not is_on_floor():
|
||||
# 上升用 jump、越过顶点用 fall。jump 片段长 0.5s,
|
||||
# 恰好约等于 jump_velocity 5 / 重力 9.8 的上升时间。
|
||||
next_state = AnimState.JUMP if velocity.y > 0.0 else AnimState.FALL
|
||||
elif h_speed < anim_idle_threshold:
|
||||
next_state = AnimState.IDLE
|
||||
elif Input.is_action_pressed("sprint"):
|
||||
next_state = AnimState.SPRINT
|
||||
else:
|
||||
next_state = AnimState.WALK
|
||||
|
||||
# 先把倍率写下去,再切片段 —— 顺序反了会让新片段用一帧旧倍率。
|
||||
var ref := _anim_reference_speed(next_state)
|
||||
anim_speed = h_speed / ref if ref > 0.0 else 1.0
|
||||
anim_state = next_state # setter 负责切片段
|
||||
|
||||
|
||||
## 该状态对应的「设计速度」。片段在 ref 速度下按 1.0 倍播放,快慢全靠比例缩。
|
||||
func _anim_reference_speed(state: int) -> float:
|
||||
match state:
|
||||
AnimState.WALK:
|
||||
return walk_speed
|
||||
AnimState.SPRINT:
|
||||
return sprint_speed
|
||||
_:
|
||||
return 0.0
|
||||
|
||||
|
||||
func _apply_anim_state() -> void:
|
||||
if animation_player == null:
|
||||
return
|
||||
var clip: String = ANIM_CLIPS.get(anim_state, "")
|
||||
if clip.is_empty() or not animation_player.has_animation(clip):
|
||||
return
|
||||
animation_player.play(clip, anim_blend_time)
|
||||
|
||||
|
||||
## 关卡 0 的临时自检:把「我这一端」和「这个节点」分层打印出来。
|
||||
## 联机框架调通后可以整段删掉(连同 _ready() 里的那一行调用)。
|
||||
func _report_identity() -> void:
|
||||
File diff suppressed because one or more lines are too long
@@ -10,3 +10,9 @@ properties/1/replication_mode = 1
|
||||
properties/2/path = NodePath(".:health")
|
||||
properties/2/spawn = true
|
||||
properties/2/replication_mode = 1
|
||||
properties/3/path = NodePath(".:anim_state")
|
||||
properties/3/spawn = true
|
||||
properties/3/replication_mode = 1
|
||||
properties/4/path = NodePath(".:anim_speed")
|
||||
properties/4/spawn = true
|
||||
properties/4/replication_mode = 1
|
||||
@@ -1,53 +0,0 @@
|
||||
[gd_scene format=3 uid="uid://bekuuw153mv03"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://f5ptp3is4agf" path="res://player/player.gd" id="1_oul6g"]
|
||||
[ext_resource type="SceneReplicationConfig" uid="uid://c4ubbxjfrrs47" path="res://player/player_sync.tres" id="2_rkbax"]
|
||||
|
||||
[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_rkbax"]
|
||||
radius = 0.4
|
||||
height = 1.8
|
||||
|
||||
[sub_resource type="CapsuleMesh" id="CapsuleMesh_g1dw6"]
|
||||
radius = 0.4
|
||||
height = 1.8
|
||||
radial_segments = 24
|
||||
|
||||
[sub_resource type="StandardMaterial3D" id="StandardMaterial3D_yw30f"]
|
||||
albedo_color = Color(0.23137255, 0.50980395, 0.9647059, 1)
|
||||
roughness = 0.6
|
||||
|
||||
[node name="Player" type="CharacterBody3D" unique_id=869687860]
|
||||
script = ExtResource("1_oul6g")
|
||||
|
||||
[node name="Collision" type="CollisionShape3D" parent="." unique_id=215132582]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0)
|
||||
shape = SubResource("CapsuleShape3D_rkbax")
|
||||
|
||||
[node name="CameraRig" type="Node3D" parent="." unique_id=1774966304]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.45, 0)
|
||||
|
||||
[node name="SpringArm" type="SpringArm3D" parent="CameraRig" unique_id=917938711]
|
||||
transform = Transform3D(1, 0, 0, 0, 0.9659257, 0.25881964, 0, -0.25881964, 0.9659257, 0, 0, 0)
|
||||
spring_length = 4.0
|
||||
|
||||
[node name="Camera" type="Camera3D" parent="CameraRig/SpringArm" unique_id=52294557]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 4)
|
||||
fov = 82.2
|
||||
|
||||
[node name="Body" type="Node3D" parent="." unique_id=394283421]
|
||||
|
||||
[node name="Mesh" type="MeshInstance3D" parent="Body" unique_id=1230094944]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.9, 0)
|
||||
mesh = SubResource("CapsuleMesh_g1dw6")
|
||||
surface_material_override/0 = SubResource("StandardMaterial3D_yw30f")
|
||||
|
||||
[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1979575210]
|
||||
replication_config = ExtResource("2_rkbax")
|
||||
|
||||
[node name="HealthLabel" type="Label3D" parent="." unique_id=300336561]
|
||||
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 2.05, 0)
|
||||
billboard = 1
|
||||
no_depth_test = true
|
||||
text = "HP 100 / 100"
|
||||
font_size = 64
|
||||
outline_size = 8
|
||||
+1
-1
@@ -11,7 +11,7 @@ config_version=5
|
||||
[application]
|
||||
|
||||
config/name="agame"
|
||||
run/main_scene="res://world/main.tscn"
|
||||
run/main_scene="uid://bp8l1uwgshc86"
|
||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||
config/icon="res://icon.svg"
|
||||
|
||||
|
||||
+113
-19
@@ -1,32 +1,51 @@
|
||||
@tool
|
||||
extends McpTestSuite
|
||||
## 回归测试:Player 的同步配置必须真的被解析成条目。
|
||||
## 回归测试:Player 的同步配置与动作库必须真的被解析。
|
||||
##
|
||||
## 这是一处典型的「静默失效」:如果 .tres 里的键名写错,或 replication_mode
|
||||
## 落到 NEVER,远端玩家会僵在原地不动,而且不打任何错误。用测试钉住它。
|
||||
## 两处典型的「静默失效」:
|
||||
## 1. player_sync.tres 键名写错 / replication_mode 落到 NEVER
|
||||
## → 远端玩家僵在原地,而且不打任何错误。
|
||||
## 2. 动作库是从 .glb 拷进来的,track 路径带一层 character-male-e/ 前缀。
|
||||
## 一旦模型换名、层级变动、蒙皮骨骼改名,AnimationPlayer 会「有片段但什么都播不出来」,
|
||||
## 同样不报错。
|
||||
|
||||
const CONFIG_PATH := "res://player/player_sync.tres"
|
||||
const PLAYER_SCENE_PATH := "res://player/player.tscn"
|
||||
const CONFIG_PATH := "res://character/player/player_sync.tres"
|
||||
const PLAYER_SCENE_PATH := "res://character/player/player.tscn"
|
||||
const ANIM_PLAYER_PATH := "Body/character-male-e2/AnimationPlayer"
|
||||
|
||||
## player.gd 的 ANIM_CLIPS 写死了这几个名字,少一个对应状态就会静默不动。
|
||||
const REQUIRED_CLIPS: Array[String] = ["idle", "walk", "sprint", "jump", "fall"]
|
||||
|
||||
## 必须被复制到远端的字段。远端物理与输入都关着,全靠这几个值回放。
|
||||
const REQUIRED_SYNCED: Array[String] = [
|
||||
".:position",
|
||||
"Body:rotation",
|
||||
".:health",
|
||||
".:anim_state",
|
||||
".:anim_speed",
|
||||
]
|
||||
|
||||
|
||||
func suite_name() -> String:
|
||||
return "net"
|
||||
|
||||
|
||||
func test_config_has_expected_paths() -> void:
|
||||
var config := load(CONFIG_PATH) as SceneReplicationConfig
|
||||
assert_true(config != null, "player_sync.tres 未能加载为 SceneReplicationConfig")
|
||||
func test_config_has_required_synced_fields() -> void:
|
||||
var config := _load_config()
|
||||
assert_true(config != null, "%s 未能加载为 SceneReplicationConfig" % CONFIG_PATH)
|
||||
if config == null:
|
||||
return
|
||||
var paths := config.get_properties()
|
||||
assert_eq(paths.size(), 2, "同步字段数量不对:%s" % [paths])
|
||||
assert_true(config.has_property(^".:position"), "缺少 .:position")
|
||||
assert_true(config.has_property(^"Body:rotation"), "缺少 Body:rotation")
|
||||
for required in REQUIRED_SYNCED:
|
||||
assert_true(
|
||||
config.has_property(NodePath(required)),
|
||||
"缺少同步字段 %s,实际为 %s" % [required, paths]
|
||||
)
|
||||
|
||||
|
||||
func test_config_replication_mode_is_always() -> void:
|
||||
var config := load(CONFIG_PATH) as SceneReplicationConfig
|
||||
assert_true(config != null, "player_sync.tres 未能加载")
|
||||
var config := _load_config()
|
||||
assert_true(config != null, "%s 未能加载" % CONFIG_PATH)
|
||||
if config == null:
|
||||
return
|
||||
var paths := config.get_properties()
|
||||
@@ -35,20 +54,95 @@ func test_config_replication_mode_is_always() -> void:
|
||||
assert_eq(
|
||||
config.property_get_replication_mode(path),
|
||||
SceneReplicationConfig.REPLICATION_MODE_ALWAYS,
|
||||
"%s 的 replication_mode 不是 ALWAYS" % path
|
||||
"%s 的 replication_mode 不是 ALWAYS(改为 NEVER 会静默断掉同步)" % path
|
||||
)
|
||||
|
||||
|
||||
func test_player_scene_wires_synchronizer() -> void:
|
||||
var packed := load(PLAYER_SCENE_PATH) as PackedScene
|
||||
assert_true(packed != null, "player.tscn 未能加载")
|
||||
if packed == null:
|
||||
var player := _instantiate_player()
|
||||
if player == null:
|
||||
return
|
||||
var player := packed.instantiate()
|
||||
track(player)
|
||||
var sync := player.get_node_or_null("Sync") as MultiplayerSynchronizer
|
||||
assert_true(sync != null, "Player 下缺少 Sync 节点")
|
||||
if sync == null:
|
||||
return
|
||||
assert_true(sync.replication_config != null, "Sync 没有绑定 SceneReplicationConfig")
|
||||
assert_eq(sync.root_path, ^"..", "Sync.root_path 应为 .. (相对 Player)")
|
||||
if sync.replication_config != null:
|
||||
assert_eq(
|
||||
sync.replication_config.resource_path,
|
||||
CONFIG_PATH,
|
||||
"Sync 绑定的是别的同步配置:%s" % sync.replication_config.resource_path
|
||||
)
|
||||
|
||||
|
||||
func test_required_animation_clips_exist() -> void:
|
||||
var anim_player := _find_animation_player()
|
||||
if anim_player == null:
|
||||
return
|
||||
for clip in REQUIRED_CLIPS:
|
||||
assert_true(
|
||||
anim_player.has_animation(clip),
|
||||
"动作库里缺少片段 %s(player.gd 的 ANIM_CLIPS 会指向不存在的片段)" % clip
|
||||
)
|
||||
|
||||
|
||||
## 片段是从 glb 里拷出来的,track 路径带层级前缀。换模型/改层级后整组 track 会一起失效,
|
||||
## 而 AnimationPlayer 不报任何错 —— 现象是「人站着不动」。用测试钉住路径真的能解析。
|
||||
func test_animation_track_paths_resolve() -> void:
|
||||
var anim_player := _find_animation_player()
|
||||
if anim_player == null:
|
||||
return
|
||||
var root := anim_player.get_node_or_null(anim_player.root_node)
|
||||
assert_true(root != null, "AnimationPlayer.root_node 解析不到:%s" % anim_player.root_node)
|
||||
if root == null:
|
||||
return
|
||||
|
||||
# 逐帧验证 5 个要用到的片段,任何一个 track 断了都会让该动作失效
|
||||
for clip in REQUIRED_CLIPS:
|
||||
var anim := anim_player.get_animation(clip)
|
||||
if anim == null:
|
||||
continue
|
||||
assert_gt(anim.get_track_count(), 0, "%s 片段没有任何 track" % clip)
|
||||
for i in anim.get_track_count():
|
||||
var track_path := anim.track_get_path(i)
|
||||
# 不能直接 get_node(track_path):路径含 ":骨骼名" 属性部分,get_node 只认节点段
|
||||
var owner_node := root.get_node_or_null(NodePath(track_path.get_concatenated_names()))
|
||||
assert_true(
|
||||
owner_node != null,
|
||||
"%s 的 track %s 在 %s 下解析不到(动画会静默无效)" % [clip, track_path, anim_player.root_node]
|
||||
)
|
||||
if owner_node == null:
|
||||
continue
|
||||
# 属性段是骨骼名,模型换皮时要确保骨骼名没变
|
||||
var bone := str(track_path.get_concatenated_subnames())
|
||||
if owner_node is Skeleton3D and not bone.is_empty():
|
||||
assert_true(
|
||||
(owner_node as Skeleton3D).find_bone(bone) != -1,
|
||||
"%s 引用了骨骼 %s,但骨架里没有这根骨头(模型换过了?)" % [clip, bone]
|
||||
)
|
||||
|
||||
|
||||
## 直接用 load() 拿到的是编辑器进程里的内存副本 —— 外部刚改过磁盘时它可能还是旧的,
|
||||
## 测试会因此假绿或假红。这里强制按磁盘重读,断言的是文件本身。
|
||||
func _load_config() -> SceneReplicationConfig:
|
||||
return ResourceLoader.load(CONFIG_PATH, "", ResourceLoader.CACHE_MODE_REPLACE) as SceneReplicationConfig
|
||||
|
||||
|
||||
func _find_animation_player() -> AnimationPlayer:
|
||||
var player := _instantiate_player()
|
||||
if player == null:
|
||||
return null
|
||||
var anim_player := player.get_node_or_null(ANIM_PLAYER_PATH) as AnimationPlayer
|
||||
assert_true(anim_player != null, "找不到 %s" % ANIM_PLAYER_PATH)
|
||||
return anim_player
|
||||
|
||||
|
||||
func _instantiate_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
|
||||
|
||||
+3
-2
@@ -3,8 +3,9 @@ extends Node3D
|
||||
##
|
||||
## 只做一件事:把玩家场景注册给 MultiplayerSpawner,并把 spawner 托管给 Net 单例。
|
||||
## 玩家的生成/回收时机完全由 Net 决定,本文件不主动调用 spawn。
|
||||
@export
|
||||
var player_scene: PackedScene
|
||||
|
||||
const PLAYER_SCENE: PackedScene = preload("res://player/player.tscn")
|
||||
const PLAYER_GROUP: String = "net_player"
|
||||
## 用节点路径而不是全局标识符访问单例:
|
||||
## autoload 的全局名只在引擎启动时注册,这样写不依赖注册时机。
|
||||
@@ -26,7 +27,7 @@ func _ready() -> void:
|
||||
## 这样「谁是本地玩家」在各端结论一致,玩家脚本里的 is_multiplayer_authority() 才可靠。
|
||||
## recursive=true 很关键 —— 节点 authority 不自动继承,Sync 子节点必须一起设。
|
||||
func _create_player(peer_id: int) -> Node:
|
||||
var player := PLAYER_SCENE.instantiate()
|
||||
var player := player_scene.instantiate()
|
||||
player.name = str(peer_id)
|
||||
player.add_to_group(PLAYER_GROUP)
|
||||
player.set_multiplayer_authority(peer_id, true)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[gd_scene format=3 uid="uid://bp8l1uwgshc86"]
|
||||
|
||||
[ext_resource type="Script" uid="uid://11rbhffe5dhv" path="res://world/main.gd" id="1_hyim0"]
|
||||
[ext_resource type="PackedScene" uid="uid://bekuuw153mv03" path="res://character/player/player.tscn" id="2_4f7fj"]
|
||||
[ext_resource type="Sky" uid="uid://bi632suyifms5" path="res://world/sky/cartoon_sky.tres" id="2_cartoon"]
|
||||
|
||||
[sub_resource type="BoxShape3D" id="BoxShape3D_hyim0"]
|
||||
@@ -23,6 +24,7 @@ reflected_light_source = 2
|
||||
|
||||
[node name="Main" type="Node3D" unique_id=58155864]
|
||||
script = ExtResource("1_hyim0")
|
||||
player_scene = ExtResource("2_4f7fj")
|
||||
|
||||
[node name="Ground" type="StaticBody3D" parent="." unique_id=305091665]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user