> 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/web-plugins/playcanvas-plugin/convai-integration.md).

# Convai 集成

在 URL 部分添加 Convai web-sdk-cdn 后，ConvaiClient 类将可直接在浏览器中使用。

{% hint style="info" %}
将下面所有脚本添加到你的角色实体中。
{% endhint %}

### Convai 初始化脚本

{% hint style="success" %}
将空的 "" 替换为你的 API 密钥和角色 ID。
{% endhint %}

该 `ConvaiNpc` 脚本负责处理用户与由 Convai AI 驱动的虚拟角色之间的交互。

该脚本通过提供 API 密钥和角色 ID 来初始化 Convai 客户端。它设置了必要的回调，以处理各种事件，例如错误、用户查询以及来自 Convai 服务的音频响应。

该 `initializeConvaiClient` 函数是设置 Convai 客户端的入口点。它会创建 `ConvaiClient` 的新实例，并使用提供的 API 密钥、角色 ID 以及其他设置进行配置，例如启用音频和面部数据。

该脚本通过两种方式处理用户输入：通过表单进行文本输入，以及使用 "T" 键进行语音输入。对于语音输入， `handleKeyDown` 是位于 `handleKeyUp` 函数分别用于检测 "T" 键何时被按下和释放。当按下 "T" 键时，脚本会开始录制音频并将其发送到 Convai 服务进行处理。

该 `ConvaiNpc.prototype.initialize` 函数每个实体只会调用一次，并设置 Convai 客户端。它还注册了用于处理音频播放事件的回调，更新 `isTalking` 是位于 `conversationActive` 标志。

该 `ConvaiNpc.prototype.handleAnimation` 函数根据 `isTalking` 状态更新角色的动画，从而实现同步的口型动作和面部表情。

<pre class="language-javascript"><code class="lang-javascript"><strong>var ConvaiNpc = pc.createScript('convaiNpc');
</strong>
/** 加载 convai-sdk cdn */
var convaiClient = null;
var userTextStream = ""; // 存储用户的文本输入
var npcTextStream = ""; // 存储 NPC 的文本回复
var keyPressed = false; // 记录 "T" 键是否被按下
var isTalking = false; // 指示 NPC 当前是否正在说话
var conversationActive = false; // 指示当前是否有正在进行的对话
var visemeData = []; // 存储用于口型同步动画的 viseme 数据
var visemeDataActive = false; // 指示是否有 viseme 数据可用
var formLoaded = false; // 指示表单是否已加载
let timeoutId;
/**
 * 使用 API 密钥、角色 ID 以及用于启用音频录制器和播放器的标志来初始化 ConvaiClient。请先从 Start() 调用此函数
 * 设置多个回调，以处理来自 Convai Client 的不同类型响应。
 * @param {string} apiKey - Convai 服务的 API 密钥。
 * @param {string} characterId - 在 Convai 服务中使用的角色 ID。
 * @param {boolean} enableAudioRecorder - 启用音频录制器的标志。
 * @param {boolean} enableAudioPlayer - 启用音频播放器的标志。
 * @param {number} faceModal - 角色要使用的 face modal。
 * @param {boolean} enableFacialData - 启用面部数据的标志。
*/
function initializeConvaiClient () {
    console.log("Convai client initiated.");
    convaiClient = new ConvaiClient({
        apiKey: "", // 替换为你的 API 密钥
        characterId: "", // 替换为你的角色 ID
        enableAudio: true,
        faceModal: 3,
        enableFacialData: true,
    });

    // 错误回调
    convaiClient.setErrorCallback(function(type, statusMessage) {
        console.log("Error Callback", type, statusMessage);
    });

    // 响应回调
    convaiClient.setResponseCallback(function(response) {
        // 处理用户查询
        if (response.hasUserQuery) {
            var transcript = response.getUserQuery();
            if (transcript) {
                var isFinal = transcript?.getIsFinal();
                var transcriptText = transcript.getTextData();
                if (isFinal) {
                    userTextStream += " " + transcriptText;
                }
                userTextStream = transcriptText;
            }
        }

        // 处理音频响应
        if (response.hasAudioResponse()) {
            var audioResponse = response.getAudioResponse();
            npcTextStream += " " + audioResponse.getTextData();

            // 处理用于口型同步动画的 viseme 数据
            if (audioResponse.hasVisemesData()) {
                let lipsyncData = audioResponse.getVisemesData().array[0];
                if (lipsyncData[0] !== -2) {
                    visemeData.push(lipsyncData);
                    visemeDataActive = true;
                }
            } else {
                visemeDataActive = false;
            }
        }
    });
}

