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

第 2 单元 / 解剖官

说明书 1/1 · systems/02-interaction

交互与拾取(单元二)

本文只解剖一件事:玩家怎么”选中”世界里的东西,以及选中之后发生什么。对应课程单元二”交互与拾取”。

0. 本文范围与口径

  • 源码根:/home/jay/gameprojects/U3-SDK/,下文 Unturned/... 相对 Assets/Runtime/Assembly-CSharp/。
  • 涉及目录规模(wc -l 实测):Unturned/Interactable/ 55 个文件,Unturned/Useable/ 31 个文件、合计 18207 行。其中 UseableGun.cs 一个文件 6602 行——单元二不要读它。
  • 带 ⚠️ 的是没逐行验证的。
  • 一句话结论:交互的架构是**“客户端选目标 + 服务端判合法性”两层**,而且交互(Interactable)和用(Useable)是两套完全独立的机制——这是单元二最容易搞混的地方。

1. 两张表:Interactable 与 Useable 是两条路

初学者最容易把这两件事当成一件事。源码里它们是分开的:

InteractableUseable
挂在哪世界里的物体(门、补给、掉落的物品)玩家身上的 PlayerEquipment 组件动态挂载
什么时候出现场景/生成时就有装备物品时才 AddComponent
触发者交互键(对着物体按)主/副攻击键(手里的枪、近战武器、食物)
基类契约Interactable.cs(72 行)Useable.cs(60 行)
生命周期无 tickequip/dequip/tick/simulate/tock 全套

证据:Useable 挂在玩家身上——Unturned/Player/PlayerEquipment.cs:1621Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerEquipment.cs:1620-1622 _useable = gameObject.AddComponent(asset.useableType) as Useable; _useable.AssignNetId(useableNetId); _useable = gameObject.AddComponent(asset.useableType) as Useable;,这里的 gameObject 是玩家对象。装备时按物品资产的 useableType 反射出具体类型,并调 useable.equip()(:1632)。

复刻含义:单元二你只需要做 Interactable 一条路。Useable 属于单元三(物品栏与装备)。但你现在必须知道它们是分开的,否则单元三会把两套东西揉成一个大 interact() 函数。

2. Interactable 的完整契约(只有 5 个虚函数)

Unturned/Interactable/Interactable.cs 全文只有 72 行,全部接口:

:13  public virtual void updateState(Asset asset, byte[] state)     // 从存档/网络恢复状态
:18  public virtual bool checkInteractable()                        // 现在能不能被聚焦
:23  public virtual bool checkUseable()                             // 现在能不能按
:28  public virtual bool checkHighlight(out Color color)             // 描边颜色
:35  public virtual bool checkHint(out EPlayerMessage message, out string text, out Color color)  // 提示文字
:44  public virtual void use()                                      // 按下后发生什么

另外两个非虚的:

  • :11 IsChildOfVehicle => transform.parent != null && transform.parent.CompareTag("Vehicle")
  • :49 / :54 / :60 GetNetId / AssignNetId / ReleaseNetId —— 网络 ID(单元二单机不用)

复刻必留:这套契约的形状。它把”能不能交互”(checkInteractable)、“能不能按”(checkUseable)、“说什么”(checkHint)、“按了做什么”(use)分成了四个独立问题——这正是 UI 能分别显示灰/绿/红色提示的原因。

复刻可砍:updateState(存档用)、NetId 三件套。

