全向冲刺的实现:dash 本体与三个特效

2026-08-12 · unity

basic_movement 是 Unity 2022.3 的 2D 平台跳跃 demo。这篇记录冲刺(dash)的完整实现:dash 本体,以及拉伸、残影、无敌三个特效。

需求与设计

冲刺的玩法定义:

对应的参数组(都在 Inspector 里):

dashSpeed = 15       冲刺速度
dashTime = 0.18      冲刺持续时间
dashCooldown = 0.4   冷却
dashStretch = 1.35   拉伸幅度(scale.x)
dashGravity = 0.5    冲刺期间重力缩放
dashGhostInterval = 0.03  残影生成间隔
dashIFrame = 0.3     无敌时长
dashBlink = true     无敌期间是否闪烁

结构决策:逻辑留在控制器,残影独立成组件

dash 的移动逻辑留在 PlayerController 里,因为它在冲刺期间每物理帧接管 rb.velocitygravityScale,和移动、重力判断强耦合、执行顺序敏感。残影则抽成独立组件 GhostTrail,通过 public 方法 SpawnGhost() 被控制器调用——它只负责”生成一个残影并淡出”,和 dash 本体解耦。

原理:协程是”计时指挥”,FixedUpdate 是”干活的人”

冲刺用了一个协程 DashRoutine 做生命周期管理,而真正推着角色动的是 FixedUpdate:

这样的分工让”什么时候冲”和”冲的时候怎么动”互不干扰。三个特效是三个独立协程,各自计时,并行执行。

实现

触发与方向

void Update()
{
    // ... 移动输入、跳跃缓冲 ...
    if (Input.GetKeyDown(KeyCode.K) && !isDashing && dashCooldownTimer <= 0f)
    {
        StartCoroutine(DashRoutine());
    }
}

Vector2 GetDashDirection()
{
    Vector2 input = new Vector2(Input.GetAxisRaw("Horizontal"),
                                Input.GetAxisRaw("Vertical"));
    if (input == Vector2.zero)
    {
        input = new Vector2(sr.flipX ? -1 : 1, 0f); // 没按方向就朝面朝方向冲
    }
    return input.normalized;    // 归一化,不放大斜向速度
}

触发条件三连:没在冲 && 冷却结束,防止连按刷冲刺。方向用 normalized,否则斜向(对角)的位移会比水平/垂直方向大 √2 倍。

冲刺生命周期(协程)

IEnumerator DashRoutine()
{
    isDashing = true;
    isInvincible = true;
    dashCooldownTimer = dashCooldown;
    dashDirection = GetDashDirection();
    anim.SetBool("IsDashing", true);

    // 并行启动三个特效
    StartCoroutine(StretchRoutine());
    StartCoroutine(GhostRoutine());
    StartCoroutine(BlinkRoutine());

    yield return new WaitForSeconds(dashTime);   // 冲刺本体

    isDashing = false;
    anim.SetBool("IsDashing", false);

    // 无敌持续到 dashIFrame,补上冲刺结束后的剩余时间
    yield return new WaitForSeconds(Mathf.Max(0f, dashIFrame - dashTime));
    isInvincible = false;
}

isDashing 是”冲刺中”的总开关,isInvincible 是”无敌中”的开关。无敌时间比冲刺长(0.3 > 0.18),所以分两段等。

物理帧里的速度接管

void FixedUpdate()
{
    if (isDashing)
    {
        rb.gravityScale = dashGravity;               // 冲刺期间重力减半,抵消下落
    }
    // ... 重力、土狼/缓冲、跳跃 ...

    if (isDashing)
    {
        rb.velocity = dashDirection * dashSpeed;     // 全接管,保证直线不飘
    }
    else
    {
        rb.velocity = new Vector2(moveX * moveSpeed, rb.velocity.y);
    }
}

关键在”全接管”:冲刺时每一物理帧都把速度设成固定值,玩家任何输入都改不了它,所以轨迹是笔直的,不会因为误按方向而飘。

特效一:拉伸(StretchRoutine)

冲刺前 30% 时间把 scale.x 拉长,后 70% 慢慢收回。缩放必须基于 baseScale(Start 里存的原始缩放),否则会把历史拉伸累加起来越拉越长:

IEnumerator StretchRoutine()
{
    float stretchDuration = dashTime * 0.3f;
    float t = 0f;
    while (t < stretchDuration)
    {
        t += Time.deltaTime;
        float k = Mathf.Clamp01(t / stretchDuration);
        Vector3 s = baseScale;
        s.x = baseScale.x * Mathf.Lerp(1f, 1f + dashStretch, k);
        transform.localScale = s;
        yield return null;
    }
    float relaxDuration = dashTime * 0.7f;
    t = 0f;
    while (t < relaxDuration)
    {
        t += Time.deltaTime;
        float k = Mathf.Clamp01(t / relaxDuration);
        Vector3 s = baseScale;
        s.x = baseScale.x * Mathf.Lerp(1f + dashStretch, 1f, k);
        transform.localScale = s;
        yield return null;
    }
    transform.localScale = baseScale;   // 保险:结束一定还原
}

特效二:残影(GhostTrail.cs)

残影是独立组件。GhostRoutine 在冲刺期间每隔 dashGhostInterval 调用一次 SpawnGhost();SpawnGhost 生成一个临时的 SpriteRenderer 残影,复制本体的 sprite / 翻转 / 缩放(所以残影也会跟着拉伸),然后协程淡出并销毁:

public void SpawnGhost()
{
    GameObject ghost = new GameObject("Ghost");
    ghost.transform.position = transform.position;
    ghost.transform.rotation = transform.rotation;
    ghost.transform.localScale = transform.localScale;  // 残影继承本体的拉伸

    SpriteRenderer g = ghost.AddComponent<SpriteRenderer>();
    g.sprite = sr.sprite;
    g.flipX = sr.flipX;
    g.sortingOrder = sr.sortingOrder - 1;   // 压在玩家下面一层
    g.color = ghostColor;

    StartCoroutine(FadeAndDestroy(ghost, g));
}

IEnumerator FadeAndDestroy(GameObject ghost, SpriteRenderer g)
{
    Color start = g.color;
    float t = 0f;
    while (t < ghostLifetime)
    {
        t += Time.deltaTime;
        g.color = new Color(start.r, start.g, start.b,
                            Mathf.Lerp(start.a, 0f, t / ghostLifetime));
        yield return null;
    }
    Destroy(ghost);
}

特效三:无敌闪烁(BlinkRoutine)

isInvincible 期间每 0.1s 隐藏/显示一次 SpriteRenderer,提示玩家处于无敌状态。结束时强制恢复显示,避免停在隐藏态:

IEnumerator BlinkRoutine()
{
    while (isInvincible)
    {
        sr.enabled = !dashBlink;
        yield return new WaitForSeconds(0.1f);
        sr.enabled = true;
        yield return new WaitForSeconds(0.1f);
    }
    sr.enabled = true;
}

遇到的问题

1. 按下冲刺完全没反应:协程没被启动

DashRoutine() 写好了,但调用它的 StartCoroutine(...) 那行被注释掉了。协程是”暂停、过会、接着跑”的任务,不启动它,isDashing 永远是 false,FixedUpdate 里所有 if (isDashing) 分支都不执行。协程/方法定义了但没被调度,等于没写——这和”生命周期回调里忘了调某个方法”是同一类错误。

2. 编译报错:引用了还不存在的类型

PlayerController 里写了 private GhostTrail ghost;GetComponent<GhostTrail>()。C# 要求被引用的类型必须存在才能编译,所以必须先建好 GhostTrail.cs,再改控制器。顺序 = 先建被依赖的小文件,再改引用它的文件。

3. 冲刺动画”糊”、收尾发黏:过渡时长大于冲刺时长

新建动画过渡默认 Transition Duration = 0.25s,而 dashTime 只有 0.18s——0.25s 的混合动画根本放不完,冲刺结束它还在从 Idle/Run 往 Dash 糊。改成 Has Exit Time 关 + Duration = 0 后,动画立即切换、干净利落。2D 动作游戏的过渡几乎一律这么设。

4. 冲刺期间不能跳

冲刺时如果还能起跳,会和速度全接管冲突。起跳判定里加了 !isDashing:

if (jumpBufferTimer > 0 && (isGrounded || coyoteTimer > 0f) && !isDashing)

小结

冲刺的实现拆成两层:协程管”开多久”(isDashing / isInvincible 的生命周期),FixedUpdate 管”怎么动”(每帧全接管速度保证直线)。三个特效各自独立协程并行,参数全部暴露在 Inspector 方便调手感。过程中踩的坑——协程没启动、类型编译顺序、过渡时长大于冲刺时长——都是”写了但没接到调度”这类问题的变体,排查时优先检查调度链。