一、项目介绍

本文将带你基于 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 搭建,方便观察和跟随

    1. SnakeHead (空物体, 位置在 0,0,0)
    2. ├── HeadCube (主体)
    3. ├── EyeLeft (左眼)
    4. └── EyeRight (右眼)

每个物体的属性

如下旋转皆为(0, 0, 0)

1. SnakeHead (空物体)

位置(0, 0, 0)

缩放(1, 1, 1) 只是一个父物体容器,下面放真正的方块。


2. HeadCube (主体方块)

父物体SnakeHead

组件Cube

位置(0, 0, 0) (保持原点)

缩放(1, 1, 1.5) 作为蛇头的长方体。


3. EyeLeft (左眼方块)

父物体SnakeHead

组件Cube

位置(-0.25, 0.25, 0.8) 在蛇头前面靠左上。

缩放(0.2, 0.2, 0.2)

材质:这里我使用的是蓝色


  1. 4. 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)蛇身跟随

蛇的身体之所以能一节一节地跟随蛇头移动,靠的是一个 轨迹记录 + 队列追踪 的思路:

    1. 轨迹记录 每一帧,都会把蛇头的位置记录到 positionsHistory 列表里;
    2. 这样就相当于在空间里留下了一条「移动痕迹」。
// 记录蛇头轨迹
positionsHistory.Add(snakeHead.position);
    1. 逐节取点跟随 蛇身的每一节,都要沿着蛇头走过的轨迹,保持一定间隔;
    2. (i+1) * bodySpacing 来计算第 i 节身体应该落在哪个轨迹点上;
    3. 然后从 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;
}
    1. 旋转对齐 为了让蛇身自然朝向前进方向,需要根据轨迹点的方向来调整旋转:
Vector3 dir = positionsHistory[Mathf.Max(index - 1, 0)] - point;
if (dir.sqrMagnitude > 0.001f)
    bodyParts[i].rotation = Quaternion.LookRotation(dir, Vector3.up);
    1. 限制历史长度 如果不限制 positionsHistory 的大小,时间长了会无限增长,占用内存;
    2. 所以加一个上限(例如 3000),超出时删除最旧的点。
if (positionsHistory.Count > 3000)
    positionsHistory.RemoveAt(0);

📌 最终效果

蛇头移动时,蛇身就像沿着轨迹排队跟随,前进流畅,不会乱掉,也不会「穿模」。


(4)新增蛇身

当蛇头和食物发生碰撞时,会调用 AddBodyPart(),让食物「变身」为蛇身的一节。

    1. 移除物理组件 食物原本有 RigidbodyCollider,用于被检测;
    2. 变成蛇身后,就不需要再参与物理运算,否则可能继续触发碰撞;
    3. 所以要移除这些组件:
Rigidbody rb = newPart.GetComponent<Rigidbody>();
if (rb != null) Destroy(rb);


Collider col = newPart.GetComponent<Collider>();
if (col != null) Destroy(col);
    1. 替换材质 为了区分「蛇身」和「食物」,这里通过 Inspector 拖入一个 defaultBodyMaterial
    2. 吃掉后,自动把食物材质换成统一的蛇身材质。
Renderer rend = newPart.GetComponent<Renderer>();
if (rend != null && defaultBodyMaterial != null)
    rend.material = defaultBodyMaterial;
    1. 加入蛇身队列 把新增加的一节放进 bodyParts 列表;
    2. 这样它在下一帧 Update() 时,就会自动开始跟随轨迹。
newPart.transform.SetParent(this.transform);
bodyParts.Add(newPart.transform);
    1. 更新得分 UI 每次蛇身长度增加,更新 foodEatenCount
    2. UI 上显示最新得分,让玩家直观感受到成长。
foodEatenCount = bodyParts.Count - 1;
if (foodEatenCountText != null)
    foodEatenCountText.text = "得分: " + foodEatenCount;
    1. 播放音效反馈 如果配置了 eatSound,每次增长时播放,给玩家听觉反馈。
if (eatSound != null && audioSource != null)
    audioSource.PlayOneShot(eatSound);


2. 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 脚本,不需要写内容

完整 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);
            }
        }
    }
}


3. 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);
        }
    }
}


4. CountdownTimer —— 倒计时

在贪吃蛇游戏里,如果没有时间限制,玩家可能一直玩下去。

为了增加紧迫感和挑战性,我们加入了 倒计时机制,用 CountdownTimer 脚本来管理。


(1)功能需求
    1. 控制总时长 在 Inspector 面板输入「分钟 + 秒」;
    2. 运行时自动转化为总秒数,开始倒计时。
    1. 每帧递减Update() 里,每帧减去 Time.deltaTime
    2. 这样倒计时和实际时间保持一致,不会受帧率影响。
    1. 更新 UI 剩余时间会显示在 TextMeshProUGUI 或 Unity 自带 Text 上;
    2. 格式为 MM:SS,例如 01:29

时间结束事件

timeRemaining <= 0,触发 OnTimerEnd()

      1. 隐藏游戏 UI(例如操作面板、计分板);
      2. 显示「Game Over」界面,让玩家看到结果。


(2)倒计时逻辑

核心逻辑如下:

if (isRunning && timeRemaining > 0)
{
    timeRemaining -= Time.deltaTime; // 每帧递减
    UpdateUI();                       // 更新 UI
}
else if (isRunning && timeRemaining <= 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);
    }
}

这里的 objectsToHideobjectsToShow 可以在 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 && timeRemaining > 0)
        {
            timeRemaining -= Time.deltaTime; // 每帧递减
            if (timeRemaining < 0) timeRemaining = 0;
            UpdateUI();
        }
        else if (isRunning && timeRemaining <= 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" 改成你要跳转的场景名
    }
}

注:1、摄影机位置需置为默认

2、开启6Dof模式

3、RKInput需设置手势追踪

4、需把场景按顺序拖入生成面板

六、打包与安装

当我们在编辑器里调试完成后,最后一步就是 打包并安装到 Rokid AR Studio 眼镜上进行真机体验

检查场景设置

  • 确保游戏的主场景已经添加到File → Build Settings → Scenes In Build 列表中。
  • 这里的主菜单场景名称需要和 SceneManager.LoadScene("xxx") 里的名字保持一致。

七、结束语

到这里,我们已经完成了一个 基于 Rokid UXR SDK + 团结引擎 的空间贪吃蛇小游戏。从 手势追踪 获取食指尖位置,到 蛇头跟随、蛇身增长、食物生成,再到 倒计时与游戏结束逻辑,我们一步步把传统玩法带进了 AR 空间

这个项目虽然简单,但涵盖了 Rokid AR 开发的核心要点

  • 如何使用 UXR 获取骨架关节数据并驱动场景对象;
  • 如何结合 团结引擎完成动态物体生成与材质管理;
  • 如何构建一个完整的「开始 → 进行 → 结束」游戏流程。

希望通过这篇实战,你不仅能收获一个可玩的 AR 小游戏,更能对 Rokid AR 开发流程 有整体的理解。未来,当手势追踪、空间交互与现实环境更好地结合时,我们将能够把更多传统游戏带到现实世界中,真正实现 游戏与现实的融合

最终完成安装包

003.apk

工程源码
RokidSnake.zipRokid UXR 的手势追踪空间贪吃蛇小游戏实战开发【含 工程源码 和 最终完成APK】RokidSnake.zip

更多推荐