Stories

Detail Return Return

Rokid UXR 的手勢追蹤空間貪吃蛇小遊戲實戰開發 - Stories Detail

一、項目介紹

本文將帶你基於 Rokid UXR SDK 3.0.3 + 團結引擎
開發一個沉浸式的 AR 空間小遊戲——貪吃蛇。
在這個遊戲中:
玩家只需移動 食指指尖,蛇頭就會在空間中跟隨移動;
吃到食物後,蛇會增長一節身體;
遊戲內帶有倒計時機制,限定時間內比拼得分。
這是一個非常適合初學者上手的 Rokid AR 實戰案例。

二、環境準備

設備:Rokid AR Studio眼鏡
SDK:Rokid UXR SDK 3.0.3
引擎:團結引擎 1.6.5

注:全文必須在官方SDK Rokid UXR SDK 3.0.3 已完成 接入指南 後,也就是完成如下章節後才可開始學習(custom.rokid.com/prod/rokid_web/c88be4bcde4c42c0b8b53409e1fa1701/pc/cn/8ad2e74dbd7c4f5bb21a964dbdd5512d.html?documentId=965ccb56e0284976ab0c701346f5833e)

三、核心功能拆解

  1. 蛇頭跟隨食指指尖

    在 SnakeGame 腳本中,我們通過 GesEventInput.Instance.GetSkeletonPose 獲取 左右手食指指尖的 3D 座標,讓蛇頭跟隨指尖進行移動。
    優先使用右手,其次左手,保證遊戲一直有控制點。
    Pose rightPose = GesEventInput.Instance.GetSkeletonPose(SkeletonIndexFlag.INDEX_FINGER_TIP, HandType.RightHand);
    Pose leftPose = GesEventInput.Instance.GetSkeletonPose(SkeletonIndexFlag.INDEX_FINGER_TIP, HandType.LeftHand);
    通過 Lerp 平滑移動 + Slerp 平滑旋轉,實現蛇頭跟隨:
    保證動作不生硬
    可以自由在空間裏畫出路徑
  2. 蛇身增長

    蛇頭吃到食物時也就是OnTriggerEnter,會調用 AddBodyPart方法:
    將食物對象轉化為蛇身
    移除食物物理組件,替換成蛇身材質
    放入 bodyParts 隊列,自動跟隨蛇頭軌跡
    這裏可以加入音效 eatSound,提升遊玩沉浸感。
  3. 食物生成

    由 FoodManager 控制:
    在一個 區域標定物體(areaMarker) 內隨機生成食物
    可隨機指定材質(紅/藍/綠等),提升趣味性
    每次被吃掉後重新生成
  4. 倒計時機制

    由 CountdownTimer 腳本實現:
    UI 顯示倒計時(使用TextMeshPro 或 Unity Text)
    倒計時結束時,顯示「遊戲結束」面板

    四、準備美術素材和音效

    1、蛇頭與蛇身

這裏我使用基礎 純方塊 Cube 搭建,方便觀察和跟隨

SnakeHead (空物體, 位置在 0,0,0)
├── HeadCube (主體)
├── EyeLeft (左眼)
└── EyeRight (右眼)
每個物體的屬性
如下旋轉皆為:(0, 0, 0)

SnakeHead (空物體)
位置:(0, 0, 0)
縮放:(1, 1, 1) 只是一個父物體容器,下面放真正的方塊。

HeadCube (主體方塊)
父物體:SnakeHead
組件:Cube
位置:(0, 0, 0) (保持原點)
縮放:(1, 1, 1.5) 作為蛇頭的長方體。

EyeLeft (左眼方塊)
父物體:SnakeHead
組件:Cube
位置:(-0.25, 0.25, 0.8) 在蛇頭前面靠左上。
縮放:(0.2, 0.2, 0.2)
材質:這裏我使用的是藍色

EyeRight (右眼方塊)
父物體:SnakeHead
組件:Cube
位置:(0.25, 0.25, 0.8) 在蛇頭前面靠右上。
縮放:(0.2, 0.2, 0.2)
材質:這裏我使用的是橘色

2、蛇身

建立1個 Sphere 預製體,由於需要不需要碰撞,需要不使用重力,需要在屬性面板加一個剛體組件,且取消勾選重力,並勾選運動學,我這裏製作了一個默認材質Body

