本文介绍如何使用 PICO Spatial 模式从 0 到 1 搭建一个 PICO Spatial 项目。
## 通用能力介绍
### 空间相机
在 PICO Spatial 模式中，不再需要 PXR 相关的各种类，甚至不需要相机，取而代之的是空间相机（spatial camera）。
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/112d280c8b474c4d87bebc8d6120e0f7~tplv-goo7wpa0wc-image.image)
空间相机有两种可选择的模式：

* **Constrained**：有边界限制，画面会被裁剪，Unity 场景只会出现在特定区域内。
* **Unconstrained**：无边界限制，画面不会被裁剪，整个 Unity 场景都会被显示。

你可以为每个空间相机单独定义，也能在 PICO Spatial 设置中为所有空间相机统一定义。
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/ae1e3a93ca0f421eb80666dc3ef716a8~tplv-goo7wpa0wc-image.image)
### 交互类型与交互阶段
一个 `SpatialInputState` 结构体包含了关于正在进行的交互的所有信息，如下所示：
| **参数** | **类型** | **描述** |
| --- | --- | --- |
| devicePosition | Vector3 | 发起交互的设备（例如你的 PICO 手柄或手）的当前位置。 |
| deviceRotation | Quaternion | 发起交互的设备（例如你的 PICO 手柄或手）的当前朝向。 |
| type | MultiSpatial.InteractionType | 交互类型：`Touch`（触碰）、`Pinch`（捏合）或 `Indirect Pinch`（间接捏合）。 <br>  ***提示***：当前所有交互均为 `Indirect Pinch`。 |
| phase <br>  |  | 此交互的当前阶段，可取值如下： <br>  <br> * `None`：当前无进行中的交互  <br> * `Began`：交互的首帧 <br> * `Moved`：交互进行中 <br> * `Ended`：成功交互的最后一帧  <br> * `Canceled`：失败交互的最后一帧  <br>  <br> ***提示***：目前不会出现 `Stationary` 状态。 |
| currentPosition | Vector3 | 交互本身的当前位置。 |
| startPosition | Vector3 | 交互的初始位置。若交互阶段为 `Began`，则其值与当前位置相同。 |
| deltaPosition | Vector3 | 起始位置与当前位置之间的差值。若交互阶段为 `Began`，则该值为 `Vector3.zero`。 |
| targetObject | GameObject | 当前正在被交互的 Unity GameObject。 |
| interactionId | int | 发起交互的设备 ID： <br>  <br> * `0`：左手 <br> * `1`：右手  <br> * `-1`：无（该值保留，用于空的 `SpatialInputState` 结构体）。 |
开发者可以主要关注 `type` 和 `phase` 这两个参数。
为了简化交互，可以对 `SpatialInput` 做简单的封装。如下代码所示，在 `Update` 阶段对 `type` 进行判断：
```C#
void Update(){
    foreach (var touch in Touch.activeTouches){
        SpatialInputState inputState = SpatialInputSupport.GetInputState(touch);
        GameObject obj = inputState.targetObject;
        int Id = inputState.interactionId;
        switch (inputState.type)
        {
            case InteractionType.Touch:
                InputHandler(inputState.type, inputState, obj, Id);
                break;
            case InteractionType.Pinch:
                InputHandler(inputState.type, inputState, obj, Id);
                break;
            case InteractionType.IndirectPinch:
                InputHandler(inputState.type, inputState, obj, Id);
                break;
            default:
                break;
        }
    }
}
```

然后，再定义一个简单的接口：
```C#
public interface ISpatialPointer
{
    public virtual void OnBegan() { }
    public virtual void OnBegan(Vector3 position, Quaternion rotation) { }
    public virtual void OnMoved() { }
    public virtual void OnMoved(Vector3 position, Quaternion rotation) { }
    public virtual void OnEnded() { }
    public virtual void OnEnded(Vector3 position, Quaternion rotation) { }
}
```

