From 66d87d48d640c58221fd3395f730610abc7ebea9 Mon Sep 17 00:00:00 2001 From: yvmou_ Date: Fri, 9 Oct 2026 15:10:44 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=84=E4=BB=B6=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .workbuddy/memory/2026-10-09.md | 10 ++ character/player/player.gd | 162 ++++++-------------------- character/player/player.tscn | 23 +++- character/player/player_look.gd | 54 +++++++++ character/player/player_look.gd.uid | 1 + character/player/player_motor.gd | 83 +++++++++++++ character/player/player_motor.gd.uid | 1 + character/player/player_pickup.gd | 54 +++++++++ character/player/player_pickup.gd.uid | 1 + 9 files changed, 260 insertions(+), 129 deletions(-) create mode 100644 character/player/player_look.gd create mode 100644 character/player/player_look.gd.uid create mode 100644 character/player/player_motor.gd create mode 100644 character/player/player_motor.gd.uid create mode 100644 character/player/player_pickup.gd create mode 100644 character/player/player_pickup.gd.uid diff --git a/.workbuddy/memory/2026-10-09.md b/.workbuddy/memory/2026-10-09.md index 8de9873..ff9eb88 100644 --- a/.workbuddy/memory/2026-10-09.md +++ b/.workbuddy/memory/2026-10-09.md @@ -9,3 +9,13 @@ - 最终形态:准心 = 屏幕中心一个点,无十字、无样式开关(`enum Style` 已移除)。 - 导出参数:`dot_color` / `dot_radius`(默认 2.5) / `outline` / `outline_color` / `outline_width`。 - `_draw()` 先画描边圆再画主体圆,保证浅背景可见。 + +## player.gd 拆解方案(待执行) +- 现状:`character/player/player.gd` 155 行,混了视角 / 移动跳跃 / 拾取 / 多人权限四类职责。 +- 方案:**子节点组件化**(与既有 `PlayerAnimator` 同模式)。根节点降为协调器(~40 行),新增 `Look` / `Motor` / `Pickup` 三个 Node 子节点,各自挂 `player_look.gd` / `player_motor.gd` / `player_pickup.gd`。 +- 三条硬约束:① `move_and_slide()` 留在根节点,Motor 只写 `body.velocity`;② 物理顺序由根节点显式编排(motor.step → move_and_slide → animator → pickup),不让子节点各写 `_physics_process`;③ 非 authority 时三个组件的输入/物理必须一并关闭。 +- 范式定位:组合优于继承 + 组件模式(Component Pattern);协调器部分带 Facade/Mediator 色彩;`setup()` 注入属手工依赖注入。**不是 ECS**(Godot 节点是「带行为的对象」,非纯数据组件)。 +- 状态:**已落地**(2026-10-09)。新增 `player_look.gd` / `player_motor.gd` / `player_pickup.gd`(均带 `class_name`:PlayerLook / PlayerMotor / PlayerPickup);`player.gd` 重写为协调器(~65 行);`player.tscn` 追加 Look / Motor / Pickup 三个 Node 子节点(挂 FaceRig 之后,ext_resource id 13_look / 14_motor / 15_pickup,未写 uid 由 Godot 补)。 +- 场景格式注意:本项目的 .tscn 节点带非标准 `unique_id=`(Godot 4.7 特性),`unique_id` 与 `load_steps` 均可省略;`paint_panel.tscn` 就完全没写,可作参照。 +- 已确认无外部脚本引用被搬走的属性(仅 `world/main.tscn` 通过场景 uid 引用玩家场景)。 +- 顺手去掉了原 `_pickObject` 里的两行 `print()` 调试输出。 diff --git a/character/player/player.gd b/character/player/player.gd index 478f624..44facd6 100644 --- a/character/player/player.gd +++ b/character/player/player.gd @@ -1,45 +1,21 @@ extends CharacterBody3D - -## 跳跃输入缓冲(秒):按下跳跃后这段时间内若落地,自动补上一次起跳 -const JUMP_BUFFER_TIME: float = 0.1 - -@export_group("视角") -## 鼠标灵敏度(弧度/像素) -@export var mouse_sensitivity: float = 0.0025 -## 最低俯角(向下看) -@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 +## 玩家协调器:只做三件事 —— 多人权限、依赖注入、按固定顺序编排各组件。 +## +## 具体能力全在子节点组件里: +## Look 视角 character/player/player_look.gd +## Motor 移动跳跃 character/player/player_motor.gd +## Pickup 拾取 character/player/player_pickup.gd +## Animator 动画表现 character/player/player_animator.gd(挂在 Body/AnimationPlayer 上) +## +## 本脚本不实现任何操作,只负责「谁先谁后」和「谁有权跑」。 @onready var body: Node3D = $Body -## 眼睛分两层转:CameraRig 绕竖直轴转(yaw),Camera 原地抬头低头(pitch)。 -## 分开是为了让 pitch 不掺进移动方向 —— 移动只用 rig 的 basis,低头看地也不会走歪。 @onready var camera_rig: Node3D = $CameraRig @onready var camera: Camera3D = $CameraRig/Camera -## 动画状态机。控制器只把「运动结果」交给它,它也只管自己播动画。 -@onready var animator: PlayerAnimator = $Body/AnimationPlayer -@onready var interaction_pos: Marker3D = $CameraRig/Camera/InteractionPos -@onready var interaction_ray: RayCast3D = $CameraRig/Camera/InteractionRay - -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: float = 0.0 -# 拾取 -var _pickObj = null -var _pickPower = 10 +@onready var animation_player: PlayerAnimator = $AnimationPlayer +@onready var look: PlayerLook = $Look +@onready var motor: PlayerMotor = $Motor +@onready var pickup: PlayerPickup = $Pickup func _ready() -> void: @@ -47,109 +23,47 @@ func _ready() -> void: # 拆掉相机 —— 否则每多一个玩家就多一台 Camera3D 抢渲染; # 关掉输入与物理 —— 否则与权威端双重模拟,表现为抖动、漂移、穿地。 # 它的 position 与 Body.rotation 由 Sync 子节点同步;血条由 health 同步。 + # + # 注意:输入已经分散到各组件里,所以这里必须把组件也一起关掉 —— + # 只关自己是不够的,否则远端玩家照样读本机输入。 if not is_multiplayer_authority(): camera_rig.queue_free() set_physics_process(false) set_process_unhandled_input(false) + look.set_process_unhandled_input(false) + motor.set_process_unhandled_input(false) + pickup.set_process_unhandled_input(false) return - _yaw = camera_rig.rotation.y - _pitch = clampf(camera.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg)) - _apply_look() + # 依赖注入:组件不自己去 get_node,由这里接线。 + # 好处是组件不认识玩家的场景结构,换场景 / 单测时注入别的节点即可。 + look.setup(camera_rig, camera, body) + motor.setup(self, camera_rig) + pickup.setup( + camera.get_node("InteractionRay") as RayCast3D, + camera.get_node("InteractionPos") as Marker3D + ) set_mouse_captured(true) 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"): - _jump_buffer = JUMP_BUFFER_TIME - elif event.is_action_pressed("ui_cancel"): + # 这里只留「跨组件的全局开关」,具体操作归各组件自己处理。 + if 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: + elif event is InputEventMouseButton and event.pressed \ + and Input.mouse_mode != Input.MOUSE_MODE_CAPTURED: set_mouse_captured(true) - - if event is InputEventMouseButton and event.is_action_pressed("carry"): - if _pickObj == null: - _pickObject() - else: - _removePickObject() + 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 is_on_floor(): - if velocity.y < 0.0: - velocity.y = 0.0 - else: - velocity.y -= _gravity * delta - - # --- 跳跃(读取输入缓冲)--- - if _jump_buffer > 0.0: - _jump_buffer -= delta - if is_on_floor(): - velocity.y = jump_velocity - _jump_buffer = 0.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 - if wish.length_squared() > 0.000001: - wish = wish.normalized() - - # 只有真正按了方向键才允许疾跑(避免原地"疾跑"触发跑动动画) - var sprinting := Input.is_action_pressed("sprint") and input_dir.length_squared() > 0.000001 - var target_speed := sprint_speed if sprinting else walk_speed - 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) * target_speed, accel * delta) - velocity.x = horizontal.x - velocity.z = horizontal.y - + # 顺序固定:算速度 → 推进 → 播动画 → 搬物体。 + # 由根节点统一编排,各组件自己不许写 _physics_process —— + # 否则顺序取决于节点树顺序,改个节点位置就会出现「先滑再算速度」这类隐形 bug。 + motor.step(delta) move_and_slide() - - # 动画只读运动结果。控制器不认识任何片段名,动画器也不认识输入。 - animator.set_motion( - velocity, - is_on_floor(), - sprinting, - Input.is_action_pressed("crouch"), - ) - - # 物体拖动 - if _pickObj != null: - var pickObjPos = _pickObj.global_position - var pickPlacePos = interaction_pos.global_position - _pickObj.set_linear_velocity((pickPlacePos - pickObjPos) * _pickPower) - - -func _pickObject() -> void: - var pickCollider = interaction_ray.get_collider() - print(pickCollider) - if pickCollider != null and pickCollider is RigidBody3D: - print(pickCollider) - _pickObj = pickCollider - -func _removePickObject() -> void: - _pickObj = null + animation_player.set_motion(velocity, is_on_floor(), motor.sprinting, motor.crouching) + pickup.physics_step() diff --git a/character/player/player.tscn b/character/player/player.tscn index 8e026b7..c2c581c 100644 --- a/character/player/player.tscn +++ b/character/player/player.tscn @@ -13,6 +13,9 @@ [ext_resource type="AnimationLibrary" uid="uid://dy8a178vlbsof" path="res://character/player/assets/animations/sneak.glb" id="8_jo6ux"] [ext_resource type="AnimationLibrary" uid="uid://db83440o2cyr7" path="res://character/player/assets/animations/walk.glb" id="9_fkbv7"] [ext_resource type="Script" uid="uid://c6l2fl5jgvr1h" path="res://character/player/face/face_rig.gd" id="12_lqp71"] +[ext_resource type="Script" uid="uid://bo1hob2whrlu0" path="res://character/player/player_look.gd" id="13_look"] +[ext_resource type="Script" uid="uid://xta8h2snalsd" path="res://character/player/player_motor.gd" id="14_motor"] +[ext_resource type="Script" uid="uid://b3ko4cfjf0vgu" path="res://character/player/player_pickup.gd" id="15_pickup"] [sub_resource type="CapsuleShape3D" id="CapsuleShape3D_rkbax"] radius = 0.54541016 @@ -50,6 +53,7 @@ grow_horizontal = 2 grow_vertical = 2 size_flags_horizontal = 4 size_flags_vertical = 4 +mouse_filter = 2 script = ExtResource("2_vuhga") [node name="Body" type="Node3D" parent="." unique_id=394283421] @@ -57,8 +61,11 @@ script = ExtResource("2_vuhga") [node name="mouse" parent="Body" unique_id=1995806863 instance=ExtResource("2_wu8e1")] transform = Transform3D(-0.18240134, 0, -1.5946032e-08, 0, 0.18240134, 0, 1.5946032e-08, 0, -0.18240134, 0, 0.8705118, 0) -[node name="AnimationPlayer" type="AnimationPlayer" parent="Body" unique_id=1656879850] -root_node = NodePath("../mouse") +[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1979575210] +replication_config = ExtResource("3_wu8e1") + +[node name="AnimationPlayer" type="AnimationPlayer" parent="." unique_id=1656879850] +root_node = NodePath("../Body/mouse") libraries/crouch = ExtResource("4_g20gq") libraries/fall = ExtResource("4_hr31g") libraries/idle = ExtResource("5_lqp71") @@ -68,8 +75,14 @@ libraries/sneak = ExtResource("8_jo6ux") libraries/walk = ExtResource("9_fkbv7") script = ExtResource("3_g20gq") -[node name="Sync" type="MultiplayerSynchronizer" parent="." unique_id=1979575210] -replication_config = ExtResource("3_wu8e1") - [node name="FaceRig" type="Node" parent="." unique_id=792293233] script = ExtResource("12_lqp71") + +[node name="Look" type="Node" parent="." unique_id=1738452096] +script = ExtResource("13_look") + +[node name="Motor" type="Node" parent="." unique_id=1043927815] +script = ExtResource("14_motor") + +[node name="Pickup" type="Node" parent="." unique_id=1587230140] +script = ExtResource("15_pickup") diff --git a/character/player/player_look.gd b/character/player/player_look.gd new file mode 100644 index 0000000..148496e --- /dev/null +++ b/character/player/player_look.gd @@ -0,0 +1,54 @@ +class_name PlayerLook +extends Node +## 视角组件:把鼠标位移转成 CameraRig 的 yaw 与 Camera 的 pitch。 +## 只管转摄像机 —— 不碰速度、不碰动画。 +## +## 接法:Player 下挂一个普通 Node,脚本挂上去;由 Player._ready() 调 setup() 注入依赖。 + +@export_group("视角") +## 鼠标灵敏度(弧度/像素) +@export var mouse_sensitivity: float = 0.0025 +## 最低俯角(向下看) +@export var pitch_min_deg: float = -89.0 +## 最高仰角(向上看) +@export var pitch_max_deg: float = 89.0 + +## 眼睛分两层转:CameraRig 绕竖直轴转(yaw),Camera 原地抬头低头(pitch)。 +## 分开是为了让 pitch 不掺进移动方向 —— 移动只用 rig 的 basis,低头看地也不会走歪。 +var _yaw: float = 0.0 +var _pitch: float = 0.0 + +var _rig: Node3D +var _camera: Camera3D +var _body: Node3D + + +## 由 Player 在 _ready() 注入依赖。组件不自己 get_node,才能脱离具体场景复用。 +func setup(rig: Node3D, camera: Camera3D, body: Node3D) -> void: + _rig = rig + _camera = camera + _body = body + _yaw = rig.rotation.y + _pitch = clampf(camera.rotation.x, deg_to_rad(pitch_min_deg), deg_to_rad(pitch_max_deg)) + _apply() + + +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() + + +## 把 _yaw / _pitch 落到节点上。鼠标一动就调一次(渲染帧),所以视角跟手、不等物理帧。 +## 身体跟着一起转 —— 第一人称里「你看哪,身体就朝哪」,别人看你也是这个朝向。 +func _apply() -> void: + if _rig == null: + return + _rig.rotation.y = _yaw + _camera.rotation.x = _pitch + _body.rotation.y = _yaw diff --git a/character/player/player_look.gd.uid b/character/player/player_look.gd.uid new file mode 100644 index 0000000..df661bd --- /dev/null +++ b/character/player/player_look.gd.uid @@ -0,0 +1 @@ +uid://bo1hob2whrlu0 diff --git a/character/player/player_motor.gd b/character/player/player_motor.gd new file mode 100644 index 0000000..c292c65 --- /dev/null +++ b/character/player/player_motor.gd @@ -0,0 +1,83 @@ +class_name PlayerMotor +extends Node +## 移动组件:重力、跳跃缓冲、水平加减速。 +## +## 只算速度写回 body.velocity,**不调 move_and_slide()** —— 推进由 Player 统一做。 +## 这样「算速度 → 推进 → 播动画」的顺序永远固定,不受节点树顺序影响。 + +## 跳跃输入缓冲(秒):按下跳跃后这段时间内若落地,自动补上一次起跳 +const JUMP_BUFFER_TIME: float = 0.1 + +@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 + +## 本帧是否在疾跑 / 是否按住蹲键。Player 读这两个值转交给 PlayerAnimator。 +var sprinting: bool = false +var crouching: bool = false + +var _body: CharacterBody3D +var _look_rig: Node3D +var _gravity: float = 9.8 +var _jump_buffer: float = 0.0 + + +## 由 Player 在 _ready() 注入依赖。look_rig 用来把输入转成「相对视线」的方向。 +func setup(body: CharacterBody3D, look_rig: Node3D) -> void: + _body = body + _look_rig = look_rig + _gravity = float(ProjectSettings.get_setting("physics/3d/default_gravity", 9.8)) + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("jump"): + _jump_buffer = JUMP_BUFFER_TIME + + +## 每物理帧由 Player 调用一次,必须排在 move_and_slide() 之前。 +## 只写 _body.velocity,不做位移推进。 +func step(delta: float) -> void: + if _body == null or _look_rig == null: + return + + # --- 重力 --- + if _body.is_on_floor(): + if _body.velocity.y < 0.0: + _body.velocity.y = 0.0 + else: + _body.velocity.y -= _gravity * delta + + # --- 跳跃(读取输入缓冲)--- + if _jump_buffer > 0.0: + _jump_buffer -= delta + if _body.is_on_floor(): + _body.velocity.y = jump_velocity + _jump_buffer = 0.0 + + # --- 相对视线方向的水平输入 --- + # 只用 rig 的 basis(只有 yaw),低头看地时前进方向不会跟着往下扎。 + var input_dir := Input.get_vector("move_left", "move_right", "move_back", "move_forward") + var look_basis := _look_rig.global_transform.basis + var wish := look_basis.x * input_dir.x - look_basis.z * input_dir.y + wish.y = 0.0 + if wish.length_squared() > 0.000001: + wish = wish.normalized() + + # 只有真正按了方向键才允许疾跑(避免原地"疾跑"触发跑动动画) + sprinting = Input.is_action_pressed("sprint") and input_dir.length_squared() > 0.000001 + crouching = Input.is_action_pressed("crouch") + var target_speed := sprint_speed if sprinting else walk_speed + var accel := acceleration if _body.is_on_floor() else air_acceleration + + var horizontal := Vector2(_body.velocity.x, _body.velocity.z) + horizontal = horizontal.move_toward(Vector2(wish.x, wish.z) * target_speed, accel * delta) + _body.velocity.x = horizontal.x + _body.velocity.z = horizontal.y diff --git a/character/player/player_motor.gd.uid b/character/player/player_motor.gd.uid new file mode 100644 index 0000000..b8d7da5 --- /dev/null +++ b/character/player/player_motor.gd.uid @@ -0,0 +1 @@ +uid://xta8h2snalsd diff --git a/character/player/player_pickup.gd b/character/player/player_pickup.gd new file mode 100644 index 0000000..6174d3f --- /dev/null +++ b/character/player/player_pickup.gd @@ -0,0 +1,54 @@ +class_name PlayerPickup +extends Node +## 拾取组件:射线看中的 RigidBody3D 会被吸附到 InteractionPos 前。 +## 只管「拿 / 放 / 拖」,不碰移动与视角。 +## +## 接法:Player 下挂一个普通 Node,脚本挂上去;由 Player._ready() 注入射线与持握点。 + +## 拖拽力度:物体被拉向持握点的速度系数 +@export var pick_power: float = 10.0 + +var _current: RigidBody3D = null +var _ray: RayCast3D +var _hold_pos: Marker3D + + +## 由 Player 在 _ready() 注入依赖。 +func setup(ray: RayCast3D, hold_pos: Marker3D) -> void: + _ray = ray + _hold_pos = hold_pos + + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed("carry"): + if _current == null: + _pick() + else: + _release() + + +## 每物理帧由 Player 调用一次,排在 move_and_slide() 之后。 +## 用速度把物体拽向持握点,而不是直接改 global_position —— +## 这样它仍会和场景里其他刚体正常碰撞,不会被硬塞进墙里。 +func physics_step() -> void: + if _current == null or _hold_pos == null: + return + var to_hold := _hold_pos.global_position - _current.global_position + _current.set_linear_velocity(to_hold * pick_power) + + +func _pick() -> void: + if _ray == null: + return + var collider := _ray.get_collider() + if collider is RigidBody3D: + _current = collider as RigidBody3D + + +func _release() -> void: + _current = null + + +## 当前是否抓着东西。调试用,也方便以后加「搬运时不能开枪」这类规则。 +func has_object() -> bool: + return _current != null diff --git a/character/player/player_pickup.gd.uid b/character/player/player_pickup.gd.uid new file mode 100644 index 0000000..e277be8 --- /dev/null +++ b/character/player/player_pickup.gd.uid @@ -0,0 +1 @@ +uid://b3ko4cfjf0vgu