// 处理按键按下事件以开始音频输入
function handleKeyDown(e) {
    const convaiFormEl = document.getElementById("convai-input");
    if (convaiClient &#x26;&#x26; e.keyCode === 84 &#x26;&#x26; !keyPressed &#x26;&#x26; !(document.activeElement === convaiFormEl)) {
        e.stopPropagation();
        e.preventDefault();
        keyPressed = true;
        userTextStream = "";
        npcTextStream = "";
        convaiClient.startAudioChunk();
        conversationActive = true;
    }
}

// 处理按键抬起事件以停止音频输入
function handleKeyUp(e) {
    const convaiFormEl = document.getElementById("convai-input");
    if (convaiClient &#x26;&#x26; e.keyCode === 84 &#x26;&#x26; keyPressed &#x26;&#x26; !(document.activeElement === convaiFormEl)) {
        e.preventDefault();
        keyPressed = false;
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => {
        convaiClient.endAudioChunk();
        }, 500);
    }
}

// 添加按键按下和按键抬起的事件监听器
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);

// 每个实体只调用一次的初始化代码
ConvaiNpc.prototype.initialize = function() {
    initializeConvaiClient();

    // 设置音频播放的回调
    convaiClient.onAudioPlay(() => {
        isTalking = true;
    });
    convaiClient.onAudioStop(() => {
        isTalking = false;
        conversationActive = false;
    });
};

// 根据说话状态处理动画
ConvaiNpc.prototype.handleAnimation = function() {
    this.entity.anim.setBoolean("Talking", isTalking);
};

// 每帧调用的更新代码
ConvaiNpc.prototype.update = function(dt) {
    this.handleAnimation();

    // 检查表单是否已加载并添加事件监听器
    if (!formLoaded &#x26;&#x26; document.getElementById("convai-form")) {
        this.formListener();
        formLoaded = true;
    }
};

// 添加表单提交事件监听器
ConvaiNpc.prototype.formListener = function() {
    document.getElementById("convai-form").addEventListener("submit", (e) => {
        e.preventDefault();
        var inputValue = document.getElementById("convai-input").value;
        window.convaiClient.sendTextChunk(inputValue);
        userTextStream = inputValue;
        document.getElementById("convai-form").reset();
        conversationActive = true;
        npcTextStream = "";
    });
}
</code></pre>

### PlayerAnimationHandler 脚本

该 `PlayerAnimationHandler` 脚本负责根据某些条件（例如速度或其他因素）控制玩家角色的动画。

该脚本定义了三个属性：

1. `blendTime`: 此属性控制动画之间的混合时间，决定动画过渡的平滑程度。默认值设为 0.2。
2. `velMin`: 此属性表示触发特定动画所需的最小速度。默认值设为 10。
3. `velMax`: 此属性表示触发特定动画所需的最大速度。默认值设为 50。

```javascript
// 创建一个名为 'playerAnimationHandler' 的新脚本
var PlayerAnimationHandler = pc.createScript('playerAnimationHandler');

// 添加一个名为 'blendTime' 的属性，默认值为 0.2
// 此属性控制动画之间的混合时间
PlayerAnimationHandler.attributes.add('blendTime', { type: 'number', default: 0.2 });

// 添加一个名为 'velMin' 的属性，默认值为 10
// 此属性表示某个特定动画的最小速度
PlayerAnimationHandler.attributes.add('velMin', { type: 'number', default: 10 });

// 添加一个名为 'velMax' 的属性，默认值为 50
// 此属性表示某个特定动画的最大速度
PlayerAnimationHandler.attributes.add('velMax', { type: 'number', default: 50 });

// 当脚本初始化时调用此函数
PlayerAnimationHandler.prototype.initialize = function() {
    // 使用指定的混合时间播放 'Idle' 动画
    this.entity.animation.play('Idle', this.blendTime);
};
```

