Article

Unity 入门实战 14:战斗手感打磨(伤害数字、命中停顿、镜头震动)

路线阶段:Unity 入门实战第 14 章。
本章目标:在不破坏战斗逻辑稳定性的前提下,系统化提升打击反馈质量。

学习目标

完成本章后,你应该能做到:

  1. 统一管理伤害数字弹字,避免频繁实例化导致 GC 抖动。
  2. 实现可配置 HitStop(命中停顿)并区分轻重攻击。
  3. 接入镜头震动并做强度分级,防止眩晕。
  4. 将反馈系统与伤害结算、音频、回放系统打通。

背景问题

很多项目“有伤害但没手感”,常见原因:

  1. 命中反馈只有扣血,没有时间反馈与视觉冲击。
  2. 每次伤害都 Instantiate 文本,战斗高峰帧抖动。
  3. 镜头震动无节制,长时间体验疲劳。
  4. 回放时反馈不一致,难调参。

反馈事件模型

public struct HitFeedbackEvent
{
    public int AttackerId;
    public int TargetId;

    public int Damage;
    public bool IsCritical;
    public bool IsKill;

    public Vector3 HitPoint;
    public float Intensity; // 0~1
}

伤害数字系统

1. 弹字视图

public sealed class DamageTextView : MonoBehaviour, IPoolResettable
{
    [SerializeField] private TMPro.TextMeshPro _text;
    [SerializeField] private float _duration = 0.6f;
    [SerializeField] private AnimationCurve _riseCurve;

    private float _timer;
    private Vector3 _start;
    private Vector3 _offset;
    private Action<DamageTextView> _onDone;

    public void Play(int damage, bool crit, Vector3 worldPos, Action<DamageTextView> onDone)
    {
        _onDone = onDone;
        _timer = 0f;
        _start = worldPos;
        _offset = new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 1.2f, 0f);

        _text.text = damage.ToString();
        _text.color = crit ? new Color(1f, 0.86f, 0.2f) : Color.white;
        _text.fontSize = crit ? 6.8f : 5.2f;

        gameObject.SetActive(true);
        transform.position = _start;
    }

    private void Update()
    {
        _timer += Time.unscaledDeltaTime;
        var t = Mathf.Clamp01(_timer / _duration);

        var y = _riseCurve.Evaluate(t);
        transform.position = _start + _offset * y;

        var c = _text.color;
        c.a = 1f - t;
        _text.color = c;

        if (t >= 1f)
        {
            if (_onDone != null) _onDone(this);
        }
    }

    public void ResetForPool()
    {
        _timer = 0f;
        _onDone = null;
        gameObject.SetActive(false);
        transform.position = Vector3.zero;
    }
}

2. 弹字控制器

public sealed class DamageTextSystem
{
    private readonly SafePool<DamageTextView> _pool;

    public DamageTextSystem(SafePool<DamageTextView> pool)
    {
        _pool = pool;
    }

    public void Spawn(int damage, bool crit, Vector3 point)
    {
        var view = _pool.Get();
        view.Play(damage, crit, point, OnDone);
    }

    private void OnDone(DamageTextView view)
    {
        view.ResetForPool();
        _pool.Release(view);
    }
}

HitStop(命中停顿)

1. 停顿配置

[Serializable]
public sealed class HitStopConfig
{
    public float Light = 0.03f;
    public float Heavy = 0.06f;
    public float Critical = 0.09f;
    public float MaxTotalInOneSecond = 0.18f;
}

2. 停顿执行器

public sealed class HitStopSystem : IUpdatable
{
    public int Order { get { return 10; } }

    private readonly HitStopConfig _cfg;
    private float _remain;
    private float _accWindow;
    private float _accUsed;

    public HitStopSystem(HitStopConfig cfg)
    {
        _cfg = cfg;
    }

    public void Tick(float dt, float unscaledDt)
    {
        _accWindow += unscaledDt;
        if (_accWindow >= 1f)
        {
            _accWindow = 0f;
            _accUsed = 0f;
        }

        if (_remain > 0f)
        {
            _remain -= unscaledDt;
            if (_remain <= 0f)
            {
                _remain = 0f;
                Time.timeScale = 1f;
            }
        }
    }

    public void Trigger(float duration)
    {
        if (duration <= 0f)
        {
            return;
        }

        if (_accUsed + duration > _cfg.MaxTotalInOneSecond)
        {
            return;
        }

        _accUsed += duration;
        _remain = Mathf.Max(_remain, duration);
        Time.timeScale = 0.02f;
    }

    public float ResolveDuration(HitFeedbackEvent e)
    {
        if (e.IsCritical) return _cfg.Critical;
        if (e.Intensity >= 0.7f) return _cfg.Heavy;
        return _cfg.Light;
    }
}

镜头震动