2、食物

由於食物是可以轉化為身體的,因此只需要複製粘貼蛇身預製體即可,我們這裏可以隨便新建幾個材質,待會放到FoodManager 中,讓腳本隨機給食物賦材質,其次由於使用OnTriggerEnter,因此需要新建一個tag,並命名為Food,當然如果不改tag,在接下來的FoodManager 中會自動賦予 Food 標籤

3、生成區域

需要在場景裏放置一個Sphere作為 areaMarker,其縮放(Scale)決定食物生成的範圍

四、腳本實現

在完成了場景物體和材質的準備之後,我們開始進入核心代碼實現。整個遊戲主要由三個腳本構成:

SnakeGame.cs —— 控制蛇頭移動、蛇身跟隨和增長邏輯
FoodManager.cs —— 管理食物的生成與刷新
CountdownTimer.cs —— 遊戲倒計時與結束邏輯

另外,還有一個小腳本 SnakeHeadCollision 專門用於檢測蛇頭吃到食物的碰撞,為了省略我和SnakeGame寫在了一起。1. SnakeGame —— 蛇的核心控制
這是整個遊戲的主腳本,功能包括:

獲取手勢食指指尖位置
控制蛇頭跟隨手指移動
記錄蛇頭的運動軌跡
控制蛇身跟隨軌跡
吃到食物後新增蛇身

核心邏輯分為幾個部分:

(1)獲取食指位置

通過 Rokid UXR 的手勢接口,實時獲取左右手食指尖的位置,優先右手:
Pose rightPose = GesEventInput.Instance.GetSkeletonPose(SkeletonIndexFlag.INDEX_FINGER_TIP, HandType.RightHand);
Pose leftPose  = GesEventInput.Instance.GetSkeletonPose(SkeletonIndexFlag.INDEX_FINGER_TIP, HandType.LeftHand);

(2)蛇頭移動與旋轉

使用 Vector3.Lerp 讓蛇頭平滑跟隨手指位置;
使用 Quaternion.Slerp 讓蛇頭始終朝向前進方向:
snakeHead.position = Vector3.Lerp(snakeHead.position, targetPosition, moveSpeed * Time.deltaTime);
Quaternion targetRot = Quaternion.LookRotation(moveDirection, Vector3.up);
snakeHead.rotation = Quaternion.Slerp(snakeHead.rotation, targetRot, rotateSpeed * Time.deltaTime);

(3)蛇身跟隨

蛇的身體之所以能一節一節地跟隨蛇頭移動,靠的是一個 軌跡記錄 + 隊列追蹤 的思路:

軌跡記錄 每一幀,都會把蛇頭的位置記錄到 positionsHistory 列表裏;
這樣就相當於在空間裏留下了一條「移動痕跡」。

// 記錄蛇頭軌跡
positionsHistory.Add(snakeHead.position);
逐節取點跟隨 蛇身的每一節,都要沿着蛇頭走過的軌跡,保持一定間隔;
用 (i+1) * bodySpacing 來計算第 i 節身體應該落在哪個軌跡點上;
然後從 positionsHistory 裏取出對應點,設置為蛇身的位置。
for (int i = 0; i < bodyParts.Count; i++)
{
    float spacingOffset = (i + 1) * bodySpacing;
    int index = positionsHistory.Count - 1 - (int)(spacingOffset / 0.1f);
    index = Mathf.Clamp(index, 0, positionsHistory.Count - 1);


    Vector3 point = positionsHistory[index];
    bodyParts[i].position = point;
}
旋轉對齊 為了讓蛇身自然朝向前進方向,需要根據軌跡點的方向來調整旋轉:
Vector3 dir = positionsHistory[Mathf.Max(index - 1, 0)] - point;
if (dir.sqrMagnitude > 0.001f)
    bodyParts[i].rotation = Quaternion.LookRotation(dir, Vector3.up);
限制歷史長度 如果不限制 positionsHistory 的大小,時間長了會無限增長,佔用內存;
所以加一個上限(例如 3000),超出時刪除最舊的點。
if (positionsHistory.Count > 3000)
    positionsHistory.RemoveAt(0);

