VIVE Eagle Banner

在現代遊戲開發中 NPC(非玩家角色)互動是提升遊戲沉浸感的重要元素之一。當玩家靠近 NPC 時,通常會觸發對話或任務提示,這種互動機制能讓遊戲世界更加生動。本文將介紹如何利用 HTML 和 VIVERSE EXTENSION 的 TriggerAndAction 功能,快速實現 NPC 對話面板效果,並提供完整操作流程與範例程式碼,讓開發者可以在 VIVERSE WORLD 中輕鬆製作類似功能。

我們將示範的 NPC 對話效果包括:當玩家進入觸發區域時彈出對話框、玩家點擊滑鼠可進行逐句對話,並支援角色頭像與文字顯示。無論是新手還是有經驗的遊戲開發者,都能透過本文的教學快速掌握 NPC 互動面板的實作技巧。

[PlayCanvas + VIVERSE] 一次性 NPC 對話框

效果展示

  1. 玩家觸發 Trigger Box 後跳出對話框
  2. 點擊滑鼠可進行對話直到播放完畢
[PlayCanvas + VIVERSE] 一次性 NPC 對話框

概念說明

  1. 顯示機制
    • 對話面板透過 this.app.graphicsDevice.canvas.parentNode 建立
    • 即使遊戲切換至全螢幕模式,面板仍能正常顯示與運作
  2. 使用方式
    • 在場景中放置一個空的物件(Empty Entity)
    • 為該物件附加此 Script
    • 直接於屬性面板中設定以下參數:
      • 對話組數
      • 角色圖像
      • 角色名稱
      • 對話內容
  3. 開發優點
    • 無需額外撰寫 UI 相關程式碼
    • 設計師可直接透過編輯器完成對話配置
    • 系統可重複使用,方便在不同場景或專案中套用
  4. 未來擴充
    • 可進一步將任務提示(Quest Hint)設計為 ICON 形式的側邊面板
    • 用以紀錄並顯示任務進度或提示資訊(此部分將於後續文章補充)
尚在嘗試的部分
  1. 無法重複觸發播放對話
  2. 思考增加對話的複雜度可能性
[PlayCanvas + VIVERSE] 一次性 NPC 對話框

流程操作

VIVERSE EXTENSION

本操作需要使用 VIVERSE EXTENSION 的功能,並於 VIVERSE WORLD 使用,因此需要先下載 VIVERSE EXTENSION。請參考教學〈為瀏覽器安裝 VIVERSE EXTENSION〉。

ASSETS

Folder: ICON
  • 上傳 ICON
    • player.png
    • NPC1.png
    • NPC2.png
[PlayCanvas + VIVERSE] 一次性 NPC 對話框
Folder: Scripts
  • 新增檔案
    • DialogManager.js
    • npc.js
[PlayCanvas + VIVERSE] 一次性 NPC 對話框

SCRIPTS

將剛剛新增的 DialogManager.jsnpc.js 分別貼入以下的程式碼

DialogManager.js
JavaScript
var DialogManager = pc.createScript('dialogManager');

// 靜態單例
DialogManager.instance = null;

// Inspector 可設定角色圖片
DialogManager.attributes.add('characterImages', {
    type: 'json',
    title: '角色圖片',
    description: '設定角色名稱與對應圖片',
    schema: [
        { name: 'name', type: 'string', title: '角色名稱' },
        { name: 'image', type: 'asset', assetType: 'texture', title: '頭像圖片' }
    ],
    array: true,
    default: [
        { name: 'NPC', image: null },
        { name: 'Player', image: null }
    ]
});

