前言

  • 这篇又是看完油管教程后写的,实现方式其实比较简单,但还是写写吧
  • 唯一稍微有点难度的就是涉及到贝塞尔曲线,好在只有二阶

PS:关于shader(写过一篇)、粒子系统、Cinemachine等基础在本文不会涉及


效果预览

简约线条版(当前实现)


极致色彩版(模仿对象)


逐步实现

  • 基础场景+移动脚本不再赘述,初始环境如下:
  • 初始的核心脚本ThrowController_Clone如下:
using UnityEngine;

[RequireComponent(typeof(Animator))]
public class ThrowController_Clone : MonoBehaviour
{
    private Animator animator; // 动画控制器
    private MovementInput input; // 移动控制脚本
    private Rigidbody weaponRb; // 斧头武器
    
    [Tooltip("飞斧")]
    public Transform weapon; // 飞斧
    [Tooltip("拿武器的手,武器的父对象")]
    public Transform hand;  // 拿武器的手


    [Space]
    [Header("开关")]
    public bool walking = true; // 是否在走路

    // Start is called before the first frame update
    void Start()
    {
        animator = GetComponent<Animator>();
        input = GetComponent<MovementInput>();
        weaponRb = weapon.GetComponent<Rigidbody>();
    }

    // Update is called once per frame
    void Update()
    {
        // 根据状态设置动画
        walking = input.Speed > 0;
        animator.SetBool("walking", walking);
    }
}

瞄准状态

  • 实现的逻辑是要进入瞄准状态后才能飞出斧头,所以先把右键瞄准给实现了
  • 首先准心的UI,就是一个小四方形:
  • 准心UI
  • 我们需要:
    • 让其在游戏开始时隐藏
    • 在右键进入瞄准状态时才出现
    • 进入瞄准状态时摄像机中心更贴近斧头
    • 斧头一些特效
    • 松开右键后退出锁定状态
  • 代码变更如下:
    public bool aiming = false; // 是否在瞄准
    public float cameraZoomOffset = .3f; // 摄像机偏移
    public ParticleSystem glowParticle; // 斧头环绕粒子
    public Image reticle; // 准心
    public CinemachineFreeLook virtualCamera; // 摄像机
    
    void Start()
    {
        // 略......
        // 隐藏准心
        reticle.DOFade(0, 0);
    }
    
    void Update()
    {
        // 略......
        // 鼠标右键+持有武器
        if (Input.GetMouseButtonDown(1) && hasWeapon)
        {
            // 进入瞄准状态
            Aim(true, true, 0);
        }

        // 没有扔出斧头就松开右键,退出瞄准状态
        if (Input.GetMouseButtonUp(1) && hasWeapon)
        {
            Aim(false, true, 0);
        }
    }
    
    /// <summary>
    /// 设置瞄准状态(镜头贴近/远离角色)
    /// </summary>
    /// <param name="state">瞄准状态</param>
    /// <param name="changeCamera">Z轴偏移</param>
    /// <param name="delay">过渡时间</param>
    void Aim(bool state, bool changeCamera, float delay)
    {

        if (walking)
            return;

        aiming = state;

        animator.SetBool("aiming", aiming);

        // 瞄准图标显现
        float fade = state ? 1 : 0;
        reticle.DOFade(fade, .2f);

        if (!changeCamera)
            return;

        // 摄像机偏移
        float newAim = state ? cameraZoomOffset : 0;
        float originalAim = !state ? cameraZoomOffset : 0;
        DOVirtual.Float(originalAim, newAim, .5f, CameraOffset).SetDelay(delay);

        // 粒子特效
        if (state)
        {
            glowParticle.Play();
        }
        else
        {
            glowParticle.Stop();
        }
    }
    
    /// <summary>
    /// 设置相机Top Rig、Middle Rig、Bottom Rig的偏移量
    /// </summary>
    /// <param name="offset"></param>
    void CameraOffset(float offset)
    {
        virtualCamera.GetRig(0).GetCinemachineComponent<CinemachineComposer>().m_TrackedObjectOffset = new Vector3(offset, 1.5f, 0);
        virtualCamera.GetRig(1).GetCinemachineComponent<CinemachineComposer>().m_TrackedObjectOffset = new Vector3(offset, 1.5f, 0);
        virtualCamera.GetRig(2).GetCinemachineComponent<CinemachineComposer>().m_TrackedObjectOffset = new Vector3(offset, 1.5f, 0);
    }
  • 还有进入瞄准状态后不可移动的脚本就略过了,当前效果如下:
  • 在确认姿势没问题后,再补上人物跟随摄像机旋转,时刻跟随准心的脚本:


    void Update()
    {
        // 如果处于瞄准状态,则角色朝向跟随摄像头;否则角色朝向恢复不跟随摄像机
        if (aiming)
        {
            input.RotateToCamera(transform);
        }
        else
        {
            transform.eulerAngles = new Vector3(Mathf.LerpAngle(transform.eulerAngles.x, 0, .2f), transform.eulerAngles.y, transform.eulerAngles.z);
        }
        // 略......
    }
  • 移动脚本中增加的实现:
	/// <summary>
	/// 角色朝向跟随摄像头(更改视角)
	/// </summary>
	/// <param name="t"></param>
    public void RotateToCamera(Transform t)
    {
        var forward = cam.transform.forward;
		forward.Normalize();
        desiredMoveDirection = forward;
        t.rotation = Quaternion.Slerp(transform.rotation, Quaternion.LookRotation(desiredMoveDirection), desiredRotationSpeed);
    }
  • 修改后效果如下:

