Files
agame/character/player/player.gd
T
2026-10-02 10:34:57 +08:00

348 lines
15 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/AnimationPlayer 上(挂 player_animator.gd),本文件不认识任何片段名。
##
## 场景结构:
## Player (CharacterBody3D) ← 只负责移动与碰撞,自身不旋转
## ├─ Collision (CollisionShape3D, CapsuleShape3D)
## ├─ CameraRig (Node3D) ← 眼睛,架在 eye_height 高,承担偏航 yaw
## │ └─ Camera (Camera3D) ← 承担俯仰 pitch(第一人称,没有弹簧臂)
## ├─ Body (Node3D) ← 视觉层,始终朝视线方向;换角色模型时挂在它下面
## │ ├─ mouse (Node3D) ← 模型(mouse.glb):MouseRig/Skeleton3D + 各部位 MeshInstance3D
## │ └─ AnimationPlayer ← 动作库 + 表现层(挂 player_animator.gd):只读运动、只写动画
## ├─ Sync (MultiplayerSynchronizer) ← 同步 position、Body.rotation、health、动画状态
## ├─ HealthLabel (Label3D) ← 头顶血条(节点配置,不是代码建的)
## └─ InteractHint (Label3D) ← 靠近滑索时的「按 E」提示,同样在场景里配好
##
## 滑索:走到缆下按 E 挂上,沿缆滑向低端,缆上按 S 刹车、空格跳离。
## 手感参数(速度/刹车/吸附半径)全在 Zipline 组件上,本文件只读不定义 —— 见文件末尾。
##
## 操作:WASD/方向键移动,鼠标转视角,Shift 疾跑,Ctrl 蹲下(蹲着走即潜行),空格跳跃,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 eye_height: float = 1.6
## 第一人称下藏掉自己的模型。不藏的话相机要么在模型里、要么贴着头顶,画面糊一片。
## 想做成「看得见自己身体」的第一人称,就把这个关掉、并把 eye_height 降到模型头部高度。
## 只影响本机这一份 —— 远端玩家的模型照常显示。
@export var hide_own_body: bool = true
## 鼠标灵敏度(弧度/像素)
@export var mouse_sensitivity: float = 0.0025
## 最低俯角(向下看)。第一人称得能抬头低头,所以放到接近 ±90°
@export var pitch_min_deg: float = -89.0
## 最高仰角(向上看)
@export var pitch_max_deg: float = 89.0
@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
@onready var body: Node3D = $Body
## 眼睛分两层转:CameraRig 绕竖直轴转(yaw),Camera 原地抬头低头(pitch)。
## 分开是为了让 pitch 不掺进移动方向 —— 移动只用 rig 的 basis,低头看地也不会走歪。
@onready var camera_rig: Node3D = $CameraRig
@onready var camera: Camera3D = $CameraRig/Camera
## 头顶血条是 player.tscn 里的 Label3D 节点(3D 世界里的文字,会跟着角色走)
@onready var _health_label: Label3D = $HealthLabel
## 滑索操作提示,同样在 player.tscn 里配好,默认隐藏
@onready var _hint: Label3D = $InteractHint
## 动画状态机(挂在 Body/AnimationPlayer 上)。只把「运动结果」交给它,也只知道它会自己播动画。
@onready var animator: PlayerAnimator = $Body/AnimationPlayer
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
camera_rig.position.y = eye_height
if hide_own_body:
body.visible = false
_yaw = camera_rig.rotation.y
_pitch = clampf(camera.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg))
_apply_look()
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)
)
_apply_look()
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
## 把 _yaw / _pitch 落到节点上。鼠标一动就调一次(渲染帧),所以视角是跟手的、不等物理帧。
## 身体跟着一起转 —— 第一人称里「你看哪,身体就朝哪」,别人看你也是这个朝向。
func _apply_look() -> void:
camera_rig.rotation.y = _yaw
camera.rotation.x = _pitch
body.rotation.y = _yaw
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
# --- 相对视线方向的水平输入 ---
# 只用 rig 的 basis(只有 yaw),低头看地时前进方向不会跟着往下扎。
var input_dir := Input.get_vector("move_left", "move_right", "move_back", "move_forward")
var look_basis := camera_rig.global_transform.basis
var wish := look_basis.x * input_dir.x + (-look_basis.z) * input_dir.y
wish.y = 0.0
var wants_move := wish.length() > 0.001
if wants_move:
wish = wish.normalized()
# 身体朝向由 _apply_look() 跟着视线走 —— 第一人称不再朝移动方向转身
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"),
Input.is_action_pressed("crouch"),
)
# ---------------- 滑索 ----------------
# 拆成三件事:找缆(_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_dir := _zipline.tangent(_zip_t) * float(_zip_dir)
# 重力沿缆方向的分量:缆越陡加速越猛,平缆上几乎不加速。
# 下坡时 tan_dir.y 是负的,所以前面要加负号。
_zip_speed += -_gravity * _zipline.gravity_scale * tan_dir.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_dir * _zip_speed
# 视线跟着摆到行进方向 —— 否则人是横着飞的,看不见前面要去哪。
# 速率刻意放慢:玩家自己用鼠标看的视角不会被一下子拽回去。
_yaw = lerp_angle(_yaw, atan2(-tan_dir.x, -tan_dir.z), clampf(3.0 * delta, 0.0, 1.0))
_apply_look()
# 缆上没有「落地」概念,交给动画器当下落姿态处理(模型里没有专门的滑索片段)
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 "这是【别人】的角色,我只负责回放",
])