// 初始化
DialogManager.prototype.initialize = function () {
    DialogManager.instance = this;

    // 建立對話框 Overlay
    this.overlay = document.createElement('div');
    Object.assign(this.overlay.style, {
        position: 'fixed',
        bottom: '15%',
        left: '50%',
        transform: 'translateX(-50%)',
        minWidth: '60%',
        minHeight: '20%',
        backgroundColor: 'rgba(0, 0, 0, 0.7)',
        color: 'white',
        display: 'none',
        flexDirection: 'row',
        alignItems: 'center',
        padding: '16px 24px',
        fontSize: '18px',
        zIndex: '9999',
        fontFamily: 'sans-serif',
        borderRadius: '12px',
        boxShadow: '0 0 12px rgba(0,0,0,0.5)',
        transition: 'opacity 0.3s ease',
        opacity: '0'
    });

    // 頭像區
    this.imageContainer = document.createElement('div');
    Object.assign(this.imageContainer.style, {
        width: '100px',
        height: '100px',
        flexShrink: '0',
        marginRight: '16px',
        borderRadius: '8px',
        overflow: 'hidden',
        backgroundColor: 'rgba(255,255,255,0.1)'
    });

    this.imageElement = document.createElement('img');
    Object.assign(this.imageElement.style, {
        width: '100%',
        height: '100%',
        objectFit: 'cover'
    });
    this.imageContainer.appendChild(this.imageElement);
    this.overlay.appendChild(this.imageContainer);

    // 文字區
    this.textContainer = document.createElement('div');
    Object.assign(this.textContainer.style, {
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'center',
        flex: '1'
    });

    this.nameElement = document.createElement('p');
    Object.assign(this.nameElement.style, {
        fontWeight: 'bold',
        fontSize: '20px',
        margin: '0 0 6px 0',
        color: '#FFD700'
    });
    this.textContainer.appendChild(this.nameElement);

    this.textElement = document.createElement('p');
    Object.assign(this.textElement.style, {
        margin: '0',
        lineHeight: '1.4em',
        fontSize: '18px'
    });
    this.textContainer.appendChild(this.textElement);

    this.overlay.appendChild(this.textContainer);
    document.body.appendChild(this.overlay);

    // 屬性
    this.typingSpeed = 40;
    this.isTyping = false;
    this.dialogQueue = [];
    this.currentDialog = 0;

    // 點擊控制
    this._onClick = () => {
        if (!this.dialogQueue.length) return;

        if (this.isTyping) {
            this.textElement.textContent = this.fullText;
            this.isTyping = false;
        } else {
            this.showNextDialog();
        }
    };
    document.addEventListener('click', this._onClick);

    // 廣播初始化完成事件
    this.app.fire('dialogmanager:ready');

    // 點擊提示文字
    this.clickHint = document.createElement('p');
    Object.assign(this.clickHint.style, {
        position: 'absolute',
        bottom: '8px',
        left: '50%',
        transform: 'translateX(-50%)',
        fontSize: '14px',
        color: '#FFFFFF',
        opacity: '0.7',
        margin: '0',
        pointerEvents: 'none',
        fontFamily: 'sans-serif'
    });
    this.clickHint.textContent = '(請點擊滑鼠繼續)';
    this.overlay.appendChild(this.clickHint);
};

// 開始對話
DialogManager.prototype.startDialog = function(dialogArray, onComplete) {
    if (!dialogArray || !dialogArray.length) return;

    this.dialogQueue = dialogArray;
    this.currentDialog = 0;
    this.onComplete = onComplete || null;
    this.isTyping = false;

    this.showNextDialog();
};

// 顯示單句對話
DialogManager.prototype.showDialog = function (name, text, imageAsset) {

    // 左右角色切換
    if (name.toLowerCase() === '玩家') {
        this.overlay.style.flexDirection = 'row-reverse';
        this.imageContainer.style.marginRight = '0';
        this.imageContainer.style.marginLeft = '16px';
        this.nameElement.style.textAlign = 'right';
        this.textElement.style.textAlign = 'right';
    } else {
        this.overlay.style.flexDirection = 'row';
        this.imageContainer.style.marginRight = '16px';
        this.imageContainer.style.marginLeft = '0';
        this.nameElement.style.textAlign = 'left';
        this.textElement.style.textAlign = 'left';
    }

    // 顯示對話框(淡入)
    this.overlay.style.display = 'flex';
    setTimeout(() => (this.overlay.style.opacity = '1'), 10);

    this.nameElement.textContent = name;
    this.textElement.textContent = '';
    this.fullText = text;

    // 設定頭像圖片
    if (imageAsset && imageAsset.resource) {
        this.imageElement.src = imageAsset.getFileUrl();
        this.imageContainer.style.display = 'block';
    } else {
        const found = this.characterImages.find((c) => c.name === name);
        if (found && found.image && found.image.resource) {
            this.imageElement.src = found.image.getFileUrl();
            this.imageContainer.style.display = 'block';
        } else {
            this.imageContainer.style.display = 'none';
        }
    }

    // 打字效果
    this.isTyping = true;
    let i = 0;
    const chars = text.split('');
    const typeNext = () => {
        if (i < chars.length) {
            this.textElement.textContent += chars[i];
            i++;
            setTimeout(typeNext, this.typingSpeed);
        } else {
            this.isTyping = false;
        }
    };
    typeNext();
};

// 顯示下一句
DialogManager.prototype.showNextDialog = function () {
    if (this.currentDialog < this.dialogQueue.length) {
        const d = this.dialogQueue[this.currentDialog];
        this.showDialog(d.name, d.text, d.image);
        this.currentDialog++;
    } else {
        // 對話結束 — 只淡出動畫
        this.overlay.style.opacity = '0';
    }
};
npc.js
JavaScript
var Npc = pc.createScript('npc');