范例:掉落在地上的物品,Unturned/Interactable/InteractableItem.cs:

  • use() 定义在 :18,方法体只有一行:ItemManager.takeItem(transform.parent, 255, 255, 0, 255)(:20)——四个 255 是”自动挑空位”的哨兵值,签名见 Unturned/Managers/ItemManager.cs:137Assets/Runtime/Assembly-CSharp/Unturned/Managers/ItemManager.cs:136-138 public static void takeItem(Transform item, byte to_x, byte to_y, byte to_rot, byte to_page) {
  • checkHighlight(:23)和 checkHint(:29)的颜色都由稀有度决定:ItemTool.getRarityColorHighlight(asset.rarity)(:25)与 getRarityColorUI(asset.rarity)(:31)
  • clampRange()(:42 起):掉了超过 20 m(sqrMagnitude > 400,:48)就把物品拉回原位——防物品卡出地图

复刻必留:稀有度决定颜色。这是一个几乎零成本、但极大提升”值不值得捡”判断的设计。

3. 第二层契约:Interactable2(长按)

Unturned/Interactable/Interactable2.cs(全文 30 行)是独立于 Interactable 的第二个组件:

:11  public bool hasOwnership => OwnershipTool.checkToggle(owner, group);
:13  public ulong owner;              // 谁建的
:14  public ulong group;              // 哪个队伍
:15  public float salvageDurationMultiplier = 1.0f;   // 长按时长倍率
:17  public virtual bool checkHint(out EPlayerMessage message, out float data)
:25  public virtual void use()

为什么要分两层:因为一个物体可能”能短按开箱”(Interactable)同时又”能长按拆掉”(Interactable2)。同一个键,靠按住的时长区分两个动作。

证据链:Unturned/Player/PlayerInteract.cs:228-229Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:227-230 // Nelson 2024-09-18: FindTargetTransform requires focus to be within interactable's tree. _interactable = focus.GetComponentInParent<Interactable>(); _interactable2 = focus.GetComponentInParent<Interactable2>(); if (_interactable == null && focus.CompareTag("Ladder")) 同时抓两个组件:

_interactable  = focus.GetComponentInParent<Interactable>();
_interactable2 = focus.GetComponentInParent<Interactable2>();

复刻必留:这个”一个键 = 短按 A / 长按 B”的双层结构。基地建造与拆除(课 21)直接依赖它。

4. 目标选择:射线检测(本单元的核心)

全在 Unturned/Player/PlayerInteract.cs 的 Update()(:170-519)。

4.1 射线从哪打、打多远

PlayerInteract.cs:182-195Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:181-196 int interactRayMask = RayMasks.PLAYER_INTERACT; if (player.stance.stance == EPlayerStance.CLIMB) { interactRayMask &= ~RayMasks.LADDER; } if (player.look.IsLocallyUsingFreecam) { Physics.Raycast(new Ray(player.look.aim.position, player.look.aim.forward), out hit, 4f, interactRayMask); } else { Physics.Raycast(new Ray(MainCamera.instance.transform.position, MainCamera.instance.transform.forward), out hit, player.look.perspective == EPlayerPerspective.THIRD ? 6 : 4, interactRayMask); }:

int interactRayMask = RayMasks.PLAYER_INTERACT;
if (player.stance.stance == EPlayerStance.CLIMB)
    interactRayMask &= ~RayMasks.LADDER;          // 爬梯时别再选中梯子
...
if (player.look.IsLocallyUsingFreecam)
    Physics.Raycast(aim.position, aim.forward, out hit, 4f, interactRayMask);
else
    Physics.Raycast(MainCamera.instance.transform.position,
                    MainCamera.instance.transform.forward, out hit,
                    player.look.perspective == EPlayerPerspective.THIRD ? 6 : 4, interactRayMask);

三个关键数字与选择:

项值证据
第一人称射程4 mPlayerInteract.cs:194Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:193-195 { Physics.Raycast(new Ray(MainCamera.instance.transform.position, MainCamera.instance.transform.forward), out hit, player.look.perspective == EPlayerPerspective.THIRD ? 6 : 4, interactRayMask); }
第三人称射程6 m同上(三元表达式)
Freecam 射程4 m,从瞄准点起算:190
射线间隔0.1 s:178 Time.realtimeSinceStartup - lastInteract > 0.1f
射线掩码PLAYER_INTERACT:182,定义在 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;

RayMasks.PLAYER_INTERACT(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;)是 13 个层的并集:ENEMY | ITEM | RESOURCE | LARGE | MEDIUM | SMALL | ENVIRONMENT | GROUND | VEHICLE | BARRICADE | STRUCTURE | LADDER | DEFAULT。

注意它含 ENEMY 但不含 PLAYER(层号见 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)。也就是说原版本地玩家自己单独占一层,而交互射线掩码里根本没列这一层——第三人称从身后打出去也不会命中自己。这一条与单元一说明书 §11 是同一件事,那一节写了完整的碰撞矩阵。

复刻含义(重要体会):第三人称给 6 m、第一人称给 4 m。这不是随便定的——第三人称时摄像机在玩家身后,如果还用 4 m,玩家会觉得”我明明指着它却点不到”。这是”摄像机位置改变后必须重新标定交互距离”的真实案例。

⚠️ 但注意这 6 和 4 的起点都是摄像机,不是眼睛(:194 用的是 MainCamera.instance.transform.position)。原版第三人称相机只在眼后约 1.9 m,所以 6 和 4 折算到”眼前”是同一个数(约 4 m)。我们单元一把相机退到 4 m,抄 6 就只剩眼前 2 m——这条落差由复刻师的 reference/01-player-controller/interact_range.tscn 实测钉死了,结论是8 m 才等价原版手感。

Godot 对应:RayCast3D 或 PhysicsRayQueryParameters3D + intersect_ray()。Godot 的碰撞层(collision layer/mask)对应这里的 Unity LayerMask。

4.2 只在”焦点变化”时才重建目标

PlayerInteract.cs:204-275Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:203-276

…还有 58 行没显示(共 74 行)

Transform newFocus = hit.collider != null ? hit.collider.transform : null; bool newHasFocus = newFocus != null; if (newFocus != focus || newHasFocus != didHaveFocus) { UnityEngine.Profiling.Profiler.BeginSample("Unhighlight"); clearHighlight(); UnityEngine.Profiling.Profiler.EndSample(); focus = null; didHaveFocus = false; target = null; _interactable = null; _interactable2 = null; UnityEngine.Profiling.Profiler.BeginSample("Highlight"); if (newFocus != null) { focus = newFocus; didHaveFocus = true; UnityEngine.Profiling.Profiler.BeginSample("GetInteractable"); // Nelson 2024-09-18: FindTargetTransform requires focus to be within interactable's tree. _interactable = focus.GetComponentInParent<Interactable>(); _interactable2 = focus.GetComponentInParent<Interactable2>(); if (_interactable == null && focus.CompareTag("Ladder")) { // 2022-04-19: adding Climb [F] prompt to ladders, but we have many years of ladders // created without this component. _interactable = focus.gameObject.AddComponent<InteractableLadder>(); } UnityEngine.Profiling.Profiler.EndSample(); if (interactable != null) { UnityEngine.Profiling.Profiler.BeginSample("FindChildRecursive"); target = FindTargetTransform(focus, interactable.transform); UnityEngine.Profiling.Profiler.EndSample(); if (interactable.checkInteractable()) { if (PlayerUI.window.isEnabled) { Color color; if (interactable.checkUseable()) { if (!interactable.checkHighlight(out color)) { color = Color.green; } } else { color = Color.red; } setHighlight(interactable.transform, color); } } else { target = null; _interactable = null; } } } UnityEngine.Profiling.Profiler.EndSample(); } }:拿到 hit.collider.transform 后,只有焦点变了才重算:

:206  if (newFocus != focus || newHasFocus != didHaveFocus)
:210      clearHighlight();
:214-218  focus = null; didHaveFocus = false; target = null; _interactable = null; _interactable2 = null;
:224      focus = newFocus; didHaveFocus = true;
:228-229  _interactable = focus.GetComponentInParent<Interactable>();
:244      if (interactable.checkInteractable())   // 通过才保留
:250          if (interactable.checkUseable()) color = 绿色
:259          否则 color = 红色
:262          setHighlight(interactable.transform, color)
:266-269  else(checkInteractable 返回 false)→ target/_interactable 全部清空

复刻必留:这个”只在焦点变化时才更新”的写法。它避免每帧重算 UI 与描边,也让提示文字不会闪烁。Godot 里对应做法:用一个 _current_focus 变量在 _process 里比较。

注意 GetComponentInParent(:228-229):射线打到的是子物体(比如箱子的盖子),但交互组件挂在父物体上。⚠️ 源码注释(:227)明确要求 focus 必须在 interactable 的层级树内。

4.3 一个运行时补丁,值得学

PlayerInteract.cs:230-235Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:229-236 _interactable2 = focus.GetComponentInParent<Interactable2>(); if (_interactable == null && focus.CompareTag("Ladder")) { // 2022-04-19: adding Climb [F] prompt to ladders, but we have many years of ladders // created without this component. _interactable = focus.gameObject.AddComponent<InteractableLadder>(); } UnityEngine.Profiling.Profiler.EndSample();:

if (_interactable == null && focus.CompareTag("Ladder"))
{
    // 2022-04-19: adding Climb [F] prompt to ladders,
    // but we have many years of ladders created without this component.
    _interactable = focus.gameObject.AddComponent<InteractableLadder>();
}

十年老工程的现实:地图里几万个梯子没有 InteractableLadder 组件,改资产会破坏兼容,所以运行时补一个。这是”兼容历史数据”的典型手法,对教学复刻是噪音,直接砍。

5. 提示与高亮:三个颜色状态

  • :250-263 —— checkUseable() 为真 → checkHighlight 给的颜色,没给就默认绿色;为假 → 红色。所以玩家不看文字也能知道”这个现在能不能按”。
  • :301-311 —— 提示文字:checkHint() 为真时才调 PlayerUI.hint(...)。物品走特殊分支(多传 item 与 asset,:305),为了让 UI 画出物品图标。
  • :313-316 —— 有个 purchaseAsset 分支(经验值购买补给点),属于经济系统,可砍。
  • :317-330 —— 对着玩家显示敌人名字,受 EPluginWidgetFlags.ShowInteractWithEnemy 控制,插件相关,可砍。
  • :334-343 —— 第二层的提示:PlayerUI.hint2(message, isHoldingKey ? salvageHeldTime / interactableSalvageTime : 0.0f, data),那个除法就是进度条。

6. 长按进度条:本单元最有价值的一段

PlayerInteract.cs:409-515Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:408-516

…还有 93 行没显示(共 109 行)

UnityEngine.Profiling.Profiler.BeginSample("Keys"); if (InputEx.GetKeyDown(ControlsSettings.interact)) { salvageHeldTime = 0.0f; isHoldingKey = true; } if (InputEx.GetKeyDown(ControlsSettings.inspect) && ControlsSettings.inspect != ControlsSettings.interact) { localInspect(); } if (isHoldingKey) { salvageHeldTime += Time.deltaTime; if (InputEx.GetKeyUp(ControlsSettings.interact)) { isHoldingKey = false; if (PlayerUI.window.showCursor) { if (player.inventory.isStoring && player.inventory.shouldInteractCloseStorage) { PlayerDashboardUI.close(); PlayerLifeUI.open(); } else if (PlayerBarricadeSignUI.active) { PlayerBarricadeSignUI.close(); PlayerLifeUI.open(); } else if (PlayerUI.instance.boomboxUI.active) { PlayerUI.instance.boomboxUI.close(); PlayerLifeUI.open(); } else if (PlayerBarricadeLibraryUI.active) { PlayerBarricadeLibraryUI.close(); PlayerLifeUI.open(); } else if (PlayerUI.instance.mannequinUI.active) { PlayerUI.instance.mannequinUI.close(); PlayerLifeUI.open(); } else if (PlayerNPCDialogueUI.active) { PlayerNPCDialogueUI.HandleInteractPressed(); } else if (PlayerNPCQuestUI.active) { PlayerNPCQuestUI.closeNicely(); } else if (PlayerNPCVendorUI.active) { PlayerNPCVendorUI.closeNicely(); } } else { if (player.stance.stance == EPlayerStance.DRIVING || player.stance.stance == EPlayerStance.SITTING) { VehicleManager.exitVehicle(); } else { if (focus != null && interactable != null) { if (interactable.checkUseable()) { interactable.use(); } } else if (purchaseAsset != null) { if (player.skills.experience >= player.movement.purchaseNode.cost) { player.skills.sendPurchase(player.movement.purchaseNode); } } else if (ControlsSettings.inspect == ControlsSettings.interact) { localInspect(); } } } } else if (salvageHeldTime > interactableSalvageTime) { isHoldingKey = false; if (!PlayerUI.window.showCursor) { if (interactable2 != null) { interactable2.use(); } } } } UnityEngine.Profiling.Profiler.EndSample(); 是”按住键”的完整实现:

:410-414  if (InputEx.GetKeyDown(interact)) { salvageHeldTime = 0.0f; isHoldingKey = true; }
:421      if (isHoldingKey)
:423          salvageHeldTime += Time.deltaTime;          // 累加真实时间
:425          if (InputEx.GetKeyUp(interact)) {            // 松手 = 短按
:427              isHoldingKey = false;
:482-487          if (focus != null && interactable != null)
                       if (interactable.checkUseable()) interactable.use();   // 第一层动作
:503-514  else if (salvageHeldTime > interactableSalvageTime) {            // 按够了 = 长按
:505              isHoldingKey = false;
:509-512          if (interactable2 != null) interactable2.use();          // 第二层动作

一个键,三个结局:松手太快 → 第一层 use();按够时长 → 第二层 use();中途焦点丢了 → :344-348 把 salvageHeldTime 归零(防作弊:// Prevent holding salvage before focusing the object.)。

时长从哪来(数值)

PlayerInteract.cs:35-74Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:34-75

…还有 26 行没显示(共 42 行)

private float salvageTime { get { if (shouldOverrideSalvageTime) { return overrideSalvageTimeValue; } if (player.equipment.useable is UseableHousingPlanner) { return 0.5f; } if (Provider.isServer || channel.owner.isAdmin) { LevelAsset asset = Level.getAsset(); if (asset == null || asset.enableAdminFasterSalvageDuration) { return 1.0f; } } return 8.0f; } } private float interactableSalvageTime { get { float time = salvageTime; if (_interactable2 != null) { time *= _interactable2.salvageDurationMultiplier; } return time; } }:

salvageTime 的取值顺序(:39-58):
  1. 被插件覆盖过        → overrideSalvageTimeValue      (:39-42)
  2. 拿着建筑规划器      → 0.5 s                          (:44-47)
  3. 服务端 or 管理员    → 1.0 s(若关卡开启管理员快速拆除)(:49-56)
  4. 其余(普通玩家)    → 8.0 s                          (:58)

interactableSalvageTime = salvageTime * interactable2.salvageDurationMultiplier   (:62-74)

enableAdminFasterSalvageDuration 定义在 Unturned/Bundles/LevelAsset.cs:63Assets/Runtime/Assembly-CSharp/Unturned/Bundles/LevelAsset.cs:62-64 /// </summary> public bool enableAdminFasterSalvageDuration = true;(默认 true),从 .dat 读 Enable_Admin_Faster_Salvage_Duration(:622)。倍率来自建筑资产:Unturned/Bundles/ItemBarricadeAsset.cs:180Assets/Runtime/Assembly-CSharp/Unturned/Bundles/ItemBarricadeAsset.cs:179-181 public float salvageDurationMultiplier { 与 ItemStructureAsset.cs:79Assets/Runtime/Assembly-CSharp/Unturned/Bundles/ItemStructureAsset.cs:78-80 public float salvageDurationMultiplier {,.dat 键 Salvage_Duration_Multiplier 默认 1.0(ItemBarricadeAsset.cs:451Assets/Runtime/Assembly-CSharp/Unturned/Bundles/ItemBarricadeAsset.cs:450-452 _isSalvageable = !p.data.ContainsKey("Unsalvageable"); salvageDurationMultiplier = p.data.ParseFloat("Salvage_Duration_Multiplier", 1.0f); _isSaveable = !p.data.ContainsKey("Unsaveable");)。

复刻必留:8.0 s 是普通玩家拆家的默认时长,0.5 s 是建造时的——同一套代码,靠”你手里拿的是什么”切换节奏。 复刻可砍:管理员 1.0 s、插件覆盖。

⚠️ 一个 Godot 陷阱:这里累加的是 Time.deltaTime(渲染帧时间,见 PlayerInput 那套固定步长的对照),不是逻辑帧。Godot 里对应 _process 的 delta。如果你把它写在 _physics_process 里,60/144 Hz 下行为会不一致。

7. 另一个交互键:F 的”检视”

:416-419:如果检视键和交互键不是同一个键,按下就 localInspect();:496-499 则是”两个键被绑成同一个时,短按变成检视”。localInspect() 在 :139-152。

可砍(这是纯表现功能)。

8. Useable 的契约(单元三用,单元二只需知道形状)

Unturned/Useable/Useable.cs 全文 60 行:

:14  public virtual bool startPrimary()      // 返回 true = 动作已开始,之后要调 stopPrimary
:19  public virtual void stopPrimary()
:24  public virtual bool startSecondary()
:29  public virtual void stopSecondary()
:32  public virtual bool canInspect => true;
:38  public virtual bool isUseableShowingMenu => false;
:40  public virtual void equip()
:43  public virtual void dequip()
:46  public virtual void tick()               // 每渲染帧
:49  public virtual void simulate(uint simulation, bool inputSteady)   // 每逻辑帧
:52  public virtual void tock(uint clock)     // 更高频的时钟帧
:55  public virtual void updateState(byte[] newState)
:58  internal float movementSpeedMultiplier = 1.0f;

start* 返回 bool 的语义写在注释里(:12-13):返回 true 表示”动作启动了,之后要记得调 stop*,方便支持按住不放”。

三种时钟,别搞混

方法频率驱动点
tick()渲染帧PlayerEquipment.cs:3193Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerEquipment.cs:3192-3194 { useable.tick(); }(在 Update 里)
simulate(simulation, inputSteady)12.5 Hz 逻辑帧PlayerEquipment.cs:2663Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerEquipment.cs:2662-2664 { useable.simulate(simulation, inputSteady); },由 PlayerInput.cs:1636Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:1635-1637 UnityEngine.Profiling.Profiler.BeginSample("Equipment"); player.equipment.simulate(simulation, pendingPrimaryAttackInput, pendingSecondaryAttackInput, player.stance.localWantsToSteadyAim); UnityEngine.Profiling.Profiler.EndSample(); 调用
tock(clock)50 HzPlayerInput.cs:1652Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:1651-1653 player.equipment.tock(clock);(player.equipment.tock(clock))

_clock 字段与驱动:Player/PlayerInput.cs:912-913Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:911-914 private uint _clock; public uint clock => _clock; 声明,:1648-1655 每次 consumed < buffer 就 tock 一次并 _clock++。TOCK_PER_SECOND = 50(PlayerInput.cs:884Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:883-885 /// </summary> public static readonly uint TOCK_PER_SECOND = 50; // (1 / RATE) * SAMPLES,注释写明 = (1 / RATE) * SAMPLES,即 12.5 × 4 = 50)。

复刻含义:枪的射速单位是 tock(1/50 秒),不是秒。见 Unturned/Useable/UseableGun.cs:4483Assets/Runtime/Assembly-CSharp/Unturned/Useable/UseableGun.cs:4482-4484 int fireRateTicks = equippedGunAsset.firerate; if (thirdAttachments.sightAsset != null) int fireRateTicks = equippedGunAsset.firerate;,然后在 :4506 与时钟比较:if (clock - lastFire > fireRateTicks)。瞄准精度也是按 tock 增减的(:4561-4574 每次 ±1)。

⚠️ tock 这个命名和”tick-tock 交替”没有关系,就是”第二个更高频的时钟”。这是 Unturned 自己的术语,我第一次看也误解了。

单元二的取舍:你不需要实现 Useable,但如果你想让”捡起物品”有个 0.5 秒的开箱动作,Interactable2 那套长按进度条就够了,别提前引入 Useable。

9. 拾取流程(端到端)

以”地上有一把枪,你把它捡起来”为例:

  1. PlayerInteract.Update 每 0.1 s 打一条 4 m(第一人称)射线(PlayerInteract.cs:194Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:193-195 { Physics.Raycast(new Ray(MainCamera.instance.transform.position, MainCamera.instance.transform.forward), out hit, player.look.perspective == EPlayerPerspective.THIRD ? 6 : 4, interactRayMask); })
  2. 命中 ITEM 层 → 拿到 focus = hit.collider.transform(:204)
  3. focus.GetComponentInParent<Interactable>() 拿到 InteractableItem(:228)
  4. 焦点变了 → checkInteractable() 为真 → 描边用稀有度色(:252)→ checkHint() 返回物品名(InteractableItem.cs:31-40Assets/Runtime/Assembly-CSharp/Unturned/Interactable/InteractableItem.cs:30-41 { message = EPlayerMessage.ITEM; text = asset.itemName; color = ItemTool.getRarityColorUI(asset.rarity); //if(asset.showQuality) //{ // color = ItemTool.getQualityColor(item.quality / 100.0f); //} //else //{ // color = Color.white; //})→ UI 显示”[F] 名称”(PlayerInteract.cs:301-306Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:300-307 if (interactable.checkHint(out message, out text, out color) && !PlayerUI.window.showCursor) { if (message == EPlayerMessage.ITEM) { PlayerUI.hint(target != null ? target : focus, message, text, color, ((InteractableItem) interactable).item, ((InteractableItem) interactable).asset); } else)
  5. 按下 F(:410)→ 松手(:425)→ interactable.use()(:486)
  6. InteractableItem.use() → ItemManager.takeItem(transform.parent, 255, 255, 0, 255)(InteractableItem.cs:19Assets/Runtime/Assembly-CSharp/Unturned/Interactable/InteractableItem.cs:18-20 public override void use() { ItemManager.takeItem(transform.parent, 255, 255, 0, 255);)→ 进物品栏

注意第 6 步把”进哪个格子”交给物品栏系统决定(四个 255 是哨兵)。单元二如果你还没做物品栏,就让它进一个简单的数组或直接打印——不要在 Interactable 里塞物品栏逻辑,否则单元三要拆。

10. 网络权威(只列归属)

动作/状态权威方证据
射线检测与目标选择客户端PlayerInteract.cs:172Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:171-173 { if (channel.IsLocalPlayer) { if (channel.IsLocalPlayer) 包住整段
提示/高亮/进度条客户端同上一段,全在客户端分支内
Interactable.use()客户端发起:486、:511(use() 内部再走各自的请求/校验)
salvageTime 的 8 s / 1 s 判定服务端(读 Provider.isServer):49
状态恢复 updateState服务端下发Interactable.cs:13Assets/Runtime/Assembly-CSharp/Unturned/Interactable/Interactable.cs:12-14 public virtual void updateState(Asset asset, byte[] state) {
Useable.simulate逻辑帧驱动,服务端为准PlayerEquipment.cs:2673Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerEquipment.cs:2672-2674 if (Provider.isServer && HasValidUseable && IsEquipAnimationFinished) // Check again in case we dequipped during simulate. { if (Provider.isServer && ...)

⚠️ 每一类 Interactable 子类自己的 use() 走客户端还是服务端请求(比如 InteractableDoor、InteractableClaim),我没有逐个读。单元二做单机不需要,课 25-28我再按子类逐个解剖。

11. 与其它系统的耦合点

  • → PlayerLook:射线方向来自 MainCamera 或瞄准点(PlayerInteract.cs:190Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:189-191 { Physics.Raycast(new Ray(player.look.aim.position, player.look.aim.forward), out hit, 4f, interactRayMask); }、:194)
  • → PlayerStance:爬梯时从掩码里剔除 LADDER(:185);驾驶/坐姿时整个交互关闭(:174)
  • → PlayerWorkzone:!player.workzone.isBuilding 是交互的前置条件(:174)——建造模式下交互被禁用
  • → PlayerLife:死亡时不提示(:292)
  • → PlayerUI:PlayerUI.hint() / PlayerUI.hint2() / PlayerUI.window.showCursor(:301、:341、:429)
  • → ItemManager:拾取的实际执行(InteractableItem.cs:19Assets/Runtime/Assembly-CSharp/Unturned/Interactable/InteractableItem.cs:18-20 public override void use() { ItemManager.takeItem(transform.parent, 255, 255, 0, 255);)
  • → LevelAsset:拆除时长的关卡级配置(PlayerInteract.cs:51-52Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:50-53 { LevelAsset asset = Level.getAsset(); if (asset == null || asset.enableAdminFasterSalvageDuration) {)

复刻含义:课 5你会在 workzone.isBuilding 这个条件上撞到课 21的建造系统。单元一先把它当成一个恒为 false 的占位标志。

12. 复刻清单(单元二)

必须保留

  1. 射线目标选择:从摄像机(不是玩家胶囊中心)起算,带碰撞掩码与最大距离。
  2. 射线不需要每帧打——Unturned 是 0.1 s 一次,够用且省。程序员的直觉是每帧打,其实不必。
  3. 只在焦点变化时更新目标、提示与描边。
  4. checkInteractable / checkUseable / checkHint / use 四问分离的契约。
  5. 三色状态:可交互 + 可用 = 绿,可交互 + 不可用 = 红,不可交互 = 无描边。
  6. 短按 / 长按双层(Interactable + Interactable2),带进度条显示。
  7. 焦点丢失时进度条归零。
  8. 第三人称的交互距离要比第一人称长(Unturned 是 6 m vs 4 m)。

可以砍掉

  • updateState 存档恢复、NetId 网络 ID
  • purchaseAsset 经验购买(经济系统)
  • 敌人名字提示(插件功能)
  • 检视(localInspect)与 F1–F10 座位热键
  • 爬梯时剔除 LADDER 的特例(等你做爬梯再说)
  • 运行时补 InteractableLadder 的历史兼容代码
  • 管理员加速拆除、插件覆盖时长
  • 整个 Useable 体系(单元三的活)

13. 数值速查表(单元二)

项值证据
交互射线距离(第一人称/Freecam)4 mPlayerInteract.cs:190Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:189-191 { Physics.Raycast(new Ray(player.look.aim.position, player.look.aim.forward), out hit, 4f, interactRayMask); }、:194
交互射线距离(第三人称)6 mPlayerInteract.cs:194Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:193-195 { Physics.Raycast(new Ray(MainCamera.instance.transform.position, MainCamera.instance.transform.forward), out hit, player.look.perspective == EPlayerPerspective.THIRD ? 6 : 4, interactRayMask); }
射线重算间隔0.1 sPlayerInteract.cs:178Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:177-179 if (Time.realtimeSinceStartup - lastInteract > 0.1f) {
普通玩家拆除时长8.0 sPlayerInteract.cs:58Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:57-59 return 8.0f; }
服务端/管理员拆除时长1.0 sPlayerInteract.cs:54Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:53-55 { return 1.0f; }
建筑规划器时长0.5 sPlayerInteract.cs:46Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:45-47 { return 0.5f; }
掉落物回收距离20 m(平方 400)InteractableItem.cs:48Assets/Runtime/Assembly-CSharp/Unturned/Interactable/InteractableItem.cs:47-49 { if (wasReset) {
交互掩码包含的层数13Utils/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;
tock 频率50 HzPlayerInput.cs:884Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInput.cs:883-885 /// </summary> public static readonly uint TOCK_PER_SECOND = 50; // (1 / RATE) * SAMPLES

射线起点口径(引用这些数字前必须先看这一条):射线的起点是摄像机,不是眼睛——PlayerInteract.cs:190Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:189-191 { Physics.Raycast(new Ray(player.look.aim.position, player.look.aim.forward), out hit, 4f, interactRayMask); }(Freecam 用瞄准点 player.look.aim)与 :194(其余用 MainCamera.instance.transform.position)。所以这几个米数不能直接搬到相机位置不同的工程里。

原版第三人称相机在眼后约 1.9 m(球扫长 2.0、方向 forward * -1.5 + up * 0.5,Unturned/Player/PlayerLook.cs:1803Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerLook.cs:1802-1804 { direction = (mainCamera.transform.forward * -1.5f) + (mainCamera.transform.up * 0.5f) + (mainCamera.transform.right * player.animator.shoulder2 * 0.5f); }、:1808;归一化后 1.897 m——这个换算由 @Chief 做的,我核了公式与行号)。于是:

原版第一人称  4 m − 0.0 m 相机偏移 ≈ 眼前 4.0 m
原版第三人称  6 m − 1.9 m 相机偏移 ≈ 眼前 4.1 m     ← 是同一个数

不变式:眼前可达 ≈ 射线长度 − 相机后移距离。 我们单元一 spring_length = 4.0,所以等价的射线长度是 4 + 4 = 8 m。复刻师已在 reference/01-player-controller/interact_range.tscn 实测(8 m → 眼前 4 m,与上表吻合)。

14. 未验证项(诚实清单)

  • Interactable 的 55 个子类各自 use() 的具体实现(只读了 InteractableItem 全文与 Interactable2 全文,其余只看了类名)。
  • PlayerUI.hint / hint2 的渲染实现(属 UI 系统)。
  • OwnershipTool.checkToggle(owner, group) 的判定逻辑(属所有权系统,课 21)。
  • PlayerEquipment.cs 的 simulate → simulate_UseableInput 全链路(只读了 :2617-2671 与 :2736-2813 两段)。
  • fireDelay(UseableGun.cs:3200Assets/Runtime/Assembly-CSharp/Unturned/Useable/UseableGun.cs:3199-3201 { fireDelayCounter = equippedGunAsset.fireDelay;)与 firerate(:4483)两个字段的资产来源没追到 .dat 定义。