> For the complete documentation index, see [llms.txt](https://docs.convai.com/api-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.convai.com/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/features/character-actions/writing-custom-executors.md).

# 编写自定义动作执行器

通过编写自己的执行器组件，将自定义移动、库存、UI 或物理行为连接到 Convai 动作管道。

当内置执行器不适合你的项目的移动系统、交互模型或游戏规则时，实现 `IConvaiActionExecutor`。自定义执行器是一个标准的 C# `MonoBehaviour` ，只包含一个 async 方法。调度器将其与任何内置执行器一视同仁——所有策略、事件和取消行为都会自动生效。

### 何时构建自定义执行器

在以下情况下构建自定义执行器：

* 你的项目使用自定义移动系统（根运动， `CharacterController`、转向行为）
* 某个动作会修改物品栏、UI 状态、任务标记或物理对象
* 某个动作会调用外部服务或触发基于协程的动画系统
* 你需要条件逻辑——例如，根据角色状态表现不同的动作
* 该动作是发现某些信息，而不是执行可见行为——请参见 [回答问题，而不是执行动作](#answer-a-question-instead-of-acting) 如下

### IConvaiActionExecutor 接口

```csharp
public interface IConvaiActionExecutor
{
    Task<ConvaiActionExecutionResult> ExecuteAsync(
        ConvaiActionInvocation invocation,
        CancellationToken cancellationToken);
}
```

在任何 `MonoBehaviour`上实现此接口。调度器会调用 `ExecuteAsync` 用于每个步骤，并在继续下一步之前等待结果。保持任务存活直到游戏玩法工作完成——过早返回会结束该步骤，即使动画或移动仍在运行。

{% hint style="info" %}
执行器运行在 Unity 的主线程上。你可以安全地调用 Unity API（`transform`, `GetComponent`, `Instantiate`等）在 `ExecuteAsync`。请使用 `await Task.Yield()` 中让出一帧，而无需离开主线程。
{% endhint %}

### 选择基类，而不是直接使用原始接口

每个已发布的执行器都派生自 `ConvaiActionExecutorBase` ，而不是实现 `IConvaiActionExecutor` ，你的自定义执行器也应如此——通过派生，组件会自动获得 Convai 检视面板（分区字段、工具提示和动作绑定状态块），无需你自己编写编辑器代码。

| 基类                                     | 在以下情况下使用它                            | 它会添加什么                                                             |
| -------------------------------------- | ------------------------------------ | ------------------------------------------------------------------ |
| `ConvaiActionExecutorBase`             | 你希望对调用拥有完全的手动控制                      | `CharacterTransform`, `ResolvePlayer()`, `DeclaredButNotSent(...)` |
| `ConvaiTargetedActionExecutor`         | 该行为通过层级中的对等组件（控制器、移动组件或装配体）作用于已解析的目标 | 目标验证、对等组件解析/缓存、仅记录一次的缺失对等组件诊断、调用参数覆盖                               |
| `ConvaiCharacterActionExecutor<TPeer>` | 该行为只需要角色上的一个特定组件                     | 全部 `ConvaiTargetedActionExecutor` 都会添加，并加上已解析的 `TPeer` 组件，直接传给你的方法 |
| `ConvaiActionExecutor<TParameters>`    | 你希望使用有类型的参数 DTO，而不是手动访问字典            | 将调用参数绑定到一个 `TParameters` 对象上，并在方法运行前按名称匹配                          |

`ConvaiTargetedActionExecutor`, `ConvaiCharacterActionExecutor<TPeer>`，以及 `ConvaiActionExecutor<TParameters>` 它们都派生自 `ConvaiActionExecutorBase`，因此它们都已经获得 Convai 检视面板以及下一节中的成员。用 `ConvaiInspectorSectionAttribute` 来分组检视面板字段，并为每个序列化字段添加一个 `[Tooltip]` ——Convai 检视面板会在任何 `ConvaiActionExecutorBase`派生组件上同时渲染二者。

### ConvaiActionExecutorBase 为你提供的成员

| 成员                                              | 类型                            | 说明                                                                           |
| ----------------------------------------------- | ----------------------------- | ---------------------------------------------------------------------------- |
| `CharacterTransform`                            | `Transform` （protected）       | Convai Character 的 transform，只解析一次并缓存。请用它代替执行器自身的 `transform` 用于任何世界空间的读取或写入 |
| `ResolvePlayer()`                               | `protected virtual Transform` | 玩家实际所在的位置——请参见 [解析玩家位置](#resolve-the-players-position) 如下                    |
| `DeclaredButNotSent(invocation, parameterName)` | `protected static bool`       | 该动作是否声明了 `parameterName` 且 Convai Character 没有为其发送任何值                        |

#### 读取角色的 transform，而不是你自己的

执行器可能挂在 Convai Character 本身上，也可能挂在一个保存角色行为的子对象上（分配为 **Action Behaviors Object** 位于 `ConvaiActionConfigSource`）。两种布局都受支持，因此执行器绝不能假设自己的 `GameObject` 就是角色的：

```csharp
// 错误——在一个被移离原点的 behaviors object 上，这不是角色。
_home = transform.position;

// 正确——Convai Character，无论这个组件实际挂在哪。
_home = CharacterTransform.position;
```

使用你自己的 `transform` 用于任何真正表示“此组件对象”的情况——例如父子关系、层级遍历——仍然没问题。这个规则针对的是 *角色* 的位置。

#### 解析玩家位置

`ResolvePlayer()` 会返回用于测量玩家位置的 transform。它是 `protected virtual` (`ConvaiActionExecutorBase.cs:113`），因此如果你的项目有分屏、多个 rig 或过场镜头摄像机，只需重写一次，那个层级中的每个执行器就会对“玩家”是谁达成一致。

底层查找位于 `ConvaiPlayerBody`、一个 `public static` 类（`ConvaiPlayerBody.cs:26`），可供非执行器的场景代码使用：

```csharp
Transform player = ConvaiPlayerBody.Resolve();

// 将其平移到给定高度——摄像机位于头部高度，而角色
// 否则测量与其距离时会显得稍微远了一些。
if (ConvaiPlayerBody.TryResolveFloorPosition(CharacterTransform.position.y, out Vector3 floorPosition))
{
    // 使用 floorPosition
}
```

`ConvaiPlayerBody.Resolve()` 优先使用 `ConvaiPlayer` 场景中的一个，然后回退到 `Camera.main`。当它找到一个 `ConvaiPlayer`时，它会解析玩家的 rig 实际移动的 transform——也就是 `CharacterController` 或 `Rigidbody` rig 内部的那个，而不是 prefab 根节点——因为第一人称控制器通常会让根节点在整个会话中停留在出生点。改为读取根节点会报告玩家 *开始时所在的位置*：一个带人去某处的角色停下来等待，玩家走到它旁边，而它仍然待在原地，却没有任何日志解释原因。

### ConvaiActionInvocation 对象

每个 `ExecuteAsync` 调用会接收一个 `ConvaiActionInvocation` ，其中包含执行该行为所需的一切：

| 属性               | 类型                           | 包含                                                                                                      |
| ---------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- |
| `Command`        | `ConvaiActionCommand`        | 原始后端命令—— `名称`, `Target`, `HasTarget`                                                                    |
| `定义`             | `ConvaiActionDefinition`     | 本地定义—— `ActionName`, `目标要求`, `Executor`, `TimeoutSeconds`                                               |
| `ResolvedTarget` | `ConvaiResolvedActionTarget` | 已解析的目标绑定—— `Kind`, `名称`, `ObjectBinding`, `CharacterBinding`, `GameObjectReference`, `InteractionPoint` |
| `Character`      | `ConvaiCharacter`            | 执行中的 NPC                                                                                                |
| `BatchIndex`     | `int`                        | 该批次在调度器生命周期中的顺序索引                                                                                       |
| `StepIndex`      | `int`                        | 当前批次中此步骤的索引（从 0 开始）                                                                                     |

访问目标 `GameObject` 包括：

```csharp
GameObject targetGo = invocation.ResolvedTarget?.GameObjectReference;
```

优先使用 `invocation.ResolvedTarget?.InteractionPoint` 在……上方 `GameObjectReference`自身的 transform 用于移动、指向、凝视或锚点行为——如果目标定义了明确的交互点，就会解析为该点，否则回退到 `GameObjectReference`的 transform。设置了交互点的作者希望每个执行器都遵守它。

不要重新解析 `invocation.Command.Name` 或 `invocation.Command.Target` 来重新推导要做什么。请使用 `invocation.Definition` 和 `invocation.ResolvedTarget` ——它们已经被解析并验证。

### 执行结果类型

从以下工厂方法中返回其一： `ExecuteAsync`:

| 工厂方法                                                                                                               | 何时使用                                                                 |
| ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| `ConvaiActionExecutionResult.Succeeded(string message = null)`                                                     | 该行为已成功完成，没有什么需要告诉玩家                                                  |
| `ConvaiActionExecutionResult.Answered(string answer, string message = null)`                                       | 该行为发现了某些信息——请参见 [回答问题，而不是执行动作](#answer-a-question-instead-of-acting) |
| `ConvaiActionExecutionResult.Failed(string message, ConvaiActionFailureReason reason, Exception exception = null)` | 发生了真实错误；优先使用此重载，以便调用方可以对结构化原因作出反应                                    |
| `ConvaiActionExecutionResult.Unhandled(string message = null)`                                                     | 此执行器有意拒绝处理该调用（上下文或目标类型不正确）                                           |
| `ConvaiActionExecutionResult.Canceled()`                                                                           | 当调度器将未捕获的取消判断为非超时引起时，会自动返回它。不要手动返回它——让异常继续传播（见下方“取消”）                |

{% hint style="danger" %}
不要 **不是** 返回 `ConvaiActionExecutionResult.TimedOut()` 手动返回。调度器会返回 `TimedOut` ，当 `TimeoutSeconds` 到期且 `CancellationToken` 被触发时自动返回。若你自己返回它，结果就会含糊不清，并且会绕过调度器的超时跟踪。
{% endhint %}

**`Failed` vs `Unhandled`:** 使用 `Failed` 当你尝试执行该行为但出了问题时使用。请使用 `Unhandled` 当此执行器根本不应处理这个特定调用时使用——例如，目标类型不正确。调度器会触发 `OnStepFailed` 对于 `Failed` 和 `OnStepUnhandled` 对于 `Unhandled`；二者都会被视为对 `StopBatch` StopBatch 失败策略的非成功结果。

### 回答问题，而不是执行动作

有些动作会执行可见行为——行走、打开、交付。另一些 *则是发现某些信息*：读取仪表、统计一组对象、测量距离。对于这些动作，玩家要求得到的内容就是结果，而且它必须传达给 Convai Character：

```csharp
// 对查询动作来说是错误的：Message 只是诊断信息。它会到达控制台、Actions Editor，
// 以及你自己的游戏代码——绝不会到达角色。玩家什么也听不到。
return ConvaiActionExecutionResult.Succeeded($"{dial.name} 显示 {value} kW");

// 正确：这句话就是动作的答案，角色会被告知。
return ConvaiActionExecutionResult.Answered($"{dial.name} 显示 {value} 千瓦。");
```

把答案写成一句朴素的话，使用第三人称，描述世界而不是你的代码—— `“四个箱子里还有两个是密封的。”`，而不是 `“count=2”`。返回答案并不会让角色说话；这由动作的 **完成时** 设置（`ConvaiActionAnswerDelivery`）在 Actions Editor 中决定；若未设置，则回退到角色的 `ConvaiActionFeedbackRelay`。设置为除 **告诉玩家** 的任何内容的答案仍会进入角色自己的记忆，因此之后可以提及这个事实——而且答案不必争抢轮次：SDK 会一直保留它直到角色停止说话，而不会丢弃它。

### 取消

该 `CancellationToken` 会在以下情况下触发：

1. `BatchPolicy.ReplaceCurrent` 生效（新的批次抢占当前批次）
2. `TimeoutSeconds` ，且该动作定义上的
3. 调度器被禁用或销毁
4. `CancelOnUserSpeech` 已在 `ConvaiActionDispatcher` 上启用，且玩家开始说话

在任何循环中，或在每次 `await`:

```csharp
while (!arrived)
{
    cancellationToken.ThrowIfCancellationRequested();
    // 前进一步
    await Task.Yield();
}
```

更建议让 `OperationCanceledException` 继续传播，而不是自己捕获。调度器会将你的 `ExecuteAsync` 包装在 try/catch 中，并对未捕获的 `OperationCanceledException` 为你分类：如果该步骤自身的 `TimeoutSeconds` 已过期，它会返回 `TimedOut`；对于其他任何取消，它会返回 `Canceled`。如果你自己捕获该异常并无条件返回 `ConvaiActionExecutionResult.Canceled()`，你就会把由超时引起的取消误报为 `Canceled` ，而不是 `TimedOut`.

如果你需要在取消时执行清理，请使用 `finally` 而不是 `catch` ，这样异常仍会传播，以便正确分类：

```csharp
try
{
    await SomeAsyncOperation(cancellationToken);
}
finally
{
    // 无论操作成功还是被取消，都必须运行的清理
}
```

### 完整示例：高亮对象执行器

此执行器会在已解析的目标上启用轮廓效果，等待三秒，然后将其关闭。

```csharp
using System.Threading;
using System.Threading.Tasks;
using Convai.Runtime.Actions;
using UnityEngine;

[AddComponentMenu("MyProject/Actions/Highlight Object Executor")]
public sealed class HighlightObjectExecutor : ConvaiActionExecutorBase
{
    [SerializeField] private float _highlightDuration = 3f;

    public override async Task<ConvaiActionExecutionResult> ExecuteAsync(
        ConvaiActionInvocation invocation,
        CancellationToken cancellationToken)
    {
        // 1. 获取目标
        GameObject targetGo = invocation.ResolvedTarget?.GameObjectReference;
        if (targetGo == null)
            return ConvaiActionExecutionResult.Failed(
                “未为 Highlight 动作解析到目标。”, ConvaiActionFailureReason.TargetMissing);

        // 2. 查找所需组件
        var outline = targetGo.GetComponent<OutlineEffect>();
        if (outline == null)
            return ConvaiActionExecutionResult.Failed(
                $"目标“{invocation.ResolvedTarget.Name}”没有 OutlineEffect 组件。",
                ConvaiActionFailureReason.PeerMissing);

        // 3. 执行行为
        outline.enabled = true;

        try
        {
            // 4. 等待，同时尊重取消
            await Task.Delay(
                (int)(_highlightDuration * 1000),
                cancellationToken);
        }
        finally
        {
            // 无论等待完成还是被取消，都进行清理
            if (outline != null)
                outline.enabled = false;
        }

        // 5. 让取消从上面的 try 块中向外传播，意味着
        // 调度器会正确地将其分类为（TimedOut vs. Canceled），而不是让这个
        // 执行器自行猜测——见上面的“取消”。
        return ConvaiActionExecutionResult.Succeeded();
    }
}
```

此示例直接派生自 `ConvaiActionExecutorBase` 以获得完全手动控制。当执行器只需要一个已解析的目标和层级对等组件时，派生自 `ConvaiTargetedActionExecutor` 或 `ConvaiCharacterActionExecutor<TPeer>` 就可以去掉步骤 1 和 2 中显示的目标为空检查和对等组件查找。

### 复合动作

将整个游戏流程放入一个 `ExecuteAsync`。调度器将一个动作定义视为不可分割——它会等到你的任务完成后才开始下一步。这是拾取、检查、先打开再拿取，或任何涉及多个子行为的动作的正确模式。

```csharp
public override async Task<ConvaiActionExecutionResult> ExecuteAsync(
    ConvaiActionInvocation invocation,
    CancellationToken cancellationToken)
{
    // 阶段 1：导航
    ConvaiActionExecutionResult moveResult =
        await _mover.ExecuteAsync(invocation, cancellationToken);
    if (moveResult.Status != ConvaiActionExecutionStatus.Succeeded)
        return moveResult;

    // 阶段 2：交互
    cancellationToken.ThrowIfCancellationRequested();
    _animator.SetTrigger("Interact");

    // 阶段 3：等待动画
    await Task.Delay(1200, cancellationToken);

    // 阶段 4：应用效果
    ApplyInteractionEffect(invocation.ResolvedTarget?.GameObjectReference);

    return ConvaiActionExecutionResult.Succeeded();
}
```

### 执行器设计规则

* **使用 `invocation.ResolvedTarget`，而不是 `invocation.Command.Target`.** 调度器已经将名称解析为一个 `GameObject` 绑定——不要重新解析原始字符串。
* **返回 `Unhandled` 当此执行器不合适时。** 一个执行器组件可以在多个动作定义之间共享。返回 `Unhandled` 会指示调度器触发 `OnStepUnhandled` 而不会将其视为严重失败。
* **返回 `Answered`，而不是 `成功`，用于查询动作。** 一个 `成功` 消息永远不会到达 Convai Character；只有 `Answer` 的作用。
* **设置 `TimeoutSeconds` 在动作定义中。** 使用超时机制，而不是在执行器内部自己实现截止时间逻辑。
* **在取消时进行清理。** 如果你的执行器启用了某个效果、移动了某个对象或占用了某个资源，请在一个 `finally` 代码块中释放它，然后再让取消异常传播。
* **不要在多次调用之间保留状态。** 同一个执行器实例可能会在多个批次中针对不同目标被调用。不要假设上一次调用的状态仍然有效。
* **绝不要读取你自己的 `transform` 用于世界空间位置或旋转的值。** 使用 `CharacterTransform` —— 见 [读取角色的 transform，而不是你自己的](#read-the-characters-transform-not-your-own).

### 下一步

{% content-ref url="/pages/111bca064ba7041a987662d038af4d71d58a32cd" %}
[配置角色动作](/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/features/character-actions/configuring-actions.md)
{% endcontent-ref %}

{% content-ref url="/pages/0341126fa4c492311dab4fb6aca6d0c64191016b" %}
[角色动作脚本参考](/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/features/character-actions/actions-scripting-reference.md)
{% endcontent-ref %}


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.convai.com/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/features/character-actions/writing-custom-executors.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