📌 最終效果:

蛇頭移動時,蛇身就像沿着軌跡排隊跟隨,前進流暢,不會亂掉,也不會「穿模」。(4)新增蛇身
當蛇頭和食物發生碰撞時,會調用 AddBodyPart(),讓食物「變身」為蛇身的一節。

移除物理組件 食物原本有 Rigidbody 和 Collider,用於被檢測;
變成蛇身後,就不需要再參與物理運算,否則可能繼續觸發碰撞;
所以要移除這些組件:

Rigidbody rb = newPart.GetComponent<Rigidbody>();
if (rb != null) Destroy(rb);
Collider col = newPart.GetComponent<Collider>();
if (col != null) Destroy(col);
替換材質 為了區分「蛇身」和「食物」,這裏通過 Inspector 拖入一個 defaultBodyMaterial;
吃掉後,自動把食物材質換成統一的蛇身材質。
Renderer rend = newPart.GetComponent<Renderer>();
if (rend != null && defaultBodyMaterial != null)
    rend.material = defaultBodyMaterial;
加入蛇身隊列 把新增加的一節放進 bodyParts 列表;
這樣它在下一幀 Update() 時,就會自動開始跟隨軌跡。
newPart.transform.SetParent(this.transform);
bodyParts.Add(newPart.transform);
更新得分 UI 每次蛇身長度增加,更新 foodEatenCount;
UI 上顯示最新得分,讓玩家直觀感受到成長。
foodEatenCount = bodyParts.Count - 1;
if (foodEatenCountText != null)
    foodEatenCountText.text = "得分: " + foodEatenCount;
播放音效反饋 如果配置了 eatSound,每次增長時播放,給玩家聽覺反饋。
if (eatSound != null && audioSource != null)
    audioSource.PlayOneShot(eatSound);
  1. SnakeHeadCollision —— 碰撞檢測

    蛇頭物體上掛載了這個腳本,用於檢測 OnTriggerEnter:
private void OnTriggerEnter(Collider other)
{
    if (other.CompareTag(foodTag))
    {
        if (other.gameObject.GetComponent<AlreadyEaten>() == null)
        {
            other.gameObject.AddComponent<AlreadyEaten>();
            snakeGame.AddBodyPart(other.gameObject);
        }
    }
}

這裏通過添加一個 AlreadyEaten 標記,避免同一個食物被重複觸發。可以直接新建一個AlreadyEaten 腳本,不需要寫內容
AlreadyEaten.cs

完整 SnakeGame.cs + SnakeHeadCollision.cs腳本

using System.Collections.Generic;
using TMPro;
using UnityEngine;
using Rokid.UXR.Interaction;


public class SnakeGame : MonoBehaviour
{
    [Header("拖拽設置")]
    public GameObject snakeHeadPrefab;
    public List<GameObject> snakeBodyPrefabs;
    public Transform rightIndexTipTransform;
    public Transform leftIndexTipTransform;


    [Header("移動參數")]
    public float moveSpeed = 5f;
    public float rotateSpeed = 10f;
    public float bodySpacing = 0.5f;


    [Header("吃東西設置")]
    public string foodTag = "Food";


    [Header("遊戲統計")]
    public int foodEatenCount = 0;


    [Header("蛇身材質設置")]
    public Material defaultBodyMaterial;


    [Header("UI 顯示")]
    public TextMeshProUGUI foodEatenCountText;


    public AudioClip eatSound;
    private AudioSource audioSource;


    private Transform snakeHead;
    public List<Transform> bodyParts = new List<Transform>();
    private List<Vector3> positionsHistory = new List<Vector3>();