该接口用于处理各种交互事件：
```C#
private void InputHandler(in InteractionType type, in SpatialInputState inputState, in GameObject obj, int Id)
{
        var objects = interacteObjects[type];
        if (objects.ContainsKey(Id) && objects[Id] != obj)
        {
            objects.Remove(Id);
            if (objects[Id].TryGetComponent(out ISpatialPointer prePinchObj))
            {

                prePinchObj.OnEnded();
                prePinchObj.OnEnded(inputState.currentPosition, inputState.deviceRotation);
            }
        }
        if (!obj.TryGetComponent(out ISpatialPointer currentObj)) return;
        switch (inputState.phase)
        {
            case TouchPhase.Began:
                if (!objects.ContainsKey(Id))
                {
                    objects.Add(Id, obj);
                    currentObj.OnBegan();
                    currentObj.OnBegan(inputState.currentPosition, inputState.deviceRotation);
                }

                break;
            case TouchPhase.Moved:
                if (objects.ContainsKey(Id))
                {
                    currentObj.OnMoved();
                    currentObj.OnMoved(inputState.currentPosition, inputState.deviceRotation);
                }
                break;
            case TouchPhase.Ended:
                if (objects.ContainsKey(Id))
                {
                    currentObj.OnEnded();
                    currentObj.OnEnded(inputState.currentPosition, inputState.deviceRotation);
                    objects.Remove(Id);
                }
                break;
            case TouchPhase.Canceled:
                if (objects.ContainsKey(Id)) objects.Remove(Id);
                break;
            case TouchPhase.None:
                break;
        }
}
```

在 `InputHandler` 中，可以看到 `interacteObjects` 字典缓存了生命周期中的各个交互事件，并且根据 `phase` 去调用各阶段的处理函数。
这样便完成了对交互的基本封装。举例来说，若某个按钮需要通过点击来切换其显示的文字，只需要实现`ISpatialPointer`接口上的特定生命周期的方法即可。
## 场景介绍
| **场景** | **视觉效果** | **交互** |
| --- | --- | --- |
| Scene Launcher | ![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/b78d0725857d407cae26a0f1a16baf7c~tplv-goo7wpa0wc-image.image) | * 点击左右箭头按钮，实现以下操作： <br>    * 切换下方的场景球 <br>    * 显示当前场景球的对应介绍 <br> * 点击最前方的场景球进入场景 <br>  <br>  |
| Bird and Blocks | ![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/f4ead9dc833d4000804e74ec67fc6877~tplv-goo7wpa0wc-image.image) | 通过两指捏合拾取小鸟雕塑： <br>  <br> * 悬停时，小鸟雕塑变色 <br> * 可以拾取与释放小鸟雕塑 <br> * 小鸟雕塑彼此可以产生碰撞 |
| Plane Commander | ![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/13b9da01aba444e4bfe8901c6559ed19~tplv-goo7wpa0wc-image.image) | * 场景可以循环开始每一轮四架飞机螺旋上升 <br> * 玩家可通过点击飞机获得反馈 <br> * 当飞机全部点击完毕后进入下一轮 <br> * 轮次每增加一轮，飞行速度加快 |
| Spatial UI | ![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/1b66ae0fc56143779d064743b7644575~tplv-goo7wpa0wc-image.image) | * 在 **Dropdown** 列表中，选择几何体类型（圆柱/胶囊/立方体） <br> * 在 **Slider** 处，选择几何体的数量（1 至 3） <br> * 在 **Button** 处，点击 **add ball** 按钮来生成所选数量的几何体 |
| Robot Runner | ![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/bb7ffced9c3f4894acec3f9a8222b9fa~tplv-goo7wpa0wc-image.image) | 可通过拖拽来移动网球，并且网球边界为空间相机的区域。 <br> 机器人会追逐网球。 <br> 移动网球时，空间相机会缓慢跟随网球运动并不断裁减可视区域。 <br>  |
## 主场景
### 切换按钮
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/2b06207942ef4895ba3399a346d2cd11~tplv-goo7wpa0wc-image.image)
显示面板下方有两个按钮，用于控制场景下顺/逆时针选装：
```C#
public class ToggleButton : MonoBehaviour, ISpatialPointer
{
    public int direction = 1;
    public Action<int> OnPress;
    private IEnumerator OnButtonClick()
    {
        AinimationHandler(true);
        yield return new WaitForSeconds(animationTime);
        OnPress?.Invoke(direction);
        yield return new WaitForSeconds(delayTime);
        AinimationHandler(false);
    }
    private void AinimationHandler(bool state){
        isClickPress = state;
        anim.SetBool("IsClick", state);
    }
    public void OnBegan()
    {
        if (!isClickPress) StartCoroutine(OnButtonClick());
    }
}
```


