U3 → Godot 复刻课程 交互式课程站 · 本地学习件

第 1 单元 / 复刻师

参考实现 reference/01-player-controller/

这是能直接跑的 Godot 工程,是这一单元所有讲义的"地面真值": 讲义里的代码块会和它逐字对账,不一致就在构建日志里报出来。

跑起来:godot --path reference/01-player-controller/(或打开 Godot 编辑器导入这个目录)。 站点只读它,不改它。

← 回第 1 单元

文件(26 个)

源码

interact_range.gd · 112 行
extends Node3D
# 单元二的靶子表:射线起点是摄像机,量"每个射线长度能在玩家眼前够到多远"。
# 用法:godot --headless --path . interact_range.tscn
#
# 铁律 7:以摄像机为起点的数值必须换算到我们自己的相机几何,并标明起点(这里是摄像机)。
# 靶子是一排,一次只留一个(近的临时移到 0 层),否则永远只会打到最近的那个。

var player
var arm: SpringArm3D
var cam: Camera3D
var targets: Array = []          # [{dist: float, body: StaticBody3D}]

const DISTANCES := [2.0, 4.0, 6.0, 8.0]   # 靶子摆在玩家眼前这几米

func _frames(n: int) -> void:
	for i in n:
		await get_tree().physics_frame

func _ready() -> void:
	var floor_body := StaticBody3D.new()
	floor_body.name = "地板"
	var fcs := CollisionShape3D.new()
	var fbox := BoxShape3D.new()
	fbox.size = Vector3(60, 1, 60)
	fcs.shape = fbox
	floor_body.add_child(fcs)
	add_child(floor_body)
	floor_body.global_position = Vector3(0, -0.5, 0)

	for d in DISTANCES:
		var b := StaticBody3D.new()
		b.name = "靶_%dm" % int(d)
		var cs := CollisionShape3D.new()
		var box := BoxShape3D.new()
		box.size = Vector3(1.2, 1.2, 0.4)
		cs.shape = box
		b.add_child(cs)
		var mi := MeshInstance3D.new()
		var bm := BoxMesh.new()
		bm.size = box.size
		mi.mesh = bm
		var mat := StandardMaterial3D.new()
		mat.albedo_color = Color(0.9, 0.3, 0.2)
		mi.material_override = mat
		b.add_child(mi)
		add_child(b)
		b.global_position = Vector3(0, 1.75, -d)
		targets.append({"dist": d, "body": b})

	player = $Player
	player.global_position = Vector3(0, 0.05, 0)
	player.collision_layer = 2      # 正确做法:玩家单独一层,交互射线不打自己
	arm = $Player/Look/PitchPivot/SpringArm3D
	cam = $Player/Look/PitchPivot/SpringArm3D/Camera3D
	await _frames(10)

	print("玩家在 z=0,眼位 z=0;靶子摆在眼前 %s m" % [str(DISTANCES)])
	await _table("第三人称(spring_length = 4.0)", 4.0)
	await _table("第一人称(spring_length = 0.0)", 0.0)
	await _self_hit()
	print("done")
	get_tree().quit()

func _table(title: String, arm_length: float) -> void:
	print("\n── %s ──" % title)
	arm.spring_length = arm_length
	await _frames(8)
	var eye_z: float = player.look.pitch_pivot.global_position.z
	var cam_offset: float = cam.global_position.z - eye_z
	print("相机在眼后 %.3f m;射线掩码 = 1(世界层,玩家在 2 层)" % cam_offset)
	print("%-10s | %-22s | %-10s" % ["射线长度", "最远摸到(眼前 m)", "命中处 z"])
	for length in [4.0, 6.0, 8.0, 10.0, 12.0]:
		var reach := 0.0
		var hit_z := 0.0
		for t in targets:
			_isolate(t["dist"])
			var hit := _cast(length, 1)
			_isolate(-1.0)
			if not hit.is_empty() and hit.collider.name == t["body"].name:
				reach = t["dist"]
				hit_z = hit.position.z
		print("%-10s | %-22s | %-10.3f" % ["%.0f m" % length, "%.0f m" % reach if reach > 0.0 else "一个都够不着", hit_z])

# 只留下"眼前 dist 米"这一个靶子(dist < 0 表示全部恢复)
func _isolate(dist: float) -> void:
	for t in targets:
		t["body"].collision_layer = 1 if (dist < 0.0 or is_equal_approx(t["dist"], dist)) else 0

func _cast(length: float, mask: int) -> Dictionary:
	var space := get_world_3d().direct_space_state
	var from := cam.global_position
	var to := from + (-cam.global_transform.basis.z) * length
	return space.intersect_ray(PhysicsRayQueryParameters3D.create(from, to, mask))

# 射线从身后 4 m 出发、穿过玩家自己——会不会先打到自己?
func _self_hit() -> void:
	print("\n── 射线会不会先打到自己(第三人称,6 m 射线)──")
	arm.spring_length = 4.0
	_isolate(-1.0)
	player.collision_layer = 1
	await _frames(8)
	var hit := _cast(6.0, 1)
	print("玩家在世界层(1)、掩码 = 1   → 命中:%s%s" % ["(空)" if hit.is_empty() else hit.collider.name, "" if hit.is_empty() else ",命中处 z=%.3f(玩家自己)" % hit.position.z])
	player.collision_layer = 2
	await _frames(2)
	var hit3 := _cast(6.0, 1)
	print("玩家在 2 层、掩码 = 1       → 命中:%s" % ["(空)" if hit3.is_empty() else hit3.collider.name])
	await _frames(2)
	var hit4 := _cast(6.0, 1 | 2)
	print("玩家在 2 层、掩码 = 1|2     → 命中:%s" % ["(空)" if hit4.is_empty() else hit4.collider.name])
	player.collision_layer = 1
	print("(第一人称下相机在身体内部起步,射线从形状内部出发,Godot 不判定自己 —— 见 test_world 的结论)")