这些属性可以在编辑器中或通过代码进行调整，以微调玩家角色的动画行为。

该 `initialize` 函数在脚本初始化时调用。在此实现中，它使用指定的混合时间（`this.blendTime`）播放 'Idle' 动画。当玩家角色没有移动或速度超出 `velMin` 是位于 `velMax`.

该脚本旨在进一步扩展，以根据玩家角色的速度或其他条件处理不同的动画状态。例如，你可以添加额外的函数或逻辑来检查玩家的速度，并根据 `velMin` 是位于 `velMax`.

通过使用此脚本，你可以轻松管理玩家角色的不同动画之间的切换，进而为你的游戏或应用提供更具沉浸感和更真实的体验。

### 口型同步脚本

该 `口型同步` 脚本负责根据接收到的 viseme 数据来驱动角色的嘴部和面部表情动画。Viseme 是用于表示语音声音的关键口型和面部位置。该脚本会将 morph target 动画应用到角色的头部和牙齿组件上，以实现逼真的口型同步效果。

该脚本通过访问 `visemeData` 数组来工作，其中包含动画每一帧的 viseme 权重。然后，它将这些权重应用到头部和牙齿组件上对应的 morph target。  `runVisemeData` 函数通过遍历 viseme 权重并相应地设置 morph target 权重来处理此过程。

该脚本使用 `currentVisemeFrame` 变量和一个计时器变量来跟踪当前的 viseme 帧。这确保 viseme 动画与音频播放同步。当 viseme 数据播放完毕后， `zeroMorphs` 函数会被调用，将所有 morph target 权重重置为零，从而有效重置角色的面部表情。

```javascript
// 创建一个名为 'lipsync' 的新脚本
var Lipsync = pc.createScript('lipsync');

// 用于存储口型同步数据的数组
var lipsyncData = [];

// 每个实体只调用一次的初始化代码
Lipsync.prototype.initialize = function() {
    // 查找实体的头部组件
    this.headComponent = this.entity.findByName("Wolf3D_Head").render;

    // 查找实体的牙齿组件
    this.teethComponent = this.entity.findByName("Wolf3D_Teeth").render;

    // 将当前 viseme 帧初始化为 0
    this.currentVisemeFrame = 0;

    // 将计时器初始化为 0
    this.timer = 0;
};

// 对收到的 viseme 数据运行 morph target
Lipsync.prototype.runVisemeData = function(dt) {
    // 按时间同步帧
    if (this.currentVisemeFrame <= lipsyncData.length && lipsyncData.length !== 0) {
        if (this.headComponent && this.teethComponent) {
            // 获取头部和牙齿组件的 morph 实例
            var headMorphInstance = this.headComponent.meshInstances[0].morphInstance;
            var teethMorphInstance = this.teethComponent.meshInstances[0].morphInstance;

            if (lipsyncData[this.currentVisemeFrame] !== undefined) {
                // 遍历 morph targets 并设置它们的权重
                for (let i = 0; i < 15; i++) {
                    if (lipsyncData[this.currentVisemeFrame][i] !== undefined) {
                        headMorphInstance.setWeight(i, lipsyncData[this.currentVisemeFrame][i]);
                        teethMorphInstance.setWeight(i, lipsyncData[this.currentVisemeFrame][i]);
                    }
                }
            }

            // 根据计时器更新当前 viseme 帧
            this.currentVisemeFrame = Math.floor(this.timer * 100);
        }
    }
}

// 将所有 morph target 重置为 0
Lipsync.prototype.zeroMorphs = function(dt) {
    if (this.headComponent && this.teethComponent) {
        // 获取头部和牙齿组件的 morph 实例
        var headMorphInstance = this.headComponent.meshInstances[0].morphInstance;
        var teethMorphInstance = this.teethComponent.meshInstances[0].morphInstance;

        // 遍历 morph targets 并将它们的权重设置为 0
        for (let i = 0; i < 15; i++) {
            headMorphInstance.setWeight(i, 0);
            teethMorphInstance.setWeight(i, 0);
        }
    }
}

// 每帧调用的更新代码
Lipsync.prototype.update = function(dt) {
    // 检查是否有新的 viseme 数据并更新 lipsyncData 数组
    if (window.visemeData && window.visemeDataActive) {
        lipsyncData = window.visemeData;
    }

    if (lipsyncData.length > 0) {
        // 更新计时器
        this.timer += dt;

        // 运行 viseme 数据
        this.runVisemeData();

        // 检查 viseme 数据是否已播放完毕
        if (this.timer * 100 >= lipsyncData.length) {
            // 将所有 morph target 重置为 0
            this.zeroMorphs();

            // 清空 lipsyncData 数组并重置计时器和 currentVisemeFrame
            lipsyncData = [];
            this.timer = 0;
            this.currentVisemeFrame = 0;
            window.visemeData = [];
        }
    }
};
```

