【Unity】简单机甲运动系统——坦克式操控方式

news2024/11/13 13:01:53

最近两天想做一个人形机甲的游戏,由于本人又是一个拟真军事爱好者,不太喜欢机动特别高的,所以打算参考坦克类游戏来制作一个脚!踏!实!地!的机甲游戏

这个运动系统基本实现了逻辑和动画的分离,同时也是使用状态机的方式来进行运动的控制

案例分为三大部分

Unity机甲

文章目录

    • 前言
    • 第一部分:状态机部分
      • 一、图
      • 二、基础代码
        • State
        • StateMachine
        • PlayerState
        • PlayerStateMachine
      • 三、上层状态代码
        • PlayerGroundState
        • PlayerIdleState
        • PlayerMoveState
        • PlayerJumpState
        • PlayerFallingState
      • 四、使用状态机
    • 第二部分:预制动画部分
      • AnimatorController
      • PlayerAnimator.cs
    • 第三部分:输入驱动部分
      • PlayerController
    • 额外章节

前言

我的代码是在官方的HDRP3DSample里提供的PlayerMovementCameraController的基础上改的,但基本就是复用了里面对移动速度的计算和光标输入的接受和隐藏这些

鉴于UE仍然是我接触代码前最常使用的游戏引擎,因此我在地图原点放了一个空物体,充当PlayerController,我的这个PlayerController只负责接收玩家的鼠标输入,并使用其修改自身的旋转。该PlayerController还是一个单例类,对外提供了获取自身的Quaternion和Transform的方法

我还在PlayerController中制作了一个列表,编辑器界面可以对其添加物体,添加的物体有以下字段

  • 跟随PlayerController旋转的Transform
  • Transform的父级(null则会寻找直接父级)
  • 是否同步水平/垂直旋转
  • 是否限制水平/垂直旋转
  • 水平/垂直的限制范围(-180 ~ 180)
  • 水平/垂直的旋转速率(度每秒)

这样包括机甲的炮塔,炮管,玩家的摄像机都可以通过将其添加到这个列表来解决旋转的问题

这一块也花费了我大半天的时间才调试完成

详细信息放在第三部分进行阐述



第一部分:状态机部分

一、图

目前运动比较简单,只提供Idle,Walk,Jump,Falling四个状态,之后新的状态也可以直接扩展,比较方便

在这里插入图片描述

在Idle和Move上再抽象出一层GroundState,可以方便的处理二者共同的转换条件

二、基础代码

先看一下状态机的基础部分,即State和StateMachine

State

我在State中创建委托来防止在Enter和Exit中书写过多其他逻辑,实现相关代码的解耦

其他的就是普通的State的所需函数Enter,Exit,Update

值得注意的是该类不需要继承自Monobehavior,这也意味着我们可以使用构造函数来为类赋初值

public class State
{
    public StateMachine stateMachine;

    public UnityAction onStateEnter;
    public UnityAction onStateExit;
    public UnityAction<bool> onStateChanged;    //进入true,离开false

    public State(StateMachine stateMachine)
    {
        this.stateMachine=stateMachine;
    }

    public virtual void Enter()
    {
        onStateEnter?.Invoke();
        onStateChanged?.Invoke(true);
    }

    public virtual void Exit()
    {
        onStateExit?.Invoke();
        onStateChanged?.Invoke(false);
    }

    public virtual void Update()
    {
    }
}
StateMachine

为了保证通用性,我在普通的StateMachine中只提供了最低限度的函数和字段,即一个记录当前状态,一个初始化函数,一个切换状态的函数和一个调用状态的Update的函数

值得注意的是该类也不需要继承自Monobehavior,因为这个类将作为其他类的字段出现,依附于其他类的生命周期函数即可,无需自身也继承。

public class StateMachine
{
    public State curState;

    public virtual void Init(State state)
    {
        curState = state;
        curState.Enter();
    }

    public virtual void Update()
    {
        curState.Update();
    }

    public virtual void ChangeState(State newState)
    {
        curState.Exit();
        curState = newState;
        curState.Enter();
    }
}
PlayerState

在PlayerState中,为了后续类继承使用的方便,我分别新增了一个玩家字段和一个更确定的PlayerStateMachine字段

因为显而易见,PlayerState肯定是依赖于PlayerStateMachine,因此将原本State中的StateMachine字段覆盖为PlayerStateMachine是完全合理的。

