当前位置: 首页 > news >正文

[Unity] 项目的一些系统架构思想

  1. 系统架构

玩家以及状态机结构 Character

这一块我们只讲设计架构,继承关系,不具体讲内部实现功能

玩家 Player

抽象出实体Entity

PlayerEnemy一样都是Entity,有一些共同的行为,这里我们将他们抽象出来,所以底层是由Entity继承过来的

// Entity.cs
public abstract class Entity : MonoBehaviour{}
public abstract class Entity<T> : Entity where T : Entity<T>{}

这里一开始就有疑问了,为什么要这样子写呢?这里用到的就是奇异模板递归(CRTP)这种写法可以在我们继承的时候写通用行为的时候就确定好当前调用类的类型,这里其实我还没什么感触,最大的感触在后面的单例模式里面

Player继承Entity

// Player.cs
public class Player : Entity<Player>

这里player继承自Entity<T>由于Entity里面的T约束所以这里Player : Entity< Player >约束通过.

状态机 StateMachine

这里也是我们将Statemachine抽象出来,也是通过CRTP设计模式来继承

// EntityStateManager.cs
public abstract class EntityStateManager : MonoBehaviour{}
public abstract class EntityStateManager<T> : EntityStateManager where T : Entity<T>{}
// PlayerStateManager.cs
[RequireComponent(typeof(Player))]
public class PlayerStateManager : EntityStateManager<Player>

状态 State

// EntityState.cs
public abstract class EntityState<T> where T : Entity<T>
// PlayerState.cs
public abstract class PlayerState : EntityState<Player>
// xxxxPlayerState.cs
public class xxxxPlayerState : PlayerState

数据 Stats

// EntityStats.cs
public class EntityStats<T> : ScriptableObject where T : ScriptableObject

这里和之前学的2D银河恶魔城不一样的点为在恶魔城里面玩家的Stats是写在Script里面的

抛出问题

  1. 什么是ScriptableObject?
  2. 为什么用?
  3. 解决什么问题?

这里先抛出问题,先不解决.

// PlayerStats.cs
public class PlayerStats : EntityStats<PlayerStats>

状态切换

至此,我们的架构里面已经有了

  1. 玩家
  2. 状态机
  3. 状态
  4. 数据

现在我们怎么进行状态的切换呢?

在2D银河恶魔城当中,状态的切换是根据InputManager也就是老的输入系统来进行切换的,这个项目的状态切换并不是这样做的,如图:

我们可以看到LilyAnimator里有数个状态,左侧有若干个参数,就拿WalkRun举例

这里可以看到WalkRun的状态切换是由StateLateral Speed这两个参数来决定的,也就是说状态的切换很大一部分是靠角色当前的数据来进行切换

玩家控制器 Controller

我们在上面已经完成了玩家和状态机的类构建,但是我们的玩家还无法动起来

相比于传统的InputManager作为输入,新的输入系统InputSystem更加注重逻辑的开发,将类似于Input.GetKeyDown()之类的方法从代码中解放出来,并提供了更多强大的功能

// PlayerInputManager.cs
public class PlayerInputManager : MonoBehaviour{public InputActionAsset actions;protected InputAction m_movement;protected InputAction m_run;protected InputAction m_jump;protected InputAction m_look;protected InputAction m_pause;protected void Awake(){CacheActions();}...// 获取PlayerInputActions里对于的Mapper输入protected virtual void CacheActions(){m_movement = actions["Movement"];m_run = actions["Run"];m_jump = actions["Jump"];m_look = actions["Look"];m_pause = actions["Pause"];}
}

至此我们可以获取玩家的输入,也就对其有了实现控制逻辑的基石

摄像机 Camera

至此系统已经拥有了

  1. 玩家和状态机
  2. 控制器

接下来需要的就是跟随玩家的摄像头了

这里使用到插件CinemachinePost Process

因为摄像机这块涉及到插件的介绍和脚本的逻辑,所以具体的使用和教程这里不赘述,至此补上最后一个3C的结构

