> 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/multi-character-sessions/usage-examples.md).

# 多角色使用示例

实现“看向地址”目标定位和脚本化成员列表切换，让 Unity 场景管理玩家正在对房间中哪个角色发话。

两个经过验证的模式展示了当房间已连接后，应用程序如何驱动多角色会话：一种是按玩家所看的方向路由玩家输入，另一种是在场景进行中用另一个角色替换当前角色，并且始终不会让房间失去有效的目标。这两种模式都是基于公开连接 API 编写的应用代码——都不是 SDK 的一部分。

{% hint style="info" %}
这两个示例都假设已连接的多角色会话中 `IConvaiRoomConnectionService.CurrentMultiCharacterSession` 已经填充。参见 [构建你的第一个多角色会话](/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/features/multi-character-sessions/quick-start.md) 如果房间尚未连接。
{% endhint %}

对于大多数场景，内置的 **注视** 定向模式在 **Convai Manager > Who The Player Talks To** 已经完成了下面第一个示例手动构建的工作——参见 [对话目标定位](/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/features/conversation-targeting.md)。仅在项目需要内置模式和 `SetInteractionTargetAsync` 直接无法表达的目标规则时，才直接使用 `IConversationTargetProvider` ，因为调用它会绕过 `ConvaiManager`自身的目标定位状态（`ConversationTarget`, `AddressedCharacter`，而且目标定位事件不会观察以这种方式在房间级别发出的调用）。

### 按视线地址定位

**上下文：** 一个场景中有多个角色站在不同位置。玩家应当与其当前正面对着的角色交谈，而不因每次轻微转头就打断某个角色的回答。

#### 此模式所强制的规则

从摄像机前方发出的射线检测用于识别玩家当前正面对着哪个角色。短暂的宽限期会对结果进行去抖，因此短暂移开视线不会立即改变交互目标。在切换之前，脚本会检查当前持有目标的角色是否仍在说话，并将切换延后到该轮发言结束。

#### 实现

{% code title="Assets/Scripts/LookToAddressTargeting.cs" %}

```csharp
using System;
using System.Threading;
using Convai.Runtime.Components;
using Convai.Runtime.Room;
using UnityEngine;

public class LookToAddressTargeting : MonoBehaviour
{
    [SerializeField] private Camera _lookCamera;
    [SerializeField] private LayerMask _characterLayerMask;
    [SerializeField] private float _maxLookDistance = 10f;
    [SerializeField] private float _lookAwayGracePeriod = 1.5f;

    private IConvaiRoomConnectionService _roomService;
    private ConvaiCharacter _addressedCharacter;
    private ConvaiCharacter _candidateCharacter;
    private float _candidateStableSince;
    private bool _switchInFlight;

    private readonly CancellationTokenSource _lifetime = new();

    public void Attach(IConvaiRoomConnectionService roomService, ConvaiCharacter initialTarget)
    {
        _roomService = roomService;
        _addressedCharacter = initialTarget;
    }

    private void Update()
    {
        if (_roomService?.CurrentMultiCharacterSession == null) return;

        ConvaiCharacter looked = ResolveLookedAtCharacter();
        if (looked != _candidateCharacter)
        {
            _candidateCharacter = looked;
            _candidateStableSince = Time.time;
        }

        if (_candidateCharacter == null || _candidateCharacter == _addressedCharacter) return;
        if (Time.time - _candidateStableSince < _lookAwayGracePeriod) return;

        TrySwitchTarget(_candidateCharacter);
    }

    private ConvaiCharacter ResolveLookedAtCharacter()
    {
        if (!Physics.Raycast(_lookCamera.transform.position, _lookCamera.transform.forward,
                out RaycastHit hit, _maxLookDistance, _characterLayerMask))
            return null;

        return hit.collider.GetComponentInParent<ConvaiCharacter>();
    }

    private async void TrySwitchTarget(ConvaiCharacter target)
    {
        if (_switchInFlight) return;
        if (_addressedCharacter != null && _addressedCharacter.IsSpeaking) return;

        _switchInFlight = true;
        try
        {
            InteractionTargetResult result = await _roomService.SetInteractionTargetAsync(target, _lifetime.Token);
            if (result.Changed) _addressedCharacter = target;
        }
        catch (InvalidOperationException error)
        {
            Debug.LogError($"[MultiCharacter] Could not switch target: {error.Message}");
        }
        catch (ArgumentException error)
        {
            Debug.LogError($"[MultiCharacter] {error.Message}");
        }
        catch (TimeoutException error)
        {
            Debug.LogError($"[MultiCharacter] {error.Message}");
        }
        finally
        {
            _switchInFlight = false;
        }
    }

    private void OnDestroy()
    {
        _lifetime.Cancel();
        _lifetime.Dispose();
    }
}
```

