交互系统:先校验,再执行

玩家按键后,系统通过 Raycast 命中交互层物体,收集目标上的 IInteractable,并在真正调用 Interact() 前检查父级所有条件组件。任一条件失败,交互中止,失败原因可以进入 UI 或日志。

Raycast

命中可交互物体。

Condition

逐一执行 CanInteract,包括道具需求、解锁状态和门禁条件。

Interact

通过后调用目标行为,例如开门、切换机关或拾取物品。

Callback

成功后触发消耗道具、解锁状态、播放旁白或写入 Facts。

这条链路让 AI 即使提出“开门”,也必须走到同样的系统门槛中,而不是绕过玩家交互规则。

真实代码节选整理:玩家按下交互键后,射线命中对象,先收集交互组件,再逐个检查父级条件,全部通过后才调用 Interact()

private void PerformInteract()
{
    if (cameraTransform == null) return;

    Ray ray = new Ray(cameraTransform.position, cameraTransform.forward);
    if (Physics.Raycast(ray, out RaycastHit hit, interactRange, interactLayer))
    {
        var interactables = new List<IInteractable>(8);
        var unique = new HashSet<IInteractable>();

        foreach (var it in hit.collider.GetComponents<IInteractable>())
            if (it != null && unique.Add(it)) interactables.Add(it);

        foreach (var it in hit.collider.GetComponentsInParent<IInteractable>())
            if (it != null && unique.Add(it)) interactables.Add(it);

        if (interactables.Count <= 0) return;

        var conditions = hit.collider.GetComponentsInParent<InteractConditionBehaviour>();
        for (int i = 0; i < conditions.Length; i++)
        {
            if (conditions[i] != null
                && !conditions[i].CanInteract(gameObject, out string failReason))
            {
                var failedTriggers =
                    hit.collider.GetComponentsInParent<Core.Triggers.InteractFailedTrigger>();
                for (int t = 0; t < failedTriggers.Length; t++)
                    failedTriggers[t]?.OnFailed(failReason);
                return;
            }
        }

        for (int i = 0; i < interactables.Count; i++)
            interactables[i]?.Interact();

        for (int i = 0; i < conditions.Length; i++)
            conditions[i]?.OnInteractSucceeded(gameObject);
    }
}

任务系统:把叙事阶段变成可见目标

TaskSystem 使用 TaskData 定义静态任务,用 TaskEntry 保存运行时状态。任务状态包括 Todo、Completed 和 Expired,TaskService 负责添加、更新、移除与广播变化。

TaskData

任务 ID、短标题、描述文本,适合做成 ScriptableObject 资产。

TaskEntry

运行时状态、加入时间、超时时间与计划移除时间。

TaskPanel

监听 TaskService 事件,刷新任务列表与详情 UI。

AI 的命令负责提出任务更新请求,实际状态变化仍由 TaskService 统一处理。UI、存档和剧情触发器因此读到同一个状态源。

《回声》任务面板实机截图
任务面板将当前目标和完成状态集中呈现,使 AI 触发的任务变化能够被玩家及时感知。

真实代码节选:TaskCommandReceiver 不直接改 UI,而是把命令统一落到 TaskService

public override bool TryExecute(LLMCommand command)
{
    if (command == null) return false;
    if (!string.Equals(command.type, "Task", System.StringComparison.OrdinalIgnoreCase))
        return false;

    TaskService service = taskService != null ? taskService : TaskService.Instance;
    if (service == null) return false;

    string taskId = string.IsNullOrWhiteSpace(command.payload)
        ? command.targetId
        : command.payload;
    if (string.IsNullOrWhiteSpace(taskId)) return false;

    if (string.Equals(command.action, "Remove", System.StringComparison.OrdinalIgnoreCase))
        return service.RemoveTask(taskId);

    if (string.Equals(command.action, "Todo", System.StringComparison.OrdinalIgnoreCase))
        return service.SetTaskStatus(taskId, TaskStatus.Todo);

    if (string.Equals(command.action, "Expired", System.StringComparison.OrdinalIgnoreCase))
        return service.SetTaskStatus(taskId, TaskStatus.Expired);

    return service.SetTaskStatus(taskId, TaskStatus.Completed);
}

旁白系统:给剧情一个可控窗口

旁白系统用 ScriptableObject 保存段落式叙事内容,每一页都可以绑定进入事件或继续事件。播放期间可以锁定玩家控制,让关键剧情节点有稳定的呈现时机。

在《回声》中,旁白承担两类工作:一是醒来、恢复供电、接近真相等阶段性叙事;二是在关键交互后呈现那些不应完全交给 ECHO-7 解释的信息。它和 LLM 的关系不是替代,而是分工:AI 负责动态回应,旁白负责确定节拍。

《回声》旁白界面实机截图
旁白界面在关键节点锁定玩家注意力,保证剧情事实先被明确呈现,再进入自由探索或对话。

结局系统:门槛裁决和演出分离

结局系统由触发器、门槛服务和演出控制器组成。玩家是否能触发结局,不由碰撞区域单独决定,而由 EndingWinConditionService 维护的状态判断。

Can Win

任务、对话或剧情节点调用 UnlockEnding,开放通关门槛。

Trigger

玩家进入出口触发区,系统检查当前是否允许触发。

Sequence

锁定输入,黑屏传送,控制门与光,最后展示结算 UI。

这让“说服 ECHO-7”不会停留在台词层面。只有当任务状态、Facts 和门槛条件真正满足,系统才会把流程收束到结局演出。

真实代码节选:触发器只负责接收碰撞,是否进入结局由 EndingWinConditionService 裁决。

private void OnTriggerEnter(Collider other)
{
    if (hasTriggered) return;

    if (other.CompareTag(playerTag))
    {
        TriggerSequence();
    }
}

public void TriggerSequence()
{
    if (hasTriggered) return;
    hasTriggered = true;

    if (!allowManualTriggerWhenLocked && requireWinConditionToTrigger)
    {
        var wc = EndingWinConditionService.EnsureInstance();
        if (wc != null && !wc.CanTriggerEnding)
        {
            hasTriggered = false;
            return;
        }
    }

    sequenceController?.StartEndingSequence();
}

public void SetCanTriggerEnding(bool value)
{
    if (canTriggerEnding == value) { return; }
    canTriggerEnding = value;
    CanTriggerEndingChanged?.Invoke(canTriggerEnding);
}