说明书 1/1 · systems/01-player-controller
玩家控制器(单元一)
本文只解剖一个系统:玩家的移动与视角。对应课程单元一”项目骨架 + 角色移动与摄像机”。
0. 本文范围与口径
- 源码根:
/home/jay/gameprojects/U3-SDK/,下文所有Unturned/...路径都相对Assets/Runtime/Assembly-CSharp/。 - 所有源码断言都给了行号;带 ⚠️ 的是我没有逐行验证或只看了签名的地方。
- 本单元只讲单机语义。权威性(谁说了算)单独列一章,但不展开 RPC 细节——按群公告约定。
一句话结论:Unturned 的玩家控制器不是一个脚本,是一组同挂在 Player 预制体上的组件,靠一个固定步长的 simulate() 被外部统一驱动。这是复刻时最值钱的架构信息。
1. 系统地图:谁是谁
Player 组件就是这一堆组件的”外壳”,在 Awake 里全部 GetComponent 抓到手:
Unturned/Player/Player.cs:1696-1716Assets/Runtime/Assembly-CSharp/Unturned/Player/Player.cs:1695-1717—…还有 7 行没显示(共 23 行)
private void Awake() { _channel = GetComponent<SteamChannel>(); agro = 0; _animator = GetComponent<PlayerAnimator>(); _clothing = GetComponent<PlayerClothing>(); _inventory = GetComponent<PlayerInventory>(); _equipment = GetComponent<PlayerEquipment>(); _life = GetComponent<PlayerLife>(); _crafting = GetComponent<PlayerCrafting>(); _skills = GetComponent<PlayerSkills>(); _movement = GetComponent<PlayerMovement>(); _look = GetComponent<PlayerLook>(); _stance = GetComponent<PlayerStance>(); _input = GetComponent<PlayerInput>(); _voice = GetComponent<PlayerVoice>(); _interact = GetComponent<PlayerInteract>(); _workzone = GetComponent<PlayerWorkzone>(); _quests = GetComponent<PlayerQuests>(); }Awake()依次GetComponent<PlayerMovement>()、<PlayerLook>()、<PlayerStance>()、<PlayerInput>()、<PlayerLife>()等。- 与单元一直接相关的四个:
| 组件 | 文件 | 行数 | 职责 |
|---|---|---|---|
PlayerMovement | Unturned/Player/PlayerMovement.cs | 2092 | 速度、重力、碰撞移动、着地判定、步频音效 |
PlayerStance | Unturned/Player/PlayerStance.cs | 914 | 姿态状态机(站/蹲/趴/冲刺/游泳/爬梯/坐/开车) |
PlayerLook | Unturned/Player/PlayerLook.cs | 2005 | 俯仰/偏航、摄像机摆放、瞄准点 |
PlayerInput | Unturned/Player/PlayerInput.cs | 1962 | 采样输入、按固定步长驱动上面三个 |
行数来自 wc -l,已验证。
复刻含义:这正好就是你要在 Godot 里分的四个 Node/Resource。别写成一个 player.gd 巨脚本。
2. 数据模型
2.1 PlayerMovement 的常量(可调参数的中枢)
Unturned/Player/PlayerMovement.cs:91-104Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:90-105
private static readonly float SPEED_CLIMB = 4.5f;
private static readonly float SPEED_SWIM = 3f;
private static readonly float SPEED_SPRINT = 7f;
private static readonly float SPEED_STAND = 4.5f;
private static readonly float SPEED_CROUCH = 2.5f;
private static readonly float SPEED_PRONE = 1.5f;
/// <summary>
/// Jump speed = sqrt(2 * jump height * gravity)
/// Jump height = (jump speed ^ 2) / (2 * gravity)
/// With 7 speed and 9.81 * 3 gravity = apex height of 1.66496772
/// </summary>
private static readonly float JUMP = 7.0f;
private static readonly float SWIM = 3;:
SPEED_CLIMB = 4.5f
SPEED_SWIM = 3f
SPEED_SPRINT = 7f
SPEED_STAND = 4.5f
SPEED_CROUCH = 2.5f
SPEED_PRONE = 1.5f
JUMP = 7.0f // 起跳垂直速度初值
SWIM = 3 // 水中上浮速度
SKIN_WIDTH = 0.1f // PlayerMovement.cs:57
身高与半径(Unturned/Player/PlayerMovement.cs:49-51Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:48-52 {
public static readonly float HEIGHT_STAND = 2;
public static readonly float HEIGHT_CROUCH = 1.2f;
public static readonly float HEIGHT_PRONE = 0.8f;、Unturned/Player/PlayerStance.cs:19Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerStance.cs:18-20
public static readonly float RADIUS = 0.4f;):
HEIGHT_STAND = 2.0 HEIGHT_CROUCH = 1.2 HEIGHT_PRONE = 0.8 // 胶囊高
RADIUS = 0.4f // PlayerStance.RADIUS,胶囊半径
复刻必留:这套”一个基础速度表 + 姿态决定取哪一项”的模型。它让”蹲下变慢”这种玩法调节只改一行。
2.2 速度是怎么算出来的(不是常数)
…还有 26 行没显示(共 42 行)Unturned/Player/PlayerMovement.cs:291-330Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:290-331 的 speed 属性:
- 站立:
SPEED_STAND * exercise * totalSpeedMultiplier - 冲刺:
SPEED_SPRINT * exercise * totalSpeedMultiplier - 游泳单独一支:
SPEED_SWIM * (1 + DIVING熟练度 * 0.25) * totalSpeedMultiplier - 其中
exercise = 1 + (EXERCISE熟练度 * 0.25)(:305)
复刻可砍: …还有 13 行没显示(共 29 行)skills.mastery(...) 熟练度乘区(PlayerMovement.cs:297-323Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:296-324,依赖 PlayerSkills)。单元一先留 1.0,课 27 再挂回来。
2.3 输入与状态的表示
- 移动输入是整数:
input_x/input_y ∈ {-1, 0, 1},再压成byte(_horizontal = input_x + 1)。见Unturned/Player/PlayerMovement.cs:1570-1594Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1569-1595(取值)与…还有 11 行没显示(共 27 行)
{ if (InputEx.GetKey(ControlsSettings.left)) { input_x = -1; } else if (InputEx.GetKey(ControlsSettings.right)) { input_x = 1; } else { input_x = 0; } if (InputEx.GetKey(ControlsSettings.up)) { input_y = 1; } else if (InputEx.GetKey(ControlsSettings.down)) { input_y = -1; } else { input_y = 0; } }:1616-1617(压 byte)。 - 视角用0-360 度的俯仰角,不是 Unity 惯用的 -90~90:
_pitch,clampPitch()把它夹在[0, 180](Unturned/Player/PlayerLook.cs:630-681Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:629-682)。俯仰 90 = 水平。⚠️ 这个反直觉约定在…还有 38 行没显示(共 54 行)
/// </summary> private void clampPitch() { float pitchMin; float pitchMax; Passenger seat = player.movement.getVehicleSeat(); if (seat != null) { if (seat.turret != null) { pitchMin = seat.turret.pitchMin; pitchMax = seat.turret.pitchMax; } else { pitchMin = MIN_ANGLE_SIT; pitchMax = MAX_ANGLE_SIT; } } else if (player.stance.stance == EPlayerStance.STAND || player.stance.stance == EPlayerStance.SPRINT) { pitchMin = MIN_ANGLE_STAND; pitchMax = MAX_ANGLE_STAND; } else if (player.stance.stance == EPlayerStance.CLIMB) { pitchMin = MIN_ANGLE_CLIMB; pitchMax = MAX_ANGLE_CLIMB; } else if (player.stance.stance == EPlayerStance.SWIM) { pitchMin = MIN_ANGLE_SWIM; pitchMax = MAX_ANGLE_SWIM; } else if (player.stance.stance == EPlayerStance.CROUCH) { pitchMin = MIN_ANGLE_CROUCH; pitchMax = MAX_ANGLE_CROUCH; } else if (player.stance.stance == EPlayerStance.PRONE) { pitchMin = MIN_ANGLE_PRONE; pitchMax = MAX_ANGLE_PRONE; } else { pitchMin = 0.0f; pitchMax = 180.0f; } _pitch = Mathf.Clamp(_pitch, pitchMin, pitchMax); }PlayerLook.cs:628-629Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:627-630 /// <summary> /// Clamp _pitch within the [0, 180] range. /// </summary> private void clampPitch()的注释里明确写了 “Clamp _pitch within the [0, 180] range”。 - 网络用角度是压成 byte 的:
angle = (byte)pitch; rot = MeasurementTool.angleToByte(yaw)(Unturned/Player/PlayerLook.cs:549Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:548-550 public void updateRot() {updateRot(),赋值在:551-565)。 - 打包好的状态结构:
// Unturned/Player/PlayerMovement.cs:26-38
public struct PlayerStateUpdate { public Vector3 pos; public byte angle; public byte rot; }
复刻必留:pitch ∈ [0,180](或你自己选一套,但必须统一,否则摄像机与瞄准会互相打架);复刻可砍:byte 压缩(单机不需要,课 25-28 再引入)。
3. 状态机与生命周期
转移条件
- 轻点姿态键(短按)短按在"蹲 / 站"之间切
- 轻点姿态键(短按)再短按一次切回站
- 按住姿态键 ≥ 0.33 秒长按才进趴,短按只切蹲/站
- 轻点姿态键趴着短按回到蹲
- 按住冲刺键并移动冲刺还要过体力/装备等门槛
- 松开冲刺键停止冲刺即回站姿
数值取自 docs/tutorials/lesson-01-engine-skeleton.md 的常量表,并与 reference 下的 tuning.gd 构建期对账;本图不另存一份数字。
3.1 姿态枚举与 8 种状态
Unturned/Player/EPlayerStance.cs 全文:
CLIMB, SWIM, SPRINT, STAND, CROUCH, PRONE, DRIVING, SITTING
3.2 状态转移全在一个函数里
…还有 132 行没显示(共 148 行)PlayerStance.simulate(uint simulation, bool inputCrouch, bool inputProne, bool inputSprint),Unturned/Player/PlayerStance.cs:570-715Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerStance.cs:569-716。判定顺序(这个顺序就是”优先级”):
:572-573— 先用眼睛/脚的位置问水:isSubmerged = areEyesUnderwater; inShallows = areFeetUnderwater:576-634— 爬梯优先:向前 0.75m 射线找Ladder标签(:579),并要求法线与梯子 up 轴点积> 0.9(:583),再用 CapsuleCast 确认落点无阻挡(:594-601),才checkStance(CLIMB):636-659— 身体入水 →SWIM并return(水优先于蹲):660-673— 齐脚深水 → 回STAND;离开水 → 回STAND:675-714— 陆地姿态:inputCrouch → CROUCH;inputProne → PRONE;两者都松开 →STAND;inputSprint && 体力>0 && isMoving → SPRINT
冲刺的三个门槛(PlayerStance.cs:703Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerStance.cs:702-704 }
if (inputSprint && !player.life.isBroken && player.life.stamina > 0 && doesEquipmentAllowSprinting && player.movement.isMoving)
{):inputSprint && !player.life.isBroken && player.life.stamina > 0 && doesEquipmentAllowSprinting && player.movement.isMoving。
复刻必留:一个”按固定优先级顺序检查、命中即改状态”的 simulate。可砍:爬梯、游泳、载具(DRIVING/SITTING)——单元一只做 STAND/CROUCH/PRONE/SPRINT 四态就够,但保留这段代码的形状,后面加状态是往序列里插一段。
3.3 蹲/趴是”按键时长”分辨的
…还有 29 行没显示(共 45 行)PlayerStance.Update(),Unturned/Player/PlayerStance.cs:725-767Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerStance.cs:724-768:按住姿态键超 0.33f(:735、:751)才从蹲切趴;短按就是在”蹲/站”之间切。
复刻必留(这是手感细节,成本极低):一个键 + 时长阈值 = 三种姿态。
3.4 驱动链条:谁在什么时候调用 simulate
这是本单元最重要的发现。Update 只采样输入,simulate 才是唯一改状态的地方。
PlayerInput.FixedUpdate() // Unturned/Player/PlayerInput.cs:1524
└─ if (count % SAMPLES == 0) // :1542 SAMPLES = 4
player.life.simulate(simulation) // :1574
player.stance.simulate(...) // :1582
player.movement.simulate(...) // :1590
player.equipment.simulate(...) // :1636
player.animator.simulate(...) // :1640
时间常量:
SAMPLES = 4 // Unturned/Player/PlayerInput.cs:878
RATE = 0.08f // :879 —— 一次 simulate 的固定步长(秒)
TOCK_PER_SECOND = 50 // :884 = (1 / RATE) * SAMPLES
所以:物理帧采样 4 次 → 逻辑帧 1 次,逻辑步长固定 0.08 秒(= 12.5 Hz 的权威模拟,50 Hz 的输入采样)。
PlayerMovement.simulate(...) 的正确签名在 Unturned/Player/PlayerMovement.cs:1051Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1050-1052 /// </summary>
public void simulate(uint simulation, int recov, int input_x, int input_y, float look_x, float look_y, bool inputJump, bool inputSprint, float deltaTime)
{:
public void simulate(uint simulation, int recov, int input_x, int input_y,
float look_x, float look_y, bool inputJump, bool inputSprint, float deltaTime)
复刻必留:把”读输入”和”改状态”分成两层,用一个固定步长的函数驱动。Godot 里对应就是”_unhandled_input/_process 只记账,_physics_process 里跑固定步长的 simulate()”。这直接决定了课 25-28能不能平滑接上联机,以及你的跳跃在不同帧率下是否一致。
3.5 初始化与销毁
Player.Awake():Unturned/Player/Player.cs:1696-1716Assets/Runtime/Assembly-CSharp/Unturned/Player/Player.cs:1695-1717— 抓组件引用。…还有 7 行没显示(共 23 行)
private void Awake() { _channel = GetComponent<SteamChannel>(); agro = 0; _animator = GetComponent<PlayerAnimator>(); _clothing = GetComponent<PlayerClothing>(); _inventory = GetComponent<PlayerInventory>(); _equipment = GetComponent<PlayerEquipment>(); _life = GetComponent<PlayerLife>(); _crafting = GetComponent<PlayerCrafting>(); _skills = GetComponent<PlayerSkills>(); _movement = GetComponent<PlayerMovement>(); _look = GetComponent<PlayerLook>(); _stance = GetComponent<PlayerStance>(); _input = GetComponent<PlayerInput>(); _voice = GetComponent<PlayerVoice>(); _interact = GetComponent<PlayerInteract>(); _workzone = GetComponent<PlayerWorkzone>(); _quests = GetComponent<PlayerQuests>(); }Player.InitializePlayer():Unturned/Player/Player.cs:1614-1646Assets/Runtime/Assembly-CSharp/Unturned/Player/Player.cs:1613-1647— 按固定顺序逐个初始化子组件:…还有 19 行没显示(共 35 行)
/// </summary> internal void InitializePlayer() { // 2022-10-13 this is a workaround for thirdperson-only servers which reparent the PlayerUI component during initialization. PlayerUI playerUI = null; if (channel.IsLocalPlayer) { playerUI = transform.Find("First")?.Find("Camera")?.GetComponent<PlayerUI>(); } // This is old fragile code that used to run in Start, and the components were in this order. InitializePlayerStart(); clothing.InitializePlayer(); inventory.InitializePlayer(); life.InitializePlayer(); skills.InitializePlayer(); crafting.InitializePlayer(); stance.InitializePlayer(); movement.InitializePlayer(); look.InitializePlayer(); interact.InitializePlayer(); animator.InitializePlayer(); equipment.InitializePlayer(); input.InitializePlayer(); voice.InitializePlayer(); if (workzone != null) { workzone.InitializePlayer(); } quests.InitializePlayer(); if (playerUI != null) { playerUI.InitializePlayer(); }clothing → inventory → life → skills → crafting → stance → movement → look → interact → animator → equipment → input。- 调用者:
NetMessaging/ClientMessageHandler_PlayerConnected.cs:111Assets/Runtime/Assembly-CSharp/NetMessaging/ClientMessageHandler_PlayerConnected.cs:110-112 default); newClient.player.InitializePlayer(); }—newClient.player.InitializePlayer()。 PlayerMovement.InitializePlayer():Unturned/Player/PlayerMovement.cs:1966-2052Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1965-2053— 初始化区域索引(…还有 73 行没显示(共 89 行)
internal void InitializePlayer() { itemGravityMultiplier = 1; pluginGravityMultiplier = 1; pluginSpeedMultiplier = 1; _region_x = 255; _region_y = 255; _bound = 255; _nav = 255; if (channel.IsLocalPlayer || Provider.isServer) { _loadedRegions = new LoadedRegion[Regions.WORLD_SIZE, Regions.WORLD_SIZE]; for (byte x = 0; x < Regions.WORLD_SIZE; x++) { for (byte y = 0; y < Regions.WORLD_SIZE; y++) { loadedRegions[x, y] = new LoadedRegion(); } } _loadedBounds = new LoadedBound[LevelNavigation.bounds.Count]; for (byte index = 0; index < LevelNavigation.bounds.Count; index++) { loadedBounds[index] = new LoadedBound(); } } warp_x = 1; warp_y = 1; if (Provider.isServer || channel.IsLocalPlayer) // if(channel.isOwner)// { controller = GetComponent<CharacterController>(); // Overlap recovery tries to force character's capsule out of // rigidbodies that it overlaps which might be fine in singleplayer, // but is inevitable in multiplayer. Often players abused overlaps // to teleport through walls and the like, so it's important to disable. // // Sadly there are issues when walking toward slopes that bend inward, // so CheckedMove performs a capsule sweep from start to end. // To test if it works in the future walk toward the inside of a barracks building in PEI base. // // 2021-09-16: tried enabling again because CheckedMove can return false positives, for example when // substep moves around a corner. Unfortunately vehicles falling on players heads or driven into a // player near a wall were abused in multiplayer to teleport through walls. In the future we might be // able to work around this by using fully authoritative vehicle physics. // // 2026-05-04: there is speculation that overlap and sweep tests necessary when overlap recovery is // *disabled* were very expensive on maps with lots of colliders, so we added an option to enable it. controller.enableOverlapRecovery = CharacterControllerExtension.EnableOverlapRecovery; } if (Provider.isServer) { //capsule = gameObject.AddComponent<CapsuleCollider>(); //capsule.isTrigger = true; //capsule.center = new Vector3(0, 1, 0); //capsule.radius = controller.radius; //capsule.height = controller.height; //capsule.enabled = false; //!channel.isOwner; player.life.onVisionUpdated += onVisionUpdated; } else { nsb = new NetworkSnapshotBuffer<PitchYawSnapshotInfo>(Provider.UPDATE_TIME, Provider.UPDATE_DELAY); } applySize(); if (Dedicator.IsDedicatedServer) { gameObject.AddComponent<Rigidbody>(); GetComponent<Rigidbody>().useGravity = false; GetComponent<Rigidbody>().isKinematic = true; } updateMovement(); updates = new List<PlayerStateUpdate>(); canAddSimulationResultsToUpdates = true; lastFootstep = Time.time; }_region_x = 255作”未设置”哨兵,:1972-1975)、抓CharacterController(:2000)、构建区域/Bound 加载表(:1979-1992)、applySize()(:2037)、updateMovement()(:2046)。
复刻必留:初始化顺序要有明确顺序,别依赖 _ready 的偶然顺序。
可砍:Region/Bound 加载表、Dedicator.IsDedicatedServer 分支(:2039-2044)、nsb 快照缓冲(:2034)。
4. 移动的核心算法(复刻要照抄的部分)
全部在 PlayerMovement.simulate(...) 里,按姿态分叉:
- 输入落到字段:
_move.x = input_x; _move.z = input_y(Unturned/Player/PlayerMovement.cs:1066-1067Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1065-1068 _move.x = input_x; _move.z = input_y;) - SITTING/DRIVING:
velocity = Vector3.zero直接返回(:1069-1105) - CLIMB:垂直速度
_move.z * speed * 0.5f(:1127) - SWIM:水中朝瞄准方向
look.aim.rotation * move.normalized * speed * 1.5f(:1143);贴水面游时 y 用(水面高度 - 1.275 - y) / 8f做浮力收敛(:1164) - 陆地(其余姿态):
:1173-1411- 可走坡度:默认
59度,可被关卡配置覆盖Level.info.configData.Max_Walkable_Slope(:1187-1191)。超坡度即sliding,加slide * 16.0f * deltaTime的侧滑(:1211-1219) - 站在僵尸头上会被判为滑动(
CompareTag("Agent"),:1199-1208) - 地面速度两套摩擦模型(
:1226-1273):ImmediatelyResponsive直接赋速度;否则用”基础减速 2.0 m/s² × 材质倍率”+“加速度与目标速度成正比”逼近——注释里写明”走速 4.5 m/s 则加速度 4.5 m/s²”(:1255、:1264-1266) - 空中:
velocity.y += Physics.gravity.y * 倍率 * deltaTime * 3(:1277),下落速度下限-100(:1280),水平方向用AirStrafing_*配置做加速/减速(:1297-1301) - 起跳条件(
:1308-1315):着地 && 未骨折 &&stamina >= 10 * (1 - PARKOUR*0.5)&& 姿态是 STAND 或 SPRINT && 跳跃倍率非零 →velocity.y = JUMP * (1 + PARKOUR*0.25) * 倍率,并扣体力 - 移动与贴地:
controller.CheckedMove(velocity * deltaTime)(:1343),落地事件onLanded?.Invoke(velocity.y)(:1354) - 贴地吸附:斜向下球扫
stepOffset + SKIN_WIDTH,命中且坡度 < 可走坡度才把玩家往下压(:1366-1401)。注释记录了一个真实事故:贴地吸附曾被用来”爬树”(:1390) - 移动后用位移反推真实速度
velocity = (position - oldPosition) / deltaTime(:1408)
- 可走坡度:默认
⚠️ …还有 77 行没显示(共 93 行)controller.CheckedMove 是 SDK 自写的扩展方法(不是 Unity 原生 Move),实现已在 Unturned/Utils/CharacterExtension.cs:94-184Assets/Runtime/Assembly-CSharp/Unturned/Utils/CharacterExtension.cs:93-185 读到:
- 若
EnableOverlapRecovery为真,直接退化成原生Move(motion)并返回(:96-100) - 否则先
Move,再用缩小后的胶囊(半径radius / 2、半高height / 3,:122-124)沿本次位移方向做CapsuleCast,命中就把位置回滚到出发前(:181) - 缓冲区只有 8 条(
CHECKED_MOVE_BUFFER_SIZE = 8,:15);重叠或命中数超过缓冲就直接放行(:136-143、:146-153)——宁可穿墙也不卡死 - 它扫的层见
RayMasks.CHARACTER_CONTROLLER_MOVE(Unturned/Utils/RayMasks.cs:179Assets/Runtime/Assembly-CSharp/Unturned/Utils/RayMasks.cs:178-180 /// </summary> public const int CHARACTER_CONTROLLER_MOVE = RESOURCE | LARGE | MEDIUM | ENVIRONMENT | GROUND | CLIP | BARRICADE | STRUCTURE;),不含 PLAYER 层(见 §11 的碰撞矩阵)
复刻取舍建议:Godot 的 CharacterBody3D.move_and_slide() 已经内建了坡度、台阶与滑动处理。单元一不要逐行照抄这套摩擦模型,先做到”四态速度表 + 跳 + 重力 + 着地判定”,跑起来有感觉再回来加摩擦与贴地吸附。
5. 摄像机
5.1 节点层级(Unity 侧)
PlayerLook.InitializePlayer(),Unturned/Player/PlayerLook.cs:1935-1983Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1934-1984:瞄准点是…还有 35 行没显示(共 51 行)
internal void InitializePlayer() { _aim = transform.Find("Aim").Find("Fire"); updateLook(); yawInputMultiplier = 1.0f; pitchInputMultiplier = 1.0f; if (channel.IsLocalPlayer) { if (Provider.cameraMode == ECameraMode.THIRD) { _perspective = EPlayerPerspective.THIRD; MainCamera.instance.transform.parent = player.transform; } else { _perspective = EPlayerPerspective.FIRST; } MainCamera.instance.fieldOfView = OptionsSettings.DesiredVerticalFieldOfView; targetExplosionLocalRotation.currentRotation = Quaternion.identity; targetExplosionLocalRotation.targetRotation = Quaternion.identity; characterHeight = 0; _characterYaw = 180; characterYaw = 0; if (player.character != null) { _characterCamera = player.character.Find("Camera").GetComponent<Camera>(); _characterCamera.eventMask = 0; } _scopeCamera = MainCamera.instance.transform.Find("Scope").GetComponent<Camera>(); scopeCamera.layerCullDistances = MainCamera.instance.layerCullDistances; scopeCamera.layerCullSpherical = MainCamera.instance.layerCullSpherical; scopeCamera.fieldOfView = 10.0f; scopeCamera.eventMask = 0; UnturnedPostProcess.instance.setScopeCamera(scopeCamera); LevelLighting.updateLighting(); player.life.onVisionUpdated += onVisionUpdated; player.life.onLifeUpdated += onLifeUpdated; player.movement.onSeated += onSeated; }transform.Find("Aim").Find("Fire")(:1937);MainCamera在第三人称时挂到player.transform(:1949),第一人称时挂到player.first(:482)。- 眼睛高度常量,
PlayerLook.cs:17-20Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:16-21 { private static readonly float HEIGHT_LOOK_SIT = 1.6f; private static readonly float HEIGHT_LOOK_STAND = 1.75f; private static readonly float HEIGHT_LOOK_CROUCH = 1.2f; private static readonly float HEIGHT_LOOK_PRONE = 0.35f;:HEIGHT_LOOK_SIT 1.6 / STAND 1.75 / CROUCH 1.2 / PRONE 0.35 - 摄像机局部位移常量,
PlayerLook.cs:47-50Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:46-51 private static readonly float HEIGHT_CAMERA_SIT = 0.7f; private static readonly float HEIGHT_CAMERA_STAND = 1.05f; private static readonly float HEIGHT_CAMERA_CROUCH = 0.95f; private static readonly float HEIGHT_CAMERA_PRONE = 0.3f;:HEIGHT_CAMERA_SIT 0.7 / STAND 1.05 / CROUCH 0.95 / PRONE 0.3 - 眼球高度是平滑过渡的:
eyes = Mathf.Lerp(eyes, targetEyeHeight, 4 * Time.deltaTime)(:1234-1235),目标值取自heightLook属性(:22-45,按姿态返回 1.75/1.2/0.35/1.6) - ⚠️
eyes的平滑不在固定步长里:PlayerLook.simulate(look_x, look_y, delta)(:593)只接收鼠标角度增量,而eyes的插值、摄像机摆放全部在Update()里(:1234、:1792),即渲染帧路径。Godot 侧对应_process而不是_physics_process。 - 平滑公式的性质:
lerp(x, target, 4 * delta)是指数收敛(时间常数 0.25 s,约 0.75 s 走完 95%),对变步长稳定,因为权重本身含delta。唯一要避开的坑是delta > 0.25时权重会 ≥ 1,需要钳制。 - 第一人称摆放:
mainCamera.transform.localPosition = new Vector3(0, eyes + verticalOffset, 0)(:1792),并且抬头会被天花板挤下来——向上球扫命中则压低eyes(:1775-1790) - 第三人称摆放:从眼位向后球扫,
sphereCastCamera(origin, direction, 2.0f, ...)(:1807-1809),支持左右肩偏移(Allow_Shoulder_Camera,:1796-1805)
复刻必留:眼睛高度随姿态平滑插值;第三人称的防穿墙球扫后退。
可砍:characterCamera(全身第三人称预览)、scopeCamera 与双渲染瞄具(:1973-1980)、Freecam 管理员功能(F1–F7,:1150-1232)、灵敏度缩放模式(:1420-1440)。
5.1.1 verticalOffset 的来源(已验证)
它是 PlayerLook.cs:1792Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1791-1793
mainCamera.transform.localPosition = new Vector3(0, eyes + verticalOffset, 0);
} 所在分支内的局部变量,不是字段,只影响第一人称:
PlayerLook.cs:1768-1770Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1767-1771 float verticalOffset; if (player.stance.stance == EPlayerStance.SITTING && player.movement.getVehicle() != null) { verticalOffset = player.movement.getVehicle().asset.camPassengerOffset; }— 坐姿且在载具里:verticalOffset = 载具资产.camPassengerOffsetPlayerLook.cs:1772-1774Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1771-1775 } else { verticalOffset = 0.0f;— 其余情况(含驾驶、站立、蹲、趴):verticalOffset = 0.0f- 载具资产字段定义:
Unturned/Bundles/VehicleAsset.cs:1400Assets/Runtime/Assembly-CSharp/Unturned/Bundles/VehicleAsset.cs:1399-1401 /// </summary> public float camDriverOffset {(camDriverOffset)与:1409(camPassengerOffset),从.dat读Cam_Driver_Offset/Cam_Passenger_Offset(:2288-2289) - 驱动姿态还有另一条独立分支:
PlayerLook.cs:1753Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1752-1754 { float verticalOffset = player.movement.getVehicle().asset.camDriverOffset + player.movement.getVehicle().asset.camPassengerOffset; if (yaw > 0)用的是camDriverOffset + camPassengerOffset
结论:单元一玩家步行状态下 verticalOffset 恒为 0,PitchPivot 高度就是 eyes。复刻时可以直接不实现它,等课 27 载具再回来。
5.1.2 第一人称的防穿顶(可选保留)
…还有 1 行没显示(共 17 行)PlayerLook.cs:1775-1789Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1774-1790:从 HEIGHT_LOOK_PRONE 高度向上做半径 0.25、长度 2.0 - 0.35 - 0.25 的球扫(castLength 的算法在 :1781),命中则把 eyes 压到 命中高度 - 0.25。
注意 :1788 用的是 Mathf.Min(eyes, maxEyesHeight)——只压不提,之后靠 :1235 的插值慢慢抬回去。所以”头顶有天花板时眼高会缓慢回弹又立刻被压住”,是源码的真实行为,不是 bug。
5.2 俯仰/偏航的夹取随姿态变化
…还有 38 行没显示(共 54 行)PlayerLook.clampPitch(),PlayerLook.cs:630-681Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:629-682:
STAND [0, 180] CROUCH [20, 160] PRONE [60, 120]
SWIM [45, 135] CLIMB [45, 100] 坐姿 [60, 120]
复刻必留:趴下不能抬头看天——这个夹取表就是”姿态=能力限制”的最便宜实现。
5.3 输入 → 视角的公式
PlayerLook.Update(),PlayerLook.cs:1465-1466Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1464-1467 {
_look_x = ControlsSettings.mouseAimSensitivity * Input.GetAxis("mouse_x") * yawInputMultiplier;
_look_y = ControlsSettings.mouseAimSensitivity * -Input.GetAxis("mouse_y") * pitchInputMultiplier;
}:
_look_x = ControlsSettings.mouseAimSensitivity * Input.GetAxis("mouse_x") * yawInputMultiplier;
_look_y = ControlsSettings.mouseAimSensitivity * -Input.GetAxis("mouse_y") * pitchInputMultiplier;
注意 …还有 3 行没显示(共 19 行)mouse_y 前的负号——它把”鼠标下移”变成 pitch 增大。然后 simulate() 里 _pitch = look_y; _yaw = look_x; 再夹取、updateRot()、transform.localRotation = Quaternion.Euler(0, yaw, 0)(PlayerLook.cs:593-609Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:592-610)——身体只转偏航,俯仰交给摄像机。
复刻必留:身体转 Yaw、摄像机转 Pitch 的分离。
6. 网络同步点(只列权威归属)
| 状态 | 权威方 | 证据 |
|---|---|---|
| 位置/旋转(移动结果) | 服务端 | PlayerMovement.cs:1474-1493Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1473-1494:只有 !channel.IsLocalPlayer && Provider.isServer 才把位置写进 updates 列表 |
姿态 EPlayerStance | 服务端模拟 | PlayerStance.cs:570Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerStance.cs:569-571
public void simulate(uint simulation, bool inputCrouch, bool inputProne, bool inputSprint)
{ 的 simulate 在服务端与客户端都被调用,像素级一致是”客户端预测”的前提 |
视角 pitch/yaw | 客户端采样 → 服务端接收 | PlayerLook.cs:1465Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1464-1466 {
_look_x = ControlsSettings.mouseAimSensitivity * Input.GetAxis("mouse_x") * yawInputMultiplier;
_look_y = ControlsSettings.mouseAimSensitivity * -Input.GetAxis("mouse_y") * pitchInputMultiplier;;服务端用 PlayerInput 收到的 walkingPacket.yaw/pitch 回放(PlayerInput.cs:1794-1806Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:1793-1807 UnityEngine.Profiling.Profiler.BeginSample("Look");
player.look.simulate(walkingPacket.yaw, walkingPacket.pitch, RATE);
UnityEngine.Profiling.Profiler.EndSample();
UnityEngine.Profiling.Profiler.BeginSample("Stance");
player.stance.simulate(simulation, keys[3], keys[4], keys[5]);
UnityEngine.Profiling.Profiler.EndSample();
UnityEngine.Profiling.Profiler.BeginSample("Move");
int moveInputX = ((analog >> 4) & 15) - 1;
int moveInputY = ((analog) & 15) - 1;
bool moveInputJump = keys[0];
bool moveInputSprint = keys[5];
player.movement.simulate(simulation, walkingPacket.recov, moveInputX, moveInputY, 0, 0, moveInputJump, moveInputSprint, PlayerInput.RATE);
UnityEngine.Profiling.Profiler.EndSample();) |
体力 _stamina | 服务端 | PlayerLife.cs:1162Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLife.cs:1161-1163
public void askTire(byte amount)
{ askTire / :1188 askRest,且 :1241 注释 “Called from the server to modify stamina” |
| 输入本身 | 客户端 | PlayerInputPacket 家族;PlayerInput.cs:1790-1810Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:1789-1811 是服务端回放点 |
⚠️ askTire 的具体鉴权路径我没有逐行读,只读了签名与注释。课 25-28 再细究。
复刻含义(单机):即使不做联机,把”输入(客户端)“和”模拟结果(权威)“分成两组字段,单元一就分开写。这样课 25-28只需要换掉”谁来调用 simulate”。
7. 与其它系统的耦合点
PlayerMovement→PlayerLife:起跳扣体力(PlayerMovement.cs:1313Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1312-1314 velocity.y = JUMP * (1f + (player.skills.mastery((int) EPlayerSpeciality.OFFENSE, (int) EPlayerOffense.PARKOUR) * 0.25f)) * pluginJumpMultiplier; player.life.askTire((byte) (10 * (1f - (player.skills.mastery((int) EPlayerSpeciality.OFFENSE, (int) EPlayerOffense.PARKOUR) * 0.5f)))); }调askTire)、player.life.isBroken禁跳(:1310)PlayerMovement→PlayerSkills:速度与跳跃的熟练度乘区(:297-323、:1310-1312)PlayerMovement→PlayerStance:几乎所有分支都读player.stance.stance(第一次出现在:1069)PlayerMovement→Level:坡度上限与地图边界钳制(:1188、:1413-1456)PlayerStance→ 装备:UseableGun瞄准时是否允许冲刺(PlayerStance.cs:698-702Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerStance.cs:697-703 bool doesEquipmentAllowSprinting = true; if (player.equipment.useable is UseableGun gun && player.equipment.asset is ItemGunAsset gunAsset) { doesEquipmentAllowSprinting = gunAsset.canAimDuringSprint || !gun.isAiming; } if (inputSprint && !player.life.isBroken && player.life.stamina > 0 && doesEquipmentAllowSprinting && player.movement.isMoving))PlayerLook→PlayerAnimator:前倾/flinch/枪械摆动(PlayerLook.cs:315-320Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:314-321 case EGraphicQuality.ULTRA: isDualRender = true; desiredResolution = 2048; break; default: case EGraphicQuality.OFF:、:1600-1601)PlayerInput→ 全部:PlayerInput.cs:1574-1640Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:1573-1641一次性驱动 life/stance/movement/equipment/animator…还有 53 行没显示(共 69 行)
UnityEngine.Profiling.Profiler.BeginSample("Life"); player.life.simulate(simulation); UnityEngine.Profiling.Profiler.EndSample(); bool inputCrouch = player.stance.crouch; bool inputProne = player.stance.prone; bool inputSprint = player.stance.sprint; UnityEngine.Profiling.Profiler.BeginSample("Stance"); player.stance.simulate(simulation, inputCrouch, inputProne, inputSprint); UnityEngine.Profiling.Profiler.EndSample(); int input_x = player.movement.horizontal - 1; int input_y = player.movement.vertical - 1; bool inputJump = player.movement.jump; UnityEngine.Profiling.Profiler.BeginSample("Move"); player.movement.simulate(simulation, 0, input_x, input_y, player.look.look_x, player.look.look_y, inputJump, inputSprint, PlayerInput.RATE); UnityEngine.Profiling.Profiler.EndSample(); if (Provider.isServer) { inputs.Clear(); } else { if (player.stance.stance == EPlayerStance.DRIVING) { clientPendingInput = new DrivingPlayerInputPacket(player.movement.getVehicle()); } else { WalkingPlayerInputPacket clientPendingWalkingInput = new WalkingPlayerInputPacket(); clientPendingWalkingInput.analog = (byte) ((player.movement.horizontal << 4) | player.movement.vertical); clientPendingWalkingInput.clientPosition = transform.position; clientPendingInput = clientPendingWalkingInput; ClientMovementInput historyInput = new ClientMovementInput(); historyInput.frameNumber = simulation; historyInput.crouch = inputCrouch; historyInput.prone = inputProne; historyInput.input_x = input_x; historyInput.input_y = input_y; historyInput.jump = inputJump; historyInput.sprint = inputSprint; historyInput.rotation = transform.rotation; historyInput.aimRotation = player.look.aim.rotation; #if UNITY_EDITOR || DEVELOPMENT_BUILD historyInput.debugPosition = transform.position; historyInput.debugVelocity = player.movement.velocity; historyInput.debugIsGrounded = player.movement.isGrounded; #endif // UNITY_EDITOR || DEVELOPMENT_BUILD clientInputHistory.Add(historyInput); } clientPendingInput.clientSimulationFrameNumber = simulation; clientPendingInput.recov = recov; clientPendingInput.pitch = player.look.pitch; clientPendingInput.yaw = player.look.yaw; } UnityEngine.Profiling.Profiler.BeginSample("Equipment"); player.equipment.simulate(simulation, pendingPrimaryAttackInput, pendingSecondaryAttackInput, player.stance.localWantsToSteadyAim); UnityEngine.Profiling.Profiler.EndSample(); UnityEngine.Profiling.Profiler.BeginSample("Animator"); player.animator.simulate(simulation, player.animator.leanLeft, player.animator.leanRight); UnityEngine.Profiling.Profiler.EndSample();
复刻含义:这就是你要画的依赖图。单元一只需要实现实线里的 PlayerInput → {Stance, Movement, Look},其余用空实现占位。
8. 复刻清单(单元一)
必须保留
- 四组件分层:Input / Stance / Movement / Look,别合并。
- 固定步长
simulate(deltaTime)驱动;输入采样与状态修改分离。 - 姿态状态机用”固定优先级顺序 + 命中即改”。
- 速度表按姿态取值;身高/半径/眼睛高度随姿态变。
- 俯仰随姿态夹取;身体转 Yaw、摄像机转 Pitch。
- 起跳的体力门槛(证明”移动”和”生存”是耦合的)。
可以砍掉(单元一)
- 载具、坐姿、爬梯、游泳、水中浮力
- 熟练度乘区、断骨状态
- 关卡边界钳制、区域/Bound 加载表、地下白名单
- 所有
DEDICATED_SERVER/UNITY_EDITOR分支、RuntimeGizmos调试 - Freecam、瞄具摄像机、灵敏度缩放模式、视角切换
- byte 压缩与快照插值(
NetworkSnapshotBuffer)
9. 一句话给复刻师
你要在 Godot 里搭的是:CharacterBody3D(Movement + 胶囊)+ 一个只管 Yaw 的 AimPivot 子节点(Look)+ 一个持状态枚举的 StanceController + 一个积累输入的 InputBuffer,由 _physics_process 以固定步长调用四者的 simulate()。数值直接抄 PlayerMovement.cs:91-104Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:90-105
private static readonly float SPEED_CLIMB = 4.5f;
private static readonly float SPEED_SWIM = 3f;
private static readonly float SPEED_SPRINT = 7f;
private static readonly float SPEED_STAND = 4.5f;
private static readonly float SPEED_CROUCH = 2.5f;
private static readonly float SPEED_PRONE = 1.5f;
/// <summary>
/// Jump speed = sqrt(2 * jump height * gravity)
/// Jump height = (jump speed ^ 2) / (2 * gravity)
/// With 7 speed and 9.81 * 3 gravity = apex height of 1.66496772
/// </summary>
private static readonly float JUMP = 7.0f;
private static readonly float SWIM = 3;,单元一把 exercise、multiplier 全部固定为 1.0。
四个可直接落地的细节:
SpringArm3D的shape用SphereShape3D,半径取 0.39——Unturned 的球扫半径是NEAR_CLIP_SWEEP_RADIUS = 0.39f(Unturned/Player/PlayerLook.cs:1091Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1090-1092 // otherwise wide aspect ratios with wide field of view easily clips through walls. private const float NEAR_CLIP_SWEEP_RADIUS = 0.39f; // PlayerStance.RADIUS,注释里写明它等于PlayerStance.RADIUS)。Godot 的SpringArm3D默认不设 shape 就是一条射线,贴墙时摄像机会插进几何体。SpringArm3D的后退距离:单元一取 4 m 没问题。源码里第三人称的长度是2.0f(PlayerLook.cs:1808Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1807-1809 Vector3 origin = player.first.position + new Vector3(0.0f, thirdPersonEyeHeight, 0.0f); float length = 2.0f; mainCamera.transform.position = sphereCastCamera(origin, direction, length, RayMasks.BLOCK_PLAYERCAM);),但那是从”眼位”起算并额外带一个向后 1.5 m + 向上 0.5 m 的方向偏移(:1803),不是纯后退,所以别把 2.0 当成 Unturned 的真实摄像机距离。- 眼高插值放
_process,权重写成min(4.0 * delta, 1.0),避免低帧率下权重超过 1 导致过冲。姿态切换时眼高应从旧值插值,不要瞬移,否则蹲下抬头会有跳变(源码行为见 5.1)。 verticalOffset单元一写 0(见 5.1.1),别为它留接口。
9.1 与 Godot 的已知落差
move_and_slide()属于CharacterBody3D自身,所以位移执行必须放在外壳,Movement节点只负责算velocity。这与 Unity 里PlayerMovement自己持有CharacterController不同,但分层不变。- Autoload 单元一为 0 个:输入映射属于 Project Settings → Input Map;单例只在该系统需要活过场景切换时才引入。
10. 数值速查表(单元一实现用)
| 项 | 值 | 证据 |
|---|---|---|
| 走 / 冲刺 / 蹲 / 趴 / 爬 / 游 速度 | 4.5 / 7 / 2.5 / 1.5 / 4.5 / 3 m·s⁻¹ | PlayerMovement.cs:91-96Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:90-97
private static readonly float SPEED_CLIMB = 4.5f;
private static readonly float SPEED_SWIM = 3f;
private static readonly float SPEED_SPRINT = 7f;
private static readonly float SPEED_STAND = 4.5f;
private static readonly float SPEED_CROUCH = 2.5f;
private static readonly float SPEED_PRONE = 1.5f; |
| 起跳初速度 | 7.0 | PlayerMovement.cs:103Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:102-104 /// </summary>
private static readonly float JUMP = 7.0f;
private static readonly float SWIM = 3; |
| 胶囊高 站/蹲/趴 | 2.0 / 1.2 / 0.8 | PlayerMovement.cs:49-51Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:48-52 {
public static readonly float HEIGHT_STAND = 2;
public static readonly float HEIGHT_CROUCH = 1.2f;
public static readonly float HEIGHT_PRONE = 0.8f; |
| 胶囊半径 | 0.4 | PlayerStance.cs:19Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerStance.cs:18-20
public static readonly float RADIUS = 0.4f; |
| 眼高 站/蹲/趴/坐 | 1.75 / 1.2 / 0.35 / 1.6 | PlayerLook.cs:17-20Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:16-21 {
private static readonly float HEIGHT_LOOK_SIT = 1.6f;
private static readonly float HEIGHT_LOOK_STAND = 1.75f;
private static readonly float HEIGHT_LOOK_CROUCH = 1.2f;
private static readonly float HEIGHT_LOOK_PRONE = 0.35f; |
| 摄像机局部高 站/蹲/趴/坐 | 1.05 / 0.95 / 0.3 / 0.7 | PlayerLook.cs:47-50Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:46-51
private static readonly float HEIGHT_CAMERA_SIT = 0.7f;
private static readonly float HEIGHT_CAMERA_STAND = 1.05f;
private static readonly float HEIGHT_CAMERA_CROUCH = 0.95f;
private static readonly float HEIGHT_CAMERA_PRONE = 0.3f; |
| 俯仰范围 站/蹲/趴/游/爬 | [0,180] / [20,160] / [60,120] / [45,135] / [45,100] | PlayerLook.cs:77-86Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:76-87
private static readonly float MIN_ANGLE_SIT = 60;
private static readonly float MAX_ANGLE_SIT = 120;
private static readonly float MIN_ANGLE_CLIMB = 45;
private static readonly float MAX_ANGLE_CLIMB = 100;
private static readonly float MIN_ANGLE_SWIM = 45;
private static readonly float MAX_ANGLE_SWIM = 135;
private static readonly float MIN_ANGLE_STAND = 0;
private static readonly float MAX_ANGLE_STAND = 180;
private static readonly float MIN_ANGLE_CROUCH = 20;
private static readonly float MAX_ANGLE_CROUCH = 160;
private static readonly float MIN_ANGLE_PRONE = 60; + :649-673 |
| 可走坡度上限 | 59° | PlayerMovement.cs:1187Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1186-1188
float maxWalkableSlope = 59;
if (Level.info != null && Level.info.configData != null && Level.info.configData.Max_Walkable_Slope > -0.5f) |
| 地面摩擦基础减速 | 2.0 m·s⁻² | PlayerMovement.cs:1255Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1254-1256 {
// Base deceleration is 2.0 m/s²
float deceleration = -2.0f * frictionProperties.decelerationMultiplier; |
| 空中重力倍率 | g * 3 | PlayerMovement.cs:1277Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1276-1278 {
velocity.y += Physics.gravity.y * (fall <= 0 ? totalGravityMultiplier : 1f) * deltaTime * 3; |
| 下落速度下限 | −100 m·s⁻¹ | PlayerMovement.cs:1280Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1279-1281 // Clamp free-fall / terminal velocity
float minVerticalVelocity = totalGravityMultiplier < 0.99f ? Physics.gravity.y * 2.0f * totalGravityMultiplier : -100.0f;
velocity.y = Mathf.Max(minVerticalVelocity, velocity.y); |
| 蹲/趴切分阈值 | 0.33 s | PlayerStance.cs:735Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerStance.cs:734-736 {
if (Time.realtimeSinceStartup - lastHold > 0.33f)
{、:751 |
| 眼高插值系数 | 4 /s | PlayerLook.cs:1235Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1234-1236 float targetEyeHeight = heightLook;
eyes = Mathf.Lerp(eyes, targetEyeHeight, 4 * Time.deltaTime);
if (player.movement.controller != null) |
| 相机球扫半径 | 0.39 | PlayerLook.cs:1091Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1090-1092 // otherwise wide aspect ratios with wide field of view easily clips through walls.
private const float NEAR_CLIP_SWEEP_RADIUS = 0.39f; // PlayerStance.RADIUS |
| 姿态切换冷却 | 0.5 s | PlayerStance.cs:17Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerStance.cs:16-18 {
public static readonly float COOLDOWN = 0.5f; |
| 体力 上限 / 冲刺每秒耗 | 100 / 1(每次 simulate 扣 1) | PlayerLife.cs:364Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLife.cs:363-365
_stamina = 100;
_oxygen = newOxygen;、:1792-1799 |
⚠️ 最后一行说明:SimulateStaminaFrame 在满足条件时每次调用扣 1 点,而它由输入逻辑帧驱动(RATE = 0.08f),所以量级约 12.5 点/秒,满体力冲刺约 8 秒见底。单元一若用 60 Hz 逻辑帧,必须按时间累加而不是每帧扣 1,否则体力会 1.7 秒就见底。⚠️ 它还带一个 simulation - lastTire > 的间隔门槛(PlayerLife.cs:1793Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLife.cs:1792-1794 }
internal void SimulateStaminaFrame(uint simulation)),精确的每秒扣除率我没逐帧验证,只用它做量级参考。
11. 碰撞层与射线掩码(原版怎么避免”打到自己”)
这一节是读者提问带出来的,直接改单元一的一条结论,所以补在文末。
Unturned/Utils/LayerMasks.cs:9-10Assets/Runtime/Assembly-CSharp/Unturned/Utils/LayerMasks.cs:8-11 {
DEFAULT = 0,
TRANSPARENT_FX = 1,
IGNORE_RAYCAST = 2,:PLAYER = 9、ENEMY = 10——本地玩家和其他玩家在两个不同的层上。
Unturned/Utils/RayMasks.cs:93Assets/Runtime/Assembly-CSharp/Unturned/Utils/RayMasks.cs:92-94 public static readonly int CLOTHING_INTERACT = PLAYER | ENEMY | ITEM;
public static readonly int PLAYER_INTERACT = ENEMY | ITEM | RESOURCE | LARGE | MEDIUM | SMALL | ENVIRONMENT | GROUND | VEHICLE | BARRICADE | STRUCTURE | LADDER | DEFAULT;
public static readonly int EDITOR_INTERACT = LARGE | MEDIUM | SMALL | BARRICADE | STRUCTURE;:PLAYER_INTERACT = ENEMY | ITEM | RESOURCE | LARGE | MEDIUM | SMALL | ENVIRONMENT | GROUND | VEHICLE | BARRICADE | STRUCTURE | LADDER | DEFAULT。
关键:它含 ENEMY,不含 PLAYER。所以原版从摄像机打出去的交互射线不会命中本地玩家自己——不是靠”忽略自己”,而是掩码里根本没有本地玩家那一层;其他玩家在 ENEMY 层照常能被交互(:317 的”对着玩家显示名字”就这么来的)。
同一条思路还有第二处:RayMasks.CHARACTER_CONTROLLER_MOVE(:179)同样不含 PLAYER / ENEMY,也就是说原版玩家之间不互相碰撞(角色控制器移动扫描的层里没有对方)。
还有一处容易被忽略的一致性:RayMasks.BLOCK_PLAYERCAM(:115,第三人称相机球扫用的掩码)也不含 PLAYER / ENEMY——相机不会被其他玩家顶飞。
结论(对 Godot 侧):Godot 里如果玩家和世界在同一个碰撞层,从身后 4 m 打出去的交互射线会先命中自己的胶囊。这不是 bug,是层的划分问题。合理做法是原版的划法:玩家单独一层(默认第 2 层,避开 Godot 物理内建的 layer 1 用途),且地面/道具/建筑在 layer 1;交互射线的 collision_mask 只写 1。⚠️ Godot 原生 CharacterBody3D.move_and_slide() 与自己所在层的关系我没实测,这一条待复刻师验证。
12. 未验证项(诚实清单)
CharacterControllerExtension.CheckedMove的实现 → 已读到,见 §4(Unturned/Utils/CharacterExtension.cs:94-184Assets/Runtime/Assembly-CSharp/Unturned/Utils/CharacterExtension.cs:93-185)。…还有 77 行没显示(共 93 行)
/// </summary> public static void CheckedMove(this CharacterController component, Vector3 motion) { if (EnableOverlapRecovery) { component.Move(motion); return; } Vector3 oldPosition = component.transform.position; component.Move(motion); Vector3 newPosition = component.transform.position; Vector3 translation = newPosition - oldPosition; float sqrMaxDistance = translation.sqrMagnitude; if (sqrMaxDistance < 0.00001f) { // Did not move, so our custom check is not required. // Warning: be careful with the threshold because 0.01f was too high (players could wiggle into stuff). return; } float maxDistance = Mathf.Sqrt(sqrMaxDistance); Vector3 direction = translation / maxDistance; // 2022-04-04: experimented with separate height+radius for overlap and cast in order to catch any *slight* // overlaps, but it made it too easy to clip through objects like the PEI barracks. float adjustedHalfHeight = component.height / 3; float adjustedRadius = component.radius / 2; Vector3 capOffset = new Vector3(0, adjustedHalfHeight - adjustedRadius, 0); Vector3 capsuleCenter = oldPosition + component.center; Vector3 point1 = capsuleCenter - capOffset; Vector3 point2 = capsuleCenter + capOffset; int layerMask = RayMasks.CHARACTER_CONTROLLER_MOVE; const QueryTriggerInteraction queryTriggerInteraction = QueryTriggerInteraction.Ignore; // Character may have been overlapping a collider prior to moving. For example a tree respawning while // standing on the stump, in which case we do not want to trap them. int initialOverlapCount = Physics.OverlapCapsuleNonAlloc(point1, point2, adjustedRadius, initialOverlaps, layerMask, queryTriggerInteraction); if (initialOverlapCount >= initialOverlaps.Length) { // Overlapping more colliders than our buffer has space for, so just allow character to move through. #if LOG_CC_CHECKED_MOVE UnturnedLog.info("Too many overlaps: {0}", string.Join(", ", initialOverlaps, 0, initialOverlapCount)); #endif return; } int hitCount = Physics.CapsuleCastNonAlloc(point1, point2, adjustedRadius, direction, results, maxDistance, layerMask, queryTriggerInteraction); if (hitCount >= results.Length) { // Hit more colliders than our buffer has space for, so just allow character to move through. #if LOG_CC_CHECKED_MOVE UnturnedLog.info("Too many hits: {0}", string.Join(", ", results, 0, hitCount)); #endif return; } // May have went through a wall, so if the hits were not initially overlapping revert the move. if (hitCount > 0) { if (initialOverlapCount > 0) { if (wereAllHitsInitialOverlaps(hitCount, initialOverlapCount)) { #if LOG_CC_CHECKED_MOVE UnturnedLog.info("Overlaps: {0}", overlapsToString(initialOverlapCount)); #endif return; // Only went through objects that were initially overlapping, so do not revert. } else { #if LOG_CC_CHECKED_MOVE UnturnedLog.info("Hits: {0} Overlaps: {1}", hitsToString(hitCount), overlapsToString(initialOverlapCount)); #endif } } else { #if LOG_CC_CHECKED_MOVE UnturnedLog.info("Hits: {0}", hitsToString(hitCount)); #endif } component.transform.position = oldPosition; } } private struct PendingEnableRigidbodyPlayerInput的包结构细节与walkingPacket/drivingPacket字段全集——只读了:1790-1810的回放片段。PhysicsMaterialCharacterFrictionProperties的默认值表——只读了PlayerMovement.cs:1226-1269Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerMovement.cs:1225-1270的使用方式。…还有 30 行没显示(共 46 行)
{ PhysicsMaterialCharacterFrictionProperties frictionProperties = PhysicMaterialCustomData.GetCharacterFrictionProperties(materialName); if (frictionProperties.mode == EPhysicsMaterialCharacterFrictionMode.ImmediatelyResponsive) { // Rather than adding gravity while grounded to smoothly walk down slopes, we adjust the // downward velocity to align with the floor plane. We do not allow an upward velocity here // because it would bounce us over the top of the ramp while walking up a slope. Vector3 desiredWalkRightDirection = Vector3.Cross(Vector3.up, desiredWalkVelocity).normalized; Vector3 desiredWalkForwardDirection = Vector3.Cross(desiredWalkRightDirection, ground.normal).normalized; desiredWalkVelocity = desiredWalkForwardDirection * speed; desiredWalkVelocity.y = Mathf.Min(desiredWalkVelocity.y, 0.0f); // Immediately responsive velocity = desiredWalkVelocity; } else { Vector3 currentVelocityAlongFloor = Vector3.ProjectOnPlane(velocity, ground.normal); float currentSpeedAlongFloor = currentVelocityAlongFloor.magnitude; Vector3 desiredWalkRightDirection = Vector3.Cross(Vector3.up, desiredWalkVelocity).normalized; Vector3 desiredWalkDirectionAlongFloor = Vector3.Cross(desiredWalkRightDirection, ground.normal).normalized; Vector3 desiredWalkVelocityAlongFloor = desiredWalkDirectionAlongFloor * speed; // note we do not clamp Y component here so that we can slide off jumps desiredWalkVelocityAlongFloor *= frictionProperties.maxSpeedMultiplier; float desiredSpeed = desiredWalkVelocityAlongFloor.magnitude; float maxSpeed; if (currentSpeedAlongFloor > desiredSpeed) { // Base deceleration is 2.0 m/s² float deceleration = -2.0f * frictionProperties.decelerationMultiplier; maxSpeed = Mathf.Max(desiredSpeed, currentSpeedAlongFloor + (deceleration * deltaTime)); } else { maxSpeed = desiredSpeed; } // Questionable units-wise, but pretend base acceleration is proportional to desired speed. // For example if walk speed is 4.5 m/s then acceleration is 4.5 m/s². Vector3 acceleration = desiredWalkVelocityAlongFloor * frictionProperties.accelerationMultiplier; Vector3 newVelocity = currentVelocityAlongFloor + (acceleration * deltaTime); velocity = newVelocity.ClampMagnitude(maxSpeed);MainCamera单例本身的实现(Unturned/Game/MainCamera.cs:14Assets/Runtime/Assembly-CSharp/Unturned/Game/MainCamera.cs:13-15 public class MainCamera : MonoBehaviour {),只确认了类声明位置。Player.cs:298-304Assets/Runtime/Assembly-CSharp/Unturned/Player/Player.cs:297-305 private Transform _first; public Transform first => _first; private Transform _third; public Transform third => _third; private Transform _character; public Transform character => _character;的first/third/character三个 Transform 的父子关系与预制体层级没读预制体资源,只从PlayerLook.cs:482Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:481-483 { MainCamera.instance.transform.parent = player.first; MainCamera.instance.transform.localPosition = Vector3.up * eyes;、:1726、:1807、:1949的使用反推:first是眼位,character是第三人称预览模型。
已从清单里划掉(本轮验证):verticalOffset 的来源 → 见 5.1.1;eyes 插值是否受变步长影响 → 见 5.1;CheckedMove 的实现 → 见 §4。