小结

  • 3C系统中抽象出了几层,形成继承结构,有利于系统的扩展
  • 使用InputSystem而不是InputManager,实现输入逻辑解耦
  • 使用Unity官方提供的cinemachine,极大简化了Camera开发难度,提升开发效率
  • 数据使用ScriptableObject本地化持久存储
  • 状态动画使用角色数值和list index切换,使状态切换更自然
  • 该系统的设计注重解耦,可扩展性高

2. 功能实现思路

从这里开始有大量的代码实现思路

玩家

玩家的控制使用CharacterController

移动

主要参数有:

  1. lateralVelocity 侧向移动速度
  2. verticalVelocity 垂直方向速度
  3. velocity 实际移动速度

核心代码:

速率计算

我们先来看这个主要的计算移动的函数

protected override void OnStep(Player player){// 获取当前输入的移动方向(相对于相机)var inputDirection = player.inputs.GetMovementCameraDirection();// 判断是否有输入if (inputDirection.sqrMagnitude > 0){// 获取输入移动方向相对于当前移动方向的偏移量var dot = Vector3.Dot(inputDirection, player.lateralVelocity);// 这里是触发制动状态的条件判断,下面画图的时候进行讲解if (dot >= player.stats.current.brakeThreshold){player.Accelerate(inputDirection);player.FaceDirectionSmooth(player.lateralVelocity);} else{player.states.Change<BrakePlayerState>();}}}

首先根据注释我们讲这个计算速率分成几个步骤

  1. 通过InputSystem获取Vecotr2输入值算出当前输入相对于摄像机的移动方向
  2. 在非制动状态下通过Accelerate(Vector3 direction)方法计算速率
  3. 平滑转向指定方向

进入函数

获取方向


// 这个函数的作用是:获取玩家的输入方向,并将其转换为相对于摄像机朝向的方向。
public virtual Vector3 GetMovementCameraDirection(){// 获取移动方向var direction = GetMovementDirection();// 由于direction返回的是一个Vector3,这里就取模长if (direction.sqrMagnitude > 0){// 这里计算出相机在Y轴上的偏航角度(Yaw)var rotation = Quaternion.AngleAxis(m_camera.transform.eulerAngles.y, Vector3.up);// 根据摄像机的朝向修改人物的移动方向,例如人物现在按W,摄像机看向相对右手边,此时人物不是绝对移动向前,而是向摄像机的偏航角方向移动direction = rotation * direction;// 归一化,只取方向direction = direction.normalized;}return direction;}
public virtual Vector3 GetMovementDirection(){if (Time.time < m_movementDirctionUnlock){return Vector3.zero;}// 从InputSystem里获取Vector2var value = m_movement.ReadValue<Vector2>();return GetAxisWithCrossDeadZone(value);}
public virtual Vector3 GetAxisWithCrossDeadZone(Vector2 axis){var deadZone = InputSystem.settings.defaultDeadzoneMin;// Abs仅仅判断输入的值是否大于死区的值axis.x = Mathf.Abs(axis.x) > deadZone ? RemapToDeadzone(axis.x, deadZone) : 0;axis.y = Mathf.Abs(axis.y) > deadZone ? RemapToDeadzone(axis.y, deadZone) : 0;// 这里仅控制平面移动return new Vector3(axis.x, 0, axis.y);}
// 死区的值如果设置成0.2,当ABS==0.21时,如果不调用这个方法则启动时输入的值就是0.21,会非常的突兀
// 这里除法的作用就是归一化,防止出现速度比预期少,例如输入value为1,没有除法的话最大速度就是(value - deadzone)
private float RemapToDeadzone(float value, float deadzone) => (value - deadzone) / (1 - deadzone);

计算速率


有了Vector3方向之后我们就可以进行速率的计算了

让我们进入Accelerate方法当中去

public virtual void Accelerate(Vector3 direction){// 转向时的摩擦力var turningDrag = isGrounded && inputs.GetRun()? stats.current.runningTurnningDrag: stats.current.turningDrag;// 速率var acceleration = isGrounded && inputs.GetRun()? stats.current.runningAcceleration: stats.current.acceleration;// 最快速度var topSpeed = inputs.GetRun()? stats.current.runningTopSpeed: stats.current.topSpeed;// 最终得到的速率var finalAcceleration = isGrounded ? acceleration : stats.current.airAcceleration;Accelerate(direction, turningDrag, finalAcceleration, topSpeed);}
public virtual void Accelerate(Vector3 direction, float turningDrag, float acceleration, float topSpeed){if (direction.sqrMagnitude > 0){// 代表“我当前的速度(lateralVelocity),有多‘顺着’我想去的新方向(direction)”。// 如果数字很大很正:非常顺路。// 如果数字是0:正好垂直,不顺路也不逆路。// 如果数字是负数:完全是反方向,在“开倒车”。// speed 的初始值,就是你当前速度 lateralVelocity 在你想去的新方向 direction 上的“贡献值”。var speed = Vector3.Dot(direction, lateralVelocity);// direction 是一个只有方向没有大小的“路标”,speed 是一个只有大小没有方向的“油门大小”。两者一乘,就得到了一个既有正确方向,又有合适大小				  的速度向量。这就是我们分解出的“好速度”。var velocity = direction * speed;// 坏速度,需要被摩擦力抵消的速度var turningVelocity = lateralVelocity - velocity;var turiningDelta = turningDrag * turningDragMultiplier * Time.deltaTime;// 当前能达到的最大移速var targetTopSpeed = topSpeed * topSpeedMultiplier;// 如果当前速度未达到最高移速或者速度小于0if (lateralVelocity.magnitude < targetTopSpeed || speed < 0){// 持续加速speed += acceleration * acclerationMultiplier * Time.deltaTime;// 限制速度,在当前可达到最大速度区间speed = Mathf.Clamp(speed, -targetTopSpeed, targetTopSpeed);}// 当前的移动向量(这里不仅需要方向,而且要有大小了)velocity = direction * speed;// 每次调用方法将当前的坏速率向量慢慢消除turningVelocity = Vector3.MoveTowards(turningVelocity, Vector3.zero, turiningDelta);// 将当前的速率更新为计算过后的速率lateralVelocity = velocity + turningVelocity;}}

这里Dot之前一直存在一个误区,单纯把Dot拿来当作判断位置的值,但其实dot值的大小还是受到模长的影响,并非只有[-1,1]

  • 它的正负号 依然告诉我们方向关系(前方(>0)还是后方(<0)或者是垂直(=0))。
  • 它的绝对值 告诉我们“有多顺路”或“有多逆路”,这个值的大小直接受到 lateralVelocity 模长的影响。
// 之前提到重要的参数,这里就是平面和垂直各个负责的向量
public Vector3 lateralVelocity
{get { return new Vector3(velocity.x, 0, velocity.z); }set { velocity = new Vector3(value.x, velocity.y, value.z); }
}public Vector3 verticalVelocity
{get { return new Vector3(0, velocity.y, 0); }set { velocity = new Vector3(velocity.x, value.y, velocity.z); }
}
// 获取了速率,就要用到人物当中
protected virtual void Update()
{if (controller.enabled){HandleState();// 计算人物移动HandleController();}
}
// 这里使用CharacterController来控制人物移动
protected virtual void HandleController()
{if (controller.enabled){controller.Move(velocity * Time.deltaTime);return;}transform.position = velocity * Time.deltaTime;
}

减速

如果只是实现了加速,没有减速,那么人物在移动之后就算停止输入也还会一直朝一个方向飘,所以我们需要减速

// deceleration为减速的力度
public virtual void Decelerate(float deceleration)
{var delta = deceleration * decelerationMultiplie * Time.deltaTime;// 这里不用过多解释了,平滑减速平面向量至0模长lateralVelocity = Vector3.MoveTowards(lateralVelocity, Vector3.zero, delta);
}

相机

[玩家输入/角色状态] -> [HandleOrbit, HandleOffset等函数] -> [计算出m_cameraTarget...等变量] -> [MoveTarget函数] -> [更新m_target的位置和旋转] -> [Cinemachine自动处理] -> [最终相机画面]

代码拆解 (按功能模块)

我们不按函数顺序,而是按功能逻辑来拆解。

1. 基础设置与初始化 (Start & Helpers)

  • 功能: 准备好所有需要的组件和初始状态,确保游戏一开始摄像机就在正确的位置。
  • InitializeComponents:
    • 作用: 获取并设置所有必要的组件引用。
    • 关键点: m_camera.AddCinemachineComponent() 这一行非常重要。它保证了无论你在Inspector里怎么设置,这个脚本都会强制给虚拟相机添加一个 3rdPersonFollow 组件。这使得脚本的设置非常健壮和自动化。CinemachineBrain 则是Cinemachine的总控制器,管理哪个虚拟相机是激活状态。
  • InitializeFollower:
    • 作用: 创建一个看不见的、专门给摄像机跟随的目标点 m_target。
    • 关键点: 这是整个解耦设计的核心。摄像机的旋转(俯仰角、偏航角)实际上是应用在这个 m_target 上的,而不是玩家身上。
  • InitializeCamera:
    • 作用: 告诉Cinemachine:“你要跟随 m_target,但你的眼睛要一直看着 player”。
    • 关键点: Follow 和 LookAt 的分离,允许摄像机围绕玩家旋转,而不是死死地固定在玩家背后。
  • Reset:
    • 作用: 设置摄像机的初始距离、角度等参数。可以被外部调用,比如在玩家重生时重置摄像机视角。
    • 关键点: m_cameraTargetPosition = player.unsizePosition + Vector3.up * heightOffset; 这一行确保了摄像机初始位置基于玩家的“标准”身高,避免了因玩家蹲下或变形导致的摄像机初始位置错误。

2. 玩家输入控制 (HandleOrbit)

  • 功能: 响应鼠标或手柄的移动,让玩家可以自由地观察四周。
  • HandleOrbit:
    • 作用: 根据输入设备的移动量,累加到 m_cameraTargetYaw (水平旋转) 和 m_cameraTargetPitch (垂直俯仰)。
    • 关键点: deltaTimeMultiplier 的区分处理。用鼠标时,输入值(direction)通常与鼠标移动像素有关,与帧率无关,所以乘以 Time.timeScale 保证在游戏暂停时视角也不动。用手柄时,输入值是轴向,需要乘以 Time.deltaTime 来保证旋转速度在不同帧率下保持一致。
  • ClampAngle:
    • 作用: 限制垂直方向的俯仰角度,防止摄像机转到地底下或者“翻跟头”。这是第三人称摄像机的标配功能。

3. 动态与自动控制

  • HandleVelocityOrbit:
    • 功能: 当玩家左右横向移动时,让摄像机也自动地、轻微地向移动方向摆动。
    • 作用: 将玩家的本地速度(localVelocity.x 代表横向速度)乘以一个系数,叠加到 m_cameraTargetYaw 上。
    • 关键点: 这是一种非常高级的“游戏感”(Game Feel)设计。它让摄像机不那么“死板”,感觉更具动态和电影感,能增强玩家对速度和移动的感知。
  • HandleOffset:
    • 功能: 处理摄像机目标点的垂直(Y轴)移动。这是脚本中最复杂也最能体现效果的部分。
    • 作用: 它不是简单地让 m_target.y = player.y,而是创建了一套带“死区”(Dead Zone)和不同速度的跟随逻辑。
    • 关键点:
      • 死区 (verticalUpDeadZone, verticalDownDeadZone): 想象一下摄像机和玩家之间有一根有弹性的绳子。只有当玩家的垂直移动超过了死区(绳子被拉伸到一定程度),摄像机才开始跟随。这可以过滤掉玩家在不平整地面上的小幅颠簸,或者小跳跃,让画面更稳定。
      • 不同状态下的不同速度: 在地面上 (maxVerticalSpeed) 和在空中 (maxVerticalAirSpeed) 使用不同的跟随速度。通常在空中时,玩家的垂直速度变化更快(比如从高处下落),所以需要一个更快的跟随速度来避免角色掉出屏幕。
      • Mathf.Min / Mathf.Max: 这两行是平滑移动的关键。它保证了每一帧摄像机跟随的移动量不会超过设定的最大速度 (maxVerticalSpeed),从而实现平滑(Lerp-like)的跟随效果,而不是瞬移。

这么写解决什么问题? (Why this way?)

  1. 【解耦】解决了“我想看东边,但角色必须往东边跑”的问题。
    • 通过独立的 m_target,玩家的移动方向和摄像机的观察方向可以完全分开。这是现代第三人称游戏的基础。
  2. 【平滑与稳定】解决了摄像机“晕车”和“抖动”的问题。
    • 抖动: 使用 LateUpdate 确保摄像机总是在角色移动计算完毕后才更新,避免了因更新顺序错乱导致的视觉抖动。
    • 晕车: HandleOffset 中的死区和速度限制,过滤掉了不必要的、高频的摄像机运动(如颠簸),让整体运镜非常平滑、专业,大大提升了玩家体验。
  3. 【动态感】解决了摄像机“死板”的问题。
    • HandleVelocityOrbit 让摄像机对玩家的移动做出自然的反应,感觉更像一个有经验的摄影师在跟拍,而不是一个固定的监控摄像头。这提升了游戏的沉浸感和“手感”。
  4. 【鲁棒性与可维护性】解决了代码“一团糟”和“难以扩展”的问题。
    • 每个Handle...函数只负责一项功能(输入、速度、偏移),逻辑清晰。
    • 利用Cinemachine处理了最麻烦的底层问题(如碰撞检测、摄像机切换混合等),让开发者可以专注于高层逻辑。如果以后想加一个“锁定敌人”的功能,只需修改计算 m_cameraTargetYaw/Pitch 的逻辑,而不用重写整个摄像机系统。
  5. 【可配置性】解决了“硬编码”和“策划不便调整”的问题。
    • 大量使用 [Header], public, [Range] 属性,将摄像机的几乎所有感觉参数(距离、角度、死区大小、速度)都暴露在Inspector中,方便设计师实时调整,找到最佳的摄像机感觉,而无需修改代码。

如果不这么写会发生什么? (What if not?)

  • 如果不使用中间目标 m_target
    • 直接让摄像机跟随 player.transform。那么摄像机将无法自由旋转,永远固定在玩家背后。transform.LookAt 可以让你看,但你无法控制摄像机在玩家身边的位置(比如从左侧或右侧观察玩家)。
  • 如果把所有逻辑都写在 Update 而不是 LateUpdate
    • 当玩家移动时,你会在屏幕上看到明显的、一帧延迟的抖动。因为可能出现:摄像机更新 -> 玩家移动 -> 渲染。下一帧,摄像机再追赶上一帧的玩家位置,周而复始。
  • 如果垂直跟随是 target.y = player.y 这样直接赋值
    • 玩家每次小跳、走上一个台阶,摄像机都会瞬间“Duang”地一下跟上去,然后“Duang”地一下掉下来。画面会非常不稳定和业余,尤其是在复杂地形上。
  • 如果不使用Cinemachine
    • 你需要自己手动写摄像机防穿墙的逻辑:从摄像机位置向玩家发射射线(Raycast),如果撞到墙,就把摄像机移动到碰撞点前面。这个逻辑写起来非常繁琐,且要处理各种边缘情况(比如在角落里)。你还需要自己写摄像机之间的平滑过渡(Blending)。整个工作量会指数级增加。

canOrbit 视角是否自由移动

CanOrbitWithVelocity 视角是否根据移动方向移动

[Range(0, 90)]
public float verticalMaxRotation = 80f;[Range(-90, 0)]
public float verticalMinRotation = -20f;

这两个参数就是指摄像机垂直移动角度

这里cinemachine可以防止摄像机穿模

摄像机视角转动

// 控制摄像机围绕target水平和垂直旋转
protected virtual void HandleOrbit(){if (canOrbit){// 获取当前鼠标输入的三维坐标var direction = player.inputs.GetLookDirection();if (direction.sqrMagnitude > 0){// 是否为鼠标操作?var usingMouse = player.inputs.IsLookingWithMouse();// 如果为鼠标操作,则为时间系数,否则为delta,这里如果不使用鼠标而是手柄的话就相当于一个速率float deltaTimeMultiplier = usingMouse ? Time.timeScale : Time.deltaTime;// 偏航角m_cameraTargetYaw += direction.x * deltaTimeMultiplier;// 俯仰角m_cameraTargetPitch -= direction.z * deltaTimeMultiplier;m_cameraTargetPitch = ClampAngle(m_cameraTargetPitch, verticalMinRotation, verticalMaxRotation);}}}

为什么是-=?

一开始看到这里的时候其实我也愣了一下,后面晚上在拿手柄打游戏的时候突然就知道为什么了

当你y轴(也就是转化过后的z轴)增大时,这是摄像机会往上看

这时摄像机就得向下,所以这里就解释了为什么是-=

跟视频的时候第一次做到这里的时候说实话懵了一段时间XD

// PlayerInputManager.cs
public virtual Vector3 GetLookDirection()
{// 获取鼠标输入的vector2坐标var value = m_look.ReadValue<Vector2>();if (IsLookingWithMouse()){// 如果输入设备为鼠标的话,则将当前的二维坐标转换为三维坐标return new Vector3(value.x, 0, value.y);}return GetAxisWithCrossDeadZone(value);
}
//限制角度
public virtual float ClampAngle(float angle, float min, float max)
{// unity里Transform会出现-720°这种角度,这里就是将角度限制在(-360,360)之间if (angle < -360){angle += 360;}if (angle > 360){angle -= 360;}// 真正的限制角度return Mathf.Clamp(angle, min, max);
}
// 控制相机是否根据玩家朝向改变偏航角
protected virtual void HandleVelocityOrbit()
{if (CanOrbitWithVelocity && player.isGrounded){// 这个理解的可能有点抽象,说白了就是m_target将player.velocity目前的方向转化为相对于自己而言的方向// m_target.transform.InverseTransformVector(player.velocity) 这个操作,将 player.velocity 这个在世界坐标系(World Space)下表		示方向和大小的向量,转换成了以 m_target 自身坐标系为参照的本地坐标系(Local Space)下的向量。var localVelocity = m_target.InverseTransformVector(player.velocity);m_cameraTargetYaw += localVelocity.x * orbitVelocityMultiplier * Time.deltaTime;}
}

控制偏移

protected virtual void HandleOffset()
{// unsizePosition防止PlayerController在蹲下的时候改变height移动摄像机//  public Vector3 unsizePosition => position - transform.up * height * 0.5f + transform.up * originalHeight * 0.5f;var target = player.unsizePosition + Vector3.up * heightOffset;// 记录上一帧的摄像机位置var previousPosition = m_cameraTargetPosition;// 上一帧摄像机的垂直位置var targetHeight = previousPosition.y;// 在地面上(例如走上斜坡之类的)if (player.isGrounded || VerticalFollowingStates()){// 如果当前的位置大于(上一帧数+死区)的位置if (target.y > previousPosition.y + verticalUpDeadZone){// 计算上升的距离target.y-(previousPosition.y + verticalUpDeadZone)var offset = target.y - previousPosition.y - verticalUpDeadZone;// 摄像机的垂直移动距离targetHeight += Mathf.Min(offset, maxVerticalSpeed * Time.deltaTime);}// 如果当前的位置小于(上一帧数+死区)的位置else if (target.y < previousPosition.y - verticalDownDeadZone){// 计算下降的距离,关于这里为什么是+// target.y - (previousPosition.y - verticalDownDeadZone)var offset = target.y - previousPosition.y + verticalDownDeadZone;// 注意这家多了个"-"号,所以这里是取最大值targetHeight += Mathf.Max(offset, -maxVerticalSpeed * Time.deltaTime);}}// 不在地面上(起跳和坠落等操作)else if (target.y > previousPosition.y + verticalAirUpDeadZone){var offset = target.y - previousPosition.y - verticalAirUpDeadZone;targetHeight += Mathf.Min(offset, maxVerAirVerticalSpeed * Time.deltaTime);} else if (target.y < previousPosition.y - verticalAirDownDeadZone){var offset = target.y - previousPosition.y + verticalAirDownDeadZone;targetHeight += Mathf.Max(offset, -maxVerAirVerticalSpeed * Time.deltaTime);}// 新的摄像机高度m_cameraTargetPosition = new Vector3(target.x, targetHeight, target.z);
}

犯过的错误

之前在写的时候粗心大意,导致人物掉落的时候摄像机会出现很明显的抽搐,这里的参数填写错误了

之前的逻辑:

  • 这里的Min和-maxVerAirVerticalSpeed(这里变量名还打错了)导致每一次玩家在空中坠落的时候一旦触发了if无论如何都会每秒下降100个单位及以上的距离,然后在下一帧马上又会被target.y > previousPosition.y + verticalAirUpDeadZone这个函数给拽回来,拽回来了之后又每秒下降100个单位,所以出现了抽搐的情况

正确的逻辑:

  • 最多只能下降100个单位,这里也看到上面的空中死区是0,所以这里就是每次lateUpdate都会移动target.y-previousposition.y的距离,这样子也能让摄像机精确的跟随玩家下降

更新相机位置

protected virtual void MoveTarget()
{m_target.position = m_cameraTargetPosition;// 这里的z轴感觉像游戏里开飞机那种滚筒的时候翻转(实现思路大概类似于canRotateZ ? xxxxx : 0)m_target.rotation = Quaternion.Euler(m_cameraTargetPitch, m_cameraTargetYaw, 0f);m_cameraBody.CameraDistance = m_cameraDistance;
}
http://www.vanclimg.com/news/1870.html

相关文章:

  • 多github账号的仓库配置
  • Project 2024 专业增强版安装激活步骤(附安装包)2025最新详细教程
  • MX galaxy Day15
  • Plant Com | 将基因编辑与组学、人工智能和先进农业技术相结合以提高作物产量
  • PhenoAssistant:一个用于自动植物表型分析的人工智能系统
  • 在Docker中,可以在一个容器中同时运行多个应用进程吗?
  • Computomics:利用先进的机器学习实现预测性植物育种
  • 在运维工作中,Docker 与 Kvm 有何区别?
  • 利用分子与数量遗传学最大化作物改良的遗传增益
  • 在运维工作中,详细说一下 Docker 有什么作用?
  • 7.29总结
  • busybox的编译简介
  • 基因组辅助作物改良
  • 洛谷题解:P1514 [NOIP 2010 提高组] 引水入城
  • 如何利用机器学习构建种质资源/品种分子鉴定系统?
  • 科学通报 | 万向元:生物育种技术助力作物杂种优势利用
  • 7-29
  • DP 优化 - 决策单调性与四边形不等式优化
  • 科学通报 | 大豆杂种优势利用的挑战与创新路径
  • 整合多组学先验信息来提升肉牛基因组预测的准确性
  • windows下的/data目录
  • 2025/7/29 总结
  • gComm 综述:大数据驱动的水稻群体基因组学研究
  • 基于遗传标记的连锁作图(QTL定位)群体
  • 软工作业day28
  • 22天
  • Unix/Linux编辑器使用
  • CropDesign文章导读 | 浙江大学棉花团队开发了利用机器学习模型预测棉花冷胁迫响应基因的研究方法
  • 题解:CF2125E Sets of Complementary Sums
  • 解决终端编译时乱码问题