路线阶段:Unity 入门实战第 14 章。
本章目标:在不破坏战斗逻辑稳定性的前提下,系统化提升打击反馈质量。
学习目标
完成本章后,你应该能做到:
- 统一管理伤害数字弹字,避免频繁实例化导致 GC 抖动。
- 实现可配置 HitStop(命中停顿)并区分轻重攻击。
- 接入镜头震动并做强度分级,防止眩晕。
- 将反馈系统与伤害结算、音频、回放系统打通。
背景问题
很多项目“有伤害但没手感”,常见原因:
- 命中反馈只有扣血,没有时间反馈与视觉冲击。
- 每次伤害都
Instantiate文本,战斗高峰帧抖动。 - 镜头震动无节制,长时间体验疲劳。
- 回放时反馈不一致,难调参。
反馈事件模型
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)
});
}
回放一致性
反馈系统不参与数值,只消费伤害结果,因此:
- 回放中按相同
DamageResult触发反馈事件。 - HitStop 与震动强度可复现(同事件同参数)。
- UI 与音频时序保持一致。
WebGL 注意点
- 限制同帧弹字数量(例如最多 8 个)。
- HitStop 总时长限流,防止操作感“粘住”。
- 震动只改本地位移,不触发复杂后处理。
验收清单
- 暴击与普通命中反馈有明显区分。
- 高频战斗下无明显 GC 峰值。
- 长战斗中镜头不会累计漂移。
- 回放时反馈节奏与实时基本一致。
常见坑
坑 1:把反馈逻辑写进伤害结算核心
会污染数值层。反馈必须通过事件解耦。
坑 2:HitStop 直接 Time.timeScale=0 太久
会导致输入、动画、物理异常。应短时且有上限。
坑 3:弹字对象不回收
高频命中时内存快速上升。必须池化并及时释放。
本月作业
实现“反馈配置热调”:
- 支持运行时调整 HitStop 与震动参数。
- 一键保存到调参配置文件。
- 对比三套手感配置并给出回放验证结论。
下一章进入 Unity 入门实战 15:网络同步预研(本地预测、状态回放与后续联机扩展接口)。