    void Start()
    {
        audioSource = gameObject.AddComponent<AudioSource>();
        audioSource.playOnAwake = false;


        Vector3 startPosition = Vector3.zero;
        if (rightIndexTipTransform != null && rightIndexTipTransform.gameObject.activeInHierarchy)
            startPosition = rightIndexTipTransform.position;
        else if (leftIndexTipTransform != null && leftIndexTipTransform.gameObject.activeInHierarchy)
            startPosition = leftIndexTipTransform.position;


        if (snakeHeadPrefab != null)
        {
            GameObject headInstance = Instantiate(snakeHeadPrefab, startPosition, Quaternion.identity);
            snakeHead = headInstance.transform;


            Rigidbody rb = headInstance.GetComponent<Rigidbody>();
            if (rb == null) rb = headInstance.AddComponent<Rigidbody>();
            rb.isKinematic = true;
            rb.useGravity = false;


            Collider col = headInstance.GetComponent<Collider>();
            if (col == null) col = headInstance.AddComponent<SphereCollider>();
            col.isTrigger = true;


            SnakeHeadCollision collision = headInstance.AddComponent<SnakeHeadCollision>();
            collision.Init(this, foodTag);


            snakeHead.transform.SetParent(this.transform);
        }


        foreach (GameObject bodyPrefab in snakeBodyPrefabs)
        {
            if (bodyPrefab != null)
            {
                GameObject body = Instantiate(bodyPrefab, startPosition, Quaternion.identity);
                body.transform.SetParent(this.transform);
                bodyParts.Add(body.transform);
            }
        }
    }


    void Update()
    {
        if (snakeHead == null) return;


        Pose rightPose = GesEventInput.Instance.GetSkeletonPose(SkeletonIndexFlag.INDEX_FINGER_TIP, HandType.RightHand);
        Pose leftPose = GesEventInput.Instance.GetSkeletonPose(SkeletonIndexFlag.INDEX_FINGER_TIP, HandType.LeftHand);


        Vector3 targetPosition = Vector3.zero;
        bool hasHand = false;


        if (rightPose.position != Vector3.zero)
        {
            targetPosition = rightPose.position;
            hasHand = true;
        }
        else if (leftPose.position != Vector3.zero)
        {
            targetPosition = leftPose.position;
            hasHand = true;
        }


        if (!hasHand) return;


        snakeHead.position = Vector3.Lerp(snakeHead.position, targetPosition, moveSpeed * Time.deltaTime);


        Vector3 moveDirection = (targetPosition - snakeHead.position).normalized;
        if (moveDirection.sqrMagnitude > 0.001f)
        {
            Quaternion targetRot = Quaternion.LookRotation(moveDirection, Vector3.up);
            snakeHead.rotation = Quaternion.Slerp(snakeHead.rotation, targetRot, rotateSpeed * Time.deltaTime);
        }


        positionsHistory.Add(snakeHead.position);


        for (int i = 0; i < bodyParts.Count; i++)
        {
            float spacingOffset = (i + 1) * bodySpacing;
            int index = positionsHistory.Count - 1 - (int)(spacingOffset / 0.1f);
            index = Mathf.Clamp(index, 0, positionsHistory.Count - 1);


            Vector3 point = positionsHistory[index];
            bodyParts[i].position = point;


            Vector3 dir = positionsHistory[Mathf.Max(index - 1, 0)] - point;
            if (dir.sqrMagnitude > 0.001f)
                bodyParts[i].rotation = Quaternion.LookRotation(dir, Vector3.up);
        }


        if (positionsHistory.Count > 3000)
            positionsHistory.RemoveAt(0);
    }


    public void AddBodyPart(GameObject newPart)
    {
        int beforeCount = bodyParts.Count;


        FoodManager fm = FindFirstObjectByType<FoodManager>();
        if (fm != null) fm.NotifyFoodEaten();


        Rigidbody rb = newPart.GetComponent<Rigidbody>();
        if (rb != null) Destroy(rb);


        Collider col = newPart.GetComponent<Collider>();
        if (col != null) Destroy(col);


        Renderer rend = newPart.GetComponent<Renderer>();
        if (rend != null && defaultBodyMaterial != null)
            rend.material = defaultBodyMaterial;


        newPart.transform.SetParent(this.transform);
        bodyParts.Add(newPart.transform);
        foodEatenCount = bodyParts.Count - 1;


        if (bodyParts.Count > beforeCount && eatSound != null && audioSource != null)
        {
            audioSource.PlayOneShot(eatSound);
            if (foodEatenCountText != null)
                foodEatenCountText.text = "得分: " + foodEatenCount;
        }
    }
}


public class SnakeHeadCollision : MonoBehaviour
{
    private SnakeGame snakeGame;
    private string foodTag;