### 头部追踪脚本

该 `头部追踪` 脚本负责根据摄像机的位置（代表用户视角）控制角色头部和眼睛的旋转。该脚本通过计算头部组件前向向量与摄像机前向向量之间的角度来实现这一点。如果该角度在指定阈值内（此处为 45 度），则头部和眼睛会旋转以朝向摄像机的位置。

<pre class="language-javascript"><code class="lang-javascript"><strong>// 创建一个名为 'headTracking' 的新脚本
</strong>var HeadTracking = pc.createScript('headTracking');

// 递归按名称查找子实体的辅助函数
HeadTracking.prototype.findChild = function(children, matchName) {
    for (let child of children) {
        if (child.name.includes(matchName)) {
            return child;
        }

        if (child.children) {
            const foundChild = this.findChild(child.children, matchName);
            if (foundChild) {
                return foundChild;
            }
        }
    }

    return null;
};

// 计算两个向量之间弧度角的函数
HeadTracking.prototype.getRadianAngle = function(vecA, vecB) {
    dot = vecA.normalize().dot(vecB.normalize());
    return Math.acos(dot);
};

// 每个实体只调用一次的初始化代码
HeadTracking.prototype.initialize = function() {
    // 查找头部、左眼和右眼组件
    this.headComponent = this.entity.findByName("Head");
    this.leftEye = this.entity.findByName("LeftEye");
    this.rightEye = this.entity.findByName("RightEye");
};

// 更新循环结束后调用的函数
HeadTracking.prototype.postUpdate = function(dt) {
    // 检查摄像机位置是否可用
    if (window.fpsCamera?.position !== undefined) {
        const cameraPosition = window.fpsCamera.getPosition().clone();
        const angle = this.getRadianAngle(this.headComponent.forward, window.fpsCamera.forward);
        const degree = angle * pc.math.RAD_TO_DEG;

        // 如果角度在 45 度以内，则看向摄像机位置
        if (degree &#x3C;= 45) {
            this.headComponent.lookAt(-cameraPosition.x, cameraPosition.y, -cameraPosition.z);
            this.leftEye.lookAt(-cameraPosition.x, cameraPosition.y, -cameraPosition.z);
            this.rightEye.lookAt(-cameraPosition.x, cameraPosition.y, -cameraPosition.z);
        }
    }
};
</code></pre>

将以上所有脚本添加到你的 playcanvas 项目中，并将 convaiNPC、lipsync、Headtracking 连接到 convi（你的模型）组件上。

<figure><img src="/files/1e283848c7662fbc6e3598e47c54f6a228f4c558" alt=""><figcaption><p>附加脚本</p></figcaption></figure>


---

# 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:

```
GET https://docs.convai.com/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/playcanvas-plugin/convai-integration.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
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.