public sealed class CameraShakeSystem : IUpdatable
{
    public int Order { get { return 330; } }

    private readonly Transform _cameraRoot;
    private Vector3 _baseLocalPos;
    private float _shakeTime;
    private float _amplitude;

    public CameraShakeSystem(Transform cameraRoot)
    {
        _cameraRoot = cameraRoot;
        _baseLocalPos = cameraRoot.localPosition;
    }

    public void Trigger(float intensity)
    {
        var amp = Mathf.Clamp(intensity, 0f, 1f);
        _amplitude = Mathf.Max(_amplitude, amp);
        _shakeTime = Mathf.Max(_shakeTime, 0.12f + amp * 0.08f);
    }

    public void Tick(float dt, float unscaledDt)
    {
        if (_shakeTime <= 0f)
        {
            _cameraRoot.localPosition = Vector3.Lerp(_cameraRoot.localPosition, _baseLocalPos, 18f * unscaledDt);
            return;
        }

        _shakeTime -= unscaledDt;

        var x = UnityEngine.Random.Range(-1f, 1f) * _amplitude * 0.14f;
        var y = UnityEngine.Random.Range(-1f, 1f) * _amplitude * 0.08f;
        _cameraRoot.localPosition = _baseLocalPos + new Vector3(x, y, 0f);

        _amplitude = Mathf.MoveTowards(_amplitude, 0f, 3.2f * unscaledDt);
    }
}

反馈总线

public sealed class HitFeedbackCoordinator : IDisposable
{
    private readonly EventBus _eventBus;
    private readonly DamageTextSystem _text;
    private readonly HitStopSystem _hitStop;
    private readonly CameraShakeSystem _shake;
    private readonly AudioRouter _audio;

    public HitFeedbackCoordinator(
        EventBus eventBus,
        DamageTextSystem text,
        HitStopSystem hitStop,
        CameraShakeSystem shake,
        AudioRouter audio)
    {
        _eventBus = eventBus;
        _text = text;
        _hitStop = hitStop;
        _shake = shake;
        _audio = audio;

        _eventBus.Subscribe("HitFeedback", OnHitFeedback);
    }

    public void Dispose()
    {
        _eventBus.Unsubscribe("HitFeedback", OnHitFeedback);
    }

    private void OnHitFeedback(object payload)
    {
        var e = (HitFeedbackEvent)payload;

        _text.Spawn(e.Damage, e.IsCritical, e.HitPoint);

        var stop = _hitStop.ResolveDuration(e);
        _hitStop.Trigger(stop);

        _shake.Trigger(e.Intensity);

        _audio.Play(new AudioEvent
        {
            Key = e.IsCritical ? "sfx_hit_crit" : "sfx_hit_light",
            Category = AudioCategory.Sfx,
            Is3D = true,
            Position = e.HitPoint,
            Volume = 0.8f + e.Intensity * 0.2f
        });
    }
}

与伤害管线接入点

DamagePipeline.Execute 落地后发布事件:

if (result.IsHit && result.FinalDamage > 0)
{
    _eventBus.Publish("HitFeedback", new HitFeedbackEvent
    {
        AttackerId = req.SourceId,
        TargetId = req.TargetId,
        Damage = result.FinalDamage,
        IsCritical = result.IsCritical,
        IsKill = ctx.Target.Hp <= 0f,
        HitPoint = ctx.Target.Transform.position + Vector3.up * 1.2f,
        Intensity = Mathf.Clamp01(result.FinalDamage / 220f)
    });
}

回放一致性

反馈系统不参与数值,只消费伤害结果,因此:

  1. 回放中按相同 DamageResult 触发反馈事件。
  2. HitStop 与震动强度可复现(同事件同参数)。
  3. UI 与音频时序保持一致。

WebGL 注意点

  1. 限制同帧弹字数量(例如最多 8 个)。
  2. HitStop 总时长限流,防止操作感“粘住”。
  3. 震动只改本地位移,不触发复杂后处理。

验收清单

  1. 暴击与普通命中反馈有明显区分。
  2. 高频战斗下无明显 GC 峰值。
  3. 长战斗中镜头不会累计漂移。
  4. 回放时反馈节奏与实时基本一致。

常见坑

坑 1:把反馈逻辑写进伤害结算核心

会污染数值层。反馈必须通过事件解耦。

坑 2:HitStop 直接 Time.timeScale=0 太久

会导致输入、动画、物理异常。应短时且有上限。

坑 3:弹字对象不回收

高频命中时内存快速上升。必须池化并及时释放。

本月作业

实现“反馈配置热调”:

  1. 支持运行时调整 HitStop 与震动参数。
  2. 一键保存到调参配置文件。
  3. 对比三套手感配置并给出回放验证结论。

下一章进入 Unity 入门实战 15:网络同步预研(本地预测、状态回放与后续联机扩展接口)。