> 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] 无法切换目标：{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` 即使房间里有该角色的两个克隆，也能读取到正确的角色。 `更新` 会在每一帧重新评估，因此一旦被寻址的角色停止说话，下一帧的检查就会通过，对新候选角色的待处理切换会自动继续，无需额外接线。

#### 预期结果

当玩家持续注视某个角色 `_lookAwayGracePeriod` 秒时， `SetInteractionTargetAsync` 系统就会把交互目标移动到该角色，并将 `InteractionTargetResult.Changed` 会报告 `是`设为 true。玩家在回答中途如果瞥向另一个角色，不会打断当前说话的那个角色——切换会等到 `IsSpeaking` 是 `否` 该轮结束后才执行。

### 场景中途的脚本化角色名单切换

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

#### 此模式强制执行的规则

在任何其他变化发生之前，先将进入的角色加入名册并留出时间让它抵达 `就绪` ，这样切换就绝不会把输入路由给一个尚无法响应的角色。随后，将离开的角色移除，并把 `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] 无法添加 {incoming.CharacterId}：{error.Message}");
            return;
        }
        catch (CharacterRosterUpdateException error)
        {
            Debug.LogError($"[MultiCharacter] 添加被拒绝（{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} 在切换前未变为就绪状态。");
            return;
        }

        CharacterRoomMembership outgoingMembership = session.FindByCharacter(outgoing);
        if (outgoingMembership == null)
        {
            Debug.LogWarning("[MultiCharacter] 离开的角色不是房间成员。");
            return;
        }

        try
        {
            CharacterRosterUpdateResult removeResult = await roomService.RemoveCharacterAsync(
                outgoingMembership.MembershipId,
                incomingMembership.MembershipId,
                _lifetime.Token);
            Debug.Log($"[MultiCharacter] 当前有效目标现在是 {removeResult.ActiveMembershipId}。");
        }
        catch (ArgumentException error)
        {
            Debug.LogError($"[MultiCharacter] {error.Message}");
        }
        catch (CharacterRosterUpdateException error)
        {
            Debug.LogError($"[MultiCharacter] 名册更新被拒绝（{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` 然后移除离开成员身份，并在同一条已确认的命令中，将 `当前成员 ID` 切换到进入成员身份—— `CharacterRosterUpdateResult.ActiveMembershipId` 会直接报告新的目标，无需再单独调用 `SetInteractionTargetAsync` 。在整个切换过程中，名册始终至少保留一个成员。

从调用者的角度看，这条命令是原子的，但对事件订阅者来说不是。SDK 会先应用移除，再应用新目标，因此 `InteractionTargetChanged` 会触发两次：一次是 `当前角色` 为 `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 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/multi-character-sessions/usage-examples.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.
