> 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).

# 编写自定义动作执行器

当内置执行器不符合项目的移动系统、交互模型或玩法规则时，请实现 `IConvaiActionExecutor`。自定义执行器是一个标准的 C# `MonoBehaviour` ，其中包含一个异步方法。调度器会将其与任何内置执行器同等对待——所有策略、事件和取消行为都会自动应用。

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

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

* 你的项目使用自定义移动系统（根运动、 `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
// 错误——在偏离原点的行为对象上，这不是角色的位置。
_home = transform.position;

// 正确——Convai Character，无论此组件位于何处。
_home = CharacterTransform.position;
```

使用你自己的 `transform` 来处理真正表示“此组件的对象”的事项——父子关系、层级遍历——仍然没问题。规则关乎 *角色* 所在的位置。

#### 解析玩家的位置

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

底层查找位于 `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`时，会解析玩家绑定实际移动的 transform——绑定内部的一个 `CharacterController` 或 `Rigidbody` ，而不是预制体根节点——因为第一人称控制器通常会让根节点在整个会话期间停留在出生点。读取根节点只会报告玩家 *开始时*的位置：一个角色带领某人前往某处时停下来等待，玩家走到它身旁，它却仍停在原地，并且没有任何日志说明原因。

### ConvaiActionInvocation 对象

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

| 属性               | 类型                           | 包含                                                                                                      |
| ---------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------- |
| `Command`        | `ConvaiActionCommand`        | 原始后端命令—— `Name`, `目标`, `HasTarget`                                                                      |
| `Definition`     | `ConvaiActionDefinition`     | 本地定义—— `ActionName`, `TargetRequirement`, `Executor`, `TimeoutSeconds`                                  |
| `ResolvedTarget` | `ConvaiResolvedActionTarget` | 已解析的目标绑定—— `类型`, `Name`, `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` 与 `Unhandled`:** 使用 `Failed` 当你尝试执行该行为但出现问题时。使用 `Unhandled` 当此执行器根本不应处理这个特定调用时——例如，目标类型错误。调度器会触发 `OnStepFailed` 用于 `Failed` 和 `OnStepUnhandled` 用于 `Unhandled`；两者对于 `StopBatch` 失败策略提前中止的批次数。

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

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

```csharp
// 对查询动作而言是错误的：Message 用于诊断。它会到达 Console、Actions Editor，
// 以及你自己的游戏代码——但永远不会到达角色。玩家什么也听不到。
return ConvaiActionExecutionResult.Succeeded($"{dial.name} reads {value} kW");

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

将答案写成一句朴素的第三人称句子，描述真实世界而非你的代码—— `“四个箱子中仍有两个密封着。”`，而不是 `“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`；对于任何其他取消，则返回 `已取消`。如果你自行捕获异常并无条件返回 `ConvaiActionExecutionResult.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 或 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`，而不是 `Succeeded`，用于查询动作。** 某个 `Succeeded` message 永远不会到达 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 dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
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=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

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.