public class PlayerState : State
{
    public PlayerMovement player;
    public new PlayerStateMachine stateMachine;

    public PlayerState(PlayerMovement player, PlayerStateMachine stateMachine) : base(stateMachine)
    {
        this.player = player;
        this.stateMachine = stateMachine;
    }
}
PlayerStateMachine

因为为了外部更方便的调用,并且实现区分运动状态的目标,我在PlayerStateMachine中存储了所有状态,并使用枚举值对其进行区分,外界可直接使用枚举进行获取。

虽然理论上可以在初始化时直接实例化所有状态,但我还是选择了提供一个AddPlayerState来让外界去访问并传入对应的对象,原因如下:

  • 状态类特殊字段: 某些派生的状态可能希望获取一些特殊的值,这些值在外部构造其时可以提供给其,在StateMachine内部构造则不一定能获取到这些值
  • 多态: 外部可根据自身情况传入不同的状态子类对象,从而实现更多不同的功能,充分利用多态的特点
public class PlayerStateMachine : StateMachine
{
    public enum EState
    {
        Idle,
        Move,
        Jump,
        Falling
    }

    private Dictionary<EState, PlayerState> states = new Dictionary<EState, PlayerState>();

    public virtual void Init(EState type, PlayerState state)
    {
        AddPlayerState(type, state);

        base.Init(state);
    }

    public override void Update()
    {
        base.Update();
    }

    public virtual void ChangeState(EState type)
    {
        base.ChangeState(GetPlayerState(type));
    }

    public State GetPlayerState(EState type)
    {
        return states[type];
    }

    public void AddPlayerState(EState type, PlayerState state)
    {
        if(!states.ContainsKey(type))
        {
            states.Add(type, state);
        }
    }
}

三、上层状态代码

接下来就是实际的状态代码了!

PlayerGroundState

为了保证低耦合的设计,我在player中创建了一系列委托来提供给State进行订阅

由上面的图可以很显然的知道,Ground状态最主要就是和Jump,FallingState进行转换

  • 玩家输入Jump指令就转换为Jump状态
  • 玩家脚不着地就转为Falling状态
public class PlayerGroundState : PlayerState
{
    public PlayerGroundState(PlayerMovement player, PlayerStateMachine stateMachine) : base(player, stateMachine)
    {
    }

    public override void Enter()
    {
        base.Enter();

        base.player.onJump += OnJump;
    }

    public override void Exit()
    {
        base.Exit();

        player.onJump -= OnJump;
    }

    public override void Update()
    {
        base.Update();

        if (!player.isGrounded)
        {
            stateMachine.ChangeState(PlayerStateMachine.EState.Falling);
        }
    }

    private void OnJump()
    {
        stateMachine.ChangeState(PlayerStateMachine.EState.Jump);
    }
}
PlayerIdleState

继承自Ground状态,主要负责转向Move状态,绑定玩家输入,一旦输入移动就转到移动状态

public class PlayerIdleState : PlayerGroundState
{
    public PlayerIdleState(PlayerMovement player, PlayerStateMachine stateMachine) : base(player, stateMachine)
    {
    }

    public override void Enter()
    {
        base.Enter();

        base.player.onMove += OnMove;
    }

    public override void Exit()
    {
        base.Exit();

        player.onMove -= OnMove;
    }

    private void OnMove(Vector2 move)
    {
        if(move.magnitude > 0)
        {
            stateMachine.ChangeState(PlayerStateMachine.EState.Move);
            return;
        }
    }
}
PlayerMoveState

该Move状态主要负责移动和转为Idle状态

注意该移动是相对于玩家控制器的移动

public class PlayerMoveState : PlayerGroundState
{
    private CharacterController controller;
    private float speed;

    public PlayerMoveState(PlayerMovement player, PlayerStateMachine stateMachine) : base(player, stateMachine)
    {
        this.controller = player.controller;
        this.speed = player.speed;
    }

    public override void Enter()
    {
        base.Enter();

        player.onMove += OnMove;
    }

    public override void Exit()
    {
        base.Exit();

        player.onMove -= OnMove;
    }

