ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Pico 4 + Unity VR塔防实战:环境搭建到手柄交互完整流程

Pico 4 + Unity VR塔防实战:环境搭建到手柄交互完整流程 之前在业务迭代中使用 Pico 4 做 VR 原型时最痛的环节不是玩法设计而是 Unity 工程怎么搭、手柄交互怎么接、塔防逻辑怎么正确落地。网上关于 Pico4 开发 Unity 的文章不少但多数只讲环境配置核心玩法的完整代码很少。本文以《王国保卫战》第一季为例从 Pico4 开发环境搭建、XR 交互配置、塔防核心玩法到真机调试整理一套完整的 VR 项目实战流程。新手可以照着逐步搭建有 Unity 基础的开发者可以直接复用核心脚本。先说明一下项目定位这是个人学习用途的 VR 塔防演示项目玩法参照经典塔防品类美术素材使用自制或可商用资源不涉及任何商业素材挪用。《王国保卫战》第一季的目标是完成“地图放置点-敌人路径-波次生成-炮塔射击-资源管理-手柄交互”的完整闭环。1. 项目背景与核心概念1.1 VR 塔防游戏是什么塔防类游戏的核心循环很清晰敌人沿固定路径前进玩家在路径两侧布置炮塔阻止敌人到达终点。传统塔防通过鼠标点击完成操作而在 VR 版本中玩家需要“亲手”完成炮塔选择、瞄准和放置交互形式比平面游戏更直观。简单说VR 塔防由以下几个部分组成模块作用地图网格提供炮塔放置点通常表现为地面上的高亮节点敌人路径敌人从出生点到终点的移动路线波次系统按时间间隔生成一批敌人炮塔基础索敌、转向、开火、升级资源系统金币、生命值、波次状态手柄交互VR 中完成放置、切换炮塔、开始下一波等操作在 Pico 4 上实现这套体系除了 Unity 普通游戏逻辑还需要额外处理 XR 手柄射线、头显视角和 UI 距离等方面的问题。1.2 为什么选择 Unity Pico 4Pico 4 是当前的国产 VR 一体机代表设备之一具备彩色透视、手势识别和成熟的手柄追踪能力。选择它的原因主要有三点入手门槛低设备价格适中普通开发者无需高端 PC 也能开发独立运行的 VR 应用。开发环境友好Pico 官方提供 PICO SDK基于 Unity XR 体系可按常规 XR 开发流程处理。分发方便APK 可直接安装到设备真机调试链路短。Unity 是目前 VR 开发使用率很高的引擎尤其是 2021.3 LTS 之后的版本内置 XR Interaction Toolkit 越来越成熟做成 Pico4 项目时不需要自己实现手柄射线和交互逻辑项目周期能明显缩短。1.3 第一季的功能范围为了避免项目范围过大第一季只做最核心的玩法闭环一张完整的地图场景包含 6~10 个炮塔放置点。一个敌人路径系统支持后续扩展多波次路线。基础波次生成手动触发下一波。三种炮塔原型单体攻击塔、范围攻击塔、慢速减速塔。金币与生命值管理。Pico 4 手柄射线放置炮塔、切换炮塔类型和开始下一波。第二季可继续扩展炮塔升级、多路线、Boss 波次和手势识别本文先把第一季的地基打好。2. 环境准备与版本说明2.1 开发环境清单在开始写代码之前建议先确认自己的开发环境。本文示例环境如下项目版本建议操作系统Windows 10 / 11 64 位Unity 版本Unity 2022.3 LTS2021.3 LTS 亦可设备Pico 4 一体机PICO SDKPICO Integration SDK 2.0 及以上XR 插件包XR Interaction Toolkit 2.3 及以上开发方式Android 平台USB 真机调试版本不同不一定导致无法运行但建议使用 LTS 版本稳定性更好。如果你使用的是 Unity 6 或更高版本XR 包版本会有变化核心思路仍然一致只是配置入口可能有差异。2.2 Pico 4 开启开发者模式真机调试的前提是让设备进入开发者模式打开 Pico 4进入“设置”。选择“通用”连续点击“软件版本号”多次直到提示进入开发者模式。返回设置页打开“开发者模式”同时开启“USB 调试”。使用 USB-C 数据线连接电脑设备上弹出 USB 调试授权请求点击允许。这里再强调一个点开发者模式默认开启后建议把 USB 连接方式设置为“文件传输”否则 Unity 的 adb 可能无法正确识别设备。2.3 导入 PICO SDKPICO Integration SDK 一般通过 Pico 开发者平台下载下载后得到一个.unitypackage文件。在 Unity 中双击该包即可导入。导入之后PICO SDK 会完成以下工作提供PXR_Manager等运行时组件。配置 PICO 设备相关的 Android 构建参数。提供设备模拟工具方便没有真机时调试部分交互。重点注意SDK 版本和 Unity 版本要匹配如果导入后出现脚本编译错误优先检查 Unity 版本是否在 SDK 支持范围内而不是直接删除报错脚本。2.4 配置 XR Plug-in Management在 Unity 菜单栏选择Edit - Project Settings - XR Plug-in Management勾选 Android 标签页下的 PICO 选项。这样 Unity 才知道当前应用要输出到 Pico 设备。之后在场景中创建 XR Origin菜单右键 - XR - XR Origin (XR Rig)PICO SDK 新版本普遍使用 XR Origin 作为玩家头显和手柄的根节点如果有旧版PXR_Camera或PICO_Manager相关的教程需要留意自己的 SDK 主流程是否已经切换到 XR Origin。3. 项目架构设计3.1 场景结构第一季工程建议拆成两个场景场景说明BootScene启动场景可以放 Logo 和一句加载提示BattleScene主战斗场景塔防玩法整体闭环都在这里BootScene 在项目起步时不是必须的但养成“入口场景”习惯后后续加主菜单和加载过渡会容易很多。BattleScene 的核心层级规划BattleScene ├── XR Origin (XR Rig) │ ├── Camera Offset │ │ ├── Main Camera │ │ ├── LeftHand Controller │ │ └── RightHand Controller ├── Environment │ ├── Ground │ └── Buildings ├── Gameplay │ ├── Path │ ├── Spawners │ ├── Towers │ └── Enemies └── UI └── HUD Canvas这里把环境、玩法、UI 分开后续迭代时不会互相干扰。3.2 脚本分层第一季的脚本分成四类管理器GameManager、AudioManager。实体控制EnemyController、TurretBase及其子类。流程控制WaveSpawner、TowerPlacement。数据与事件ScriptableObject定义塔的数据用事件通知 UI 刷新。脚本之间尽量减少直接引用比如金币变化通过事件广播public event Actionint OnGoldChanged;UI 只监听事件而不是每帧主动查询GameManager.Gold这样解耦明显后续加成就和道具系统也更轻松。3.3 数据驱动设计炮塔数量多时Unity 里拖字段会非常耗时。第一季用ScriptableObject定义塔的基础属性名称攻击力攻击范围攻击速度建造成本预制体以单体攻击塔为例[CreateAssetMenu(fileName TurretData, menuName TowerDefense/Turret Data)] public class TurretData : ScriptableObject { public string displayName; public float damage; public float range; public float fireRate; public int cost; public GameObject turretPrefab; }在炮塔预制体上挂一个TurretBase组件运行时把数据复制过来。这样后续增加新塔只需要创建新的TurretData资产不需要改动代码逻辑。4. 核心功能实现这一节是整个项目的重点我们按照“从静态到动态、从非交互到交互”的顺序来实现。4.1 地图网格与炮塔放置点炮塔不能摆在地图任意位置否则敌人路径会被挡住也容易出现模型穿插。第一季的做法是手动放置一组“放置点”每个放置点是一个空物体上面带一个BuildSocket组件。// 文件路径Assets/Scripts/Gameplay/BuildSocket.cs using UnityEngine; public class BuildSocket : MonoBehaviour { [SerializeField] private MeshRenderer socketRenderer; [SerializeField] private Color normalColor Color.green; [SerializeField] private Color busyColor Color.gray; private bool _isBusy; public bool IsBusy _isBusy; public Vector3 BuildPosition transform.position Vector3.up * 0.05f; public void SetHighlight(bool active) { if (socketRenderer null || _isBusy) return; socketRenderer.material.color active ? normalColor : busyColor; } public void SetBusy(bool busy) { _isBusy busy; if (socketRenderer ! null) { socketRenderer.material.color busy ? busyColor : normalColor; } } }放置点在地面上做成圆形半透明面片这样玩家在 VR 视野中一眼就能看到哪些位置可以修塔。注意这里的SetHighlight只是一个高亮示例真实项目中建议用Outline或自发光材质不要直接修改实例材质否则会产生二材质。4.2 敌人路径与移动逻辑敌人路径使用一系列Transform点构成。敌人出生后沿着这些点移动到达终点时扣减生命值。// 文件路径Assets/Scripts/Gameplay/WaypointPath.cs using System.Collections.Generic; using UnityEngine; public class WaypointPath : MonoBehaviour { [SerializeField] private ListTransform points new ListTransform(); public Vector3 GetPoint(int index) { if (points.Count 0) { return Vector3.zero; } if (index 0 || index points.Count) { return points[points.Count - 1].position; } return points[index].position; } public int PointCount points.Count; }敌人移动脚本// 文件路径Assets/Scripts/Enemies/EnemyController.cs using UnityEngine; public class EnemyController : MonoBehaviour { public float moveSpeed 2f; public int maxHealth 50; public int currentHealth; public int rewardGold 10; public int damageToBase 1; private WaypointPath _path; private int _currentWaypointIndex; private bool _isDead; public void Initialize(WaypointPath path, float speedMultiplier, int healthMultiplier) { _path path; _currentWaypointIndex 0; currentHealth Mathf.CeilToInt(maxHealth * healthMultiplier); moveSpeed * speedMultiplier; transform.position _path.GetPoint(0); _isDead false; } private void Update() { if (_path null || _isDead) return; if (_currentWaypointIndex _path.PointCount) { ReachBase(); return; } Vector3 target _path.GetPoint(_currentWaypointIndex); Vector3 direction target - transform.position; direction.y 0f; if (direction.magnitude 0.1f) { _currentWaypointIndex; return; } transform.position direction.normalized * moveSpeed * Time.deltaTime; } public void TakeDamage(float damage) { if (_isDead) return; currentHealth - Mathf.CeilToInt(damage); if (currentHealth 0) { Die(); } } private void Die() { _isDead true; if (GameManager.Instance ! null) { GameManager.Instance.AddGold(rewardGold); } EnemyDeathHandler.OnEnemyDied?.Invoke(this); Destroy(gameObject); } private void ReachBase() { _isDead true; if (GameManager.Instance ! null) { GameManager.Instance.LoseLife(damageToBase); } EnemyDeathHandler.OnEnemyReachedBase?.Invoke(this); Destroy(gameObject); } }说明WaypointPath提供路径点坐标。moveSpeed支持波次倍率。TakeDamage由炮塔子弹调用。Die()和ReachBase()都通过事件通知外部。实际项目中为了避免每个敌人Update里都做一次向量归一化可以把路径点按顺序预计算成线段数组进一步提升性能。4.3 敌人波次生成波次系统使用协程控制生成节奏// 文件路径Assets/Scripts/Gameplay/WaveSpawner.cs using System.Collections; using System.Collections.Generic; using UnityEngine; public class WaveSpawner : MonoBehaviour { [Header(关卡配置)] [SerializeField] private WaypointPath path; [SerializeField] private EnemyController enemyPrefab; [Header(波次参数)] [SerializeField] private int enemiesPerWave 8; [SerializeField] private float spawnInterval 1.2f; [SerializeField] private float waveInterval 5f; [Header(波次成长)] [SerializeField] private float speedMultiplierPerWave 1.1f; [SerializeField] private int healthMultiplierPerWave 2; private int _currentWave 0; private bool _isSpawning; public int CurrentWave _currentWave; public void StartNextWave() { if (_isSpawning) return; _currentWave; _isSpawning true; StartCoroutine(SpawnWaveProcess()); } private IEnumerator SpawnWaveProcess() { for (int i 0; i enemiesPerWave; i) { SpawnEnemy(); yield return new WaitForSeconds(spawnInterval); } yield return new WaitForSeconds(waveInterval); _isSpawning false; } private void SpawnEnemy() { if (enemyPrefab null || path null) return; EnemyController enemy Instantiate(enemyPrefab, transform.position, Quaternion.identity); float speedMul Mathf.Pow(speedMultiplierPerWave, _currentWave - 1); int healthMul Mathf.Max(1, healthMultiplierPerWave * (_currentWave - 1)); enemy.Initialize(path, speedMul, healthMul); } }第一季不自动开始下一波而是让玩家通过手柄点击“下一波”按钮触发StartNextWave()。这样玩家可以花时间先在放置点修塔节奏更从容。4.4 炮塔索敌与射击炮塔的核心逻辑分为三部分索敌、转向、开火。一次只处理一个最近的敌人配合子弹实现单体伤害范围伤害塔可以用Physics.OverlapSphere一次获取多个目标。// 文件路径Assets/Scripts/Turrets/TurretBase.cs using UnityEngine; public class TurretBase : MonoBehaviour { [SerializeField] private TurretData data; [Header(部件引用)] [SerializeField] private Transform turretHead; [SerializeField] private Transform firePoint; [Header(子弹)] [SerializeField] private BulletController bulletPrefab; private float _damage; private float _range; private float _fireRate; private float _cooldown; private void OnValidate() { // 从数据资产复制数值方便运行时直接使用 if (data ! null) { _damage data.damage; _range data.range; _fireRate data.fireRate; } } private void Update() { if (data null) return; if (_fireRate 0f) { _cooldown - Time.deltaTime; } EnemyController target FindNearestEnemy(); if (target ! null) { Vector3 targetPos new Vector3(target.transform.position.x, turretHead.position.y, target.transform.position.z); turretHead.rotation Quaternion.Slerp(turretHead.rotation, Quaternion.LookRotation(targetPos - turretHead.position), Time.deltaTime * 6f); if (_cooldown 0f turretHead ! null) { Fire(target); _cooldown 1f / _fireRate; } } } private EnemyController FindNearestEnemy() { Enemy[] aliveEnemies FindObjectsOfTypeEnemy(); if (aliveEnemies.Length 0) return null; Enemy nearest null; float nearestSqrDistance _range * _range; foreach (Enemy enemy in aliveEnemies) { if (enemy null || enemy.IsDead) continue; Vector3 offset enemy.transform.position - transform.position; float sqrDistance offset.sqrMagnitude; if (sqrDistance nearestSqrDistance) { nearest enemy; nearestSqrDistance sqrDistance; } } return nearest; } private void Fire(EnemyController target) { if (bulletPrefab null) return; BulletController bullet Instantiate(bulletPrefab, firePoint.position, Quaternion.identity); bullet.SetTarget(target.transform); bullet.SetDamage(_damage); } }这里我特意用了Enemy类的简写你在工程里可能有多种敌人类型建议把公共属性抽到一个基类或接口中。第一季可以先简单点只有一个EnemyController。注意FindObjectsOfType在敌人数量少时没问题但在敌人较多时会产生性能压力。后续建议用敌人管理器维护一个活动敌人列表索敌时从列表遍历。4.5 子弹与命中子弹不需要复杂的物理模拟用MoveTowards追踪目标命中后触发伤害即可// 文件路径Assets/Scripts/Bullets/BulletController.cs using UnityEngine; public class BulletController : MonoBehaviour { public float speed 8f; public float damage 10f; private Transform _target; public void SetTarget(Transform target) { _target target; } public void SetDamage(float value) { damage value; } private void Update() { if (_target null) { Destroy(gameObject); return; } Vector3 direction _target.position - transform.position; float distance speed * Time.deltaTime; if (direction.magnitude distance) { EnemyController enemy _target.GetComponentEnemyController(); if (enemy ! null) { enemy.TakeDamage(damage); } Destroy(gameObject); return; } transform.Translate(direction.normalized * distance, Space.World); } }这里有一个细节子弹每一次Update都做GetComponent性能一般但可读性好。第一季可以保留等敌人数量提升后改用事件或缓存EnemyController。4.6 金币、生命与 UIGameManager在整个场景中只保留一个实例单例模式方便其他脚本访问// 文件路径Assets/Scripts/Managers/GameManager.cs using System; using UnityEngine; using UnityEngine.UI; public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } [SerializeField] private int startGold 100; [SerializeField] private int startLives 20; public int Gold { get; private set; } public int Lives { get; private set; } public event Actionint OnGoldChanged; public event Actionint OnLivesChanged; private void Awake() { if (Instance null) { Instance this; } else { Destroy(gameObject); } Gold startGold; Lives startLives; } public bool TrySpendGold(int amount) { if (Gold amount) return false; Gold - amount; OnGoldChanged?.Invoke(Gold); return true; } public void AddGold(int amount) { Gold amount; OnGoldChanged?.Invoke(Gold); } public void LoseLife(int amount) { Lives - amount; OnLivesChanged?.Invoke(Lives); if (Lives 0) { Debug.Log(游戏结束生命值归零); } } }UI 部分使用 Unity 的 World Space Canvas挂在玩家视野前方约 2 米的位置。金币和生命值用Text显示并在事件回调里刷新。4.7 VR 手柄交互射线放置炮塔这是 Pico4 项目中最关键的一节。我们需要让玩家通过右手柄射线瞄准地面放置点扣动扳机放置炮塔。在 Pico 4 手柄交互中常用的映射是手柄按键默认功能扳机键点击 UI / 放置炮塔A / B 键切换炮塔类型X / Y 键切换面板Home 键回到系统菜单如果使用 XR Interaction Toolkit已经帮我们处理了手柄输入我们只需要监听SelectEntered事件。TowerPlacement脚本放在 XR Origin 下引用右手射线// 文件路径Assets/Scripts/Interaction/TowerPlacement.cs using UnityEngine; using UnityEngine.XR.Interaction.Toolkit; public class TowerPlacement : MonoBehaviour { [SerializeField] private XRRayInteractor rightHandRayInteractor; [SerializeField] private LayerMask socketLayer; [Header(炮塔类型)] [SerializeField] private TurretData[] turretDatas; private int _currentTurretIndex; private void Start() { if (rightHandRayInteractor ! null) { rightHandRayInteractor.selectEntered.AddListener(OnRaySelect); } } private void OnDestroy() { if (rightHandRayInteractor ! null) { rightHandRayInteractor.selectEntered.RemoveListener(OnRaySelect); } } private void Update() { // 用 A 键切换炮塔类型 if (UnityEngine.Input.GetKeyDown(KeyCode.A)) { _currentTurretIndex (_currentTurretIndex 1) % turretDatas.Length; Debug.Log(切换炮塔 turretDatas[_currentTurretIndex].displayName); } // 调试方便鼠标左键也可以直接放置 if (UnityEngine.Input.GetMouseButtonDown(0)) { TryPlaceWithMouse(); } } private void OnRaySelect(SelectEnterEventArgs args) { if (args.interactableObject is XRBaseInteractable interactable) { BuildSocket socket interactable.transform.GetComponentBuildSocket(); if (socket ! null !socket.IsBusy) { TryPlaceAtSocket(socket); } } } private void TryPlaceWithMouse() { Ray ray Camera.main.ScreenPointToRay(UnityEngine.Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 100f, socketLayer)) { BuildSocket socket hit.collider.GetComponentBuildSocket(); if (socket ! null !socket.IsBusy) { TryPlaceAtSocket(socket); } } } private void TryPlaceAtSocket(BuildSocket socket) { TurretData selected turretDatas[_currentTurretIndex]; if (selected null || selected.turretPrefab null) return; if (!GameManager.Instance.TrySpendGold(selected.cost)) { Debug.Log(金币不足无法建造 selected.displayName); return; } Instantiate(selected.turretPrefab, socket.BuildPosition, Quaternion.identity); socket.SetBusy(true); } }为了在没有 Pico 手柄时也能测试脚本里保留了一条鼠标射线分支方便先在 PC 上调试玩法逻辑。5. 完整实战流程第一季 Demo5.1 创建 Unity 项目打开 Unity Hub新建项目。选择 3D 核心模板并勾选“Android Build Support”。项目名称建议设置为KingdomDefenseVR。打开Project Settings - Player在Other Settings中将Package Name设置为com.example.kingdomdefense。最低 API Level 按 PICO SDK 要求设置一般选择 Android 10.0API 29以上。5.2 导入依赖包导入 PICO SDK 之后通过Window - Package Manager安装XR Interaction ToolkitXR Plugin ManagementInput System在 XR Interaction Toolkit 的安装界面选择XR Interaction Toolkit Samples可以导入 Starter Assets里面自带手柄绑定配置非常推荐。5.3 搭建 BattleScene创建地面Plane给地面添加自定义的绿色材质。创建Empty Object命名为Path在内部建立 4~6 个空节点作为路径点。在路径沿线放置BuildSocket预制体。创建WaveSpawner空物体挂上WaveSpawner脚本。创建GameManager空物体挂上GameManager脚本。创建HUD Canvas设置 World Space 渲染模式。5.4 配置 XR Origin 与手柄在场景中创建XR Origin将左右手控制器上的XRRayInteractor分别挂上。注意手柄射线要能正确命中BuildSocket所在层所以需要在Layer中新建一个Socket层然后把所有放置点设成该层。TowerPlacement脚本中的socketLayer要直接选择Socket层否则射线检测会漏掉目标。5.5 运行验证连接 Pico 4 到电脑后在 Unity 中切换到 Android 平台File - Build Settings - Android - Switch Platform然后点击Build And Run等待 APK 安装到设备。真机运行后右手柄射线应该能指向地面扣动扳机时会在放置点生成炮塔。生成敌人后炮塔会自动索敌并开火敌人到达终点后生命值下降。6. 常见问题与排查思路6.1 Pico 4 无法被 Unity 识别问题现象常见原因解决思路Build And Run 时找不到设备未开启开发者模式 / USB 调试开启开发者模式允许 USB 调试设备提示连接失败未安装 USB 驱动安装 PICO 官方驱动或使用 PICO 助手连接一次Unity 无法 adb 识别使用了 PTP 模式将 USB 连接方式改为“传输文件”模式确认设备连接可以在命令行输入adb devices如果列表里有设备但状态是unauthorized需要在设备上重新授权。6.2 手柄射线不可见手柄射线依赖 XR Interaction Toolkit 的Line Visual组件通常由XRInteractorLineVisual决定显示效果。检查点XRRayInteractor是否挂在正确的 Controller 下。XR Interactor Line Visual是否引用Shared Materials。手柄模型是否被相机裁剪射线起点隐藏在手柄内部。远距离射线的Max Raycast Distance是否过小。调试技巧先在Normal预览下看射线是否可见再检查Hit Validation是否正确。6.3 材质显示为紫红色这是 Shader 缺失的典型表现。Pico 4 开发时如果换用了 URP但模型材质还是内置管线 Shader就会出现紫红色。解决方案把项目渲染管线统一为Universal Render Pipeline。重新导入美术资源让材质自动升级为 URP Shader。普通面片材质使用Universal Render Pipeline/Lit。6.4 放置炮塔时位置偏移产生原因通常是BuildSocket.BuildPosition与模型的锚点不匹配。建议炮塔预制体的根节点放在底座中心而不是炮口或头顶。放置点只上移一点点高度避免模型陷入地面。放置完成后执行一次Physics.SyncTransforms()避免物理检测残留旧位置。6.5 敌人数量增加后帧率下降第一季最容易出现性能瓶颈的地方每个敌人使用独立Update计算路径。每个炮塔每帧调用FindObjectsOfType索敌。粒子特效过多未做 Lua 对象池。优化顺序用对象池取代频繁Instantiate/Destroy。用敌人管理器维护活动敌人列表。将子弹分为追踪弹和非追踪弹非追踪弹用直线射线检测。开启多线程或 ECS 不在第一季范围但可以留作扩展。7. 最佳实践与工程建议7.1 场景单位与坐标系VR 中 1 Unity 单位最好对应现实 1 米。Pico 4 的默认 Unity 单位就是米。第一季的地图范围建议控制在 10~20 米见方太大玩家头部转动频繁容易疲劳太小炮塔放置位置不够丰富。场景坐标注意保持地面 Y 轴为 0路径点 Y 轴统一为 0。敌人移动时保持 Y 轴方向不变避免走斜坡时抖动。7.2 使用对象池第一季的敌人生成频率不高但炮塔子弹属于高频对象。推荐在项目里做一个简单对象池// 文件路径Assets/Scripts/Managers/ObjectPool.cs using System.Collections.Generic; using UnityEngine; public class ObjectPool : MonoBehaviour { private readonly DictionaryGameObject, QueueGameObject _pool new DictionaryGameObject, QueueGameObject(); public GameObject Spawn(GameObject prefab, Vector3 position, Quaternion rotation) { GameObject item null; if (_pool.TryGetValue(prefab, out var queue) queue.Count 0) { item queue.Dequeue(); item.transform.SetPositionAndRotation(position, rotation); item.SetActive(true); } else { item Instantiate(prefab, position, rotation); } return item; } public void Despawn(GameObject instance, GameObject prefab, float delay 0f) { StartCoroutine(DespawnProcess(instance, prefab, delay)); } private System.Collections.IEnumerator DespawnProcess(GameObject instance, GameObject prefab, float delay) { if (delay 0f) { yield return new WaitForSeconds(delay); } instance.SetActive(false); if (!_pool.ContainsKey(prefab)) { _pool[prefab] new QueueGameObject(); } _pool[prefab].Enqueue(instance); } }对象池的好处是减少内存分配和 GC最终目的不是“更快”而是更稳定。7.3 尽量少用 FindXxx在场景中频繁使用FindObjectOfType、FindGameObjectWithTag会导致性能退化。正确做法在Start中缓存引用。通过事件系统和管理器获取对象。敌人列表由EnemyController在OnEnable/OnDisable注册到敌人管理器。7.4 UI 放远一点VR 中 UI 离眼睛太近容易导致视觉疲劳太远又看不清。建议主 HUD 放在 2~3 米处。炮塔选择面板放到左手侧使用抓取或射线点击。使用大字号避免细缝字体在 VR 镜头下闪烁。7.5 版权与素材安全这一点必须提。开篇也说过这是一个学习演示项目。在公开源码或教程时不要使用商业游戏的音乐、角色模型、关卡名字等受版权保护素材。建议使用 Unity Asset Store 可商用资源。使用开源 CC0 美术素材。自己制作低多边形模型。音效能用程序合成则用合成音效。《王国保卫战》这个名字在新项目、教程和演示标题中使用没有商业问题但如果你日后想发布到商店或参赛建议改一个原创名称。8. 总结与下一季扩展方向第一季完成了一个可以在 Pico 4 上跑通的 VR 塔防 Demo核心链路包括Pico 4 开发者模式与 Unity 真机调试。PICO SDK 与 XR Interaction Toolkit 的集成。基于 Waypoint 的敌人路径系统。基于协程的波次生成系统。炮塔索敌、子弹追踪和金币资源循环。手柄射线放置与 UI 事件联动。接下来可以继续做这些方向炮塔升级体系按 E 键打开炮塔面板花费金币提升等级。多路线系统第二波敌人从拆迁口或第二条路径进入。Boss 敌人拥有技能和免疫部分攻击。手势识别Pico 4 的手势追踪替代手柄放置炮塔。动态波次根据玩家经济状态调整敌人数目和强度。每一个方向都可以独立成一篇教程。你现在最应该做的是先把手柄射线放塔、波次战斗和农场炮塔这些基础跑通然后再去研究升级和特效。毕竟 VR 项目里玩法稳定比效果炫酷更重要多一帧稳定少一次眩晕玩家体验会有本质差别。如果你也正在用 Pico 4 做 Unity VR 项目欢迎把这篇教程当作第一季起步模板。收藏备用部署前再翻一遍排错清单。
返回列表