Files
agame/character/player/player.gd
T
2026-09-24 16:35:28 +08:00

333 lines
14 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends CharacterBody3D
## 第三人称控制器:只管「怎么动」—— 输入、移动物理、相机、血条、联机分叉。
## 表现层(动画)在 Body/PlayerAnimator 里,本文件不认识任何片段名。
##
## 场景结构:
## Player (CharacterBody3D) ← 只负责移动与碰撞,自身不旋转
## ├─ Collision (CollisionShape3D, CapsuleShape3D)
## ├─ Body (Node3D) ← 视觉层,朝移动方向转身;换角色模型时挂在它下面
## │ ├─ PlayerAnimator (PlayerAnimator.gd) ← 动作状态机:只读运动、只写动画
## │ └─ character-male-e2 (Node3D)
## │ ├─ character-male-e/Skeleton3D ← 骨骼 + 蒙皮网格(character-male-e.glb)
## │ └─ AnimationPlayer ← 动作库,由 PlayerAnimator 驱动
## ├─ CameraRig (Node3D) ← 承担偏航 yaw,独立于本体旋转
## │ └─ SpringArm (SpringArm3D) ← 承担俯仰 pitch,自动避障
## │ └─ Camera (Camera3D)
## ├─ Sync (MultiplayerSynchronizer) ← 同步 position、Body.rotation、health、动画状态
## ├─ HealthLabel (Label3D) ← 头顶血条(节点配置,不是代码建的)
## └─ InteractHint (Label3D) ← 靠近滑索时的「按 E」提示,同样在场景里配好
##
## 滑索:走到缆下按 E 挂上,沿缆滑向低端,缆上按 S 刹车、空格跳离。
## 手感参数(速度/刹车/吸附半径)全在 Zipline 组件上,本文件只读不定义 —— 见文件末尾。
##
## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,空格跳跃,E 滑索,Esc 释放/锁定鼠标
## 跳跃输入缓冲帧数(输入事件在渲染帧到达,物理帧读取,避免漏跳)
const JUMP_BUFFER_FRAMES: int = 6
@export_group("属性")
## 血量
@export var health: int = 100:
set(value):
health = clampi(value, 0, 100)
_refresh_health_label()
@export_group("移动")
## 常规移动速度(米/秒)
@export var walk_speed: float = 5.0
## 疾跑速度(米/秒)
@export var sprint_speed: float = 8.5
## 地面加速度(米/秒²)
@export var acceleration: float = 45.0
## 空中加速度(米/秒²)
@export var air_acceleration: float = 12.0
## 起跳初速度(米/秒)
@export var jump_velocity: float = 5.0
## 视觉层转身平滑速度
@export var turn_speed: float = 10.0
@export_group("相机")
## 鼠标灵敏度(弧度/像素)
@export var mouse_sensitivity: float = 0.0025
## 最低俯角(向下看)
@export var pitch_min_deg: float = -80.0
## 最高仰角(向上看)
@export var pitch_max_deg: float = 45.0
@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
## 滑索操作提示,同样在 player.tscn 里配好,默认隐藏
@onready var _hint: Label3D = $InteractHint
## 动画状态机(挂在 Body 下)。只把「运动结果」交给它,也只知道它会自己播动画。
@onready var animator: PlayerAnimator = $Body/PlayerAnimator
var _gravity: float = float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.8))
var _yaw: float = 0.0
var _pitch: float = 0.0
var _jump_buffer: int = 0
## 本节点是否由本机玩家操控。由世界节点在生成时设置 authority 决定,见 world/main.gd。
var _is_local: bool = true
## 当前挂着的滑索。非 null 时 _physics_process 走缆上分支,常规移动/重力/贴地全部让位。
## 只在本机权威端写;远端玩家的位置由 Sync 复制过来,跟着缆一起动。
var _zipline: Zipline = null
## 沿缆的进度参数 t∈[0,1]。缆的几何由 Zipline 负责,这里只记「我走到哪了」。
var _zip_t: float = 0.0
## 滑动方向:+1 往 AnchorB、-1 往 AnchorA。永远是往低的那一端
var _zip_dir: int = 1
## 沿缆速度(米/秒)
var _zip_speed: float = 0.0
## 挂上瞬间「人相对缆」的偏移,随后衰减到 0 —— 吸附是滑过去的,不是瞬移
var _zip_grab_offset: Vector3 = Vector3.ZERO
func _ready() -> void:
_report_identity()
# 动画准备不在这里 —— PlayerAnimator 有自己的 _ready(),而子节点先于本节点 ready
_is_local = is_multiplayer_authority()
if not _is_local:
# 远端玩家只是一具「回放身体」:
# 拆掉相机 —— 否则每多一个玩家就多一台 Camera3D 互相抢渲染;
# 关掉输入与物理 —— 否则与权威端双重模拟,表现为抖动、漂移、穿地。
# 它的 position 与 Body.rotation 由子节点 Sync 同步过来;
# 头顶血条保留 —— health 同样是同步过来的,这样谁都能看到别人还剩多少血。
camera_rig.queue_free()
set_physics_process(false)
set_process_unhandled_input(false)
return
_yaw = camera_rig.rotation.y
_pitch = clampf(spring_arm.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg))
spring_arm.rotation.x = _pitch
set_mouse_captured(true)
# 同步可能在 _ready() 之前就把 health 写进来了,那时血条还没刷新,这里补一次
_refresh_health_label()
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
_yaw -= event.relative.x * mouse_sensitivity
_pitch = clampf(
_pitch - event.relative.y * mouse_sensitivity,
deg_to_rad(pitch_min_deg),
deg_to_rad(pitch_max_deg)
)
camera_rig.rotation.y = _yaw
spring_arm.rotation.x = _pitch
elif event.is_action_pressed("jump"):
# 缆上按跳 = 主动脱手,额外给一个向上的小跳,方便够到旁边的平台
if _zipline != null:
_detach_zipline(true)
else:
_jump_buffer = JUMP_BUFFER_FRAMES
elif event.is_action_pressed("interact"):
if _zipline != null:
_detach_zipline()
else:
_attach_zipline()
elif event.is_action_pressed("ui_cancel"):
set_mouse_captured(Input.mouse_mode != Input.MOUSE_MODE_CAPTURED)
elif event is InputEventMouseButton and event.pressed and Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
set_mouse_captured(true)
elif event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_H:
# 只有权威端能改被同步的属性,别人改的会被覆盖回去
if is_multiplayer_authority():
health -= 10
func set_mouse_captured(captured: bool) -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED if captured else Input.MOUSE_MODE_VISIBLE
func _physics_process(delta: float) -> void:
if not _is_local:
return
# 靠近缆时给个操作提示。判定与 _attach_zipline 共用 _find_zipline(),
# 免得出现「提示亮了但按 E 挂不上」这种对不上的情况。
_hint.visible = _zipline != null or _find_zipline() != null
if _hint.visible:
_hint.text = "空格 跳离 · E 松手" if _zipline != null else "按 E 挂上滑索"
# 挂在缆上时,下面的常规重力/输入/贴地全部让位给缆上运动
if _zipline != null:
_ride_zipline(delta)
return
# --- 重力 ---
if is_on_floor():
if velocity.y < 0.0:
velocity.y = 0.0
else:
velocity.y -= _gravity * delta
# --- 跳跃(读取输入缓冲)---
if _jump_buffer > 0:
_jump_buffer -= 1
if is_on_floor():
velocity.y = jump_velocity
_jump_buffer = 0
# --- 相对相机的水平输入 ---
var input_dir := Input.get_vector("move_left", "move_right", "move_back", "move_forward")
var cam_basis := camera_rig.global_transform.basis
var wish := cam_basis.x * input_dir.x + (-cam_basis.z) * input_dir.y
wish.y = 0.0
var wants_move := wish.length() > 0.001
if wants_move:
wish = wish.normalized()
# 只转视觉层,不动本体,也不影响相机(Godot 前进方向为 -Z)
var target_yaw := atan2(-wish.x, -wish.z)
body.rotation.y = lerp_angle(body.rotation.y, target_yaw, clampf(turn_speed * delta, 0.0, 1.0))
var speed := sprint_speed if Input.is_action_pressed("sprint") else walk_speed
if not wants_move:
speed = 0.0
var accel := acceleration if is_on_floor() else air_acceleration
var horizontal := Vector2(velocity.x, velocity.z)
horizontal = horizontal.move_toward(Vector2(wish.x, wish.z) * speed, accel * delta)
velocity.x = horizontal.x
velocity.z = horizontal.y
move_and_slide()
# 动画只读运动结果。控制器不认识任何片段名,动画器也不认识输入。
animator.set_motion(velocity, is_on_floor(), Input.is_action_pressed("sprint"))
# ---------------- 滑索 ----------------
# 拆成三件事:找缆(_find_zipline)、挂上(_attach_zipline)、缆上运动(_ride_zipline)。
# 缆的几何与手感参数都在 Zipline 组件里,这里只管「人」的状态,参数只读不定义。
## 找脚下能挂的缆,找不到返回 null。挂索与「按 E」提示共用同一套判定。
func _find_zipline() -> Zipline:
var best: Zipline = null
var best_distance := INF
for node in get_tree().get_nodes_in_group(Zipline.GROUP):
var zip := node as Zipline
if zip == null or zip.length <= 0.0:
continue
var info := zip.closest_point(global_position)
# 两种情况都不挂:够不着,或者挂上就没几米可滑(后者会让人刚挂上就被弹下来)
if info.distance > zip.attach_radius or info.remaining < zip.min_ride:
continue
if info.distance < best_distance:
best_distance = info.distance
best = zip
return best
## 挂上最近的一条缆,人就近吸附到缆上最近一点。
func _attach_zipline() -> void:
var zip := _find_zipline()
if zip == null:
return
var info := zip.closest_point(global_position)
_zipline = zip
_zip_t = info.t
_zip_dir = info.dir
# 带上入缆时的水平速度,冲上去挂索不会突然一顿
_zip_speed = maxf(Vector2(velocity.x, velocity.z).length() * 0.5, 2.0)
# 把当前偏移记下来交给 _ride_zipline 逐帧衰减,避免瞬移到缆上
_zip_grab_offset = global_position - zip.ride_point(_zip_t)
velocity = Vector3.ZERO
## 脱离缆索。hop=true 时额外给一个向上的小跳(主动跳离用)。
func _detach_zipline(hop: bool = false) -> void:
if _zipline == null:
return
# 惯性带走:沿缆方向的速度原样保留,脱手后就是一次抛物线
velocity = _zipline.tangent(_zip_t) * float(_zip_dir) * _zip_speed
if hop:
velocity += Vector3.UP * 3.0
_zipline = null
_zip_speed = 0.0
## 缆上运动:沿曲线加速下滑,S 刹车,到头自动脱手。
##
## 位置直接写 global_position,不走 move_and_slide():悬空缆段上没有需要解算的碰撞,
## 让物理引擎去推反而会和「贴着曲线走」这个硬约束打架。
## velocity 仍然维护 —— 一是给动画器读,二是脱手后能带着惯性飞出去。
func _ride_zipline(delta: float) -> void:
var tan := _zipline.tangent(_zip_t) * float(_zip_dir)
# 重力沿缆方向的分量:缆越陡加速越猛,平缆上几乎不加速。
# 下坡时 tan.y 是负的,所以前面要加负号。
_zip_speed += -_gravity * _zipline.gravity_scale * tan.y * delta
if Input.is_action_pressed("move_back"):
_zip_speed -= _zipline.brake_accel * delta
_zip_speed = clampf(_zip_speed, 0.0, _zipline.max_speed)
# t 是曲线参数而不是弧长,但 sag 相对缆长很小时两者差别可以忽略
var next_t := _zip_t + float(_zip_dir) * _zip_speed / _zipline.length * delta
if next_t <= 0.0 or next_t >= 1.0:
# 到头了:先落到端点再脱手,免得停在离端点还差一截的地方
_zip_t = clampf(next_t, 0.0, 1.0)
global_position = _zipline.ride_point(_zip_t)
_detach_zipline()
return
_zip_t = next_t
_zip_grab_offset = _zip_grab_offset.lerp(Vector3.ZERO, clampf(delta / 0.12, 0.0, 1.0))
global_position = _zipline.ride_point(_zip_t) + _zip_grab_offset
velocity = tan * _zip_speed
var travel_yaw := atan2(-tan.x, -tan.z)
body.rotation.y = lerp_angle(body.rotation.y, travel_yaw, clampf(turn_speed * delta, 0.0, 1.0))
# 相机也跟着摆到行进方向背后 —— 否则人是横着飞的,看不见前面要去哪。
# 速率刻意比转身慢:玩家用鼠标看的视角不会被立刻拽回去。
_yaw = lerp_angle(_yaw, travel_yaw, clampf(3.0 * delta, 0.0, 1.0))
camera_rig.rotation.y = _yaw
# 缆上没有「落地」概念,交给动画器当下落姿态处理(模型里没有专门的滑索片段)
animator.set_motion(velocity, false, false)
func _refresh_health_label() -> void:
# 血条节点在 player.tscn 里(Hud/HealthLabel)。
# 同步可能在 _ready() 之前就把 health 写进来,那时 _health_label 还是 null,直接跳过。
if _health_label == null:
return
_health_label.text = "HP %d / 100" % health
## 关卡 0 的临时自检:把「我这一端」和「这个节点」分层打印出来。
## 联机框架调通后可以整段删掉(连同 _ready() 里的那一行调用)。
func _report_identity() -> void:
var me: int = multiplayer.get_unique_id()
var authority: int = get_multiplayer_authority()
# 传输层类型:以后换成 Steam 时这里会变成 SteamMultiplayerPeer
var transport := "无"
if multiplayer.multiplayer_peer != null:
transport = multiplayer.multiplayer_peer.get_class()
# 同步配置:字段为空或未绑定是「远端玩家僵在原地」的头号原因,所以顺手打出来
var sync := get_node_or_null("Sync") as MultiplayerSynchronizer
var fields := "未绑定"
if sync != null and sync.replication_config != null:
fields = str(sync.replication_config.get_properties())
print("""┌─ [节点自检] %s
│ 进程级 我是 peer %d | 服务器?%s | 传输 %s | 我知道的 peer:%s
│ 节点级 全路径 %s
│ 节点名 %s | 归属 peer %d | 归我管?%s
│ 同步 Sync 字段 %s
└─ 结论 %s""" % [
name,
me,
multiplayer.is_server(),
transport,
multiplayer.get_peers(),
get_path(),
name,
authority,
is_multiplayer_authority(),
fields,
"这是【我操控】的角色" if is_multiplayer_authority() else "这是【别人】的角色,我只负责回放",
])