// Editor 屬性:對話列表
Npc.attributes.add('dialogLines', {
    type: 'json',
    array: true,
    title: '對話列表',
    schema: [
        { name: 'image', type: 'asset', assetType: 'texture', title: '角色圖片(可選)' },
        { name: 'name', type: 'string', default: 'NPC', title: '角色名稱' },
        { name: 'text', type: 'string', default: '你好!', title: '對話文字' }
    ],
    default: [
        { image: null, name: 'NPC', text: '你好,旅行者!' },
        { image: null, name: 'Player', text: '你好,請問這裡是哪裡?' },
        { image: null, name: 'NPC', text: '這裡是新手村。' }
    ]
});

// 初始化
Npc.prototype.initialize = function () {
    // 等待 DialogManager 初始化完成再啟動對話
    this.app.once('dialogmanager:ready', this.startDialog, this);

    // 若 DialogManager 已存在,立即啟動對話
    if (DialogManager.instance) {
        this.startDialog();
    }
};

// 開始對話
Npc.prototype.startDialog = function () {
    if (!this.dialogLines || !this.dialogLines.length) return;

    // 將對話列表轉成 DialogManager 可用格式
    const dialogQueue = this.dialogLines.map(line => ({
        image: line.image,
        name: line.name,
        text: line.text
    }));

    if (DialogManager.instance) {
        DialogManager.instance.startDialog(dialogQueue, () => {
            // 對話結束自動停用 NPC
            this.entity.enabled = false;
        });
    } else {
        console.warn('DialogManager not found yet!');
    }
};

HIERACHY

新增物件與對應階層如下,底下有每個物件詳細的設定參數。

Root
├─ DialogManager
├─ Plane
├─ NPC1
│  └─ Talking
├─ NPC2
│  └─ Talking
Entity: Plane
  • Type: Plane
  • Collision: ✅
  • Rigidbody: ✅
    • Type: Static
[PlayCanvas + VIVERSE] 一次性 NPC 對話框
Entity: NPC1
  • Type: Box
  • Enabled: ✅
  • Name: NPC1
  • Collison: ✅
  • VIVERSE EXTENSION:
    • Select Pluggins: TriggerAndAction
      • Triggers: +
        • Selected module: Trigger [+]
          • type: Trigger 1.EntitySubscribeTriggerEnter
            • tags to filter: local-player
            • Action EntityEnableById: Talking
Entity: Talking
  • Type: None
  • Enabled: ❎
  • Name: Talking
  • Scripts: npc.js
    • Parse
    • 輸入對話的組數
    • 將物件與對話填入
[PlayCanvas + VIVERSE] 一次性 NPC 對話框
[PlayCanvas + VIVERSE] 一次性 NPC 對話框
Entity: NPC2
  • Type: Box
  • Enabled: ✅
  • Name: NPC2
  • Collison: ✅
  • VIVERSE EXTENSION:
    • Select Pluggins: TriggerAndAction
      • Triggers: +
        • Selected module: Trigger [+]
          • type: Trigger 1.EntitySubscribeTriggerEnter
            • tags to filter: local-player
            • Action EntityEnableById: Talking
Entity: Talking
  • Type: None
  • Enabled: ❎
  • Name: Talking
  • Scripts: npc.js
    • Parse
    • 輸入對話的組數
    • 將物件與對話填入
[PlayCanvas + VIVERSE] 一次性 NPC 對話框
[PlayCanvas + VIVERSE] 一次性 NPC 對話框

使用素材

相關文章

PlayCanvas & Viverse World
類型文章
相關文件Viverse DocsPlayCanvas Docs
基礎操作Viverse World.Claude Playcanvas Editor MCP Server.Viverse PlayCanvas Extension (安裝.觸發.媒體.撿拾.坐下)
開發者工具登入與身分驗證.排行榜
開發筆記滿版畫面.場景切換.密碼面板.模組化迷宮.科技感效果.對話框.碰撞計分.碰撞計分+遊戲重生.子彈閃避
PlayCanvas & Viverse World 我的開發筆記
教學文章VIVERSE WORLDPlayCanvas PROJECT
滿版畫面演示效果專案連結
場景切換演示效果專案連結
密碼面板演示效果專案連結
模組化迷宮演示效果專案連結
科技感材質效果演示效果專案連結
對話框演示效果專案連結
碰撞計分演示效果專案連結
碰撞計分+遊戲重生演示效果專案連結
碰撞計分+遊戲重生應用--子彈閃避演示效果專案連結
一次性對話框演示效果專案連結

0