第 1 单元 / 复刻师
参考实现 reference/01-player-controller/
这是能直接跑的 Godot 工程,是这一单元所有讲义的"地面真值": 讲义里的代码块会和它逐字对账,不一致就在构建日志里报出来。
跑起来:godot --path reference/01-player-controller/(或打开 Godot 编辑器导入这个目录)。
站点只读它,不改它。
文件(26 个)
README.mdinteract_range.gdinteract_range.tscnplayer.tscnproject.godotrender_world.gdrender_world.gd.uidrender_world.tscnscripts/player.gdscripts/player.gd.uidscripts/player_intent.gdscripts/player_intent.gd.uidscripts/player_look.gdscripts/player_look.gd.uidscripts/player_movement.gdscripts/player_movement.gd.uidscripts/player_stance.gdscripts/player_stance.gd.uidscripts/tuning.gdscripts/tuning.gd.uidsetup_input.gdsetup_input.gd.uidsetup_input.tscntest_world.gdtest_world.gd.uidtest_world.tscn
源码
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 outscripts/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 = eyesscripts/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.5scripts/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 = falsescripts/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_STANDsetup_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)