说明书 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 是两条路
初学者最容易把这两件事当成一件事。源码里它们是分开的:
Interactable | Useable | |
|---|---|---|
| 挂在哪 | 世界里的物体(门、补给、掉落的物品) | 玩家身上的 PlayerEquipment 组件动态挂载 |
| 什么时候出现 | 场景/生成时就有 | 装备物品时才 AddComponent |
| 触发者 | 交互键(对着物体按) | 主/副攻击键(手里的枪、近战武器、食物) |
| 基类契约 | Interactable.cs(72 行) | Useable.cs(60 行) |
| 生命周期 | 无 tick | equip/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() // 按下后发生什么
另外两个非虚的:
:11IsChildOfVehicle => transform.parent != null && transform.parent.CompareTag("Vehicle"):49/:54/:60GetNetId/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 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);
} |
| 第三人称射程 | 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 只在”焦点变化”时才重建目标
…还有 58 行没显示(共 74 行)PlayerInteract.cs:204-275Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:203-276:拿到 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. 长按进度条:本单元最有价值的一段
…还有 93 行没显示(共 109 行)PlayerInteract.cs:409-515Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:408-516 是”按住键”的完整实现:
: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.)。
时长从哪来(数值)
…还有 26 行没显示(共 42 行)PlayerInteract.cs:35-74Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:34-75:
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 Hz | PlayerInput.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. 拾取流程(端到端)
以”地上有一把枪,你把它捡起来”为例:
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); })- 命中
ITEM层 → 拿到focus = hit.collider.transform(:204) focus.GetComponentInParent<Interactable>()拿到InteractableItem(:228)- 焦点变了 →
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) - 按下 F(
:410)→ 松手(:425)→interactable.use()(:486) 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. 复刻清单(单元二)
必须保留
- 射线目标选择:从摄像机(不是玩家胶囊中心)起算,带碰撞掩码与最大距离。
- 射线不需要每帧打——Unturned 是 0.1 s 一次,够用且省。程序员的直觉是每帧打,其实不必。
- 只在焦点变化时更新目标、提示与描边。
checkInteractable/checkUseable/checkHint/use四问分离的契约。- 三色状态:可交互 + 可用 = 绿,可交互 + 不可用 = 红,不可交互 = 无描边。
- 短按 / 长按双层(
Interactable+Interactable2),带进度条显示。 - 焦点丢失时进度条归零。
- 第三人称的交互距离要比第一人称长(Unturned 是 6 m vs 4 m)。
可以砍掉
updateState存档恢复、NetId网络 IDpurchaseAsset经验购买(经济系统)- 敌人名字提示(插件功能)
- 检视(
localInspect)与 F1–F10 座位热键 - 爬梯时剔除
LADDER的特例(等你做爬梯再说) - 运行时补
InteractableLadder的历史兼容代码 - 管理员加速拆除、插件覆盖时长
- 整个
Useable体系(单元三的活)
13. 数值速查表(单元二)
| 项 | 值 | 证据 |
|---|---|---|
| 交互射线距离(第一人称/Freecam) | 4 m | 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 |
| 交互射线距离(第三人称) | 6 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);
} |
| 射线重算间隔 | 0.1 s | PlayerInteract.cs:178Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:177-179
if (Time.realtimeSinceStartup - lastInteract > 0.1f)
{ |
| 普通玩家拆除时长 | 8.0 s | PlayerInteract.cs:58Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:57-59
return 8.0f;
} |
| 服务端/管理员拆除时长 | 1.0 s | PlayerInteract.cs:54Assets/Runtime/Assembly-CSharp/Unturned/Player/PlayerInteract.cs:53-55 {
return 1.0f;
} |
| 建筑规划器时长 | 0.5 s | PlayerInteract.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)
{ |
| 交互掩码包含的层数 | 13 | 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; |
| tock 频率 | 50 Hz | PlayerInput.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定义。