飞出斧头

  • 飞斧做的事情主要如下:
    • 播放飞斧动画,在合适时机给斧头一个一次性的力推出去
    • 飞的过程要旋转
    • 碰到墙后插在墙上
  • 首先时第一步,把斧头飞出去,在进入瞄准状态后,再鼠标左键就进行这个动作,代码改动如下:
    public TrailRenderer trailRenderer; // 拖尾特效
    public ParticleSystem trailParticle; // 拖尾粒子
    public float throwPower = 240; // 扔出力度
    
    void Update()
    {
        // 略......
        if (hasWeapon)
        {
            // 如果持有斧头 & 瞄准状态 & 鼠标左键,播放扔斧头动画
            if (aiming && Input.GetMouseButtonDown(0))
            {
                animator.SetTrigger("throw");
            }
        }
    }
    
    public void WeaponThrow()
    {
        // 退出锁定状态
        Aim(false, true, 1f);

        hasWeapon = false;
        weaponRb.isKinematic = false;
        weaponRb.collisionDetectionMode = CollisionDetectionMode.Continuous;
        weapon.parent = null;
        weaponRb.AddForce(Camera.main.transform.forward * throwPower + transform.up * 2, ForceMode.Impulse);

        // 例子和拖尾特效
        trailRenderer.emitting = true;
        trailParticle.Play();
    }
  • 当前效果如下:
  • 这里其实还有个小毛病,那就是斧头飞出去的时候,并没有对准准心(往左偏了不少)
  • 我们可以稍微调整下斧子飞出去前的偏移:
    public void WeaponThrow()
    {
        // 略......
        weapon.transform.position += transform.right / 5;
        weaponRb.AddForce(Camera.main.transform.forward * throwPower + transform.up * 2, ForceMode.Impulse);
    }
  • 修改后效果如下:
  • 接下来就是实现斧头飞的过程中旋转了,新增WeaponScript实现如下:
using UnityEngine;

public class WeaponScript : MonoBehaviour
{

    // 飞斧是否激活
    public bool activated;

    // 旋转速度
    public float rotationSpeed;

    void Update()
    {
        // 如果激活,则使用欧拉角旋转斧头
        if (activated)
        {
            transform.localEulerAngles += Vector3.forward * rotationSpeed * Time.deltaTime;
        }

    }

    private void OnCollisionEnter(Collision collision)
    {
        // 斧头碰到墙壁就停下(墙壁是11)
        if (collision.gameObject.layer == 11)
        {
            print(collision.gameObject.name);
            // 关闭物理模拟,节省计算资源(可被唤醒)
            GetComponent<Rigidbody>().Sleep();
            // 连续推测模式,减少开销,同时让武器插进去一点(但飞得过快可能会出问题)
            GetComponent<Rigidbody>().collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative;
            // 不受物理引擎印象(包括物理模拟,不可被唤醒)
            GetComponent<Rigidbody>().isKinematic = true;
            activated = false;
        }
    }
}
  • WeaponScript实现后挂载到武器上,接着ThrowController_Clone修改如下:
    private WeaponScript weaponScript; // 武器脚本

    void Start()
    {
        // 略......
        weaponScript = weapon.GetComponent<WeaponScript>();
    }

    public void WeaponThrow()
    {
        // 激活武器脚本
        weaponScript.activated = true;
        // 略......
        // 调整角度让斧头1字型飞出
        weapon.eulerAngles = new Vector3(0, -90 +transform.eulerAngles.y, 0);
    }
  • 当前效果如下:

  • 到这里飞出斧头的效果其实就完成了,下面再补充下这个击碎盒子的实现(这部分可跳过)
  • 盒子碎开本身其实就是把正常盒子,换成碎片组成的盒子(正常盒子销毁),再施加力造成破碎效果,先看看原模型和碎片模型:
  • 非碎片模型
  • 碎片模型
  • 新建盒子破碎脚本BreakBoxScript