    private void OnMove(Vector2 input)
    {
        //规格化的水平移动方向
        Vector3 move = PlayerController.GetControllerTransform().right * input.x + PlayerController.GetControllerTransform().forward * input.y;
        move.y = 0;
        move.Normalize();

        //该速度乘数意味着玩家朝向与输入方向差异越大,则速度乘数越小
        float speedMultipler = (Vector3.Dot(new Vector3(move.x, 0, move.z), player.transform.forward) + 1) / 2;

        controller.Move(move * speed * speedMultipler * Time.deltaTime);

        if (input.magnitude < 0.01)
            stateMachine.ChangeState(PlayerStateMachine.EState.Idle);
    }
}
PlayerJumpState

因为机甲蓄力一下再起跳会比较真实,因此引入了起跳准备这一概念,体现在该State中就是使用一个Timer来延迟起跳的时间

public class PlayerJumpState : PlayerState
{
    private CharacterController controller;
    private float speed;
    private float airSpeedMultipler;

    private float jumpHeight;

    private float jumpReadyTime;
    private float jumpReadyTimer;

    public PlayerJumpState(PlayerMovement player, PlayerStateMachine stateMachine) : base(player, stateMachine)
    {
        controller = player.controller;
        speed = player.speed;
        jumpHeight = player.jumpHeight;
        jumpReadyTime = player.jumpReadyTime;
    }

    public override void Enter()
    {
        base.Enter();

        jumpReadyTimer = jumpReadyTime;
    }

    public override void Update()
    {
        base.Update();

        jumpReadyTimer -= Time.deltaTime;

        if(jumpReadyTimer < 0 )
        {
            //起跳速度计算
            player.velocity.y = Mathf.Sqrt(jumpHeight * -2f * player.gravity) + player.gravity * Time.deltaTime;

            controller.Move(player.velocity * Time.deltaTime);

            stateMachine.ChangeState(PlayerStateMachine.EState.Falling);
        }
    }
}
PlayerFallingState

这个状态实际上也是我决定把一个PlayerMovement类扩展为一整个状态机的原因,因为分辨不了在空中究竟是玩家掉下去的还是起跳悬空的

同时使用一个airSpeedMultipler来修改玩家在空中的灵活度

public class PlayerFallingState : PlayerState
{
    private CharacterController controller;
    private float speed;
    private float airSpeedMultipler;

    public PlayerFallingState(PlayerMovement player, PlayerStateMachine stateMachine) : base(player, stateMachine)
    {
        controller = player.controller;
        speed = player.speed;
        airSpeedMultipler = player.airSpeedMultipler;
    }

    public override void Enter()
    {
        base.Enter();

        player.onMove += OnMove;
    }

    public override void Exit()
    {
        base.Exit();

        player.onMove -= OnMove;
    }

    public override void Update()
    {
        base.Update();

        if (player.isGrounded)
            stateMachine.ChangeState(PlayerStateMachine.EState.Idle);
    }

    private void OnMove(Vector2 input)
    {
        Vector3 move = PlayerController.GetControllerTransform().right * input.x + PlayerController.GetControllerTransform().forward * input.y;
        move.y = 0;
        move.Normalize();

        controller.Move(move * speed * airSpeedMultipler * Time.deltaTime);
    }
}

四、使用状态机

状态机的代码书写完毕,接下来是使用状态机。将移动的代码移动到状态里后,原本的PlayerMovement就只需要去处理重力即可,因为无论什么时候都会存在重力

同时该类也提供所有外部可以自定义的角色属性

public class PlayerMovement : MonoBehaviour
{
    public CharacterController controller;

    [Header("Movement")]
    public float speed = 12f;
    public float gravity = -10f;
    public float jumpHeight = 2f;
    public float jumpReadyTime = 0.5f;
    public float airSpeedMultipler = 0.5f;
    public float turnSpeed = 4;

    [Header("Collision")]
    public Transform groundCheck;
    public Vector3 groundCheckRange;
    public LayerMask groundMask;

    //self state
    [HideInInspector] public Vector3 velocity;
    private Vector3 lastPosition;

    public bool isGrounded { get; private set; }

    //public action
    public UnityAction<Vector2> onMove;
    public UnityAction onJump;

    //input
    InputAction movement;
    InputAction jump;

    //All State
    public PlayerStateMachine stateMachine { get; private set;} = new PlayerStateMachine();