    public void Init(SnakeGame game, string tag)
    {
        snakeGame = game;
        foodTag = tag;
    }


    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag(foodTag))
        {
            if (other.gameObject.GetComponent<AlreadyEaten>() == null)
            {
                other.gameObject.AddComponent<AlreadyEaten>();
                snakeGame.AddBodyPart(other.gameObject);
            }
        }
    }
}
  1. FoodManager —— 食物生成
    在貪吃蛇的玩法中,食物是讓蛇不斷成長的關鍵元素。本項目裏,FoodManager 負責整個食物的 生成、刷新、隨機外觀。

(1)區域定義
我們在場景裏放置一個 areaMarker(比如一個 Cube 或 Sphere),它的 位置(position) 和 縮放(scale) 定義了整個食物生成的區域:

areaMarker.position = 生成區域的中心點 
areaMarker.localScale = 區域的寬度、高度、深度 

這樣可以很直觀地在場景中調整「食物能出現的範圍」。

spawnAreaCenter = areaMarker.position;
spawnAreaSize   = areaMarker.localScale;

(2)隨機生成位置
在區域範圍內,隨機一個點作為食物生成位置:

Vector3 randomPos = spawnAreaCenter + new Vector3(
    Random.Range(-spawnAreaSize.x / 2, spawnAreaSize.x / 2),
    Random.Range(-spawnAreaSize.y / 2, spawnAreaSize.y / 2),
    Random.Range(-spawnAreaSize.z / 2, spawnAreaSize.z / 2)
);
這裏的 Random.Range 保證:
X/Y/Z 座標都在 [-size/2, +size/2] 的範圍內;
這樣食物的分佈是均勻的;
且永遠不會超出 areaMarker 定義的區域。

📌 效果:食物可能出現在區域的任意地方,增加隨機性和挑戰性。

(3)多預製體支持
為了讓食物的外觀更豐富,foodPrefabs 數組裏可以放多個不同形狀的預製體(比如球體、方塊、膠囊體等)。生成時隨機挑選一個:

GameObject prefab = foodPrefabs[Random.Range(0, foodPrefabs.Length)];
currentFood = Instantiate(prefab, randomPos, Quaternion.identity);

(4)隨機材質支持
我們還可以在 Inspector 中拖入多個 randomMaterials 材質,比如紅、綠、藍、黃,生成時隨機賦值:

if (randomMaterials != null && randomMaterials.Length > 0)
{
    Renderer rend = currentFood.GetComponent<Renderer>();
    if (rend != null)
    {
        Material mat = randomMaterials[Random.Range(0, randomMaterials.Length)];
        rend.material = mat;
    }
}

這樣同樣的形狀,也能因為材質的不同而顯得更有變化。

(5)刷新邏輯
食物一旦被蛇吃掉(變成蛇身),SnakeGame 會調用 FoodManager.NotifyFoodEaten(),把 currentFood 置為 null。

下一幀 Update() 時,檢測到沒有食物,就會自動重新生成一個新的。

public void NotifyFoodEaten()
{
    currentFood = null; // 標記沒有食物,下幀會生成新的
}

📌 效果:整個遊戲過程永遠不會出現「沒有食物」的情況,保證玩法持續。

(6)輔助可視化
為了讓開發時更直觀,OnDrawGizmosSelected() 會在 Scene 視圖裏繪製一個綠色的方框,顯示生成範圍。

void OnDrawGizmosSelected()
{
    Gizmos.color = Color.green;
    if (areaMarker != null)
    {
        Gizmos.DrawWireCube(areaMarker.position, areaMarker.localScale);
    }
}

完整 FoodManager.cs 腳本

using UnityEngine;


public class FoodManager : MonoBehaviour
{
    [Header("生成配置")]
    public GameObject[] foodPrefabs;      // 可生成的食物預製體數組(Cube/Sphere/Capsule等)
    public Transform areaMarker;          // 區域標定物體,決定食物生成範圍


    [Header("材質設置")]
    public Material[] randomMaterials;    // 隨機材質數組(紅/綠/藍等)


    private GameObject currentFood;       // 當前存在的食物實例
    private Vector3 spawnAreaCenter;      // 區域中心
    private Vector3 spawnAreaSize;        // 區域大小


    void Update()
    {
        // 根據標定物體更新區域
        if (areaMarker != null)
        {
            spawnAreaCenter = areaMarker.position;
            spawnAreaSize   = areaMarker.localScale;
        }


        // 如果當前沒有食物,就生成一個新的
        if (currentFood == null)
        {
            SpawnFood();
        }
    }


    /// <summary>
    /// 生成一個新的食物
    /// </summary>
    private void SpawnFood()
    {
        if (foodPrefabs.Length == 0) return;


        // 隨機生成位置(在 areaMarker 範圍內)
        Vector3 randomPos = spawnAreaCenter + new Vector3(
            Random.Range(-spawnAreaSize.x / 2, spawnAreaSize.x / 2),
            Random.Range(-spawnAreaSize.y / 2, spawnAreaSize.y / 2),
            Random.Range(-spawnAreaSize.z / 2, spawnAreaSize.z / 2)
        );


        // 隨機選擇一個預製體
        GameObject prefab = foodPrefabs[Random.Range(0, foodPrefabs.Length)];
        currentFood = Instantiate(prefab, randomPos, Quaternion.identity);


        // 給食物設置 Food 標籤(用於碰撞檢測)
        currentFood.tag = "Food";


        // 隨機材質
        if (randomMaterials != null && randomMaterials.Length > 0)
        {
            Renderer rend = currentFood.GetComponent<Renderer>();
            if (rend != null)
            {
                Material mat = randomMaterials[Random.Range(0, randomMaterials.Length)];
                rend.material = mat;
            }
        }


        // 設為子物體,方便管理
        currentFood.transform.SetParent(this.transform);
    }


    /// <summary>
    /// 被 SnakeGame 調用:當食物被吃掉並變成蛇身時
    /// </summary>
    public void NotifyFoodEaten()
    {
        currentFood = null; // 標記當前沒有食物,下幀會重新生成
    }


    /// <summary>
    /// 在 Scene 視圖繪製區域輔助框
    /// </summary>
    void OnDrawGizmosSelected()
    {
        Gizmos.color = Color.green;
        if (areaMarker != null)
        {
            Gizmos.DrawWireCube(areaMarker.position, areaMarker.localScale);
        }
    }
}
  1. CountdownTimer —— 倒計時

在貪吃蛇遊戲裏,如果沒有時間限制,玩家可能一直玩下去。為了增加緊迫感和挑戰性,我們加入了 倒計時機制,用 CountdownTimer 腳本來管理。

(1)功能需求

控制總時長 在 Inspector 面板輸入「分鐘 + 秒」;
運行時自動轉化為總秒數,開始倒計時。
每幀遞減 在 Update() 裏,每幀減去 Time.deltaTime; 這樣倒計時和實際時間保持一致,不會受幀率影響。
更新 UI 剩餘時間會顯示在 TextMeshProUGUI 或 Unity 自帶 Text 上; 格式為 MM:SS,例如 01:29。
時間結束事件 當 timeRemaining <= 0,觸發 OnTimerEnd():隱藏遊戲 UI(例如操作面板、計分板); 顯示「Game Over」界面,讓玩家看到結果。

(2)倒計時邏輯

核心邏輯如下:


if (isRunning &amp;&amp; timeRemaining &gt; 0)
{
    timeRemaining -= Time.deltaTime; // 每幀遞減
    UpdateUI();                       // 更新 UI
}
else if (isRunning &amp;&amp; timeRemaining &lt;= 0)
{
    isRunning = false;                // 停止倒計時
    OnTimerEnd();                     // 觸發結束邏輯
}
timeRemaining 存儲剩餘時間(秒);
isRunning 表示倒計時是否正在運行;
時間減到 0 會自動調用 OnTimerEnd()。

(3)UI 更新

UpdateUI() 把秒數換算成分鐘和秒,並顯示在 UI 上:


int displayMinutes = Mathf.FloorToInt(timeRemaining / 60);
int displaySeconds = Mathf.FloorToInt(timeRemaining % 60);

string timeStr = string.Format("{0:00}:{1:00}", displayMinutes, displaySeconds);

if (timerText != null)
    timerText.text = timeStr;
if (uiText != null)
    uiText.text = timeStr;
這樣保證無論用 TextMeshPro 還是 Unity 自帶 UI,都能正確顯示。

(4)結束時觸發

OnTimerEnd() 用來切換遊戲 UI:

private void OnTimerEnd()
{
    Debug.Log("倒計時結束!");

    // 隱藏遊戲中的 UI(比如分數面板、操作按鈕)
    foreach (var obj in objectsToHide)
    {
        if (obj != null) obj.SetActive(false);
    }

    // 顯示結束界面(比如 Game Over 面板)
    foreach (var obj in objectsToShow)
    {
        if (obj != null) obj.SetActive(true);
    }
}
這裏的 objectsToHide 和 objectsToShow 可以在 Inspector 面板裏自由拖拽,靈活控制結束後的界面切換。

(5)重新開始

腳本里還提供了 RestartTimer() 方法,可以隨時重置倒計時,重新開始一局遊戲。

public void RestartTimer()
{
    timeRemaining = minutes * 60 + seconds; // 重新設置總時長
    isRunning = true;                       // 開始運行
    UpdateUI();                             // 更新 UI
}

完整 CountdownTimer.cs 腳本

using UnityEngine;
using UnityEngine.UI;
using TMPro;


public class CountdownTimer : MonoBehaviour
{
    [Header("倒計時設置")]
    public int minutes = 1;   // 輸入分鐘
    public int seconds = 30;  // 輸入秒


    [Header("UI 顯示 (可選)")]
    public TextMeshProUGUI timerText;  // TextMeshPro 顯示
    public Text uiText;                // Unity 自帶 Text 顯示


    [Header("倒計時結束後的操作")]
    public GameObject[] objectsToHide; // 結束後隱藏的物體
    public GameObject[] objectsToShow; // 結束後顯示的物體


    private float timeRemaining;       // 剩餘時間(秒)
    private bool isRunning = false;    // 是否在運行


    private void Awake()
    {
        RestartTimer(); // 遊戲開始時自動啓動
    }


    void Update()
    {
        if (isRunning &amp;&amp; timeRemaining &gt; 0)
        {
            timeRemaining -= Time.deltaTime; // 每幀遞減
            if (timeRemaining &lt; 0) timeRemaining = 0;
            UpdateUI();
        }
        else if (isRunning &amp;&amp; timeRemaining &lt;= 0)
        {
            isRunning = false;
            OnTimerEnd(); // 倒計時結束
        }
    }


    // 更新 UI 顯示
    private void UpdateUI()
    {
        int displayMinutes = Mathf.FloorToInt(timeRemaining / 60);
        int displaySeconds = Mathf.FloorToInt(timeRemaining % 60);


        string timeStr = string.Format("{0:00}:{1:00}", displayMinutes, displaySeconds);


        if (timerText != null)
            timerText.text = timeStr;


        if (uiText != null)
            uiText.text = timeStr;
    }


    // 倒計時結束時調用
    private void OnTimerEnd()
    {
        Debug.Log("倒計時結束!");


        foreach (var obj in objectsToHide)
        {
            if (obj != null) obj.SetActive(false);
        }


        foreach (var obj in objectsToShow)
        {
            if (obj != null) obj.SetActive(true);
        }
    }


    // 重置並重新開始倒計時
    public void RestartTimer()
    {
        timeRemaining = minutes * 60 + seconds;
        isRunning = true;
        UpdateUI();
    }
}

五、配置各個腳本的參數

在完成了腳本編寫和物體搭建後,我們需要在 Unity / 團結引擎的 Inspector 面板中給腳本配置參數,保證遊戲能正常運行。以下步驟逐一對應三個主要腳本。

  1. 配置 SnakeGame.cs

    掛載位置:

    在場景中新建一個空物體 SnakeGame,把 SnakeGame.cs 腳本掛載到上面。

    Inspector 參數設置:
    Snake Head Prefab 拖入製作好的蛇頭預製體(SnakeHead)。
    蛇頭內部包含 HeadCube + 眼睛兩個小方塊。

    Snake Body Prefabs 拖入 Sphere 預製體(蛇身小球),可以先放一個作為初始長度,也可以留空。

    Right Index Tip Transform / Left Index Tip Transform
    腳本會直接通過 GesEventInput 獲取,保持空即可。

    Move Speed / Rotate Speed 我這裏設置的:Move Speed = 3,Rotate Speed = 10,蛇頭不會抖動且響應靈敏。
    Body Spacing 決定蛇身之間的間隔,推薦 0.8。

    Food Tag 默認寫成 "Food",不用改。

    Default Body Material 拖入你準備好的蛇身材質(Body 材質),讓蛇身統一顏色。

    Food Eaten Count Text 拖入一個 TextMeshPro UI,用於實時顯示得分。

    Eat Sound 拖入一段「吃東西」音效(例如咀嚼聲)。

  2. 配置 FoodManager.cs

    掛載位置:
    在場景中新建一個空物體 FoodManager,掛載 FoodManager.cs。

    Inspector 參數設置:
    Food Prefabs 拖入你製作的食物預製體(可以直接用蛇身的小球 Sphere),至少放一個;
    可以拖多個(比如方塊、球體、膠囊體),遊戲會隨機選擇。

    Area Marker 拖入一個場景裏的立方體 / 球體,作為生成區域標記;
    注意:Position 表示中心點,Scale 表示生成範圍大小。

    Random Materials 拖入幾種顏色材質(紅、藍、綠、黃),增加食物外觀的隨機性。
    在 Scene 視圖選中 FoodManager 時,會看到綠色的框,表示生成範圍是否正確。3. 配置 CountdownTimer.cs
    掛載位置:

    在場景中新建一個 UI 管理器 CountdownTimer,掛載 CountdownTimer.cs。

    Inspector 參數設置:
    Minutes / Seconds 設置倒計時長度,我這裏設置的是 1 分鐘。

    Timer Text / UI Text 如果使用 TextMeshPro,拖入一個 TextMeshProUGUI 組件;
    如果使用 Unity 自帶 Text,拖入對應組件;
    兩個字段至少填一個即可。

    Objects To Hide 拖入遊戲中的與遊戲相關的物體和腳本;
    倒計時結束後,這些物體會被自動隱藏。

    Objects To Show 拖入主菜單按鈕和得分文本;
    時間結束後會自動顯示。

    我這裏的顯示邏輯是,進入該場景直接開始遊戲,在遊戲結束後,隱藏所有與遊戲相關的腳本物體,顯示主菜單和得分UI,點擊主菜單Home按鈕可切換回開始遊戲界面,把該方法綁定到按鈕上即可

SceneSwitcher.cs

using UnityEngine;
using UnityEngine.SceneManagement;


public class SceneSwitcher : MonoBehaviour
{
    // 在括號中直接填寫要跳轉的場景名稱,例如 "GameScene"
    public void SwitchScene()
    {
        SceneManager.LoadScene("GameScene");  
        // ↑ 把 "GameScene" 改成你要跳轉的場景名
    }
}

六、打包與安裝

當我們在編輯器裏調試完成後,最後一步就是 打包並安裝到 Rokid AR Studio 眼鏡上進行真機體驗。
檢查場景設置
確保遊戲的主場景已經添加到File → Build Settings → Scenes In Build 列表中。
這裏的主菜單場景名稱需要和 SceneManager.LoadScene("xxx") 裏的名字保持一致。

七、結束語

到這裏,我們已經完成了一個 基於 Rokid UXR SDK + 團結引擎 的空間貪吃蛇小遊戲。從 手勢追蹤 獲取食指尖位置,到 蛇頭跟隨、蛇身增長、食物生成,再到 倒計時與遊戲結束邏輯,我們一步步把傳統玩法帶進了 AR 空間。

這個項目雖然簡單,但涵蓋了 Rokid AR 開發的核心要點:

如何使用 UXR 獲取骨架關節數據並驅動場景對象;
如何結合 團結引擎完成動態物體生成與材質管理;
如何構建一個完整的「開始 → 進行 → 結束」遊戲流程。

希望通過這篇實戰,你不僅能收穫一個可玩的 AR 小遊戲,更能對 Rokid AR 開發流程 有整體的理解。未來,當手勢追蹤、空間交互與現實環境更好地結合時,我們將能夠把更多傳統遊戲帶到現實世界中,真正實現 遊戲與現實的融合。

Add a new Comments

Some HTML is okay.