using UnityEngine;

/// <summary>
/// 盒子破碎
/// </summary>
public class BreakBoxScript : MonoBehaviour
{

    public GameObject breakedBox;

    public void Break()
    {
        GameObject breaked = Instantiate(breakedBox, transform.position, transform.rotation);
        Rigidbody[] rbs = breaked.GetComponentsInChildren<Rigidbody>();
        foreach(Rigidbody rb in rbs)
        {
            // 每个碎片都会以自身为中心,对周围产生“爆炸”的力
            rb.AddExplosionForce(150, transform.position, 30);
        }
        Destroy(gameObject);
    }
}
  • 在武器碰到盒子时,触发破碎,修改WeaponScript如下:
    private void OnTriggerEnter(Collider other)
    {
        // 如果撞到可破碎盒子(盒子Tag设置为“Breakable”)
        if (other.CompareTag("Breakable"))
        {
            if (other.GetComponent<BreakBoxScript>() != null)
            {
                other.GetComponent<BreakBoxScript>().Break();
            }
        }
    }
  • OK,盒子破碎就到此完成了,据说还有的项目是用动画烘焙 or 《合金装备崛起复仇》那样真正计算切痕的,这些等以后有机会再研究下。

收回斧头

  • OK,扑了这么多路总算是到最关键的部分了
  • 为了让斧头以曲线飞回手里,我们需要用到贝塞尔曲线,想看公式原理的可以参考知乎上的一篇文章
  • 简而言之,就是定义三个点,起点+终点+控制点,再套入二阶贝塞尔的公式,就可以规划出一条路径(在路径上哪个点由t决定,0 < t < 1),二阶贝塞尔生成的曲线效果如下:
  • 公式如下:

((1t)((1t)x0+tx1)+t((1t)x1+tx2),(1t)((1t)y0+ty1)+t((1t)y1+ty2))\left(\left(1-t\right)\left(\left(1-t\right)x_{0}+tx_{1}\right)+t\left(\left(1-t\right)x_{1}+tx_{2}\right),\left(1-t\right)\left(\left(1-t\right)y_{0}+ty_{1}\right)+t\left(\left(1-t\right)y_{1}+ty_{2}\right)\right)

  • 你也可以在shadertoy中贴入如下代码体验(shadertoy我其实还不太会用,拿别人代码改的):
float Circle(vec2 pixel, vec2 center, float radius)
{
    float d = length(pixel - center);
    return smoothstep(fwidth(d), .0, d - radius);
}

float LineSegment(vec2 pixel, vec2 a, vec2 b, float width)
{
    vec2 a2p = pixel - a, a2b = b - a;
    float t = clamp(dot(a2p, a2b) / dot(a2b, a2b), .0, 1.0);
    vec2 r = a + a2b * t;
    float d = length(r - pixel);
    return smoothstep(fwidth(d), 0., d - width);
}

vec2 Bezier1(vec2 start, vec2 controlPtr, float timeStep)
{
    return mix(start, controlPtr, timeStep);
}

vec2 Bezier2(vec2 P1, vec2 P2, vec2 P3, float timeStep)
{
    return mix(Bezier1(P1, P2, timeStep), Bezier1(P2, P3, timeStep), timeStep);
}