    private void Awake()
    {
        stateMachine.Init(PlayerStateMachine.EState.Idle, new PlayerIdleState(this, stateMachine));
        stateMachine.AddPlayerState(PlayerStateMachine.EState.Move, new PlayerMoveState(this, stateMachine));
        stateMachine.AddPlayerState(PlayerStateMachine.EState.Jump, new PlayerJumpState(this, stateMachine));
        stateMachine.AddPlayerState(PlayerStateMachine.EState.Falling, new PlayerFallingState(this, stateMachine));

        lastPosition = transform.position;
    }

    void Start()
    {
        movement = new InputAction("PlayerMovement");
        movement.AddCompositeBinding("Dpad")
            .With("Up", "<Keyboard>/w")
            .With("Up", "<Keyboard>/upArrow")
            .With("Down", "<Keyboard>/s")
            .With("Down", "<Keyboard>/downArrow")
            .With("Left", "<Keyboard>/a")
            .With("Left", "<Keyboard>/leftArrow")
            .With("Right", "<Keyboard>/d")
            .With("Right", "<Keyboard>/rightArrow");

        jump = new InputAction("PlayerJump");
        jump.AddBinding("<Keyboard>/space");

        movement.Enable();
        jump.Enable();

        movement.performed += (InputAction.CallbackContext context) => 
        jump.performed += (InputAction.CallbackContext context) => onJump?.Invoke();
    }

    // Update is called once per frame
    void Update()
    {
        stateMachine.Update();

        HandleGravity();

        HandleMove();
    }

    void HandleGravity()
    {
        isGrounded = Physics.CheckBox(groundCheck.position, groundCheckRange, Quaternion.identity, groundMask);

        if (isGrounded && velocity.y < 0)
        {
            velocity.y = -2f;
        }

        velocity.y += gravity * Time.deltaTime;
        controller.Move(velocity * Time.deltaTime);

        velocity.x = transform.position.x - lastPosition.x;
        velocity.z = transform.position.z - lastPosition.z;

        lastPosition = transform.position;
    }

    //这个并非处理移动的,而是处理自身旋转朝向运动方向的
    void HandleMove()
    {
        Vector2 input = movement.ReadValue<Vector2>();
        onMove?.Invoke(input);

        Vector3 move = PlayerController.GetControllerTransform().right * input.x + PlayerController.GetControllerTransform().forward * input.y;
        move.y = 0;
        move.Normalize();

        if (move.magnitude > 0.1)
        {
            Quaternion q = Quaternion.LookRotation(move);
            transform.rotation = Quaternion.Slerp(transform.rotation, q, turnSpeed * Time.deltaTime);
        }
    }
}

第二部分:预制动画部分

预制动画部分也是就三个部分

  • 行走动画
  • 起步,行走循环
  • 起跳,悬空循环,落地

AnimatorController

接下来是动画状态机,实际上可能也是最难的地方,因为太容易结成蜘蛛网了()

然一共就四个状态,但还是加了两个额外的动画作为过渡,分别是Falling_End和Walk_Start

在这里插入图片描述

PlayerAnimator.cs

动画Animator的参数修改是专门建一个类进行管理,这也是解耦的地方

玩家的速度用于行走动画的速度更改,写在Update中

public class PlayerAnimator : MonoBehaviour
{
    private Animator animator;
    private PlayerMovement player;

    public float velocityToAnimationMultiper = 2.5f;

    public float minAnimSpeed = 0.5f;
    public float maxAnimSpeed = 10f;

    private void Awake()
    {
        animator = GetComponent<Animator>();
        player = GetComponentInParent<PlayerMovement>();
    }

    // Start is called before the first frame update
    void Start()
    {
        //状态变量绑定
        player.stateMachine.GetPlayerState(PlayerStateMachine.EState.Idle).onStateChanged += (enter) => animator.SetBool("IsIdle", enter);
        player.stateMachine.GetPlayerState(PlayerStateMachine.EState.Move).onStateChanged += (enter) => animator.SetBool("IsMove", enter);
        player.stateMachine.GetPlayerState(PlayerStateMachine.EState.Jump).onStateChanged += (enter) => animator.SetBool("IsJump", enter);
        player.stateMachine.GetPlayerState(PlayerStateMachine.EState.Falling).onStateChanged += (enter) => animator.SetBool("IsFalling", enter);
    }

    private void Update()
    {
        Vector2 velocity = new Vector2(player.velocity.x, player.velocity.z);
        animator.SetFloat("WalkSpeedMultipler", Mathf.Clamp(velocity.magnitude * velocityToAnimationMultiper, minAnimSpeed, maxAnimSpeed));
    }
}