* `direction`：用来控制按钮方向：
   * `+1`：顺时针旋转并展示对应文字
   * `-1`：逆时针旋转并展示对应文字
* `Action<int> OnPress`：当按钮被点击时，通知所有订阅按钮点击事件的对象
   ![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/df4cada957654b7cbc1ecd068abfc52b~tplv-goo7wpa0wc-image.image)
* `OnButtonClick`：按钮点击是异步的，可被分解成三部分：
   * 点击后立刻触发按钮动画，并锁定按钮避免出现多次点击响应
   * 按钮动画完成后，通知订阅按钮点击事件的对象
   * 当完成锁定后恢复按钮的可点击状态，锁定时长为`animationTime+delayTime`

### 场景球的旋转
`ScenesRingController` 用于控制环形场景球的转动。
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/b503f2130b594da5860e9c754c7b1907~tplv-goo7wpa0wc-image.image)
```C#
private void ToggleButton_OnPress(int direction)
{
    StartRotation(direction);
}

public IEnumerator Rotate(int direction){
    float elapsedTime = 0f;
    int n = scenes.Length;
    float targetAngle = 2 * Mathf.PI / n; // 旋转角度（弧度）
    float speed = targetAngle / duration * direction;
    while (elapsedTime < duration)
    {
        // 更新每个球的位置（绕中心旋转）
        transform.Rotate(0, speed * Time.deltaTime * Mathf.Rad2Deg, 0, Space.Self);
        for (int i = 0; i < n; i++)
        {
            scenes[i].transform.forward = Vector3.forward;
        }
        elapsedTime += Time.deltaTime;
        yield return null; // 等待下一帧
    }
    SetFinalRotation();
}
```

当点击旋转后，每帧将执行 `Rotate` 方法，根据添加的场景数量计算出目标角度，并根据旋转持续时间与方向计算出旋转速度，这对应了上一部分通过按钮的 `direction` 来控制按钮方向。
当时间超过 `duration` 后，通过 `SetFinalRotation` 设置终点位置。
```C#
 private void SetFinalRotation(){
    int n = scenes.Length;
    transform.eulerAngles = rotationTarget[currentIndex];
    for (int i = 0; i < n; i++){
        scenes[i].transform.forward = Vector3.forward;
    }
}
```

### 信息配置
```C#
public struct SceneInfo
{
        public string name;
        [TextArea] public string recommend;
}
public class EntrySceneInfo : ScriptableObject
{
        public SceneInfo[] infos;
}
```

![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/c08d3da954d241a1b0c8a0f915c60f27~tplv-goo7wpa0wc-image.image)
通过`ScriptableObject`记录场景的信息：

* `name`：场景的名字
* `recommend`：场景介绍

## 如何加载/退出场景
在 Build Setting 中添加多个场景。
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/b99af397f9084d8cbcfb981349bd3841~tplv-goo7wpa0wc-image.image)
可以通过 `SceneManager.LoadScene` 方法实现多场景间的切换。
```C#
private IEnumerator OnButtonClick()
{
    if (!isLock)
    {
        yield return new WaitForSeconds(delayTime);
        isLock = true;
        SceneManager.LoadScene(index);
    }
}
```


* `isLock`：点击按钮后，立刻锁定，避免多次点击产生的加载异常
* `delayTime`：留给按钮动画的延迟时间，使按钮的点击交互更完整，避免生硬的过度
* `SceneManager.LoadScene`：加载特定场景

这里的 `index` 为 Building Settings 中场景对应的索引。对于返回按钮，由于入口场景的 `index` 为 `0`，所以可以直接使用 `SceneManager.LoadScene(0)`。
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/5f3fece2d9724b9f953aa7fd9c3109e9~tplv-goo7wpa0wc-image.image)
上述方案虽然能够实现子场景的加载与回退，但进入子场景后主场景的状态会被重置。若希望保留主场景状态，可采用以下方法：