void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
    vec2 uv = (fragCoord-iResolution.xy * .5)/iResolution.x;
    vec3 col = vec3(0);
    float radius = .015;
    float width = .001;
    float t = sin(iTime) * .5 + 0.5;
    
    vec2 
        Ptr1 = vec2(-.4, .2), 
        Ptr2 = (iMouse.xy - iResolution.xy * .5) / iResolution.x,
        Ptr3 = vec2(-.4, -.2),
        
        Ptr12 = Bezier1(Ptr1, Ptr2, t),
        Ptr23 = Bezier1(Ptr2, Ptr3, t),
        
        Ptr123 = mix(Ptr12, Ptr23, t);
        
    col += vec3(1, 0, 0) * Circle(uv, Ptr1, radius);
    col += vec3(0, 1, 0) * Circle(uv, Ptr2, radius);
    col += vec3(0, 0, 1) * Circle(uv, Ptr3, radius);
    
    col += vec3(1, 1, 0) * Circle(uv, Ptr12, radius);
    col += vec3(0, 1, 1) * Circle(uv, Ptr23, radius);
    
    col += vec3(1, 1, 1) * Circle(uv, Ptr123, radius);
    
    col += LineSegment(uv, Ptr1, Ptr2, width);
    col += LineSegment(uv, Ptr2, Ptr3, width);
    col += LineSegment(uv, Ptr12, Ptr23, width);

    int NUM_SEGs = 100;
    vec2 currentPtr, previousPtr = Ptr1;
    for (int i=1; i < NUM_SEGs+1; ++i)
    {
        t = float(i) / float(NUM_SEGs);
        currentPtr = Bezier2(Ptr1, Ptr2, Ptr3, t);
        col = max(col, LineSegment(uv, currentPtr, previousPtr, width));
        previousPtr = currentPtr;
    }

    // Output to screen
    fragColor = vec4(col, 1.0);
}
  • 效果如下:

  • 而我们在Unity中要复刻这种效果,同样也需要三个点,如下图所示:
  • 定义起点、控制点、终点
  • 剩下的就是把公式套到代码里面去了,核心函数就是GetQuadraticCurvePointThrowController_Clone整体修改如下:
    private Vector3 pullPosition; // 斧头在墙上的位置(贝塞尔起点)
    private float returnTime; // 飞斧返回时间,相当于贝塞尔公式的t
    private Vector3 origLocPos; // 武器相对于手的位置坐标(用于复位)
    private Vector3 origLocRot; // 武器相对于手的旋转角度(用于复位)
    
    public bool pulling = false; // 是否在收回武器
    public Transform curvePoint; // 贝塞尔曲线控制点
    
    public ParticleSystem catchParticle; // 抓住武器时的粒子特效
    
    public CinemachineImpulseSource impulseSource; // 控制摄像机震动
    
    void Start()
    {
        // 略......
        // 武器在手上的相对位置不会变化,保存该值以便于复位
        origLocPos = weapon.localPosition;
        origLocRot = weapon.localEulerAngles;
    }
    
    void Update()
    {
        // 略......
        animator.SetBool("pulling", pulling);
        // 略......
        if (hasWeapon){ // 之前实现过的部分,略...... }
        else
        {
            // 没有斧头状态下左键则收回斧头
            if (Input.GetMouseButtonDown(0))
            {
                WeaponStartPull();
            }
        }
        
        // 收回斧头中
        if (pulling)
        {
            if(returnTime < 1)
            {
                weapon.position = GetQuadraticCurvePoint(returnTime, pullPosition, curvePoint.position, hand.position);
                returnTime += Time.deltaTime * 1.5f;
            }
            else
            {
                WeaponCatch();
            }
        }
    }

    /// <summary>
    /// 收回斧头
    /// </summary>
    public void WeaponStartPull()
    {
        // 主要是设置状态+旋转斧头
        pullPosition = weapon.position;
        weaponRb.Sleep();
        weaponRb.collisionDetectionMode = CollisionDetectionMode.ContinuousSpeculative;
        weaponRb.isKinematic = true;
        weapon.DORotate(new Vector3(-90, -90, 0), .2f).SetEase(Ease.InOutSine);
        weapon.DOBlendableLocalRotateBy(Vector3.right * 90, .5f);
        weaponScript.activated = true;
        pulling = true;
    }
    
    /// <summary>
    /// 根据起点+终点+控制点+时间计算二阶贝塞尔曲线上当前位置点
    /// </summary>
    /// <param name="t"></param>
    /// <param name="p0">起点</param>
    /// <param name="p1">控制点,也就是curvePoint</param>
    /// <param name="p2">中点</param>
    public Vector3 GetQuadraticCurvePoint(float t, Vector3 p0, Vector3 p1, Vector3 p2)
    {
        float u = 1 - t;
        float tt = t * t;
        float uu = u * u;
        return (uu * p0) + (2 * u * t * p1) + (tt * p2);
    }
    
    /// <summary>
    /// 武器重新附着在手上
    /// </summary>
    public void WeaponCatch()
    {
        returnTime = 0;
        pulling = false;
        weapon.parent = hand;
        weaponScript.activated = false;
        weapon.localEulerAngles = origLocRot;
        weapon.localPosition = origLocPos;
        hasWeapon = true;

        //Particle and trail
        catchParticle.Play();
        trailRenderer.emitting = false;
        trailParticle.Stop();

        // 摄像机震动
        impulseSource.GenerateImpulse(Vector3.right);

    }
  • 斧头来回的时候速度放慢些,最终效果如下: