Article

Unity 入门实战 03:交互与拾取系统(射线检测 + 命令驱动)

路线阶段:Unity 入门实战第 3 章。
本章目标:把“按键触发交互”做成可复用系统,而不是每个物体各写一套逻辑。

学习目标

完成本章后,你应该能做到:

  1. 使用射线检测识别当前可交互目标。
  2. 通过统一接口管理门、宝箱、道具、NPC 等交互对象。
  3. 用命令系统分发交互行为,支持回放与测试。
  4. 将拾取结果统一入背包并记录事件日志。

背景问题

很多项目早期的交互逻辑是这样的:

if (Input.GetKeyDown(KeyCode.E))
{
    if (distanceToChest < 2f)
    {
        OpenChest();
    }

    if (distanceToNpc < 2f)
    {
        OpenDialog();
    }
}

问题:

  1. 距离判断散落,冲突对象难处理。
  2. UI 提示和实际可交互目标不一致。
  3. 无法回放“玩家当时交互了谁”。
  4. 新增交互类型要改动多处代码。

系统设计

本章采用四层架构:

  1. InteractionDetector:每帧检测候选目标。
  2. IInteractable:统一交互接口。
  3. InteractCommand:命令驱动交互执行。
  4. LootService + Inventory:拾取与入包流程。

统一交互接口

public interface IInteractable
{
    int InteractionId { get; }
    string DisplayName { get; }
    float MaxDistance { get; }

    bool CanInteract(InteractionContext context);
    void Interact(InteractionContext context);
}
public sealed class InteractionContext
{
    public readonly Actor Player;
    public readonly Inventory Inventory;
    public readonly EventBus EventBus;

    public InteractionContext(Actor player, Inventory inventory, EventBus eventBus)
    {
        Player = player;
        Inventory = inventory;
        EventBus = eventBus;
    }
}

交互检测(射线 + 距离)

public sealed class InteractionDetector : MonoBehaviour
{
    [SerializeField] private Camera _camera;
    [SerializeField] private LayerMask _interactionMask;
    [SerializeField] private float _maxRayDistance = 4.0f;

    public IInteractable Current { get; private set; }

    private void Update()
    {
        Current = Detect();
    }

    private IInteractable Detect()
    {
        var ray = _camera.ScreenPointToRay(new Vector3(Screen.width * 0.5f, Screen.height * 0.5f, 0f));
        RaycastHit hit;

        if (!Physics.Raycast(ray, out hit, _maxRayDistance, _interactionMask))
        {
            return null;
        }

        var proxy = hit.collider.GetComponent<InteractableProxy>();
        if (proxy == null)
        {
            return null;
        }

        var target = proxy.Target;
        if (target == null)
        {
            return null;
        }

        var playerPos = proxy.Context.Player.Transform.position;
        var distance = Vector3.Distance(playerPos, hit.point);
        if (distance > target.MaxDistance)
        {
            return null;
        }

        return target;
    }
}

命令驱动交互

public sealed class InteractCommand : ICommand
{
    public int Sequence { get; set; }
    public float Timestamp { get; set; }
    public string Name { get { return "Interact"; } }

    public int InteractionId;

    public bool Execute(CommandContext context)
    {
        var world = (GameWorld)context.Host;
        var target = world.Interactions.GetById(InteractionId);
        if (target == null)
        {
            FoundationLog.Warn("Interact", "target_not_found id=" + InteractionId);
            return false;
        }

        var ctx = world.InteractionContext;
        if (!target.CanInteract(ctx))
        {
            FoundationLog.Warn("Interact", "can_not_interact id=" + InteractionId);
            return false;
        }

        target.Interact(ctx);
        FoundationLog.Info("Interact", "ok id=" + InteractionId + " name=" + target.DisplayName);
        return true;
    }

    public void Reset()
    {
        Sequence = 0;
        Timestamp = 0f;
        InteractionId = 0;
    }
}

拾取系统

背包结构

public sealed class Inventory
{
    private readonly Dictionary<int, int> _items = new Dictionary<int, int>(64);

    public void AddItem(int itemId, int count)
    {
        int current;
        if (_items.TryGetValue(itemId, out current))
        {
            _items[itemId] = current + count;
        }
        else
        {
            _items.Add(itemId, count);
        }
    }

    public int GetCount(int itemId)
    {
        int value;
        return _items.TryGetValue(itemId, out value) ? value : 0;
    }
}

道具交互对象

public sealed class LootInteractable : IInteractable
{
    public int InteractionId { get; private set; }
    public string DisplayName { get; private set; }
    public float MaxDistance { get; private set; }

    private readonly int _itemId;
    private readonly int _count;
    private bool _picked;

    public LootInteractable(int interactionId, string name, float maxDistance, int itemId, int count)
    {
        InteractionId = interactionId;
        DisplayName = name;
        MaxDistance = maxDistance;
        _itemId = itemId;
        _count = count;
    }

    public bool CanInteract(InteractionContext context)
    {
        return !_picked;
    }

    public void Interact(InteractionContext context)
    {
        if (_picked)
        {
            return;
        }

        context.Inventory.AddItem(_itemId, _count);
        context.EventBus.Publish("LootPicked", _itemId);
        _picked = true;
    }
}

UI 提示与高亮

建议流程:

  1. InteractionDetector.Current 变化时发 InteractionFocusChanged 事件。
  2. UI 层根据对象 DisplayName 显示 按 E 交互
  3. 可交互对象通过 Outline 或材质参数高亮。

与前面章节联动

  1. 命令系统InteractCommand 可录制和回放。
  2. 状态机:受击/死亡状态禁用交互输入。
  3. 生命周期治理:场景切换时清理交互注册表。
  4. 回放系统:可还原“何时拾取了何物”。

验收清单

  1. 屏幕中心对准对象时出现正确交互提示。
  2. E 触发交互,命令日志包含 InteractionId
  3. 道具拾取后背包数量变化正确,且不能重复拾取。
  4. 回放模式下能重现同一交互顺序和结果。

常见坑

坑 1:检测到对象就立即交互

应分离“焦点目标”与“执行交互”,避免误触。

坑 2:交互对象直接访问全局单例

会破坏测试和复用。应通过 InteractionContext 注入依赖。

坑 3:拾取后只隐藏模型不更新逻辑状态

会导致重复入包。逻辑层必须标记 _picked

本月作业

实现“宝箱掉落系统”:

  1. 宝箱交互后随机掉落 1~3 个道具。
  2. 掉落结果写入命令回放和日志。
  3. UI 弹出拾取明细(道具名 + 数量)。

下一章进入 Unity 入门实战 04:战斗输入与技能释放(把 C# 技能系统接到真实场景)。