在開始之前,
我們先把 Polar AI 匯入專案, 我們過後會使用到裡面的 LLM, TTS, 圖片生成等的功能
AI Dialog Prompt:
實作代碼: 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 新生成了以下的檔案:
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;
}
}
}
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 的場景
在 Dialog 的 UI上掛一個 Dialog Bubble UI, 然後把相關的物件放進去
新增一個 Input Field, 用來輸入 Prompt
新增 GeminiCore 的物件, 掛上 GeminiCore代碼
然後填上你的 Gemini API Key
新增 AIChatController 物件, 把對應的物件拉進去
為了更有沉浸感,
也可以在一開始的時候先提供係統指令
讓 AI 知道你是在跟北極熊寵物對話, 要用什麼語言回復你
( 注意Gemini不支持 system role, 所以可以改成 user )
接下來可以執行遊戲測試
按下 C, 機會打開輸入面板, 然後就可以跟北極熊聊天了