* 使用 `DontDestroyOnLoad(gameObject);` 持久化特定脚本。
* 使用 `ScriptableObject` 存储场景信息，并在加载主场景时动态读取。

## Bird and Blocks
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/fa02593668794a319f18615bc8aec2b3~tplv-goo7wpa0wc-image.image)
### 实现拾取
```C#
public class PinchObject : MonoBehaviour, ISpatialPointer
{
    private Quaternion rotationOffset;
    private Vector3 positionOffset;
    public void OnBegan(Vector3 inputPosition, Quaternion inputRotation)
    {
        var inverseDeviceRotation = Quaternion.Inverse(inputRotation);
        rotationOffset = inverseDeviceRotation * transform.rotation;
        positionOffset = inverseDeviceRotation * (transform.position - inputPosition);
    }
    public void OnMoved(Vector3 inputPosition, Quaternion inputRotation)
    {
        var rotation = inputRotation * rotationOffset;
        var position = inputPosition + inputRotation * positionOffset;
        transform.SetPositionAndRotation(position, rotation);
    }
    public void OnEnded()
    {
        rotationOffset = Quaternion.identity;
        positionOffset = Vector3.zero;
    }
}
```

通过`PinchObject` 继承 `ISpatialPointer` 的 `OnBegan`、`OnMoved` 和 ` OnEnded`，从而实现物体的拾取：

* `OnBegan`：
   * `inverseDeviceRotation`：计算输入设备旋转的逆四元数。逆四元数在数学上相当于“反向旋转”。它的作用是将一个向量或旋转从设备坐标系转换回世界坐标系。
   * `rotationOffset`：物体在世界空间中的当前旋转。
   * `positionOffset`：计算设备位置与物体位置的距离向量，用设备的逆旋转去乘以这个向量。这一步的目的是将这个位置向量从世界坐标系转换到设备的局部坐标系。
* `OnMoved`：
   * `rotation` ：在 `OnBegan` 中计算了物体相对旋转。这里，用新的设备世界旋转乘以物体相对旋转，得到新的物体世界旋转。
   * `position`：将存储在设备局部坐标系中的偏移向量转换回世界坐标系。因为设备现在可能已经旋转，所以这个偏移向量的世界坐标方向也必须随之改变。
* `OnEnded`：
   * `rotationOffset`: 将旋转偏移量重置为单位四元数（即无旋转）。
   * `positionOffset`: 将位置偏移量重置为零向量。

### 实现物体碰撞
碰撞基于 Unity 自带的物理系统实现，对地面或其他环境物体添加 **BoxCollider**，对每个小鸟雕塑模型添加 **RigidBody** 和 **CapsuleCollider**。

<div style="display: flex;">
<div style="flex-shrink: 0;width: calc((100% - 16px) * 0.5000);">

![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/a1ad01a17da543b3b9251cb3c375df14~tplv-goo7wpa0wc-image.image)


</div>
<div style="flex-shrink: 0;width: calc((100% - 16px) * 0.5000);margin-left: 16px;">

![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/80f3c6262d0a4f4d94fb6f9fafb92276~tplv-goo7wpa0wc-image.image)


</div>
</div>

### 实现悬停效果与投影
#### 悬停效果
对于所有添加了 `Spatial Adapter Hover Effect` 脚本的物体，在被交互（无论是 Touch、Pinch 还是 Indirect Pinch）时都会显示 Hover 效果。当前版本的 Hover 颜色仅支持青色，如需其他颜色，请谨慎使用此功能。
#### 投影
添加了 `Spatial Adapter Grounding Shadow` 脚本的物体可以相互投射阴影。请注意控制场景规模，因为场景大小会影响 `shadowMap` 的尺寸，从而直接影响阴影质量。
## Plane Commander
### 飞行状态
```C#
public enum AirplaneState
{
    idle,
    takeOff,
    flying,
    descending,
    died,
}
```


* `idle`：飞机在地面上
* `takeOff`：飞机起飞
* `flying`：飞机正常飞行中，会在一定范围内上下来回运动
* `descending`：飞机下落中
* `died`：飞机不再进行任何交互，等待进入下一轮

### 飞机的生命周期
```C#
void Update(){
    switch (state)
    {
        case AirplaneState.idle:
            idleTime += Time.deltaTime;
            if (idleTime > takeOffDuration) state = AirplaneState.takeOff;
            break;
        case AirplaneState.takeOff:
            transform.localPosition += speedY * Time.deltaTime * Vector3.up;
            if (transform.localPosition.y > maxHeight) state = AirplaneState.flying;
            break;
        case AirplaneState.flying:
            flyingStartTime += Time.deltaTime;
            transform.localPosition = new Vector3(transform.localPosition.x, maxHeight + Mathf.Sin(flyingStartTime * frequence) * amplitude, transform.localPosition.z);
            break;
        case AirplaneState.rotating:
            transform.localPosition -= speedY * Time.deltaTime * Vector3.up;
            if (transform.localPosition.y < defaultPosition.y) state = AirplaneState.died;
            break;
        default:
            break;
    }
}
```


* `idle` (待机)：
   * `idleTime`：`idleTime` 累加 `Time.deltaTime`，用于计算飞机已经待机的时长。
   * 当 `idleTime > takeOffDuration` 条件成立后，将飞机的状态从 `idle` 切换到 `takeOff`，飞机将在下一帧开始执行起飞逻辑。
* `takeOff` (起飞中)：
   * 不断增大飞机的本地坐标直到达到最大飞行高度 `maxHeight`。
   * 当 `transform.localPosition.y > maxHeight` 条件成立后，将飞机的状态从 `takeOff` 切换到  `flying`。
* `flying` (平飞中)：飞机保持在 `maxHeight` 高度附近，像一个正弦波一样平滑地上下起伏，模拟飞行中的气流颠簸。此状态没有自动退出条件，飞机将永远这样飞下去，除非被 hit。
* `descending` (下落中)：飞机被 hit 后会旋转一周：
   ```C#
   public void Hit()
   {
       state = AirplaneState.descending;
       StartCoroutine(OnHitAmimation());
   }
   private IEnumerator OnHitAmimation()
   {
       TogglePlaneAnimation(true);
       yield return _waitForSeconds;
       TogglePlaneAnimation(false);
   }
   private void TogglePlaneAnimation(bool state)
   {
       anim.SetBool(IsHit, state);
       anim.SetBool(IsIdle, !state);
   }
   ```

* `Hit`：飞机被 hit 后，立即将飞机的状态机状态切换到 `descending`，并执行 `OnHitAmimation`。
* `OnHitAmimation`：
   * 开启动画
   * 等待动画播放
   * 还原动画状态机

### 随机性

* 通过代码而非手动配置来生成多样化的游戏对象，极大地提高了开发效率和内容的丰富度。
* 通过 `Random.Range() * 缩放因子 + 基准值` 的模式，将随机值限制在一个合理且有意义的范围内。

```C#
void Start()
{
    defaultPosition = transform.localPosition;
    state = AirplaneState.idle;
    maxHeight = Random.Range(-1.0f, 1.0f) * 0.05f - 0.1f;
    speedY = Random.Range(.0f, 1.0f) * 0.05f + .1f;
    frequency = Random.Range(.1f, 2.0f);
    amplitude = Random.Range(-1.0f, 1.0f) * 0.05f;
    takeOffDuration = Random.Range(-1.0f, 1.0f) * 1.5f + 2f;
}
```


* `defaultPosition`：保存飞机的初始本地位置。
* `state`：设置飞机的初始状态。
* `maxHeight`：随机化飞机在平飞状态下的最大颠簸高度。
* `speedY`：随机化飞机的垂直飞行速度（用于起飞和下降）。
* `frequency`：随机化飞机在平飞状态下的上下颠簸频率。
* `amplitude`：随机化飞机在平飞状态下的上下颠簸幅度。
* `takeOffDuration`：随机化飞机在地面待机的时间长度。

### 如何升级关卡
使用`AirplaneLevelController` 统一控制：

* 玩家交互与流程控制：监听玩家的空间触摸输入，做出反馈。同时，跟踪当前关卡中所有飞机的状态，判断是否启动重生流程，开始新一轮。
* 难度递增与 UI 更新：负责更新屏幕上显示的当前关卡数。

