> 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 集成

Convai 集成——用于无缝集成的 PlayCanvas 插件指南。

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

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

### Convai 初始化脚本

{% hint style="success" %}
将空的“”替换为你的 API 密钥和 Character 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 - 角色要使用的面部模型。
 * @param {boolean} enableFacialData - 启用面部数据的标志。
*/
function initializeConvaiClient () {
    console.log("Convai 客户端已启动。");
    convaiClient = new ConvaiClient({
        apiKey: "", // 替换为你的 API 密钥
        characterId: "", // 替换为你的角色 ID
        enableAudio: true,
        faceModal: 3,
        enableFacialData: true,
    });

    // 错误回调
    convaiClient.setErrorCallback(function(type, statusMessage) {
        console.log("错误回调", 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`.

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

### Lipsync 脚本

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

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

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

```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 target 并设置其权重
                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 target 并将其权重设为 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 = [];
        }
    }
};
```

### HeadTracking 脚本

该 `HeadTracking` 脚本负责根据摄像机的位置（代表用户视角）控制角色头部和眼睛的旋转。该脚本通过计算头部组件的前向向量与摄像机前向向量之间的角度来实现这一点。如果该角度在指定阈值内（此处为 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="https://2402152281-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FEtUJA212Zc1S9ACc8T4l%2Fuploads%2FqrXouqp6zVHvKtEp7e9N%2FScreenshot%20(36).png?alt=media&amp;token=3a638284-cfcd-498e-91a1-17f629869f7b" 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 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/web-plugins/playcanvas-plugin/convai-integration.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.
