路线阶段:Unity 入门实战第 3 章。
本章目标:把“按键触发交互”做成可复用系统,而不是每个物体各写一套逻辑。
学习目标
完成本章后,你应该能做到:
- 使用射线检测识别当前可交互目标。
- 通过统一接口管理门、宝箱、道具、NPC 等交互对象。
- 用命令系统分发交互行为,支持回放与测试。
- 将拾取结果统一入背包并记录事件日志。
背景问题
很多项目早期的交互逻辑是这样的:
if (Input.GetKeyDown(KeyCode.E))
{
if (distanceToChest < 2f)
{
OpenChest();
}
if (distanceToNpc < 2f)
{
OpenDialog();
}
}
问题:
- 距离判断散落,冲突对象难处理。
- UI 提示和实际可交互目标不一致。
- 无法回放“玩家当时交互了谁”。
- 新增交互类型要改动多处代码。
系统设计
本章采用四层架构:
InteractionDetector:每帧检测候选目标。IInteractable:统一交互接口。InteractCommand:命令驱动交互执行。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 提示与高亮
建议流程:
InteractionDetector.Current变化时发InteractionFocusChanged事件。- UI 层根据对象
DisplayName显示按 E 交互。 - 可交互对象通过 Outline 或材质参数高亮。
与前面章节联动
- 命令系统:
InteractCommand可录制和回放。 - 状态机:受击/死亡状态禁用交互输入。
- 生命周期治理:场景切换时清理交互注册表。
- 回放系统:可还原“何时拾取了何物”。
验收清单
- 屏幕中心对准对象时出现正确交互提示。
- 按
E触发交互,命令日志包含InteractionId。 - 道具拾取后背包数量变化正确,且不能重复拾取。
- 回放模式下能重现同一交互顺序和结果。
常见坑
坑 1:检测到对象就立即交互
应分离“焦点目标”与“执行交互”,避免误触。
坑 2:交互对象直接访问全局单例
会破坏测试和复用。应通过 InteractionContext 注入依赖。
坑 3:拾取后只隐藏模型不更新逻辑状态
会导致重复入包。逻辑层必须标记 _picked。
本月作业
实现“宝箱掉落系统”:
- 宝箱交互后随机掉落 1~3 个道具。
- 掉落结果写入命令回放和日志。
- UI 弹出拾取明细(道具名 + 数量)。
下一章进入 Unity 入门实战 04:战斗输入与技能释放(把 C# 技能系统接到真实场景)。