interact_range.tscn · 16 行
[gd_scene load_steps=4 format=3 uid="uid://cinteractrange"]

[ext_resource type="PackedScene" path="res://player.tscn" id="1_player"]
[ext_resource type="Script" path="res://interact_range.gd" id="2_range"]

[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(60, 1, 60)

[node name="World" type="Node3D"]
script = ExtResource("2_range")

[node name="FloorShape" type="CollisionShape3D" parent="."]
shape = SubResource("BoxShape3D_floor")

[node name="Player" parent="." instance=ExtResource("1_player")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0)
player.tscn · 54 行
[gd_scene load_steps=7 format=3 uid="uid://cslice1player"]

[ext_resource type="Script" path="res://scripts/player.gd" id="1_player"]
[ext_resource type="Script" path="res://scripts/player_intent.gd" id="2_intent"]
[ext_resource type="Script" path="res://scripts/player_stance.gd" id="3_stance"]
[ext_resource type="Script" path="res://scripts/player_movement.gd" id="4_movement"]
[ext_resource type="Script" path="res://scripts/player_look.gd" id="5_look"]

[sub_resource type="CapsuleShape3D" id="CapsuleShape3D_body"]
radius = 0.4
height = 2.0

[sub_resource type="SphereShape3D" id="SphereShape3D_arm"]
radius = 0.39

[sub_resource type="CapsuleMesh" id="CapsuleMesh_visual"]
radius = 0.4
height = 2.0

[node name="Player" type="CharacterBody3D"]
script = ExtResource("1_player")

[node name="Body" type="CollisionShape3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
shape = SubResource("CapsuleShape3D_body")

[node name="Intent" type="Node" parent="."]
script = ExtResource("2_intent")

[node name="Stance" type="Node" parent="."]
script = ExtResource("3_stance")

[node name="Movement" type="Node" parent="."]
script = ExtResource("4_movement")

[node name="Look" type="Node3D" parent="."]
script = ExtResource("5_look")

[node name="PitchPivot" type="Node3D" parent="Look"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.75, 0)

[node name="SpringArm3D" type="SpringArm3D" parent="Look/PitchPivot"]
collision_mask = 1
spring_length = 4.0
margin = 0.01
shape = SubResource("SphereShape3D_arm")

[node name="Camera3D" type="Camera3D" parent="Look/PitchPivot/SpringArm3D"]

[node name="Visual" type="Node3D" parent="."]

[node name="Mesh" type="MeshInstance3D" parent="Visual"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0)
mesh = SubResource("CapsuleMesh_visual")
render_world.gd · 75 行
extends Node3D
# 用真实渲染回答"spring_length=0 到底是不是第一人称"。
# 必须在有显示的环境跑(xvfb 也行),--headless 是 dummy 渲染器,出不了图。

var player
var cam: Camera3D
var arm: SpringArm3D

func _ready() -> void:
	var light := DirectionalLight3D.new()
	light.rotation_degrees = Vector3(-50, -30, 0)
	add_child(light)

	var we := WorldEnvironment.new()
	var env := Environment.new()
	env.background_mode = Environment.BG_SKY
	var sky := Sky.new()
	sky.sky_material = ProceduralSkyMaterial.new()
	env.sky = sky
	env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
	we.environment = env
	add_child(we)

	for i in 7:
		var b := MeshInstance3D.new()
		var bm := BoxMesh.new()
		bm.size = Vector3(1, 1, 1)
		b.mesh = bm
		add_child(b)
		b.global_position = Vector3(-8 + i * 2.6, 0.5, -22.0)
		var sm := StandardMaterial3D.new()
		sm.albedo_color = Color(0.95, 0.25, 0.15) if i % 2 == 0 else Color(0.2, 0.5, 0.9)
		b.material_override = sm

	player = $Player
	player.global_position = Vector3(0, 0.05, 0)
	cam = $Player/Look/PitchPivot/SpringArm3D/Camera3D
	arm = $Player/Look/PitchPivot/SpringArm3D
	await _shots()

func _settle() -> void:
	for i in 4:
		await get_tree().physics_frame

func _shot(shot_name: String) -> void:
	await RenderingServer.frame_post_draw
	var img := get_viewport().get_texture().get_image()
	img.save_png("/tmp/shots/%s.png" % shot_name)
	print("%-18s cam=%s  离眼位 %.3f m" % [shot_name, cam.global_position, cam.global_position.distance_to(player.look.pitch_pivot.global_position)])

func _shots() -> void:
	player.global_position = Vector3(0, 0.05, -8.0)   # 远离墙,先看无遮挡
	await _settle()
	await _shot("A_third_4")

	arm.spring_length = 7.0
	await _settle()
	await _shot("B_third_7")

	arm.spring_length = 0.0
	await _settle()
	await _shot("C_first_0")

	$Player/Visual.visible = false
	await _settle()
	await _shot("D_first_0_hidden_visual")
	$Player/Visual.visible = true

	arm.spring_length = 4.0
	player.global_position = Vector3(0, 0.05, 1.5)   # 背靠墙:墙面 z=2.75
	await _settle()
	await _shot("F_third_4_near_wall")

	print("done")
	get_tree().quit()
render_world.tscn · 28 行
[gd_scene load_steps=5 format=3 uid="uid://crenderworld"]

[ext_resource type="PackedScene" path="res://player.tscn" id="1_player"]
[ext_resource type="Script" path="res://render_world.gd" id="2_render"]

[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(40, 1, 40)

[sub_resource type="BoxShape3D" id="BoxShape3D_wall"]
size = Vector3(8, 4, 0.5)

[node name="World" type="Node3D"]
script = ExtResource("2_render")

[node name="Floor" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)

[node name="CollisionShape3D" type="CollisionShape3D" parent="Floor"]
shape = SubResource("BoxShape3D_floor")

[node name="Wall" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 3)

[node name="CollisionShape3D" type="CollisionShape3D" parent="Wall"]
shape = SubResource("BoxShape3D_wall")

[node name="Player" parent="." instance=ExtResource("1_player")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0)
scripts/player.gd · 43 行
extends CharacterBody3D
# 外壳:只管"时钟"和"调用顺序",一行物理计算都不写。
# 对应原版 PlayerInput 的驱动部分(Unturned/Player/PlayerInput.cs:1524-1642)。

const Tuning = preload("res://scripts/tuning.gd")

@onready var intent: Node = $Intent
@onready var stance: Node = $Stance
@onready var movement: Node = $Movement
@onready var look: Node3D = $Look
@onready var body_shape: CollisionShape3D = $Body

var _accum := 0.0

func _ready() -> void:
	# 初始化顺序显式写死,别依赖 _ready 的偶然顺序
	# (原版 Player.InitializePlayer 也是固定顺序,Player.cs:1614-1646)
	look.setup(self)
	movement.apply_size(body_shape, stance.stance)
	Input.mouse_mode = Input.MOUSE_MODE_CAPTURED   # 不捕获的话鼠标会跑出窗口,转不了头

func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_cancel"):
		# Esc 松开鼠标,方便你切出去;再按一次收回来
		Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if Input.mouse_mode == Input.MOUSE_MODE_CAPTURED else Input.MOUSE_MODE_CAPTURED
		return
	if event is InputEventMouseMotion:
		intent.add_look(event.relative)   # 输入层只记账,不碰状态

func _physics_process(delta: float) -> void:
	intent.sample()
	_accum += delta
	while _accum >= Tuning.LOGIC_STEP:
		_accum -= Tuning.LOGIC_STEP
		_simulate(Tuning.LOGIC_STEP)
	move_and_slide()   # 唯一真正移动身体的地方

func _simulate(step: float) -> void:
	# 顺序照抄 PlayerInput.cs:1574-1640:stance 在 movement 之前。
	# 所以 stance 读到的 is_moving 是上一个逻辑帧的结果,原版也是这样。
	stance.simulate(step, intent, movement.is_moving)
	movement.simulate(self, body_shape, step, intent, stance.stance)
	look.simulate(step, intent.take_look(), stance.stance)
scripts/player_intent.gd · 34 行
extends Node
# 意图层:只记录"玩家想做什么",绝不改动任何物理状态。
# 对应原版 PlayerInput 里"采样输入"的那一半(Unturned/Player/PlayerInput.cs:1524-1642)。

var move := Vector2.ZERO      # x:左右(-1/0/1),y:前后(-1/0/1,正为前)
var sprint := false
var crouch_held := false

var _look_pixels := Vector2.ZERO   # 本逻辑帧累积的鼠标位移(像素)
var _jump_latch := false           # 跳跃"上锁":按下就记住,等逻辑帧来取

func sample() -> void:
	move = Input.get_vector("move_left", "move_right", "move_back", "move_forward")
	# 原版的移动输入是整数(PlayerMovement.cs:1570-1594),归一化是在算速度那一步做的
	# (`move.normalized`,PlayerMovement.cs:1222)——所以斜着走不会更快。
	# 这里只取符号,速度由速度表统一给。
	move = Vector2(signf(move.x), signf(move.y))
	sprint = Input.is_action_pressed("sprint")
	crouch_held = Input.is_action_pressed("stance")
	if Input.is_action_just_pressed("jump"):
		_jump_latch = true

func add_look(pixel_delta: Vector2) -> void:
	_look_pixels += pixel_delta

func take_look() -> Vector2:
	var out := _look_pixels
	_look_pixels = Vector2.ZERO
	return out

func take_jump() -> bool:
	var out := _jump_latch
	_jump_latch = false
	return out
scripts/player_look.gd · 43 行
extends Node3D
# 视角。对应原版 PlayerLook(Unturned/Player/PlayerLook.cs)。
# 分工:身体只转 Yaw(权威状态),俯仰交给摄像机(表现)。
#   原版 PlayerLook.cs:608 与 :593-599 就是这个分工。

const Tuning = preload("res://scripts/tuning.gd")

@onready var pitch_pivot: Node3D = $PitchPivot

var yaw := 0.0          # 度。身体朝向,就是 Player 的 rotation.y
var pitch := 90.0       # 度。0 = 垂直向上,90 = 水平,180 = 垂直向下(原版约定)
var eyes := Tuning.EYE_STAND

var _body: CharacterBody3D
var _stance: int = Tuning.Stance.STAND

func setup(body: CharacterBody3D) -> void:
	_body = body

# 逻辑帧调用:吃"意图里的鼠标增量",产出权威的 yaw / pitch
func simulate(step: float, look_delta: Vector2, stance: int) -> void:
	_stance = stance
	# Unity 是左手系,Godot 是右手系:左右翻一次符号。
	yaw -= look_delta.x * Tuning.MOUSE_SENSITIVITY
	# 原版鼠标下移 → pitch 增大(PlayerLook.cs:1466 取负号后 :595 直接赋值),
	# 而 pitch 越大越往下看,所以这里是加号。别按"往下就把数值改小"的直觉写。
	pitch += look_delta.y * Tuning.MOUSE_SENSITIVITY
	# 俯仰夹取随姿态变:趴下不能抬头看天(PlayerLook.cs:630-681)
	pitch = clampf(pitch, Tuning.pitch_min_for(stance), Tuning.pitch_max_for(stance))

	_body.rotation.y = deg_to_rad(yaw)
	# 原版 0 度朝上看、90 度才是水平;Godot 的 Camera3D 同样 0 度才是水平,
	# 而原版 pitch 越大越往下看,所以这里取 90 减 pitch(Unity 左手系 → Godot 右手系)。
	pitch_pivot.rotation.x = deg_to_rad(90.0 - pitch)

# 渲染帧调用:眼睛高度平滑。这是"表现",不参与权威状态,
# 所以放在 _process 而不是 _physics_process(原版也在 Update 里,PlayerLook.cs:1235)。
func _process(delta: float) -> void:
	var target := Tuning.eye_for(_stance)
	# 权重里带 delta 的 lerp 是指数收敛,帧率无关;
	# 唯一要防的是卡顿帧 delta 太大时权重 > 1 会过冲。
	eyes = lerpf(eyes, target, minf(Tuning.EYE_LERP_SPEED * delta, 1.0))
	pitch_pivot.position.y = eyes
scripts/player_movement.gd · 50 行
extends Node
# 移动。对应原版 PlayerMovement(Unturned/Player/PlayerMovement.cs:1051 的 simulate)。
# 只算速度,不执行位移 —— 位移由外壳的 move_and_slide() 干。

const Tuning = preload("res://scripts/tuning.gd")

var is_moving := false

func simulate(body: CharacterBody3D, shape: CollisionShape3D, step: float,
		intent: Node, stance: int) -> void:
	var speed := Tuning.speed_for(stance)
	apply_size(shape, stance)

	# 移动方向相对身体朝向。Godot 里前方是 -Z,所以 move.y 取负。
	var facing := Basis(Vector3.UP, body.rotation.y)
	var wish := facing * Vector3(intent.move.x, 0.0, -intent.move.y)
	wish.y = 0.0
	if wish.length() > 1.0:
		wish = wish.normalized()

	var want_jump: bool = intent.take_jump()

	if body.is_on_floor():
		# 原版两套摩擦模型(PlayerMovement.cs:1226-1273),
		# 本实现直接用 ImmediatelyResponsive 那套:速度直接赋值。
		body.velocity.x = wish.x * speed
		body.velocity.z = wish.z * speed
		# 起跳条件 PlayerMovement.cs:1308-1315:
		#   着地 && 姿态是 STAND 或 SPRINT && 体力够 && 跳跃倍率非零
		#   本单元把体力和熟练度都砍了,只剩"着地与姿态"两条。
		if want_jump and (stance == Tuning.Stance.STAND or stance == Tuning.Stance.SPRINT):
			body.velocity.y = Tuning.JUMP
		else:
			body.velocity.y = -0.1   # 一点向下的速度,帮 move_and_slide 贴住地面
	else:
		# 空中:重力 ×3(PlayerMovement.cs:1277 那个硬编码的 3)
		body.velocity.y -= Tuning.GRAVITY * Tuning.GRAVITY_TIMESCALE * step
		body.velocity.y = maxf(body.velocity.y, Tuning.TERMINAL_VELOCITY)
		# 空中不改水平速度:原版的 AirStrafing_* 配置(:1284-1301)本单元砍掉

	is_moving = Vector2(body.velocity.x, body.velocity.z).length() > 0.1

# 姿态改身高。原版叫 applySize(PlayerMovement.cs:2037)。
# 胶囊中心跟着高度走,这样 Player 的原点始终在脚底,眼睛高度才是"从脚往上量"。
func apply_size(shape: CollisionShape3D, stance: int) -> void:
	var h := Tuning.height_for(stance)
	var capsule := shape.shape as CapsuleShape3D
	if not is_equal_approx(capsule.height, h):
		capsule.height = h
		shape.position.y = h * 0.5
scripts/player_stance.gd · 56 行
extends Node
# 姿态状态机。对应原版 PlayerStance(Unturned/Player/PlayerStance.cs)。
# 单元一只做陆地四态:STAND / CROUCH / PRONE / SPRINT。
# 原版还有 CLIMB(爬梯) / SWIM(游泳) / DRIVING / SITTING —— 全砍。

const Tuning = preload("res://scripts/tuning.gd")
const Stance = Tuning.Stance

var stance: int = Stance.STAND

var _holding := false      # 姿态键当前是否按住
var _hold_time := 0.0      # 已按住多久
var _want_crouch := false  # 原版的 _localWantsToCrouch:是个"锁存",松键后依然生效
var _want_prone := false

func simulate(step: float, intent: Node, is_moving: bool) -> void:
	_update_latches(step, intent)

	# 固定优先级顺序、命中即改 —— 形状抄 PlayerStance.cs:675-714。
	# 原版顺序是 爬梯 > 游泳 > 陆地,我们只剩陆地这一段。
	if _want_prone:
		stance = Stance.PRONE
	elif _want_crouch:
		stance = Stance.CROUCH
	elif intent.sprint and is_moving:
		stance = Stance.SPRINT
	else:
		stance = Stance.STAND

# 一个键 = 三种姿态:短按在蹲/站之间切,按住超过 0.33 秒进趴下。
# 逐行对照 Unturned/Player/PlayerStance.cs:725-767。
func _update_latches(step: float, intent: Node) -> void:
	if intent.crouch_held:
		if _holding:
			_hold_time += step
			if _hold_time > Tuning.HOLD_TO_PRONE:
				_want_crouch = false
				_want_prone = true      # :737-738
		else:
			_holding = true             # :743-744
			_hold_time = 0.0
	else:
		if _holding:
			if _hold_time < Tuning.HOLD_TO_PRONE:
				# 原版这里判断的是 `crouch`,而 crouch 是个属性,指向 _localWantsToCrouch
				# 那个"锁存",不是当前姿态(PlayerStance.cs:118)。所以:
				#   本来想蹲 / 正趴着(锁存是 false)→ 这次短按 = 想蹲,趴 → 蹲
				#   本来就蹲着(锁存是 true)      → 这次短按 = 站起来
				if _want_crouch:
					_want_crouch = false    # :753-757  蹲 → 站
					_want_prone = false
				else:
					_want_crouch = true     # :759-762  站/趴 → 蹲
					_want_prone = false
			# 长按松手(趴着):什么都不做,保持趴 —— :751 的判定就落在这里
			_holding = false
scripts/tuning.gd · 89 行
extends RefCounted
# 单元一唯一的调参处。数值全部来自 U3-SDK,注释里标出处。
# 原版路径前缀是 Assets/Runtime/Assembly-CSharp/

# ── 逻辑帧率 ──────────────────────────────────────────────
# 原版:每 SAMPLES=4 个物理帧跑 1 次逻辑帧,步长 RATE=0.08s
#   Unturned/Player/PlayerInput.cs:878-879   → 逻辑帧 12.5 Hz
# 物理帧每帧都读输入,离散输入等逻辑帧才被消费(12.5 Hz,写在 :1542 的
# count % SAMPLES == 0 门禁里);50 Hz 的是 tock(:1652);鼠标视角是渲染帧率(PlayerLook.cs:1532)。
# 本课程改成 60 Hz(= Godot 物理帧率),理由:单机不需要吃那最多 80ms 延迟。
# 单元七要联机时,只改这两个数。
const LOGIC_HZ := 60.0
const LOGIC_STEP := 1.0 / LOGIC_HZ

# ── 速度表(米/秒)Unturned/Player/PlayerMovement.cs:91-104 ──
const SPEED_STAND := 4.5
const SPEED_SPRINT := 7.0
const SPEED_CROUCH := 2.5
const SPEED_PRONE := 1.5
const JUMP := 7.0

# ── 重力 Unturned/Player/PlayerMovement.cs:1277 ──
# 原版是 Physics.gravity.y * (fall <= 0 ? totalGravityMultiplier : 1f) * deltaTime * 3。
# 那个 3 是硬编码的,别丢;totalGravityMultiplier 正常恒为 1(滑翔类装备会改它,单元四之后是雷)。
# 9.81 取自工程实测:ProjectSettings/DynamicsManager.asset 的 m_Gravity y = -9.81。
const GRAVITY := 9.81
const GRAVITY_TIMESCALE := 3.0
const TERMINAL_VELOCITY := -100.0   # 原版 :1280-1281

# ── 胶囊尺寸 Unturned/Player/PlayerMovement.cs:49-51、PlayerStance.cs:19 ──
const HEIGHT_STAND := 2.0
const HEIGHT_CROUCH := 1.2
const HEIGHT_PRONE := 0.8
const RADIUS := 0.4

# ── 眼睛高度 Unturned/Player/PlayerLook.cs:17-20 ──
const EYE_STAND := 1.75
const EYE_CROUCH := 1.2
const EYE_PRONE := 0.35
const EYE_LERP_SPEED := 4.0   # 原版 :1235 的 4 * Time.deltaTime

# ── 俯仰夹取(0=垂直向上,90=水平,180=垂直向下)PlayerLook.cs:77-88、:147-149 ──
const PITCH_MIN_STAND := 0.0
const PITCH_MAX_STAND := 180.0
const PITCH_MIN_CROUCH := 20.0
const PITCH_MAX_CROUCH := 160.0
const PITCH_MIN_PRONE := 60.0
const PITCH_MAX_PRONE := 120.0

# 原版走 ControlsSettings.mouseAimSensitivity(玩家设置),本课程先用常量
const MOUSE_SENSITIVITY := 0.12   # 度 / 像素

# ── 姿态 ──────────────────────────────────────────────────
# 原版 EPlayerStance 是独立文件(8 个状态)。单元一只有 4 个状态,
# 而且速度表、俯仰表、眼高表都按它索引,所以先塞在这里,以后状态多了再拆出去。
enum Stance { STAND, CROUCH, PRONE, SPRINT }

const HOLD_TO_PRONE := 0.33   # Unturned/Player/PlayerStance.cs:735

static func speed_for(s: Stance) -> float:
	match s:
		Stance.SPRINT: return SPEED_SPRINT
		Stance.CROUCH: return SPEED_CROUCH
		Stance.PRONE: return SPEED_PRONE
		_: return SPEED_STAND

static func height_for(s: Stance) -> float:
	match s:
		Stance.CROUCH: return HEIGHT_CROUCH
		Stance.PRONE: return HEIGHT_PRONE
		_: return HEIGHT_STAND

static func eye_for(s: Stance) -> float:
	match s:
		Stance.CROUCH: return EYE_CROUCH
		Stance.PRONE: return EYE_PRONE
		_: return EYE_STAND

static func pitch_min_for(s: Stance) -> float:
	match s:
		Stance.CROUCH: return PITCH_MIN_CROUCH
		Stance.PRONE: return PITCH_MIN_PRONE
		_: return PITCH_MIN_STAND

static func pitch_max_for(s: Stance) -> float:
	match s:
		Stance.CROUCH: return PITCH_MAX_CROUCH
		Stance.PRONE: return PITCH_MAX_PRONE
		_: return PITCH_MAX_STAND
setup_input.gd · 29 行
extends Node3D
# 一次性脚本:把 Input Map 的 7 个动作写进 project.godot。
# 你手动在编辑器里加一遍就懂流程;这个脚本只是让"直接粘贴项目文件"这条路也可行。

func _ready() -> void:
	_add("move_forward", [KEY_W])
	_add("move_back", [KEY_S])
	_add("move_left", [KEY_A])
	_add("move_right", [KEY_D])
	_add("jump", [KEY_SPACE])
	_add("sprint", [KEY_SHIFT])
	_add("stance", [KEY_C])
	ProjectSettings.save()
	print("已写入 project.godot")
	get_tree().quit()

func _add(action: String, keys: Array) -> void:
	var events := []
	for k in keys:
		var ev := InputEventKey.new()
		ev.physical_keycode = k   # 物理键位:非 QWERTY 键盘也按在 WASD 的位置
		ev.device = -1            # 任意输入设备都算
		events.append(ev)
	if InputMap.has_action(action):
		InputMap.erase_action(action)
	InputMap.add_action(action, 0.2)
	for ev in events:
		InputMap.action_add_event(action, ev)
	ProjectSettings.set_setting("input/" + action, {"deadzone": 0.2, "events": events})
setup_input.tscn · 6 行
[gd_scene load_steps=2 format=3 uid="uid://csetupinput"]

[ext_resource type="Script" path="res://setup_input.gd" id="1_setup"]

[node name="Setup" type="Node3D"]
script = ExtResource("1_setup")
test_world.gd · 182 行
extends Node3D
# 自动验收:把单元一的每一条"应该发生什么"都跑一遍并打印。
# 用法:godot --headless --path . test_world.tscn

const Tuning = preload("res://scripts/tuning.gd")

var player
var fail := 0

func _frames(n: int) -> void:
	for i in n:
		await get_tree().physics_frame

func check(label: String, ok: bool, detail: String) -> void:
	if not ok:
		fail += 1
	print("[%s] %s  %s" % ["OK " if ok else "FAIL", label, detail])

func _ready() -> void:
	player = $Player
	await _frames(20)
	check("落地", player.is_on_floor(), "y=%.3f" % player.position.y)

	# ── 走 ──
	var z0: float = player.position.z
	Input.action_press("move_forward")
	await _frames(60)
	Input.action_release("move_forward")
	var dz: float = z0 - player.position.z
	check("站立速度 4.5 m/s", absf(dz - 4.5) < 0.05, "60 帧走了 %.3f m" % dz)
	check("姿态=SPRINT 判定需要 is_moving", player.stance.stance != Tuning.Stance.SPRINT, "stance=%d" % player.stance.stance)
	await _frames(5)

	# ── 冲刺 ──
	Input.action_press("move_forward")
	Input.action_press("sprint")
	await _frames(30)
	check("冲刺姿态生效", player.stance.stance == Tuning.Stance.SPRINT, "stance=%d(应为 %d)" % [player.stance.stance, Tuning.Stance.SPRINT])
	z0 = player.position.z
	await _frames(30)
	var dz2: float = z0 - player.position.z
	check("冲刺实测 7.0 m/s", absf(dz2 - 3.5) < 0.05, "30 帧走了 %.3f m" % dz2)
	Input.action_release("sprint")
	Input.action_release("move_forward")
	await _frames(5)
	check("松手回站立", player.stance.stance == Tuning.Stance.STAND, "stance=%d" % player.stance.stance)

	# ── 蹲:短按 ──
	Input.action_press("stance")
	await _frames(2)
	Input.action_release("stance")
	await _frames(3)
	check("短按进蹲", player.stance.stance == Tuning.Stance.CROUCH, "stance=%d" % player.stance.stance)
	check("蹲下胶囊高 1.2", is_equal_approx(player.body_shape.shape.height, 1.2), "h=%.3f y=%.3f" % [player.body_shape.shape.height, player.body_shape.position.y])
	z0 = player.position.z
	Input.action_press("move_forward")
	await _frames(30)
	Input.action_release("move_forward")
	check("蹲速 2.5 m/s", absf((z0 - player.position.z) - 1.25) < 0.05, "30 帧走了 %.3f m" % (z0 - player.position.z))
	await _frames(3)

	# ── 趴:长按 0.33 秒 ──
	Input.action_press("stance")
	await _frames(25)
	check("长按进趴", player.stance.stance == Tuning.Stance.PRONE, "stance=%d" % player.stance.stance)
	Input.action_release("stance")
	await _frames(6)
	check("长按松手保持趴", player.stance.stance == Tuning.Stance.PRONE, "stance=%d" % player.stance.stance)
	check("趴下胶囊高 0.8", is_equal_approx(player.body_shape.shape.height, 0.8), "h=%.3f y=%.3f" % [player.body_shape.shape.height, player.body_shape.position.y])
	z0 = player.position.z
	Input.action_press("move_forward")
	await _frames(30)
	Input.action_release("move_forward")
	check("趴速 1.5 m/s", absf((z0 - player.position.z) - 0.75) < 0.05, "30 帧走了 %.3f m" % (z0 - player.position.z))
	await _frames(3)

	# ── 俯仰夹取随姿态 ──
	player.look.pitch = 90.0
	player.look.simulate(0.016, Vector2(0, 0), Tuning.Stance.PRONE)
	player.look.simulate(0.016, Vector2(0, 99999), Tuning.Stance.PRONE)
	check("趴着低头(pitch 增大到上限)夹在 120", is_equal_approx(player.look.pitch, 120.0), "pitch=%.2f" % player.look.pitch)
	player.look.simulate(0.016, Vector2(0, -99999), Tuning.Stance.PRONE)
	check("趴着抬头(pitch 减小到下限)夹在 60", is_equal_approx(player.look.pitch, 60.0), "pitch=%.2f" % player.look.pitch)
	player.look.pitch = 90.0
	player.look.simulate(0.016, Vector2(0, 99999), Tuning.Stance.STAND)
	check("站着能低头到 180(原版 180=正下方)", is_equal_approx(player.look.pitch, 180.0), "pitch=%.2f" % player.look.pitch)
	player.look.pitch = 90.0
	player.look.simulate(0.016, Vector2(0, 0), Tuning.Stance.CROUCH)

	# ── 站回去 ──
	Input.action_press("stance")
	await _frames(2)
	Input.action_release("stance")
	await _frames(3)
	check("从趴短按回蹲", player.stance.stance == Tuning.Stance.CROUCH, "stance=%d" % player.stance.stance)
	Input.action_press("stance")
	await _frames(2)
	Input.action_release("stance")
	await _frames(3)
	check("再短按回站", player.stance.stance == Tuning.Stance.STAND, "stance=%d" % player.stance.stance)
	check("站起胶囊高 2.0", is_equal_approx(player.body_shape.shape.height, 2.0), "h=%.3f y=%.3f" % [player.body_shape.shape.height, player.body_shape.position.y])

	# ── 跳 ──
	check("跳前姿态应为站立(蹲/趴不许跳)", player.stance.stance == Tuning.Stance.STAND, "stance=%d" % player.stance.stance)
	var y0: float = player.position.y
	var apex: float = y0
	Input.action_press("jump")
	await _frames(1)
	Input.action_release("jump")
	for i in 40:
		await _frames(1)
		apex = maxf(apex, player.position.y)
	# 60 Hz 的离散积分天然比连续解高约 0.06 m:起跳那一帧还在"着地"分支里,少吃一帧重力。
	# 所以容差给 0.10 而不是 0.06——换机器、换 Godot 小版本都不会误报红。
	check("跳高约 0.83 m(跳跃 7.0 / 重力 9.81×3,容差 0.10)", absf((apex - y0) - 0.833) < 0.10, "实测 %.3f m(连续解 0.833,离散天然高约 0.06)" % (apex - y0))
	check("落回地面", player.is_on_floor(), "y=%.3f" % player.position.y)

	# ── 摄像机 ──
	var cam: Camera3D = $Player/Look/PitchPivot/SpringArm3D/Camera3D
	player.global_position = Vector3(0, 0.05, -5.0)   # 远离墙,先看无遮挡时的长度
	await _frames(15)
	check("第三人称后退 4 m", absf(cam.global_position.z - (-5.0 + 4.0)) < 0.05, "cam z=%.3f(期望 %.3f)" % [cam.global_position.z, -1.0])

	player.global_position = Vector3(0, 0.05, 1.5)   # 墙的近面在 z=2.75
	await _frames(10)
	check("摄像机不穿墙", cam.global_position.z < 2.75 - 0.38, "cam z=%.3f(墙面 z=2.75,球半径 0.39)" % cam.global_position.z)

	var arm: SpringArm3D = $Player/Look/PitchPivot/SpringArm3D
	arm.spring_length = 0.0
	await _frames(5)
	check("spring_length=0 变第一人称", cam.global_position.distance_to(player.look.pitch_pivot.global_position) < 0.02, "cam=%s pivot=%s" % [cam.global_position, player.look.pitch_pivot.global_position])
	arm.spring_length = 4.0
	await _frames(5)

	# ── 另一条路:换父节点(跟上面那条比,谁更省事)──
	var pivot: Node3D = player.look.pitch_pivot
	cam.reparent(pivot, false)
	cam.position = Vector3.ZERO
	await _frames(5)
	check("reparent 到 PitchPivot:相机落在眼位", cam.global_position.distance_to(pivot.global_position) < 0.02, "cam=%s pivot=%s" % [cam.global_position, pivot.global_position])
	cam.reparent(arm, false)
	cam.position = Vector3.ZERO
	await _frames(5)
	# 此刻玩家在 z=1.5、背靠墙(墙近面 2.75),所以又回到"被墙压到 0.859 m"的状态
	check("reparent 回 SpringArm3D:重新归 SpringArm 管", cam.get_parent() == arm and absf(cam.global_position.distance_to(pivot.global_position) - 0.859) < 0.02, "父节点=%s 离眼位 %.3f m" % [cam.get_parent().name, cam.global_position.distance_to(pivot.global_position)])

	# ── 鼠标视角:真喂一个鼠标事件进去,走完整条链 ──
	player.global_position = Vector3(0, 0.05, -5.0)
	player.look.yaw = 0.0
	player.look.pitch = 90.0
	await _frames(2)
	var mm := InputEventMouseMotion.new()
	mm.relative = Vector2(100, 0)          # 鼠标向右 100 像素
	Input.parse_input_event(mm)
	await _frames(2)
	check("鼠标右移 100px → yaw −12°(100×0.12)", absf(player.look.yaw - (-12.0)) < 0.001, "yaw=%.3f  body.rotation.y(deg)=%.3f" % [player.look.yaw, rad_to_deg(player.rotation.y)])
	mm = InputEventMouseMotion.new()
	mm.relative = Vector2(0, 50)           # 鼠标向下 50 像素
	Input.parse_input_event(mm)
	await _frames(2)
	check("鼠标下移 50px → pitch 90+6=96(低头;pitch 越大越朝下)", absf(player.look.pitch - 96.0) < 0.001, "pitch=%.3f  PitchPivot.rotation.x(deg)=%.3f" % [player.look.pitch, rad_to_deg(player.look.pitch_pivot.rotation.x)])

	# ── 自己的胶囊会不会挡住摄像机?(答案:不会,球扫从自己身体里起步)──
	player.look.yaw = 0.0
	player.look.pitch = 90.0
	player.look.simulate(0.016, Vector2.ZERO, Tuning.Stance.STAND)
	player.global_position = Vector3(0, 0.05, -5.0)
	await _frames(10)
	var z_own: float = cam.global_position.z
	check("玩家默认层 1:自己的胶囊挡不住摄像机", absf(z_own - (-1.0)) < 0.05, "cam z=%.3f(无遮挡时应为 −1.000)" % z_own)

	# ── 俯仰方向契约:pitch=0 必须抬眼向上(防止再被翻回镜像)──
	# 依据原版 PlayerLook.cs:147-149「0 is up, 90 is forward, 180 is down」。
	player.look.pitch = 0.0
	player.look.simulate(0.016, Vector2.ZERO, Tuning.Stance.STAND)
	check("pitch=0(原版 0=上)→ PitchPivot.rotation.x > 0 即抬眼向上", rad_to_deg(player.look.pitch_pivot.rotation.x) > 45.0, "rotation.x(deg)=%.1f(期望 +90)" % rad_to_deg(player.look.pitch_pivot.rotation.x))
	player.look.pitch = 180.0
	player.look.simulate(0.016, Vector2.ZERO, Tuning.Stance.STAND)
	check("pitch=180(原版 180=下)→ PitchPivot.rotation.x < 0 即低头向下", rad_to_deg(player.look.pitch_pivot.rotation.x) < -45.0, "rotation.x(deg)=%.1f(期望 −90)" % rad_to_deg(player.look.pitch_pivot.rotation.x))

	print("──── 失败项:%d ────" % fail)
	get_tree().quit(fail)
test_world.tscn · 32 行
[gd_scene load_steps=5 format=3 uid="uid://ctestworld1"]

[ext_resource type="PackedScene" path="res://player.tscn" id="1_player"]
[ext_resource type="Script" path="res://test_world.gd" id="2_test"]

[sub_resource type="BoxShape3D" id="BoxShape3D_floor"]
size = Vector3(40, 1, 40)

[sub_resource type="BoxShape3D" id="BoxShape3D_wall"]
size = Vector3(8, 4, 0.5)

[node name="World" type="Node3D"]
script = ExtResource("2_test")

[node name="Floor" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, -0.5, 0)
collision_layer = 1
collision_mask = 0

[node name="CollisionShape3D" type="CollisionShape3D" parent="Floor"]
shape = SubResource("BoxShape3D_floor")

[node name="Wall" type="StaticBody3D" parent="."]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 3)
collision_layer = 1
collision_mask = 0

[node name="CollisionShape3D" type="CollisionShape3D" parent="Wall"]
shape = SubResource("BoxShape3D_wall")

[node name="Player" parent="." instance=ExtResource("1_player")]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0.05, 0)