#### 玩家交互与流程控制
```C#
void Update()
{
    transform.eulerAngles += Vector3.up * rotateSpeed;
    if (numPopped >= numAirplanes)
    {
        numPopped = 0;
        StartCoroutine(Reborn());
    }
    foreach (var touch in Touch.activeTouches)
    {
        GameObject obj = SpatialInputSupport.GetInputState(touch).targetObject;
        if (obj != null && touch.phase == TouchPhase.Began)
        {
            if (obj.transform.TryGetComponent(out AirplaneAnimController aircraft))
            {
                if (aircraft.IsFlying)
                {
                    numPopped++;
                    aircraft.Hit();
                }
            }
        }
    }
}
```


* 场景旋转：通过 `transform.eulerAngles` 累加，创造飞机旋转的效果。
* 判断结束条件：通过 `numPopped` 与 `numAirplanes` 检查已击落的飞机数量是否大于或等于总数。如果是，说明本轮结束。
   * `numPopped = 0;`：立即将计数器清零，防止在 `Reborn` 协程等待的 4.5 秒内，`Update` 每一帧都重复启动协程。
   * `StartCoroutine(Reborn());`：启动重生流程。
* 处理玩家逻辑：
   * `IsFlying`：只有 “飞行中” 状态的飞机才能被击落。这可以防止玩家在飞机正在起飞、重生或已经坠毁时误触得分。
   * `numPopped++;`：击落数量加一。
   * `aircraft.Hit();`：调用飞机自身的 `Hit()` 方法，触发飞机的坠毁动画和状态改变。

#### 难度递增与 UI 更新
当所有飞机都阵亡时，启动重生流程并进入下一关：
```C#
private IEnumerator Reborn()
{
    yield return _waitForSeconds;
    for (int i = 0; i < airplanes.Length; i++)
    {
        airplanes[i].GetComponent<AirplaneAnimController>().ReBorn();
    }
    NextRound();
}
private void NextRound()
{
    if (level < 999)
    {
        level++;
        rotateSpeed += 0.2f;
        text.text = level.ToString("D3");
    }
}
```

`Reborn`：遍历场景中所有的飞机，并调用它们各自的 “重生” 方法。
```C#
public void ReBorn()
{
    state = AirplaneState.idle;
    idleTime = 0;
    flyingStartTime = 0;
    transform.localPosition = defaultPosition;
}
```

`NextRound`：

* `level < 999`：安全检查，设定了游戏的理论最高关卡。
* `text`：更新后的 `level` 值，显示在屏幕上。
* `rotateSpeed`：难度递增的核心。增加了场景的旋转速度，提升了游戏挑战性。