第三部分:输入驱动部分

为了区分玩家的朝向和摄像机的朝向,并且希望能够限制跟随摄像机旋转的物体的速率和角度

我借鉴UE的做法,在地图上放置了一个空物体用于记录玩家的Controller旋转,和玩家操控的Actor旋转做个区分

鉴于当前只有一个玩家,因此使用单例方便进行获取控制器的旋转和变换

以下是控制器内部代码

PlayerController

public class PlayerController : MonoBehaviour
{
    [System.Serializable]
    public class Limit
    {
        public float min = -90;	//限制最小角度
        public float max = 90;	//限制最大角度
    }

    [System.Serializable]
    public class SyncObject
    {
        public Transform syncTransform;		//需要同步旋转的组件
        public Transform parentTransform;	//该组件的父级(不赋值就寻找直接父级),有的可能希望计算限制角时和与自己同级或没关系的物体进行计算,因此专门使用一个字段进行赋值

        //对外界提供控制暂停,该项可以忽略暂停
        //比如使用自由查看,即其他物体暂停跟随移动,摄像机可以继续环绕玩家观察,就可以把摄像机勾上这个选项
        public bool ignorePause = false;

        [Header("Horizontal")]
        public bool SyncY = true;	//是否同步

        public float RateY = 3600;	//旋转速率

        public bool LimitY = false;	//是否限制

        public Limit LimitYRange;	//限制范围


		//垂直与水平同理
        [Header("Vertical")]
        public bool SyncX = true;

        public float RateX = 3600;

        public bool LimitX = false;

        public Limit LimitXRange;
    }

    private static PlayerController m_instance;

    float RotX = 0, RotY = 0;

    private bool paused = false;

    public List<SyncObject> syncControllerRotationObjects;

    //单例常规操作
    private void Awake()
    {
        if(m_instance)
        {
            Destroy(gameObject);
            return;
        }

        m_instance = this;
    }

    //从原本Demo的CameraController里粘贴过来的
    private void Update()
    {
        bool unlockPressed = false, lockPressed = false;

        float mouseX = 0, mouseY = 0;

        //捕捉输入
        if (Mouse.current != null)
        {
            var delta = Mouse.current.delta.ReadValue() / 15.0f;
            mouseX += delta.x;
            mouseY += delta.y;
            lockPressed = Mouse.current.leftButton.wasPressedThisFrame ||
                Mouse.current.rightButton.wasPressedThisFrame;
        }
        if (Keyboard.current!= null)
        {
            unlockPressed = Keyboard.current.escapeKey.wasPressedThisFrame;
        }

        //按照输入进行光标设置
        if (unlockPressed)
        {
            Cursor.lockState = CursorLockMode.None;
            Cursor.visible = true;
        }
        if (lockPressed)
        {
            Cursor.lockState = CursorLockMode.Locked;
            Cursor.visible = false;
        }
        if (Cursor.lockState == CursorLockMode.Locked)
        {
            //控制器旋转
            RotX -= mouseY;
            RotY += mouseX;

            //限制控制器的俯仰角
            RotX = RotX > 180 ? RotX - 360 : RotX;
            RotX = Mathf.Clamp(RotX, -89, 89);

            transform.rotation = Quaternion.Euler(RotX, RotY, 0f);

            //遍历需要跟踪控制器旋转的物体,对其进行旋转赋值
            foreach (var obj in syncControllerRotationObjects)
            {
                //对跟踪的物体进行修改旋转,下面细说这一块
            }
        }
    }

    //设置控制器旋转 float float
    public static void SetControllerRotation(float rotX, float rotY)
    {
        m_instance.RotX = rotX;
        m_instance.RotY = rotY;
        m_instance.transform.rotation = Quaternion.Euler(rotX, rotY, 0f);
    }
    //设置控制器旋转 Quaternion
    public static void SetControllerRotation(Quaternion quaternion)
    {
        SetControllerRotation(quaternion.eulerAngles.x, quaternion.eulerAngles.y);
    }

    //获取旋转和Transform
    public static Quaternion GetControllerRotation() => m_instance.transform.rotation;
    public static Transform GetControllerTransform() => m_instance.transform;

    //设置暂停
    public static void SetPause(bool paused) => m_instance.paused = paused;
}

最重量级的部分就是上述遍历每一个需要跟踪的物体的部分,下面单独贴出来

//遍历需要跟踪控制器旋转的物体,对其进行旋转赋值
foreach (var obj in syncControllerRotationObjects)
{
    if (paused && !obj.ignorePause)
        continue;

    //控制器原始角度	0 ~ 360
    float x = transform.rotation.eulerAngles.x;
    float y = transform.rotation.eulerAngles.y;

    //旋转物体和其父级的角度(名为dir而已)
    Vector3 dir = obj.syncTransform.eulerAngles;
    Vector3 parentDir = obj.parentTransform ? obj.parentTransform.eulerAngles : obj.syncTransform.parent.eulerAngles;

    //控制器角度(映射到了-180 ~ 180)
    float conDirX = x > 180 ? x - 360 : x;
    float conDirY = y > 180 ? y - 360 : y;

    //当前的旋转角度(映射到了-180 ~ 180)
    float dirX = dir.x > 180 ? dir.x - 360 : dir.x;
    float dirY = dir.y > 180 ? dir.y - 360 : dir.y;

    //父级的旋转角度(映射到了-180 ~ 180)
    float parentDirX = parentDir.x > 180 ? parentDir.x - 360 : parentDir.x;
    float parentDirY = parentDir.y > 180 ? parentDir.y - 360 : parentDir.y;

    //当前和父级的相对角度(用于计算限制角度)
    float angleX = parentDirX - dirX;
    float angleY = dirY - parentDirY;

    //用于线性平滑旋转
    //控制器的旋转角度(偏移一下,以dir为起点,方便计算到目标所需角度)
    float targetAngleX = (x - dir.x + 360) % 360;
    float targetAngleY = (y - dir.y + 360) % 360;
    //接着将0~360映射为 -180 ~ 180,此为在以当前朝向为原点下,控制器与原点的相对角度差
    targetAngleX = targetAngleX > 180 ? targetAngleX - 360 : targetAngleX;
    targetAngleY = targetAngleY > 180 ? targetAngleY - 360 : targetAngleY;

    if (obj.SyncX)
    {
        Vector2 rotation = obj.syncTransform.rotation.eulerAngles;

        //如果当前角度差绝对值小于旋转速率,直接转到目标角度
        if (Mathf.Abs(targetAngleX) <= obj.RateY * Time.deltaTime)
        {
            rotation.x = x;
            angleX = parentDirX - conDirX;
        }
        //目标在当前物体朝向右边,增大自身角度
        else if (targetAngleX > 0)
        {
            rotation.x += obj.RateX * Time.deltaTime;
            angleX -= obj.RateX * Time.deltaTime;
        }
        //目标在当前物体朝向左边,减小自身角度
        else
        {
            rotation.x -= obj.RateX * Time.deltaTime;
            angleX += obj.RateX * Time.deltaTime;
        }

        //角度限制判断,angleX已经是变换后的角度了
        if((obj.SyncX && angleX >= obj.LimitXRange.min && angleX <= obj.LimitXRange.max) || !obj.LimitX)
        {
            obj.syncTransform.rotation = Quaternion.Euler(rotation);
        }
    }

    //该轴与上面的计算同理
    if(obj.SyncY)
    {
        Vector2 rotation = obj.syncTransform.rotation.eulerAngles;

        if (Mathf.Abs(targetAngleY) <= obj.RateY * Time.deltaTime)
        {
            rotation.y = y;
            angleY = parentDirY - conDirY;
        }
        else if (targetAngleY > 0)
        {
            rotation.y += obj.RateY * Time.deltaTime;
            angleY -= obj.RateY * Time.deltaTime;
        }
        else
        {
            rotation.y -= obj.RateY * Time.deltaTime;
            angleY += obj.RateY * Time.deltaTime;
        }

        if(!obj.LimitY || (angleY >= obj.LimitYRange.min && angleY <= obj.LimitYRange.max))
        {
            obj.syncTransform.rotation = Quaternion.Euler(rotation);
        }
    }
}

除此之外就是在编辑器中的实际应用,下面贴几个

在这里插入图片描述

额外章节

这一块讲一下关于相机的问题,我使用的是Cinemachine

我希望通过Cinemachine来创建一个过肩动画,有碰撞,有跟随延迟

最开始采用Freelook + CameraCollider + CameraOffset的方式,结果发现CameraCollider 会默认在摄像机原点检测碰撞,而CameraOffset会偏移出这个碰撞体,不得已只能去寻找下一个解决办法

最后我采用的是和Unity官方的第三人称一样的解决办法,使用VirtualCamera组件下的3rdPersonFollow,通过在玩家身上增加一个空物体作为跟随点,再加上3rdPersonFollow自带的参数微调即可达到较为满意的效果

在这里插入图片描述

并且可以方便的切换左右肩,后续似乎也可以和瞄准,第一人称进行很好的配合

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2091858.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【Python系列】SQLAlchemy 基本介绍

&#x1f49d;&#x1f49d;&#x1f49d;欢迎来到我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里可以感受到一份轻松愉快的氛围&#xff0c;不仅可以获得有趣的内容和知识&#xff0c;也可以畅所欲言、分享您的想法和见解。 推荐:kwan 的首页,持续学…

详说 类和对象

类怎么定义 类是什么呢&#xff1f;类就是我们上篇文说的命名空间&#xff0c;单独创建一个域&#xff0c;自己有自己的生命空间&#xff0c;那么类怎么定义呢&#xff1f;C规定&#xff0c;假设 stack就是他的类名&#xff0c;那么前面要加个class&#xff0c;换行之后就是他…

软件测试面试八股文(含答案解析+文档)

&#x1f345; 点击文末小卡片&#xff0c;免费获取软件测试全套资料&#xff0c;资料在手&#xff0c;涨薪更快 一、软件测试基础面试题 1、阐述软件生命周期都有哪些阶段? 常见的软件生命周期模型有哪些? 软件生命周期是指一个计算机软件从功能确定设计&#xff0c;到…

如何在D盘创建虚拟环境?包括安装PyTorch和配置PyCharm

摘要&#xff1a;本文首先在D盘创建了虚拟环境&#xff0c;然后在虚拟环境中安装了PyTorch&#xff0c;最后配置了pycharm的解释器。 1. 在 D 盘创建虚拟环境 打开Anaconda Prompt 输入conda info --envs查看当前已有环境 创建自己的虚拟环境&#xff0c;打算命名为py310&…

一文彻底搞懂大模型 - GPT和LlaMA的模型架构

GPT vs LlaMA GPT与LlaMA&#xff0c;作为大语言模型的两大巨擘&#xff0c;均基于Transformer架构却各有千秋。GPT系列以强大的生成能力著称&#xff0c;通过不断增大的参数规模引领复杂语言与推理任务的前沿&#xff1b;而Llama则以开源姿态&#xff0c;通过技术创新提升模型…

江协科技stm32————10-5 硬件I2C读写MPU6050

步骤 一、配置I2C外设&#xff0c;对I2C2外设进行初始化&#xff08;MyI2C_Init&#xff09; 开启I2C外设和对应的GPIO口的时钟把I2C对应的GPIO口初始化为复用开漏模式使用结构体配置I2CI2C_Cmd,使能I2C I2C_GenerateSTART //生产起始条件 I2C_GenerateSTOP /…

MySQL:复合查询

MySQL&#xff1a;复合查询 聚合统计分组聚合统计group byhaving 多表查询自连接子查询单行子查询多行子查询多列子查询from子查询 合并查询unionunion all 内连接外连接左外连接右外连接全外连接 视图 MySQL 复合查询是数据分析和统计的强大工具&#xff0c;本博客将介绍如何使…

黑马点评——商户查询缓存(P37店铺类型查询业务添加缓存练习题答案)redis缓存、更新、穿透、雪崩、击穿、工具封装

文章目录 什么是缓存&#xff1f;添加Redis缓存店铺类型查询业务添加缓存练习题 缓存更新策略给查询商铺的缓存添加超时剔除和主动更新的策略 缓存穿透缓存空对象布隆过滤 缓存雪崩解决方案 缓存击穿解决方案基于互斥锁方式解决缓存击穿问题基于逻辑过期的方式解决缓存击穿问题…

【教程】实测np.fromiter 和 np.array 的性能

转载请注明出处&#xff1a;小锋学长生活大爆炸[xfxuezhagn.cn] 如果本文帮助到了你&#xff0c;欢迎[点赞、收藏、关注]哦~ 目录 函数简介 np.fromiter np.array 测试代码 实验结果 结果分析 实验总结 学长想说 函数简介 np.fromiter np.fromiter 是 NumPy 提供的一…

【SuperCraft AI:无限工作流画布】

SuperCraft AI&#xff1a;无限工作流画布 SuperCraft 是一款全新的 AI 工具。它具有将手绘草图转换为不同产品图像的功能&#xff0c;提供了一个无限大的协作画布&#xff0c;让设计师能够在此手绘草图&#xff0c;并利用生成式 AI 技术将草图转化为高质量的 2D 图像和 3D 渲…

NC 二分查找-II

系列文章目录 文章目录 系列文章目录前言 前言 前些天发现了一个巨牛的人工智能学习网站&#xff0c;通俗易懂&#xff0c;风趣幽默&#xff0c;忍不住分享一下给大家。点击跳转到网站&#xff0c;这篇文章男女通用&#xff0c;看懂了就去分享给你的码吧。 描述 请实现有重复…

Unity TreeView扩展

实现效果 这里原来是做的一个检测网络、事件回调耗时的工具。简单改了成了一个演示TreeView的demo。实现了TreeView的基本功能并且实现了对列的排序。TreeView还可以制作点击&#xff0c;双击&#xff0c;右键等事件&#xff0c;但这里暂时不需要用到。 思维导图 工程&#xf…

arcgisjs4.0 内网部署字体不显示问题处理

问题背景问题定位解决方案 问题背景 内网环境&#xff0c;通过压缩包的hash值验证了包是一摸一样的&#xff0c;ningx也读到了index.html&#xff0c;但是网格的字提显示出不来&#xff0c;并且地图上的注记文字均不显示 本地环境地图情况&#xff1a; 内网环境地图情况&…

Bluetooth: att protocol

一篇搞懂 ATT 支持的东西都有什么。 READ_BY_GROUP_TYPE_REQ/RSP 如下是 Spec 内容: The attributes returned shall be the attributes with the lowest handles within the handle range. These are known as the requested attributes.If the attributes with the requeste…

石油设备和相关机械都包涵那些?

关键字&#xff1a;钻杆测径仪&#xff0c;泵管测径仪&#xff0c;固井管道直线度测量仪&#xff0c;输送管测径仪&#xff0c;输送管检测设备&#xff0c; 石油设备是指在石油和天然气的勘探、开发、生产、储存和运输等过程中使用的各种机械和装置。这些设备通常包括但不限于…

黄力医生科普:如何有效预防冠心病,这几个保健措施不可少!

冠心病&#xff0c;作为心血管系统的一种常见病&#xff0c;主要因冠状动脉粥样硬化导致管腔狭窄或闭塞&#xff0c;进而引发心肌缺血缺氧。此病多发于中老年群体&#xff0c;且具有一定遗传性。然而&#xff0c;无论发病因素如何&#xff0c;我们都可以通过一系列有效的预防措…

C++类和对象(6)——初始化列表

一般的初始化 class A { public:A(int a){ //一般的初始化&#xff0c;在{}括号里面给成员变量赋值_a a;cout << _a << endl;}~A() {}private:int _a; }; 用 初始化列表 初始化 当成员变量是以下两种情况时&#xff0c; 必须使用初始化列表&#xff01; cons…

单自由度无阻尼系统振动分析

特别感谢&#xff1a;https://www.bilibili.com/video/BV114411y7ab/?p6&spm_id_frompageDriver&vd_sourceebe07816bf845358030fc92d23830b29 本文图片该系列视频 tips&#xff1a;关于特征方程与振动方程&#xff1a; 特征方程有助于我们理解和确定系统的固有频率和模…

【算法】贪心算法解析:基本概念、策略证明与代码例题演示

文章目录 1. 什么是贪心算法&#xff1f;2. 贪心算法的特点3. 例题&#xff08;贪心策略&#xff09;① 找零问题② 最小路径和③ 背包问题 4. 贪心策略证明 1. 什么是贪心算法&#xff1f; 在学习贪心算法之前&#xff0c;一定要理解的是贪心策略&#xff1a; 贪心策略是一种…

Ubuntu中qt类与类信号槽的创建及使用

今天学习到了新的一个小玩意&#xff0c;我们在QT中创建一个大项目的时候一般会创建多个类&#xff0c;那我们就来学习一下如何在自定义的类中声名和使用信号与槽函数。 首先我们CTRLn来创建我们新的类&#xff1a; 我们创建新的C的类&#xff0c;一个School&#xff0c;一个S…