iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
Software Development

Vibe Unity - AI時代的遊戲開發工作流系列 第 25

Day 25 - 幫寵物加上AI對話功能

  • 分享至 

  • xImage
  •  

在開始之前,

我們先把 Polar AI 匯入專案, 我們過後會使用到裡面的 LLM, TTS, 圖片生成等的功能

image.png

AI Dialog Prompt:

image.png

實作代碼: AI 對話框功能
1. 使用 Polar AI 的 GeminiCore 進行串接
2. 點擊鍵盤 C 的時候, 會打開 Input Field 的Obj
3. Input Field 輸入完Submit之後, 會關閉 Input Field Obj
4. Input Field 輸入完Submit之後會觸發 GeminiCore, 發送 Chat LLM
5. 回傳的AI資訊要顯示在 DialogBubbleUI 的 Text 上
6. DialogBubbleUI 的Obj過一段時間後會自己隱藏

接下來 Cursor 新生成了以下的檔案:

  1. DialogBubbleUI

    這個是用來在北極熊身上顯示對話框的代碼

    using UnityEngine;
    using UnityEngine.UI;
    
    public class DialogueBubbleUI : MonoBehaviour
    {
        [Header("Follow Target")] public GameObject targetObject;
        public float verticalOffset = 0.2f; 
    
        [Header("Canvas & Transforms")] public Canvas canvas; 
        public RectTransform bubbleRoot; // 氣泡根節點(通常就是掛腳本的 RectTransform)
        public RectTransform backgroundRect; // 背景圖 RectTransform(Image)
        public Text text; // Legacy UnityEngine.UI.Text
    
        [Header("Sizing")] public Vector2 padding = new Vector2(24f, 16f); // 背景相對文本的內邊距(左右、上下)
        public float maxWidth = 420f; // 最大寬度(超過會換行)
        public float minWidth = 80f; // 最小寬度(背景不會更窄)
        public bool clampToCanvas = true; // 是否把氣泡限制在畫布可見範圍內
    
        public string contentDemo;
        private Camera worldCam;
    
        // 佈局狀態守衛,避免重入
        private bool isRefreshingLayout = false;
        private bool deferredScheduled = false;
    
        // 由佈局引發、用於抵銷高度變化的 Y 偏移(Canvas 本地座標)
        private float layoutYOffset = 0f;
    
        [Header("Visibility")] public float autoHideSeconds = 4f; // 自動隱藏秒數(<=0 表示不自動隱藏)
        private Coroutine autoHideCoroutine;
    
        private void Reset()
        {
            bubbleRoot = transform as RectTransform;
            if (!canvas) canvas = GetComponentInParent<Canvas>();
            layoutYOffset = 0f;
        }
    
        private void Awake()
        {
            if (!canvas) canvas = GetComponentInParent<Canvas>();
            worldCam = ResolveCamera();
        }
    
        private void OnEnable()
        {
            layoutYOffset = 0f;
            RefreshLayout();
        }
    
        private void OnDisable()
        {
            deferredScheduled = false;
            isRefreshingLayout = false;
        }
    
        private Camera ResolveCamera()
        {
            if (!canvas) return Camera.main;
            if (canvas.renderMode == RenderMode.ScreenSpaceOverlay) return null; // Overlay 用 null
            return canvas.worldCamera ? canvas.worldCamera : Camera.main;
        }
    
        private void LateUpdate()
        {
            UpdateFollow();
        }
    
        // 移除 TMP 事件回調
    
        public void SetText()
        {
            if (!text) return;
            SetText(contentDemo);
        }
    
        public void SetText(string newText)
        {
            if (!text) return;
    
            text.text = newText ?? string.Empty;
            Canvas.ForceUpdateCanvases();
            RefreshLayout();
    
            if (!deferredScheduled && isActiveAndEnabled)
            {
                deferredScheduled = true;
                StartCoroutine(DeferredRefreshLayout());
            }
    
            Show();
            ScheduleAutoHide();
        }
    
        public void Show()
        {
            if (!gameObject.activeSelf) gameObject.SetActive(true);
        }
    
        public void Hide()
        {
            if (gameObject.activeSelf) gameObject.SetActive(false);
        }
    
        public void CancelAutoHide()
        {
            if (autoHideCoroutine != null)
            {
                StopCoroutine(autoHideCoroutine);
                autoHideCoroutine = null;
            }
        }
    
        public void ScheduleAutoHide()
        {
            CancelAutoHide();
            if (autoHideSeconds > 0f && isActiveAndEnabled)
            {
                autoHideCoroutine = StartCoroutine(AutoHideAfterDelay(autoHideSeconds));
            }
        }
    
        private System.Collections.IEnumerator AutoHideAfterDelay(float seconds)
        {
            yield return new WaitForSeconds(seconds);
            Hide();
            autoHideCoroutine = null;
        }
    
        private System.Collections.IEnumerator DeferredRefreshLayout()
        {
            yield return new WaitForEndOfFrame();
            deferredScheduled = false;
    
            if (!isActiveAndEnabled || !text) yield break;
    
            Canvas.ForceUpdateCanvases();
            RefreshLayout();
        }
    
        private void UpdateFollow()
        {
            if (!targetObject || !canvas || !bubbleRoot) return;
    
            // 以目標的世界座標(含向上偏移)轉成螢幕座標
            Vector3 worldPos = targetObject.transform.position + Vector3.up * verticalOffset;
            Vector3 screenPos = Camera.main.WorldToScreenPoint(worldPos);
    
            // 佈局造成的 Y 補償
            screenPos.y += layoutYOffset;
    
            // 設置 UI 位置(直接用螢幕座標)
            bubbleRoot.position = screenPos;
        }
    
        public void RefreshLayout()
        {
            if (isRefreshingLayout) return;
            if (!text || !backgroundRect || !bubbleRoot) return;
    
            isRefreshingLayout = true;
            try
            {
                float prevBgH = backgroundRect.rect.height;
    
                // 確保啟用換行(Legacy Text)
                text.horizontalOverflow = HorizontalWrapMode.Wrap;
                text.verticalOverflow = VerticalWrapMode.Overflow;
    
                // 計算首選尺寸(Legacy Text)
                float availableMaxTextWidth = Mathf.Max(0f, maxWidth - padding.x * 2f);
                float minTextWidth = Mathf.Max(0f, minWidth - padding.x * 2f);
    
                // 先計算不受限寬度下的首選寬度
                var settingsNoWrap = text.GetGenerationSettings(Vector2.zero);
                float preferredUnconstrainedWidth = text.cachedTextGeneratorForLayout
                    .GetPreferredWidth(text.text, settingsNoWrap) / text.pixelsPerUnit;
    
                float targetTextWidth = Mathf.Clamp(
                    preferredUnconstrainedWidth,
                    minTextWidth,
                    availableMaxTextWidth > 0 ? availableMaxTextWidth : preferredUnconstrainedWidth
                );
    
                // 再用限制寬度計算對應高度
                var settingsWithWidth = text.GetGenerationSettings(new Vector2(targetTextWidth, 0f));
                float targetTextHeight = text.cachedTextGeneratorForLayout
                    .GetPreferredHeight(text.text, settingsWithWidth) / text.pixelsPerUnit;
                targetTextHeight = Mathf.Max(1f, targetTextHeight);
    
                // 套用尺寸
                RectTransform textRect = text.rectTransform;
                textRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, targetTextWidth);
                textRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, targetTextHeight);
    
                float bgW = targetTextWidth + padding.x * 2f;
                float bgH = targetTextHeight + padding.y * 2f;
    
                backgroundRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, bgW);
                backgroundRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, bgH);
    
                // 高度變化補償位置
                float deltaH = bgH - prevBgH;
                if (Mathf.Abs(deltaH) > Mathf.Epsilon)
                {
                    float pivotY = bubbleRoot.pivot.y;
                    layoutYOffset += deltaH * pivotY;
                }
    
                // 保守推進一次(避免頻繁重建)
                Canvas.ForceUpdateCanvases();
            }
            finally
            {
                isRefreshingLayout = false;
            }
        }
    }
    
  2. AIChatController

    這個是用來控制跟Gemini溝通的功能

    using System;
    using System.Collections;
    using UnityEngine;
    using UnityEngine.UI;
    using PolarAI.Scripts.Core.Gemini;
    
    public class AIChatController : MonoBehaviour
    {
        [Header("Refs")] public GeminiCore geminiCore;
        public DialogueBubbleUI dialogueBubble;
    
        [Header("Input UI")] public GameObject inputRoot; // 可整個群組的 Obj
        public InputField inputField; // Legacy UI InputField
    
        [Header("Settings")] public KeyCode toggleKey = KeyCode.C;
    
        private bool _showing;
    
        private void Awake()
        {
            if (inputRoot) inputRoot.SetActive(false);
            _showing = false;
            if (inputField)
            {
                inputField.onEndEdit.AddListener(OnEndEdit);
            }
        }
    
        private void OnDestroy()
        {
            if (inputField)
            {
                inputField.onEndEdit.RemoveListener(OnEndEdit);
            }
        }
    
        private void Update()
        {
            if (Input.GetKeyDown(toggleKey))
            {
                ToggleInput();
            }
        }
    
        private void ToggleInput()
        {
            _showing = !_showing;
            if (inputRoot) inputRoot.SetActive(_showing);
            if (_showing && inputField)
            {
                inputField.text = string.Empty;
                inputField.ActivateInputField();
                inputField.Select();
            }
        }
    
        private void CloseInput()
        {
            _showing = false;
            if (inputRoot) inputRoot.SetActive(false);
        }
    
        private void OnEndEdit(string value)
        {
            // 在 Legacy InputField,按 Enter 會觸發 onEndEdit
            // 避免空字串請求
            var userText = (value ?? string.Empty).Trim();
            if (string.IsNullOrEmpty(userText))
            {
                CloseInput();
                return;
            }
    
            CloseInput();
            SendPrompt(userText);
        }
    
        private void SendPrompt(string userText)
        {
            if (geminiCore == null)
            {
                Debug.LogWarning("GeminiCore 未綁定");
                if (dialogueBubble) dialogueBubble.SetText("[錯誤] 未設定 GeminiCore");
                return;
            }
    
            StartCoroutine(CallGemini(userText));
        }
    
        private IEnumerator CallGemini(string prompt)
        {
            string aiReply = null;
            yield return geminiCore.Chat(
                prompt,
                text => { aiReply = text; }
            );
    
            if (dialogueBubble)
            {
                dialogueBubble.SetText(aiReply ?? "(無回覆)");
            }
        }
    }
    
    

基本上這樣代碼就完成了

接下來我們要設定一下 Unity 的場景

  1. 在 Dialog 的 UI上掛一個 Dialog Bubble UI, 然後把相關的物件放進去

    image.png

  2. 新增一個 Input Field, 用來輸入 Prompt

    image.png

  3. 新增 GeminiCore 的物件, 掛上 GeminiCore代碼

    image.png

    然後填上你的 Gemini API Key

  4. 新增 AIChatController 物件, 把對應的物件拉進去

    image.png


為了更有沉浸感,

也可以在一開始的時候先提供係統指令

讓 AI 知道你是在跟北極熊寵物對話, 要用什麼語言回復你

( 注意Gemini不支持 system role, 所以可以改成 user )

image.png

image.png


接下來可以執行遊戲測試

按下 C, 機會打開輸入面板, 然後就可以跟北極熊聊天了

Demo Video



上一篇
Day 24 - AI 記憶係統的開發原理
系列文
Vibe Unity - AI時代的遊戲開發工作流25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言