{% endcode %}

`ConvaiCharacter.IsSpeaking` 以及 `OnSpeechStarted`/`OnSpeechStopped` 事件都限定于 SDK 将语音事件解析到的那个角色实例，因此在这里检查 `IsSpeaking` 时，即使房间中有两个它的克隆体，也会读取到正确的角色。 `Update` 每一帧都会重新评估，因此一旦被提及的角色停止说话，下一帧的检查就会通过，随后会在无需额外连接的情况下继续进行到新候选对象的待切换操作。

#### 预期结果

当玩家持续看着某个角色达到 `_lookAwayGracePeriod` 秒时， `SetInteractionTargetAsync` 会将交互目标移动到该角色，并且 `InteractionTargetResult.Changed` 会报告 `true`。当玩家在回答中途瞥向另一个角色时，不会打断当前说话的角色——切换会等待 `IsSpeaking` 是 `false` 之后再执行。

### 脚本化的场景中途名册切换

**上下文：** 一个训练场景，其中一个角色在进行到一半时离开，另一个角色接替成为玩家正在对话的人——例如，主管离开，而安全培训员继续会话。

#### 此模式所强制的规则

先将新加入的角色添加到名册，并给它时间到达 `就绪` ，然后再改变其他任何内容，这样切换就不会把输入路由给一个尚无法响应的角色。接着使用 `replacementTargetMembershipId` 将离开的角色移除，并将目标设为新加入角色的成员资格，因此移除和目标更改在一条命令中完成，房间始终不会失去有效的交互目标。

#### 实现

{% code title="Assets/Scripts/RosterSwapController.cs" %}

```csharp
using System;
using System.Threading;
using System.Threading.Tasks;
using Convai.Runtime.Components;
using Convai.Runtime.Room;
using UnityEngine;

public class RosterSwapController : MonoBehaviour
{
    private readonly CancellationTokenSource _lifetime = new();

    public async void SwapCharacter(
        IConvaiRoomConnectionService roomService,
        MultiCharacterRoomSession session,
        ConvaiCharacter outgoing,
        ConvaiCharacter incoming)
    {
        CharacterRoomMembership incomingMembership;
        try
        {
            CharacterRosterUpdateResult addResult =
                await roomService.AddCharacterAsync(incoming, cancellationToken: _lifetime.Token);
            incomingMembership = addResult.Added[0];
        }
        catch (ArgumentException error)
        {
            Debug.LogError($"[MultiCharacter] Could not add {incoming.CharacterId}: {error.Message}");
            return;
        }
        catch (CharacterRosterUpdateException error)
        {
            Debug.LogError($"[MultiCharacter] Add rejected ({error.Code}): {error.Message}");
            return;
        }
        catch (InvalidOperationException error)
        {
            Debug.LogError($"[MultiCharacter] {error.Message}");
            return;
        }
        catch (TimeoutException error)
        {
            Debug.LogError($"[MultiCharacter] {error.Message}");
            return;
        }

        if (!await WaitForReadyAsync(session, incomingMembership.MembershipId))
        {
            Debug.LogWarning($"[MultiCharacter] {incoming.CharacterId} did not become ready before the swap.");
            return;
        }

        CharacterRoomMembership outgoingMembership = session.FindByCharacter(outgoing);
        if (outgoingMembership == null)
        {
            Debug.LogWarning("[MultiCharacter] The outgoing character is not a member of the room.");
            return;
        }

        try
        {
            CharacterRosterUpdateResult removeResult = await roomService.RemoveCharacterAsync(
                outgoingMembership.MembershipId,
                incomingMembership.MembershipId,
                _lifetime.Token);
            Debug.Log($"[MultiCharacter] Active target is now {removeResult.ActiveMembershipId}.");
        }
        catch (ArgumentException error)
        {
            Debug.LogError($"[MultiCharacter] {error.Message}");
        }
        catch (CharacterRosterUpdateException error)
        {
            Debug.LogError($"[MultiCharacter] Roster update rejected ({error.Code}): {error.Message}");
        }
        catch (InvalidOperationException error)
        {
            Debug.LogError($"[MultiCharacter] {error.Message}");
        }
        catch (TimeoutException error)
        {
            Debug.LogError($"[MultiCharacter] {error.Message}");
        }
    }

    private static Task<bool> WaitForReadyAsync(MultiCharacterRoomSession session, string membershipId)
    {
        CharacterRoomMembership existing = session.FindByMembershipId(membershipId);
        if (existing != null && existing.Status != CharacterRoomStatus.Starting)
            return Task.FromResult(existing.Status == CharacterRoomStatus.Ready);

        var readySource = new TaskCompletionSource<bool>();

        void OnStatusChanged(CharacterRoomMembership membership)
        {
            if (membership.MembershipId != membershipId) return;
            if (membership.Status == CharacterRoomStatus.Starting) return;

            session.CharacterStatusChanged -= OnStatusChanged;
            readySource.TrySetResult(membership.Status == CharacterRoomStatus.Ready);
        }

        session.CharacterStatusChanged += OnStatusChanged;
        return readySource.Task;
    }

    private void OnDestroy()
    {
        _lifetime.Cancel();
        _lifetime.Dispose();
    }
}
```

{% endcode %}

{% hint style="warning" %}
先添加新加入的角色，再移除离开的角色。即使将替代目标排在后续调用中，先移除也会短暂地让名册中缺少你打算把对话交给的那个角色。
{% endhint %}

#### 预期结果

`AddCharacterAsync` 返回新加入角色的新成员资格，并且 `WaitForReadyAsync` 在以下情况时解析完成： `CharacterStatusChanged` 报告其为 `就绪` 或 `Failed`. `RemoveCharacterAsync` 然后移除离开的成员资格，并在同一条已确认的命令中，将 `ActiveMembershipId` 移动到新加入成员资格—— `CharacterRosterUpdateResult.ActiveMembershipId` 直接报告新目标，无需单独调用 `SetInteractionTargetAsync` 。在整个切换过程中，名册至少保持有一个成员。

从调用者角度看，这条命令是原子的，但从事件订阅者角度看并非如此。SDK 会先应用移除，再应用新目标，所以 `InteractionTargetChanged` 会触发两次：一次是带着 `current` 作为 `null` ，对应离开的成员资格被移除；然后再次触发，带着新加入的成员资格。响应 `null` 目标的代码，例如通过使界面变暗来响应，不应把它当作对话结束，而应进行去抖处理。

### 下一步

{% content-ref url="/pages/adffcd7e333fbd7f83aafb6f96412854dcf886f5" %}
[角色加入与离开](/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/features/multi-character-sessions/update-the-roster.md)
{% endcontent-ref %}

{% content-ref url="/pages/d8451a87588c6ea22a959d1a3ecd7ecc09422e8a" %}
[对话目标选择](/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/features/conversation-targeting.md)
{% endcontent-ref %}

{% content-ref url="/pages/2c6d63cffd7b1ec0697bd7dd2aa8bd901ef1f717" %}
[排查多角色会话问题](/api-docs/zh/cha-jian-yu-ji-cheng/convai-unity-sdk/features/multi-character-sessions/troubleshooting.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/multi-character-sessions/usage-examples.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.