## Spatial UI
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/ffa8d3702bb64340bd4feffe72fc3a30~tplv-goo7wpa0wc-image.image)
### UIManager
![Image](https://p9-arcosite.byteimg.com/tos-cn-i-goo7wpa0wc/7c7beb7a562b49688d516f9240374253~tplv-goo7wpa0wc-image.image)
`UIManager` 是一个典型的管理器类脚本，它充当了用户界面和游戏世界之间的桥梁。当用户通过 UI（例如一个下拉菜单 `Select` 和一个 `Slider`）做出选择后，调用 `AddButtons()` 方法，该方法会实例化用户选定的几何体，并将其放置在场景中的一个指定父物体下，同时赋予它们随机的位置和旋转。
```C#
public class UIManager : MonoBehaviour
{
        public GameObject[] geometryList;
        public Transform target;
        public Select selectElement;
        public Slider sliderElement;
        public static UIManager Instance { get; private set; }
}
```


* `geometryList`：将所有可以被用户生成的几何体预制体（如 Cube、Sphere、Cylinder 等）拖拽到这个数组里。`selectElement` 控件的索引将直接对应这个数组中的某个元素。
* `target`：所有动态生成的物体都会被整齐地组织在 `target` 之下，避免场景混乱。
* `selectElement`：引用 UI 中的 `Select` 组件。通过读取 `selectElement` 的 `CurrentIndex` 属性来确定需要从 `geometryList` 数组中实例化的预制体。
* `sliderElement`：引用 UI 中的 `Slider` 组件。通过读取 `sliderElement` 的 `CurrentCount` 属性来确定需要生成的几何体数量。

#### 单例模式
确保整个游戏中只有一个 `UIManager` 实例存在，并且它不会在场景切换时被销毁：
```C#
void Awake()
{
            if (Instance == null)
            {
                Instance = this;
                DontDestroyOnLoad(gameObject);
            }
            else
            {
                Destroy(gameObject);
            }
}
```

#### 添加几何体
```C#
public void AddButtons()
{
            var index = selectElement.CurrentIndex;
            for (int i = 0; i < sliderElement.CurrentCount; i++)
            {
                var obj = Instantiate(geometryList[index], target);
                obj.transform.localPosition = Random.insideUnitSphere * 0.1f;
                obj.transform.eulerAngles = Random.insideUnitSphere;
            }
}
```

在实际场景中，需考虑性能问题，例如：

* **对象池**：如果 `AddButtons()` 被频繁调用，`Instantiate` 和 `Destroy` 会造成性能峰值和内存碎片。在这种情况下，应该引入对象池。从池中取出一个并启用，而不是重新创建。当不再需要时，将其禁用并放回池中，而不是销毁。
* **数量上限**：如果滑块的值可以设置得非常大（比如 `1000`），`for` 循环会瞬间执行 1000 次实例化，可能导致游戏卡顿。建议在代码中或通过 `Slider` 组件的 `Max Value` 属性设置一个合理的值。

### 实现滑块
`Slider` 的核心功能是创建一个具有离散步长的 3D 滑块。用户拖动滑块来进行运动，释放滑块时会自动 “吸附” 到最近的整数位置。适合 VR/AR 应用中的设置菜单（例如调节音量、亮度、数量）。参数如下：

* `min` / `max`：定义滑块可移动范围的最小和最大值。
* `defaultMat` / `hoverMat`：定义滑块的视觉反馈，包括默认材质和悬停（被交互时）时的材质。
* `step`：定义滑块的步长。
* `unitArea` / `unitStep`：用于处理 “吸附” 逻辑。

```C#
private void Uniformize()
{
    var current = transform.localPosition.x - 0.0001f; // Prevent crossing boundaries
    var count = (int)((current - min) / unitArea);
    transform.localPosition = new Vector3(min + unitStep * count, 0, 0);
    currentCount = count + 1;
    amount.text = $“{currentCount}”;
}
```


* 防止越界：减去一个极小的值可以防止浮点数精度度问题。例如，如果滑块正好停在 `max` 位置，`(max - min) / unitArea` 的结果可能正好等于 `step`，导致 `count` 越界。减去一个极小的值可以确保它落在最后一个区域内。
* 根据计算出的区域索引 `count`，计算出该区域起始点的精确坐标，并将滑块移动到该位置，完成吸附。

### 实现下拉选择菜单
`Select` 与 `SelectOption` 共同实现了一个在 3D 空间中可交互的下拉选择菜单。`Select` 是主容器，负责管理整个下拉列表的显示/隐藏状态和当前选中的项。`SelectOption` 是列表中的单个选项，负责处理自身的点击事件和视觉选中状态。这种设计模式（一个管理器 + 多个子项）在 UI 开发中非常常见和高效。
`Select` 脚本是一个优秀的管理器。它负责：

* 数据管理：持有所有选项的名称和当前选中的索引。
* 视图控制：管理整个下拉列表的显示/隐藏，以及主按钮的文本。
* 子项协调：在 `Start` 中初始化所有子 `SelectOption`，并通过事件订阅机制来响应它们的点击事件。
* 状态同步：当一个新选项被选中时，更新所有相关部分的视觉状态，以保持 UI 的一致性。

## Robot Runner
### 相机追踪
`AnchorController` 允许用户通过拖动一个目标间接控制一个空间相机的移动。将一个直接的位移操作（拖动锚点）映射为一个速度控制（相机移动），从而实现了平滑、可控的场景浏览。
| **状态** | **描述** |
| --- | --- |
| 待机状态 | 锚点静止在场景中的某个位置。此时，相机不受影响。 |
| 交互开始 | 用户通过抓取锚点激活移动逻辑。 |
| 拖拽过程 | 用户拖动锚点 (`OnMoved`)。锚点本身会跟随用户的输入在水平面上移动。 |
| 相机移动 | 在锚点被拖动的整个过程中 (`FixedUpdate`)，脚本会持续计算锚点相对于相机的方向向量。然后，它对这个方向向量进行一个非线性（三次方）的缩放，并将其结果作为相机移动的速度和方向。这创造了一种“越拖越快”的加速效果。 |
| 交互结束 | 用户释放锚点 (`OnEnded`)。移动逻辑停止，相机恢复静止。 |
```C#
public void OnBegan(Vector3 inputPosition, Quaternion inputRotation)
        {
            isPinchEnd = false;
            origin = transform.position;
        }
        public void OnMoved(Vector3 inputPosition, Quaternion inputRotation)
        {
            transform.position = new Vector3(inputPosition.x, origin.y, inputPosition.z);
        }
        public void OnEnded()
        {
            origin = Vector3.zero;
            isPinchEnd = true;
        }
        void Start()
        {
            if (spatialCamera == null)
            {
                spatialCamera = FindFirstObjectByType<SpatialCamera>().transform;
            }
            var size = spatialCamera.transform.localScale[0];
            halfSize = size / 2f;
        }

        void FixedUpdate()
        {
            if (isPinchEnd) return;
            var direction = transform.position - spatialCamera.position;
            direction = direction.normalized;
            var X = Mathf.Pow(direction.x / halfSize, 3);
            var Y = Mathf.Pow(direction.z / halfSize, 3);
            spatialCamera.position += new Vector3(X * followSpeed * Time.fixedDeltaTime, 0, Y * followSpeed * Time.fixedDeltaTime);
        }
```

`halfSize` 目前是根据相机的 `localScale` 自动计算的，也可以通过设置`[SerializeField] private float deadZoneRadius;` 来让设计师直接调整该 “死区” 的大小。

### 边界条件
`BoundaryController` 实现了边界限制功能，其核心能力是确保某个物体（通常是玩家或相机）不会移出由 `target` 定义的一个方形区域。
```C#
void Start()
{
            if (target == null)
            {
                Debug.LogError("BoundaryController: Target is not assigned! Disabling script.", this);
                enabled = false;
                return;
            }
            initialYPosition = transform.position.y;
            CalculateBoundary();
}
```


* 确保 `Target` 已被赋值
* 如果锁定 Y 轴，记录初始 Y 位置，用于 Y 轴锁定
* 计算边界

#### CalculateBoundary
当 `Target` 的 `Transform` 可能在运行时发生变化时，可以调用此方法来重新计算边界。
```C#
public void CalculateBoundary()
{
            if (target == null) return;
            boundaryCenter = target.position;
            boundaryHalfExtents = (target.localScale / 2f) - new Vector3(boundaryMargin, boundaryMargin, boundaryMargin);
            boundaryHalfExtents = Vector3.Max(boundaryHalfExtents, Vector3.zero);
}
```

#### 位置更新
使用 `FixedUpdate` 进行物理和位置相关的更新，可以获得更稳定的表现，同时提升性能。
```C#
void FixedUpdate()
{
    Vector3 clampedPosition = transform.position;
    clampedPosition.x = Mathf.Clamp(clampedPosition.x,
                                            boundaryCenter.x - boundaryHalfExtents.x,
                                            boundaryCenter.x + boundaryHalfExtents.x);

    clampedPosition.z = Mathf.Clamp(clampedPosition.z,
                                            boundaryCenter.z - boundaryHalfExtents.z,
                                            boundaryCenter.z + boundaryHalfExtents.z);

    if (lockYAxis)
    {
                clampedPosition.y = initialYPosition;
    }
    else
    {
                clampedPosition.y = Mathf.Clamp(clampedPosition.y,
                                                boundaryCenter.y - boundaryHalfExtents.y,
                                                boundaryCenter.y + boundaryHalfExtents.y);
    }

            transform.position = clampedPosition;
}
```

