多轮审查和修复
This commit is contained in:
31
Assets/Scripts/Animation/AnimationEventBinder.cs
Normal file
31
Assets/Scripts/Animation/AnimationEventBinder.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using Animancer;
|
||||
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// 静态工具类:将 AnimationEventConfigSO 中声明的事件注入 Animancer ClipTransition。
|
||||
/// 使用 Animancer Pro API:ClipTransition.Events.SetCallback(normalizedTime, Action)。
|
||||
/// 调用时机:MonoBehaviour.Awake(在 Animancer 播放前完成绑定)。
|
||||
/// </summary>
|
||||
public static class AnimationEventBinder
|
||||
{
|
||||
/// <summary>
|
||||
/// 将 config 中的所有事件回调注入到 clip 的 Animancer 事件系统。
|
||||
/// </summary>
|
||||
/// <param name="clip">目标 Animancer ClipTransition。</param>
|
||||
/// <param name="config">事件配置资产(null 时静默跳过)。</param>
|
||||
/// <param name="receiver">事件接收者(通常是同一 MonoBehaviour)。</param>
|
||||
public static void Bind(ClipTransition clip, AnimationEventConfigSO config, IAnimationEventHandler receiver)
|
||||
{
|
||||
if (clip == null || config == null || receiver == null) return;
|
||||
|
||||
foreach (var entry in config.SortedEvents)
|
||||
{
|
||||
// 捕获循环变量,避免闭包陷阱
|
||||
var captured = entry;
|
||||
clip.Events.Add(captured.normalizedTime, () =>
|
||||
receiver.HandleEvent(captured.eventType, captured.data));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c7d6fe0521388084e83314560a394951
|
||||
guid: e9c16d7d7a6978a4c98a745261fbbf4f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
64
Assets/Scripts/Animation/AnimationEventConfigSO.cs
Normal file
64
Assets/Scripts/Animation/AnimationEventConfigSO.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// 动画事件配置资产(架构 §AnimationModule)。
|
||||
/// 为指定 AnimationClip 声明一组事件时机,由 AnimationEventBinder 在运行时注入 Animancer 回调。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// 1. 在 Inspector 中创建此资产(菜单:Animation/EventConfig)。
|
||||
/// 2. 配置 TargetClip 和 Events 列表。
|
||||
/// 3. 在 MonoBehaviour.Awake 中:AnimationEventBinder.Bind(clip, config, this)。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Animation/EventConfig")]
|
||||
public class AnimationEventConfigSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct EventEntry
|
||||
{
|
||||
public AnimationEventType eventType;
|
||||
|
||||
[Range(0f, 1f)]
|
||||
[Tooltip("事件在动画中的归一化时间(0 = 开始,1 = 结束)。")]
|
||||
public float normalizedTime;
|
||||
|
||||
[Tooltip("附加字符串数据(可空)。用于 payload 参数。")]
|
||||
public string data;
|
||||
}
|
||||
|
||||
[Header("绑定的动画片段")]
|
||||
public AnimationClip targetClip;
|
||||
|
||||
[Header("事件时机列表(归一化时间)")]
|
||||
public EventEntry[] events;
|
||||
|
||||
/// <summary>
|
||||
/// 用于编辑器工具验证:记录创建 Config 时 Clip 的实际帧数,
|
||||
/// 若 Clip 被替换(长度漂移 >5 帧)则 EventConfigEditor 显示警告。
|
||||
/// </summary>
|
||||
[HideInInspector] public float ExpectedClipLength = -1f;
|
||||
|
||||
// ── 运行时查询 ───────────────────────────────────────────────────
|
||||
|
||||
/// <summary>按归一化时间升序返回所有事件(保证 SetCallback 顺序稳定)。</summary>
|
||||
public IEnumerable<EventEntry> SortedEvents =>
|
||||
events != null
|
||||
? events.OrderBy(e => e.normalizedTime)
|
||||
: Enumerable.Empty<EventEntry>();
|
||||
|
||||
/// <summary>
|
||||
/// 返回第一个匹配类型的归一化时间;未找到时返回 -1。
|
||||
/// </summary>
|
||||
public float GetNormalizedTime(AnimationEventType eventType)
|
||||
{
|
||||
if (events == null) return -1f;
|
||||
foreach (var e in events)
|
||||
if (e.eventType == eventType) return e.normalizedTime;
|
||||
return -1f;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1dfc988231a6ac14a9aa035ba1719ab0
|
||||
guid: 3dc3625236c5f8747ae7352e78c01137
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
49
Assets/Scripts/Animation/AnimationEventType.cs
Normal file
49
Assets/Scripts/Animation/AnimationEventType.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// 动画事件类型枚举(架构 §AnimationModule)。
|
||||
/// 用作 AnimationEventConfigSO 中事件时机的分类标签,
|
||||
/// 与 AnimationEventBinder 配合将 Animancer ClipTransition 回调路由到 IAnimationEventHandler。
|
||||
/// </summary>
|
||||
public enum AnimationEventType
|
||||
{
|
||||
// ── 命中判定 ──────────────────────────────────────────────────────
|
||||
EnableHitBox,
|
||||
DisableHitBox,
|
||||
AttackImpact, // 攻击命中瞬间反馈(不依赖特定 HitBox)
|
||||
|
||||
// ── 招架窗口 ──────────────────────────────────────────────────────
|
||||
EnableParryWindow,
|
||||
DisableParryWindow,
|
||||
|
||||
// ── 无敌帧 ────────────────────────────────────────────────────────
|
||||
EnableIFrame,
|
||||
DisableIFrame,
|
||||
|
||||
// ── 移动反馈 ──────────────────────────────────────────────────────
|
||||
Footstep,
|
||||
LandImpact,
|
||||
JumpLaunch,
|
||||
|
||||
// ── 交互 ──────────────────────────────────────────────────────────
|
||||
EnableInteract,
|
||||
|
||||
// ── 通用反馈 ──────────────────────────────────────────────────────
|
||||
TriggerFeedback, // payload:FeedbackPreset 名称
|
||||
PlaySFX, // payload:SFX Id
|
||||
|
||||
// ── 敌方专用 ──────────────────────────────────────────────────────
|
||||
SpawnProjectile, // payload:弹幕配置 Id
|
||||
RoarStart,
|
||||
RoarEnd,
|
||||
PhaseTwoStart,
|
||||
|
||||
// ── 玩家取消窗口 ──────────────────────────────────────────────────
|
||||
CancelWindowOpen,
|
||||
CancelWindowClose,
|
||||
|
||||
// ── 状态机钩子 ────────────────────────────────────────────────────
|
||||
StateTransition, // payload:目标状态 Id
|
||||
AnimationComplete, // payload:上下文字符串(可空)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 14d8a3e3d7371e54eb25a5d4dded4645
|
||||
guid: 97412b5f50276484fa226d97b8c3769b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
@@ -9,7 +9,12 @@
|
||||
"rootNamespace": "BaseGames.Animation",
|
||||
"references": [
|
||||
"BaseGames.Core.Events",
|
||||
"Kybernetik.Animancer"
|
||||
"Kybernetik.Animancer",
|
||||
"BaseGames.Combat",
|
||||
"BaseGames.Parry",
|
||||
"BaseGames.Feedback",
|
||||
"BaseGames.Player",
|
||||
"BaseGames.Enemies"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
|
||||
111
Assets/Scripts/Animation/EnemyAnimationEvents.cs
Normal file
111
Assets/Scripts/Animation/EnemyAnimationEvents.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System;
|
||||
using Animancer;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Enemies;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// 敌人动画事件接收器(架构 §AnimationModule)。
|
||||
/// 挂载于敌人 Prefab 根节点。负责将 Animancer 动画时间点回调路由到
|
||||
/// HitBox 激活、弹幕生成、嘶吼状态、二阶段切换等系统。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// 1. 在 Inspector 中填充 _hitBoxes、_enemy。
|
||||
/// 2. 将每条 ClipTransition + AnimationEventConfigSO 配对添加到 _bindings。
|
||||
/// 3. Awake 中 AnimationEventBinder.Bind 自动完成注入。
|
||||
/// </summary>
|
||||
public class EnemyAnimationEvents : MonoBehaviour, IAnimationEventHandler
|
||||
{
|
||||
[Serializable]
|
||||
public struct EventBinding
|
||||
{
|
||||
[Tooltip("Animancer ClipTransition(需与动画组件中同一引用绑定)。")]
|
||||
public ClipTransition clip;
|
||||
|
||||
[Tooltip("对应的 AnimationEventConfigSO 资产。")]
|
||||
public AnimationEventConfigSO config;
|
||||
}
|
||||
|
||||
[Header("子系统引用")]
|
||||
[SerializeField] private HitBox[] _hitBoxes;
|
||||
[SerializeField] private EnemyBase _enemy;
|
||||
|
||||
[Header("事件绑定(每个 Clip 对应一个配置资产)")]
|
||||
[SerializeField] private EventBinding[] _bindings;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_enemy == null)
|
||||
_enemy = GetComponentInParent<EnemyBase>();
|
||||
|
||||
foreach (var b in _bindings)
|
||||
AnimationEventBinder.Bind(b.clip, b.config, this);
|
||||
}
|
||||
|
||||
// ── IAnimationEventHandler ─────────────────────────────────────────
|
||||
|
||||
public void HandleEvent(AnimationEventType type, string payload)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
// ── 命中判定 ──────────────────────────────────────────────
|
||||
case AnimationEventType.EnableHitBox:
|
||||
SetHitBoxActive(payload, true);
|
||||
break;
|
||||
|
||||
case AnimationEventType.DisableHitBox:
|
||||
SetHitBoxActive(payload, false);
|
||||
break;
|
||||
|
||||
// ── 弹幕 / 技能 ───────────────────────────────────────────
|
||||
case AnimationEventType.SpawnProjectile:
|
||||
_enemy?.SpawnProjectile(payload);
|
||||
break;
|
||||
|
||||
// ── 嘶吼 ──────────────────────────────────────────────────
|
||||
case AnimationEventType.RoarStart:
|
||||
_enemy?.SetRoaring(true);
|
||||
break;
|
||||
|
||||
case AnimationEventType.RoarEnd:
|
||||
_enemy?.SetRoaring(false);
|
||||
break;
|
||||
|
||||
// ── 二阶段切换 ────────────────────────────────────────────
|
||||
case AnimationEventType.PhaseTwoStart:
|
||||
_enemy?.TriggerPhaseTwo();
|
||||
break;
|
||||
|
||||
// ── 状态机钩子 ────────────────────────────────────────────
|
||||
case AnimationEventType.AnimationComplete:
|
||||
_enemy?.OnAnimationComplete(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 私有辅助 ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 按 Id 激活或停用 HitBox。
|
||||
/// payload 为空时操作所有 HitBox;非空时仅操作 Id 匹配的 HitBox。
|
||||
/// </summary>
|
||||
private void SetHitBoxActive(string id, bool active)
|
||||
{
|
||||
if (_hitBoxes == null) return;
|
||||
|
||||
bool matchAll = string.IsNullOrEmpty(id);
|
||||
|
||||
foreach (var hb in _hitBoxes)
|
||||
{
|
||||
if (hb == null) continue;
|
||||
if (matchAll || hb.Id == id)
|
||||
{
|
||||
if (active) hb.Activate();
|
||||
else hb.Deactivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b9c4356cd693b604bb0889f9538eb13e
|
||||
guid: 13003b40ed7bd164d8d90eb58f0548ec
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
17
Assets/Scripts/Animation/IAnimationEventHandler.cs
Normal file
17
Assets/Scripts/Animation/IAnimationEventHandler.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// 动画事件接收接口(架构 §AnimationModule)。
|
||||
/// 由 PlayerAnimationEvents / EnemyAnimationEvents 实现。
|
||||
/// AnimationEventBinder 将 Animancer 回调路由至此接口,避免硬耦合。
|
||||
/// </summary>
|
||||
public interface IAnimationEventHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理由 Animancer ClipTransition 触发的动画事件。
|
||||
/// </summary>
|
||||
/// <param name="type">事件类型。</param>
|
||||
/// <param name="payload">附加字符串数据(可为 null 或空)。</param>
|
||||
void HandleEvent(AnimationEventType type, string payload);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Animation/IAnimationEventHandler.cs.meta
Normal file
11
Assets/Scripts/Animation/IAnimationEventHandler.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f2c57ff98093620448d1f7c8ce6d0511
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
149
Assets/Scripts/Animation/PlayerAnimationEvents.cs
Normal file
149
Assets/Scripts/Animation/PlayerAnimationEvents.cs
Normal file
@@ -0,0 +1,149 @@
|
||||
using System;
|
||||
using Animancer;
|
||||
using BaseGames.Combat;
|
||||
using BaseGames.Feedback;
|
||||
using BaseGames.Parry;
|
||||
using BaseGames.Player;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Animation
|
||||
{
|
||||
/// <summary>
|
||||
/// 玩家动画事件接收器(架构 §AnimationModule)。
|
||||
/// 挂载于玩家 Prefab 根节点。负责将 Animancer 动画时间点回调路由到
|
||||
/// HitBox 激活、招架窗口、无敌帧、移动取消窗口、反馈等系统。
|
||||
///
|
||||
/// 使用方式:
|
||||
/// 1. 在 Inspector 中填充 _hitBoxes、_hurtBox、_mover、_parrySystem。
|
||||
/// 2. 将每条 ClipTransition + AnimationEventConfigSO 配对添加到 _bindings。
|
||||
/// 3. Awake 中 AnimationEventBinder.Bind 自动完成注入。
|
||||
/// </summary>
|
||||
public class PlayerAnimationEvents : MonoBehaviour, IAnimationEventHandler
|
||||
{
|
||||
[Serializable]
|
||||
public struct EventBinding
|
||||
{
|
||||
[Tooltip("Animancer ClipTransition(需与动画组件中同一引用绑定)。")]
|
||||
public ClipTransition clip;
|
||||
|
||||
[Tooltip("对应的 AnimationEventConfigSO 资产。")]
|
||||
public AnimationEventConfigSO config;
|
||||
}
|
||||
|
||||
[Header("子系统引用")]
|
||||
[SerializeField] private HitBox[] _hitBoxes;
|
||||
[SerializeField] private HurtBox _hurtBox;
|
||||
[SerializeField] private PlayerMovement _mover;
|
||||
[SerializeField] private ParrySystem _parrySystem;
|
||||
|
||||
[Header("事件绑定(每个 Clip 对应一个配置资产)")]
|
||||
[SerializeField] private EventBinding[] _bindings;
|
||||
|
||||
private IFeedbackPlayer _feedback;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// IFeedbackPlayer 由父层或同层实现(PlayerFeedback / NullFeedbackPlayer)
|
||||
_feedback = GetComponentInParent<IFeedbackPlayer>()
|
||||
?? NullFeedbackPlayer.Instance;
|
||||
|
||||
foreach (var b in _bindings)
|
||||
AnimationEventBinder.Bind(b.clip, b.config, this);
|
||||
}
|
||||
|
||||
// ── IAnimationEventHandler ─────────────────────────────────────────
|
||||
|
||||
public void HandleEvent(AnimationEventType type, string payload)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
// ── 命中判定 ──────────────────────────────────────────────
|
||||
case AnimationEventType.EnableHitBox:
|
||||
SetHitBoxActive(payload, true);
|
||||
break;
|
||||
|
||||
case AnimationEventType.DisableHitBox:
|
||||
SetHitBoxActive(payload, false);
|
||||
break;
|
||||
|
||||
case AnimationEventType.AttackImpact:
|
||||
_feedback.PlayAttackWhoosh();
|
||||
break;
|
||||
|
||||
// ── 招架窗口 ──────────────────────────────────────────────
|
||||
case AnimationEventType.EnableParryWindow:
|
||||
_parrySystem?.OpenParryWindow();
|
||||
break;
|
||||
|
||||
case AnimationEventType.DisableParryWindow:
|
||||
_parrySystem?.CloseParryWindow();
|
||||
break;
|
||||
|
||||
// ── 无敌帧 ────────────────────────────────────────────────
|
||||
case AnimationEventType.EnableIFrame:
|
||||
_hurtBox?.SetInvincible(true);
|
||||
break;
|
||||
|
||||
case AnimationEventType.DisableIFrame:
|
||||
_hurtBox?.SetInvincible(false);
|
||||
break;
|
||||
|
||||
// ── 移动反馈 ──────────────────────────────────────────────
|
||||
case AnimationEventType.LandImpact:
|
||||
_feedback.PlayLandImpact();
|
||||
break;
|
||||
|
||||
case AnimationEventType.JumpLaunch:
|
||||
_feedback.PlayJumpLaunch();
|
||||
break;
|
||||
|
||||
case AnimationEventType.Footstep:
|
||||
_feedback.PlayFootstep();
|
||||
break;
|
||||
|
||||
// ── 取消窗口 ──────────────────────────────────────────────
|
||||
case AnimationEventType.CancelWindowOpen:
|
||||
_mover?.SetCancelWindowOpen(true);
|
||||
break;
|
||||
|
||||
case AnimationEventType.CancelWindowClose:
|
||||
_mover?.SetCancelWindowOpen(false);
|
||||
break;
|
||||
|
||||
// ── 通用反馈 ──────────────────────────────────────────────
|
||||
case AnimationEventType.TriggerFeedback:
|
||||
if (!string.IsNullOrEmpty(payload))
|
||||
_feedback.TriggerPreset(payload);
|
||||
break;
|
||||
|
||||
case AnimationEventType.PlaySFX:
|
||||
if (!string.IsNullOrEmpty(payload))
|
||||
_feedback.PlaySFXById(payload);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 私有辅助 ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 按 Id 激活或停用 HitBox。
|
||||
/// payload 为空时操作所有 HitBox;非空时仅操作 Id 匹配的 HitBox。
|
||||
/// </summary>
|
||||
private void SetHitBoxActive(string id, bool active)
|
||||
{
|
||||
if (_hitBoxes == null) return;
|
||||
|
||||
bool matchAll = string.IsNullOrEmpty(id);
|
||||
|
||||
foreach (var hb in _hitBoxes)
|
||||
{
|
||||
if (hb == null) continue;
|
||||
if (matchAll || hb.Id == id)
|
||||
{
|
||||
if (active) hb.Activate();
|
||||
else hb.Deactivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Animation/PlayerAnimationEvents.cs.meta
Normal file
11
Assets/Scripts/Animation/PlayerAnimationEvents.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4e3a90eca47d12a49a07be7f38a376e8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,3 +0,0 @@
|
||||
// Placeholder to prevent asmdef-no-scripts warning.
|
||||
namespace BaseGames.Animation { }
|
||||
|
||||
52
Assets/Scripts/Audio/AudioEventSO.cs
Normal file
52
Assets/Scripts/Audio/AudioEventSO.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 音效事件 ScriptableObject。随机从 Clips 中选取一条播放,
|
||||
/// 并在 Volume / Pitch 区间内随机变化,增强音效多样性。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Audio/AudioEvent")]
|
||||
public class AudioEventSO : ScriptableObject
|
||||
{
|
||||
[SerializeField] private AudioClip[] _clips;
|
||||
[SerializeField] private float _volumeMin = 0.9f;
|
||||
[SerializeField] private float _volumeMax = 1.0f;
|
||||
[SerializeField] private float _pitchMin = 0.95f;
|
||||
[SerializeField] private float _pitchMax = 1.05f;
|
||||
[SerializeField] private AudioMixerGroup _mixerGroup;
|
||||
|
||||
public AudioClip PickClip()
|
||||
{
|
||||
if (_clips == null || _clips.Length == 0) return null;
|
||||
return _clips[Random.Range(0, _clips.Length)];
|
||||
}
|
||||
|
||||
public float PickVolume() => Random.Range(_volumeMin, _volumeMax);
|
||||
|
||||
/// <summary>通过已有的 AudioSource 播放(适用于场景内固定位置音效)。</summary>
|
||||
public void Play(AudioSource source)
|
||||
{
|
||||
var clip = PickClip();
|
||||
if (clip == null || source == null) return;
|
||||
|
||||
source.clip = clip;
|
||||
source.volume = PickVolume();
|
||||
source.pitch = Random.Range(_pitchMin, _pitchMax);
|
||||
source.outputAudioMixerGroup = _mixerGroup;
|
||||
source.Play();
|
||||
}
|
||||
|
||||
/// <summary>通过 AudioSource.PlayOneShot 播放(不打断当前音效)。</summary>
|
||||
public void PlayOneShot(AudioSource source)
|
||||
{
|
||||
var clip = PickClip();
|
||||
if (clip == null || source == null) return;
|
||||
|
||||
source.outputAudioMixerGroup = _mixerGroup;
|
||||
source.pitch = Random.Range(_pitchMin, _pitchMax);
|
||||
source.PlayOneShot(clip, PickVolume());
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Audio/AudioEventSO.cs.meta
Normal file
11
Assets/Scripts/Audio/AudioEventSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 356d306cd9d1eaa46b9d283761bcba54
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -22,8 +22,22 @@ namespace BaseGames.Audio
|
||||
[SerializeField] private AudioSource _bgmSourceB;
|
||||
|
||||
[Header("SFX Pool(建议 6 个,均路由到 SFX MixerGroup)")]
|
||||
[Tooltip("轮转池大小:同帧触发数超过此值时最旧的音效会被打断。建议 6~8 个。")]
|
||||
[SerializeField] private AudioSource[] _sfxSources;
|
||||
|
||||
[Header("Audio Config(BGM 映射)")]
|
||||
[SerializeField] private AudioConfigSO _audioConfig;
|
||||
|
||||
[Header("SFX 注册表(key → AudioEventSO)")]
|
||||
[SerializeField] private AudioEventEntry[] _sfxRegistry;
|
||||
|
||||
[System.Serializable]
|
||||
public struct AudioEventEntry
|
||||
{
|
||||
public string Key;
|
||||
public AudioEventSO Event;
|
||||
}
|
||||
|
||||
[Header("Event Channels - Subscribe")]
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerDied;
|
||||
|
||||
@@ -31,49 +45,61 @@ namespace BaseGames.Audio
|
||||
private AudioSource _inactiveBGMSource;
|
||||
private Coroutine _crossfadeCoroutine;
|
||||
private int _sfxRoundRobin;
|
||||
|
||||
// ── 遗留单例(已废弃;新代码请使用 ServiceLocator.Get<IAudioService>())────────────
|
||||
[System.Obsolete("Use ServiceLocator.Get<IAudioService>() instead.")]
|
||||
public static AudioManager Instance { get; private set; }
|
||||
private Dictionary<string, AudioEventSO> _sfxLookup;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
#pragma warning disable CS0618
|
||||
if (Instance != null) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
#pragma warning restore CS0618
|
||||
if (ServiceLocator.GetOrDefault<IAudioService>() != null) { Destroy(gameObject); return; }
|
||||
|
||||
Debug.Assert(_audioConfig != null, "[AudioManager] _audioConfig 未赋值,请在 Inspector 中指定 AudioConfigSO。", this);
|
||||
|
||||
_activeBGMSource = _bgmSourceA;
|
||||
_inactiveBGMSource = _bgmSourceB;
|
||||
|
||||
// ServiceLocator 注册(覆盖 GameServiceRegistrar 的 NullAudioService 兜底)
|
||||
ServiceLocator.Register<IAudioService>(this);
|
||||
BuildSFXLookup();
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onPlayerDied != null)
|
||||
_onPlayerDied.OnEventRaised += HandlePlayerDied;
|
||||
_onPlayerDied?.Subscribe(HandlePlayerDied).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onPlayerDied != null)
|
||||
_onPlayerDied.OnEventRaised -= HandlePlayerDied;
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
// ── IAudioService string-key API(Phase 2 接入 AudioEventSO 后完整实现)─────────────
|
||||
/// <summary>
|
||||
/// 按 Addressable key 播放 BGM。Phase 2 接入 AudioEventSO 前为占位警告。
|
||||
/// </summary>
|
||||
public void PlayBGM(string key)
|
||||
=> Debug.LogWarning($"[AudioManager] PlayBGM(key) 尚未接入 AudioEventSO(Phase 2)。key={key}");
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<IAudioService>(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 Addressable key 播放 SFX。Phase 2 接入 AudioEventSO 前为占位警告。
|
||||
/// </summary>
|
||||
// ── IAudioService string-key API ────────────────────────────────────────────
|
||||
/// <summary>按 Zone/Boss key 查 AudioConfigSO 播放 BGM。</summary>
|
||||
public void PlayBGM(string key)
|
||||
{
|
||||
var clip = _audioConfig.GetZoneBGM(key) ?? _audioConfig.GetBossBGM(key);
|
||||
if (clip == null)
|
||||
{
|
||||
Debug.LogWarning($"[AudioManager] BGM key '{key}' 在 AudioConfigSO 中未找到。");
|
||||
return;
|
||||
}
|
||||
PlayBGM(clip);
|
||||
}
|
||||
|
||||
/// <summary>按 key 查 SFX 注册表播放 AudioEventSO。</summary>
|
||||
public void PlaySFX(string key)
|
||||
=> Debug.LogWarning($"[AudioManager] PlaySFX(key) 尚未接入 AudioEventSO(Phase 2)。key={key}");
|
||||
{
|
||||
if (_sfxLookup != null && _sfxLookup.TryGetValue(key, out var evt))
|
||||
{
|
||||
evt?.PlayOneShot(NextSFXSource());
|
||||
return;
|
||||
}
|
||||
Debug.LogWarning($"[AudioManager] SFX key '{key}' 未在注册表中找到。");
|
||||
}
|
||||
|
||||
// ── 音量控制 ─────────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
@@ -83,10 +109,15 @@ namespace BaseGames.Audio
|
||||
public void SetVolume(string exposedParam, float linear)
|
||||
=> _mixer.SetFloat(exposedParam, LinearToDecibel(linear));
|
||||
|
||||
/// <summary>读取 GlobalSettings 并应用所有音量初始值。</summary>
|
||||
/// <summary>读取 SettingsManager 已加载的设置数据并应用四路音量到 AudioMixer。</summary>
|
||||
public void Initialize()
|
||||
{
|
||||
// TODO: 从 SettingsManager / PlayerPrefs 读取保存的音量值并应用
|
||||
var settings = ServiceLocator.GetOrDefault<ISettingsService>();
|
||||
GlobalSettingsData data = settings?.Current ?? new GlobalSettingsData();
|
||||
SetVolume(AudioMixerKeys.Master, data.MasterVolume);
|
||||
SetVolume(AudioMixerKeys.BGM, data.BGMVolume);
|
||||
SetVolume(AudioMixerKeys.SFX, data.SFXVolume);
|
||||
SetVolume(AudioMixerKeys.Ambient, data.AmbientVolume);
|
||||
}
|
||||
|
||||
// ── BGM ──────────────────────────────────────────────────────────────────
|
||||
@@ -113,6 +144,7 @@ namespace BaseGames.Audio
|
||||
{
|
||||
if (clip == null) return;
|
||||
var src = NextSFXSource();
|
||||
if (src == null) return;
|
||||
src.volume = volumeScale;
|
||||
src.PlayOneShot(clip);
|
||||
}
|
||||
@@ -138,9 +170,22 @@ namespace BaseGames.Audio
|
||||
TransitionToSnapshot("Dead", 1.5f);
|
||||
}
|
||||
|
||||
private void BuildSFXLookup()
|
||||
{
|
||||
_sfxLookup = new Dictionary<string, AudioEventSO>(_sfxRegistry?.Length ?? 0);
|
||||
if (_sfxRegistry == null) return;
|
||||
foreach (var entry in _sfxRegistry)
|
||||
if (!string.IsNullOrEmpty(entry.Key) && entry.Event != null)
|
||||
_sfxLookup[entry.Key] = entry.Event;
|
||||
}
|
||||
|
||||
private AudioSource NextSFXSource()
|
||||
{
|
||||
if (_sfxSources == null || _sfxSources.Length == 0) return _bgmSourceA;
|
||||
if (_sfxSources == null || _sfxSources.Length == 0)
|
||||
{
|
||||
Debug.LogError("[AudioManager] SFX Source 池为空,请在 Inspector 中为 _sfxSources 赋值。");
|
||||
return null;
|
||||
}
|
||||
return _sfxSources[_sfxRoundRobin++ % _sfxSources.Length];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
executionOrder: -500
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
|
||||
@@ -30,19 +30,23 @@ namespace BaseGames.Audio
|
||||
|
||||
private MusicState _musicState = MusicState.Exploration;
|
||||
private string _currentRegion = string.Empty;
|
||||
private readonly CompositeDisposable _subscriptions = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Debug.Assert(_config != null, "[BGMController] _config 未赋值,请在 Inspector 中指定 AudioConfigSO。", this);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onBossFightToggled != null) _onBossFightToggled.OnEventRaised += OnBossFightToggled;
|
||||
if (_onRegionEntered != null) _onRegionEntered.OnEventRaised += OnRegionEntered;
|
||||
if (_onGameStateChanged != null) _onGameStateChanged.OnEventRaised += HandleStateChanged;
|
||||
_onBossFightToggled?.Subscribe(OnBossFightToggled).AddTo(_subscriptions);
|
||||
_onRegionEntered?.Subscribe(OnRegionEntered).AddTo(_subscriptions);
|
||||
_onGameStateChanged?.Subscribe(HandleStateChanged).AddTo(_subscriptions);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onBossFightToggled != null) _onBossFightToggled.OnEventRaised -= OnBossFightToggled;
|
||||
if (_onRegionEntered != null) _onRegionEntered.OnEventRaised -= OnRegionEntered;
|
||||
if (_onGameStateChanged != null) _onGameStateChanged.OnEventRaised -= HandleStateChanged;
|
||||
_subscriptions.Clear();
|
||||
}
|
||||
|
||||
private void OnBossFightToggled(bool started)
|
||||
@@ -50,7 +54,7 @@ namespace BaseGames.Audio
|
||||
if (started)
|
||||
{
|
||||
_musicState = MusicState.Boss;
|
||||
var clip = _config != null ? _config.GetBossBGM(_currentRegion) : null;
|
||||
var clip = _config.GetBossBGM(_currentRegion);
|
||||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 0.5f);
|
||||
_audioManager.TransitionToSnapshot("BossFight", 0.5f);
|
||||
}
|
||||
@@ -63,9 +67,9 @@ namespace BaseGames.Audio
|
||||
private IEnumerator PlayVictoryThenRestore()
|
||||
{
|
||||
_musicState = MusicState.Victory;
|
||||
_audioManager.PlayBGM(_config != null ? _config.VictoryStingBGM : null,
|
||||
_audioManager.PlayBGM(_config.VictoryStingBGM,
|
||||
fadeOutDur: 0.3f, fadeInDur: 0.1f);
|
||||
float dur = _config != null ? _config.VictoryStingDuration : 4f;
|
||||
float dur = _config.VictoryStingDuration;
|
||||
yield return new WaitForSecondsRealtime(dur);
|
||||
_musicState = MusicState.Exploration;
|
||||
OnRegionEntered(_currentRegion);
|
||||
@@ -78,16 +82,15 @@ namespace BaseGames.Audio
|
||||
_currentRegion = regionId;
|
||||
if (_musicState == MusicState.Exploration)
|
||||
{
|
||||
var clip = _config != null ? _config.GetZoneBGM(regionId) : null;
|
||||
var clip = _config.GetZoneBGM(regionId);
|
||||
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 1f);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleStateChanged(GameStateId state)
|
||||
{
|
||||
// ⚠️ GameStateId 是 struct,不能用 switch;使用 if/else + GameStates 常量
|
||||
if (state == GameStates.MainMenu)
|
||||
_audioManager.PlayBGM(_config != null ? _config.MainMenuBGM : null,
|
||||
_audioManager.PlayBGM(_config.MainMenuBGM,
|
||||
fadeOutDur: 0.5f, fadeInDur: 1.0f);
|
||||
else if (state == GameStates.Paused)
|
||||
_audioManager.TransitionToSnapshot("Paused", 0.2f);
|
||||
|
||||
@@ -5,8 +5,9 @@ using BaseGames.Combat;
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 订阅战斗/死亡事件,通过 AudioManager 播放对应 SFX。
|
||||
/// 订阅战斗/死亡事件,通过 GlobalSFXPlayer 播放对应 AudioEventSO 音效。
|
||||
/// 挂载在 Persistent 场景的 [Systems] GameObject 上。
|
||||
/// 使用 AudioEventSO 替代裸 AudioClip,支持随机音量 / 音调 / 多片段。
|
||||
/// </summary>
|
||||
public class CombatSFXController : MonoBehaviour
|
||||
{
|
||||
@@ -15,57 +16,52 @@ namespace BaseGames.Audio
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerDied;
|
||||
|
||||
[Header("Default Hit SFX")]
|
||||
[SerializeField] private AudioClip _defaultHitSFX;
|
||||
[SerializeField] private AudioEventSO _defaultHitSFX;
|
||||
|
||||
[Header("Per-Type Hit SFX (optional, overrides default)")]
|
||||
[SerializeField] private AudioClip _sparkHitSFX;
|
||||
[SerializeField] private AudioClip _slashHitSFX;
|
||||
[SerializeField] private AudioClip _bloodHitSFX;
|
||||
[SerializeField] private AudioClip _magicHitSFX;
|
||||
[SerializeField] private AudioClip _heavyHitSFX;
|
||||
[SerializeField] private AudioClip _critHitSFX;
|
||||
[SerializeField] private AudioClip _parryHitSFX;
|
||||
[SerializeField] private AudioClip _fireHitSFX;
|
||||
[SerializeField] private AudioClip _iceHitSFX;
|
||||
[SerializeField] private AudioEventSO _sparkHitSFX;
|
||||
[SerializeField] private AudioEventSO _slashHitSFX;
|
||||
[SerializeField] private AudioEventSO _bloodHitSFX;
|
||||
[SerializeField] private AudioEventSO _magicHitSFX;
|
||||
[SerializeField] private AudioEventSO _heavyHitSFX;
|
||||
[SerializeField] private AudioEventSO _critHitSFX;
|
||||
[SerializeField] private AudioEventSO _parryHitSFX;
|
||||
[SerializeField] private AudioEventSO _fireHitSFX;
|
||||
[SerializeField] private AudioEventSO _iceHitSFX;
|
||||
|
||||
[Header("Death SFX")]
|
||||
[SerializeField] private AudioClip _playerDeathSFX;
|
||||
[SerializeField] private AudioEventSO _playerDeathSFX;
|
||||
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onHitConfirmed != null)
|
||||
_onHitConfirmed.OnEventRaised += HandleHit;
|
||||
|
||||
if (_onPlayerDied != null)
|
||||
_onPlayerDied.OnEventRaised += HandlePlayerDied;
|
||||
_onHitConfirmed?.Subscribe(HandleHit).AddTo(_subs);
|
||||
_onPlayerDied?.Subscribe(HandlePlayerDied).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onHitConfirmed != null)
|
||||
_onHitConfirmed.OnEventRaised -= HandleHit;
|
||||
|
||||
if (_onPlayerDied != null)
|
||||
_onPlayerDied.OnEventRaised -= HandlePlayerDied;
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
private void HandleHit(HitInfo info)
|
||||
{
|
||||
AudioClip clip = ResolveHitClip(info.DamageInfo.FxType);
|
||||
if (clip == null) return;
|
||||
AudioEventSO sfx = ResolveHitSFX(info.DamageInfo.FxType);
|
||||
if (sfx == null) return;
|
||||
|
||||
AudioManager.Instance.PlaySFXAtPosition(clip, info.HitPoint);
|
||||
GlobalSFXPlayer.Play(sfx, info.HitPoint);
|
||||
}
|
||||
|
||||
private void HandlePlayerDied()
|
||||
{
|
||||
if (_playerDeathSFX == null) return;
|
||||
AudioManager.Instance.PlaySFX(_playerDeathSFX);
|
||||
GlobalSFXPlayer.Play(_playerDeathSFX);
|
||||
}
|
||||
|
||||
private AudioClip ResolveHitClip(HitFxType fxType)
|
||||
private AudioEventSO ResolveHitSFX(HitFxType fxType)
|
||||
{
|
||||
AudioClip perType = fxType switch
|
||||
AudioEventSO perType = fxType switch
|
||||
{
|
||||
HitFxType.Spark => _sparkHitSFX,
|
||||
HitFxType.Slash => _slashHitSFX,
|
||||
|
||||
30
Assets/Scripts/Audio/FootstepAudioConfigSO.cs
Normal file
30
Assets/Scripts/Audio/FootstepAudioConfigSO.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
// Assets/Scripts/Audio/FootstepAudioConfigSO.cs
|
||||
// 脚步声音效配置(Architecture 21_LiquidPuzzleModule §3.3)
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
[CreateAssetMenu(menuName = "BaseGames/Audio/FootstepAudioConfig")]
|
||||
public class FootstepAudioConfigSO : ScriptableObject
|
||||
{
|
||||
[Serializable]
|
||||
public struct MaterialEntry
|
||||
{
|
||||
public FootstepMaterial material;
|
||||
public AudioClip[] clips; // 随机选一个,防止重复感
|
||||
[Range(0f, 1f)] public float volume;
|
||||
[Range(0.8f, 1.2f)] public float pitchVariance; // 每次随机 pitch 偏移范围
|
||||
}
|
||||
|
||||
public MaterialEntry[] entries;
|
||||
|
||||
public MaterialEntry? GetEntry(FootstepMaterial mat)
|
||||
{
|
||||
if (entries == null) return null;
|
||||
foreach (var e in entries)
|
||||
if (e.material == mat) return e;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Audio/FootstepAudioConfigSO.cs.meta
Normal file
11
Assets/Scripts/Audio/FootstepAudioConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 902dc34e76d63a041a7e9a564b89a5e0
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Assets/Scripts/Audio/FootstepMaterial.cs
Normal file
16
Assets/Scripts/Audio/FootstepMaterial.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
// Assets/Scripts/Audio/FootstepMaterial.cs
|
||||
// 脚步声材质枚举(Architecture 21_LiquidPuzzleModule §3.3)
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
public enum FootstepMaterial
|
||||
{
|
||||
Stone, // 石板地(默认)
|
||||
Dirt, // 泥土/草地
|
||||
Wood, // 木板
|
||||
Metal, // 金属格栅
|
||||
Water, // 浅水区(溅水声)
|
||||
Sand, // 沙地
|
||||
Grass, // 草丛
|
||||
Cave, // 洞穴(回响加强)
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Audio/FootstepMaterial.cs.meta
Normal file
11
Assets/Scripts/Audio/FootstepMaterial.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3dc331b476a760a49b238b6645aae052
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
16
Assets/Scripts/Audio/FootstepMaterialMarker.cs
Normal file
16
Assets/Scripts/Audio/FootstepMaterialMarker.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
// Assets/Scripts/Audio/FootstepMaterialMarker.cs
|
||||
// 挂载到地面碰撞体所在 GameObject,标记该地面的脚步声材质
|
||||
// (Architecture 21_LiquidPuzzleModule §3.3)
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂载到地面碰撞体所在 GameObject(Tilemap 图层 or 单体地形 Prefab)。
|
||||
/// 若玩家脚下 GameObject 无此组件,默认使用 FootstepMaterial.Stone。
|
||||
/// </summary>
|
||||
public class FootstepMaterialMarker : MonoBehaviour
|
||||
{
|
||||
public FootstepMaterial material = FootstepMaterial.Stone;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Audio/FootstepMaterialMarker.cs.meta
Normal file
11
Assets/Scripts/Audio/FootstepMaterialMarker.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9bed4b6cdf4d7064793fe22fefccce43
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
44
Assets/Scripts/Audio/GlobalSFXPlayer.cs
Normal file
44
Assets/Scripts/Audio/GlobalSFXPlayer.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局 SFX 播放入口(单例 MonoBehaviour)。
|
||||
/// 通过 <see cref="Play"/> 静态方法播放 <see cref="AudioEventSO"/>,
|
||||
/// 可选传入世界坐标以在指定位置 3D 播放。
|
||||
/// </summary>
|
||||
public class GlobalSFXPlayer : MonoBehaviour
|
||||
{
|
||||
private static GlobalSFXPlayer _instance;
|
||||
|
||||
[SerializeField] private AudioSource _globalSFXSource;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_instance != null) { Destroy(gameObject); return; }
|
||||
_instance = this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 播放一个音效事件。
|
||||
/// <para>若传入 <paramref name="worldPos"/>,则在该位置 3D 播放;否则使用全局 AudioSource 2D 播放。</para>
|
||||
/// </summary>
|
||||
public static void Play(AudioEventSO audioEvent, Vector2? worldPos = null)
|
||||
{
|
||||
if (audioEvent == null || _instance == null) return;
|
||||
|
||||
if (worldPos.HasValue)
|
||||
{
|
||||
var clip = audioEvent.PickClip();
|
||||
if (clip != null)
|
||||
ServiceLocator.GetOrDefault<IAudioService>()
|
||||
?.PlaySFXAtPosition(clip, worldPos.Value, audioEvent.PickVolume());
|
||||
}
|
||||
else
|
||||
{
|
||||
audioEvent.Play(_instance._globalSFXSource);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Audio/GlobalSFXPlayer.cs.meta
Normal file
11
Assets/Scripts/Audio/GlobalSFXPlayer.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8275d76dd985c4c419f4c477b9275de3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
30
Assets/Scripts/Audio/UnderwaterAudioController.cs
Normal file
30
Assets/Scripts/Audio/UnderwaterAudioController.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
// Assets/Scripts/Audio/UnderwaterAudioController.cs
|
||||
// 进入 LiquidZone 时切换水下 DSP 处理(Architecture 21_LiquidPuzzleModule §3.4)
|
||||
using UnityEngine;
|
||||
using UnityEngine.Audio;
|
||||
|
||||
namespace BaseGames.Audio
|
||||
{
|
||||
/// <summary>
|
||||
/// 挂载于 PlayerController 所在 GameObject。
|
||||
/// 由 LiquidZone.OnTriggerEnter2D / OnTriggerExit2D 直接调用。
|
||||
/// 切换 AudioMixer Snapshot 以应用/解除水下 DSP 处理。
|
||||
/// </summary>
|
||||
public class UnderwaterAudioController : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private AudioMixer _mixer;
|
||||
[SerializeField] private float _transitionDuration = 0.3f;
|
||||
|
||||
/// <summary>玩家进入 Water 类型液体时调用。</summary>
|
||||
public void EnterWater()
|
||||
{
|
||||
_mixer?.FindSnapshot("Underwater")?.TransitionTo(_transitionDuration);
|
||||
}
|
||||
|
||||
/// <summary>玩家离开液体时调用。</summary>
|
||||
public void ExitWater()
|
||||
{
|
||||
_mixer?.FindSnapshot("Default")?.TransitionTo(_transitionDuration);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Audio/UnderwaterAudioController.cs.meta
Normal file
11
Assets/Scripts/Audio/UnderwaterAudioController.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d32189c7a2ecbe4caf2bf3d8aa174f2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,3 +0,0 @@
|
||||
// Placeholder to prevent asmdef-no-scripts warning.
|
||||
namespace BaseGames.Audio { }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
@@ -9,10 +10,8 @@ namespace BaseGames.Camera
|
||||
/// 须放置在持久化场景中。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-100)]
|
||||
public class CameraStateController : MonoBehaviour
|
||||
public class CameraStateController : MonoBehaviour, ICameraService
|
||||
{
|
||||
public static CameraStateController Instance { get; private set; }
|
||||
|
||||
[Header("引用")]
|
||||
[SerializeField] private CinemachineBrain _brain;
|
||||
[SerializeField] private CinemachineImpulseSource _impulseSource;
|
||||
@@ -27,17 +26,13 @@ namespace BaseGames.Camera
|
||||
// ── Unity Lifecycle ───────────────────────────────────────────────────
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
if (ServiceLocator.GetOrDefault<ICameraService>() != null) { Destroy(gameObject); return; }
|
||||
ServiceLocator.Register<ICameraService>(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (Instance == this) Instance = null;
|
||||
ServiceLocator.Unregister<ICameraService>(this);
|
||||
}
|
||||
|
||||
// ── 公开 API ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
@@ -26,7 +27,7 @@ namespace BaseGames.Camera
|
||||
if (!Application.isPlaying) return;
|
||||
if (!other.CompareTag(_playerTag)) return;
|
||||
if (_targetCamera != null)
|
||||
CameraStateController.Instance?.SwitchRoom(_targetCamera);
|
||||
ServiceLocator.GetOrDefault<ICameraService>()?.SwitchRoom(_targetCamera);
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
|
||||
18
Assets/Scripts/Camera/ICameraService.cs
Normal file
18
Assets/Scripts/Camera/ICameraService.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
namespace BaseGames.Camera
|
||||
{
|
||||
/// <summary>
|
||||
/// 相机服务接口。供 RoomController / CameraTriggerZone 等调用,
|
||||
/// 通过 ServiceLocator.Get<ICameraService>() 访问,无需直接依赖 CameraStateController。
|
||||
/// </summary>
|
||||
public interface ICameraService
|
||||
{
|
||||
/// <summary>切换到目标房间相机。</summary>
|
||||
void SwitchRoom(RoomCamera targetCamera);
|
||||
|
||||
/// <summary>注册一个房间相机到控制器注册表。</summary>
|
||||
void RegisterRoomCamera(RoomCamera camera);
|
||||
|
||||
/// <summary>注销一个房间相机。</summary>
|
||||
void UnregisterRoomCamera(RoomCamera camera);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Camera/ICameraService.cs.meta
Normal file
11
Assets/Scripts/Camera/ICameraService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 94f7141340a22a54aa504d7c6a09eeb3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
27
Assets/Scripts/Combat/ArcProjectile.cs
Normal file
27
Assets/Scripts/Combat/ArcProjectile.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 抛物线抛射物。以 <see cref="ProjectileConfigSO.LaunchAngleDeg"/> 指定抛射角,
|
||||
/// 并启用重力缩放,使子弹呈弧线飞行。
|
||||
/// </summary>
|
||||
public class ArcProjectile : Projectile
|
||||
{
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
float rad = _config.LaunchAngleDeg * Mathf.Deg2Rad;
|
||||
float vX = Mathf.Cos(rad) * _config.Speed * Mathf.Sign(Direction.x == 0f ? 1f : Direction.x);
|
||||
float vY = Mathf.Sin(rad) * _config.Speed;
|
||||
|
||||
_rb.velocity = new Vector2(vX, vY);
|
||||
_rb.gravityScale = _config.GravityScale;
|
||||
}
|
||||
|
||||
protected override void OnDisable()
|
||||
{
|
||||
base.OnDisable();
|
||||
_rb.gravityScale = 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/ArcProjectile.cs.meta
Normal file
11
Assets/Scripts/Combat/ArcProjectile.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f43e5039135c2f84682862a9249e2688
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -8,6 +8,7 @@
|
||||
"versionDefines": [],
|
||||
"rootNamespace": "BaseGames.Combat",
|
||||
"references": [
|
||||
"BaseGames.Core",
|
||||
"BaseGames.Core.Events",
|
||||
"BaseGames.Parry"
|
||||
],
|
||||
|
||||
24
Assets/Scripts/Combat/ClashConfigSO.cs
Normal file
24
Assets/Scripts/Combat/ClashConfigSO.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 拼刀系统参数配置(架构 06_CombatModule §15)。
|
||||
/// 资产路径: Assets/ScriptableObjects/Config/Combat/ClashConfig.asset
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Combat/ClashConfig")]
|
||||
public class ClashConfigSO : ScriptableObject
|
||||
{
|
||||
[Header("HitStop")]
|
||||
[Tooltip("拼刀冻帧数(比普通命中的 2 帧更短)")]
|
||||
public int ClashFreezeFrames = 1;
|
||||
|
||||
[Header("弹开")]
|
||||
[Tooltip("拼刀弹开力度(Impulse 模式)")]
|
||||
public float ClashKnockbackForce = 6.0f;
|
||||
|
||||
[Header("Camera Impulse")]
|
||||
[Tooltip("Cinemachine Impulse 强度(轻微震感)")]
|
||||
public float ClashImpulseStrength = 0.3f;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/ClashConfigSO.cs.meta
Normal file
11
Assets/Scripts/Combat/ClashConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2dce4002cf4f99429388b2038fa2f60
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
70
Assets/Scripts/Combat/ClashResolver.cs
Normal file
70
Assets/Scripts/Combat/ClashResolver.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 拼刀系统单例服务(架构 06_CombatModule §15)。
|
||||
/// 常驻 Persistent 场景,由 GameManager 持有。
|
||||
/// 当玩家与敌人的近战 HitBox 同时激活并相互重叠时触发拼刀:双方均不扣血,各自弹开。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-500)]
|
||||
public class ClashResolver : MonoBehaviour, IClashService
|
||||
{
|
||||
[SerializeField] private VoidEventChannelSO _onNailClash; // EVT_NailClash
|
||||
[SerializeField] private ClashConfigSO _config;
|
||||
|
||||
// 防止同一碰撞在同帧被双方 HitBox 各触发一次(去重)
|
||||
// Key = (min(idA,idB), max(idA,idB)),顺序无关且无 XOR 哈希碰撞风险
|
||||
private readonly HashSet<(int, int)> _processedThisFrame = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ServiceLocator.GetOrDefault<IClashService>() != null)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
ServiceLocator.Register<IClashService>(this);
|
||||
Debug.Assert(_config != null, "[ClashResolver] _config 未赋值,请在 Inspector 中指定 ClashConfigSO。", this);
|
||||
}
|
||||
|
||||
private void LateUpdate() => _processedThisFrame.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// 由 HitBox.OnTriggerEnter2D 调用。
|
||||
/// 对同一对 HitBox 每帧只处理一次(HashSet 去重)。
|
||||
/// </summary>
|
||||
public void ResolveClash(HitBox hitBoxA, HitBox hitBoxB)
|
||||
{
|
||||
int idA = hitBoxA.GetInstanceID();
|
||||
int idB = hitBoxB.GetInstanceID();
|
||||
(int, int) key = (System.Math.Min(idA, idB), System.Math.Max(idA, idB));
|
||||
if (!_processedThisFrame.Add(key)) return;
|
||||
|
||||
// 1. 拼刀冻帧(比普通命中的 2 帧更短,强化拼刀手感)
|
||||
ServiceLocator.GetOrDefault<IHitStopService>()?.FreezeFrames(_config.ClashFreezeFrames);
|
||||
|
||||
// 2. 双方弹开
|
||||
ApplyClashKnockback(hitBoxA.OwnerRigidbody, hitBoxB.transform.position);
|
||||
ApplyClashKnockback(hitBoxB.OwnerRigidbody, hitBoxA.transform.position);
|
||||
|
||||
// 3. 广播事件(VFX / Audio / CameraImpulse 订阅)
|
||||
_onNailClash?.Raise();
|
||||
}
|
||||
|
||||
private void ApplyClashKnockback(Rigidbody2D rb, Vector2 oppositePos)
|
||||
{
|
||||
if (rb == null) return;
|
||||
Vector2 dir = ((Vector2)rb.transform.position - oppositePos).normalized;
|
||||
rb.AddForce(dir * _config.ClashKnockbackForce, ForceMode2D.Impulse);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<IClashService>(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/ClashResolver.cs.meta
Normal file
11
Assets/Scripts/Combat/ClashResolver.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df7a93b79e446e24dbb27d2971c85f39
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -5,12 +5,13 @@ namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 单次伤害信息。流水线:RawDamage → Amount(护盾修改)→ FinalDamage(防御减免后)。
|
||||
/// ⚠️ 非 readonly struct — Builder 就地写入字段。
|
||||
/// ⚠️ 保留为可变 struct:HurtBox 流水线需要在方法内修改本地副本的 Amount / FinalDamage。
|
||||
/// Builder 通过独立字段构造,不直接修改 DamageInfo 实例。
|
||||
/// </summary>
|
||||
[System.Serializable]
|
||||
public struct DamageInfo
|
||||
{
|
||||
public int RawDamage; // HitBox 设定的原始值(Builder.SetRaw 写入一次)
|
||||
public int RawDamage; // HitBox 设定的原始值(工厂/Builder 写入一次)
|
||||
public int Amount; // 流水线中被护盾/防御修改
|
||||
public int FinalDamage; // HurtBox 写入,最终 HP 扣除量
|
||||
public Vector2 KnockbackDirection;
|
||||
@@ -28,50 +29,92 @@ namespace BaseGames.Combat
|
||||
public string SkillId;
|
||||
|
||||
// ── Builder ──────────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 通过独立字段构造 DamageInfo,避免直接持有可变 DamageInfo 实例。
|
||||
/// </summary>
|
||||
public class Builder
|
||||
{
|
||||
private DamageInfo _d;
|
||||
private int _raw;
|
||||
private DamageType _type;
|
||||
private DamageCategory _category;
|
||||
private DamageFlags _flags;
|
||||
private DamageTags _tags;
|
||||
private string _skillId;
|
||||
private string _sourceId;
|
||||
private Vector2 _knockbackDirection;
|
||||
private float _knockbackForce;
|
||||
private float _hitStunDuration;
|
||||
private HitFxType _fxType;
|
||||
private BreakLevel _break;
|
||||
private Vector2 _sourcePosition;
|
||||
private int _sourceLayer;
|
||||
|
||||
public Builder() { }
|
||||
|
||||
// SetRaw 同步初始化 Amount(Amount 始终以 RawDamage 为起点)
|
||||
public Builder SetRaw(int v) { _d.RawDamage = v; _d.Amount = v; return this; }
|
||||
public Builder SetType(DamageType v) { _d.Type = v; return this; }
|
||||
public Builder SetCategory(DamageCategory v){ _d.Category = v; return this; }
|
||||
public Builder SetFlags(DamageFlags v) { _d.Flags = v; return this; }
|
||||
public Builder SetTags(DamageTags v) { _d.Tags = v; return this; }
|
||||
public Builder SetSkillId(string v) { _d.SkillId = v; return this; }
|
||||
public Builder SetSourceId(string v) { _d.SourceId = v; return this; }
|
||||
public Builder SetKnockback(Vector2 dir, float force)
|
||||
{ _d.KnockbackDirection = dir; _d.KnockbackForce = force; return this; }
|
||||
public Builder SetStun(float dur) { _d.HitStunDuration = dur; return this; }
|
||||
public Builder SetFx(HitFxType v) { _d.FxType = v; return this; }
|
||||
public Builder SetBreak(BreakLevel v) { _d.Break = v; return this; }
|
||||
public Builder SetSourcePos(Vector2 v) { _d.SourcePosition = v; return this; }
|
||||
public Builder SetLayer(int v) { _d.SourceLayer = v; return this; }
|
||||
public DamageInfo Build() => _d;
|
||||
public Builder SetRaw(int v) { _raw = v; return this; }
|
||||
public Builder SetType(DamageType v) { _type = v; return this; }
|
||||
public Builder SetCategory(DamageCategory v) { _category = v; return this; }
|
||||
public Builder SetFlags(DamageFlags v) { _flags = v; return this; }
|
||||
public Builder SetTags(DamageTags v) { _tags = v; return this; }
|
||||
public Builder SetSkillId(string v) { _skillId = v; return this; }
|
||||
public Builder SetSourceId(string v) { _sourceId = v; return this; }
|
||||
public Builder SetKnockback(Vector2 dir, float force) { _knockbackDirection = dir; _knockbackForce = force; return this; }
|
||||
public Builder SetStun(float dur) { _hitStunDuration = dur; return this; }
|
||||
public Builder SetFx(HitFxType v) { _fxType = v; return this; }
|
||||
public Builder SetBreak(BreakLevel v) { _break = v; return this; }
|
||||
public Builder SetSourcePos(Vector2 v) { _sourcePosition = v; return this; }
|
||||
public Builder SetLayer(int v) { _sourceLayer = v; return this; }
|
||||
|
||||
public DamageInfo Build() => new DamageInfo
|
||||
{
|
||||
RawDamage = _raw,
|
||||
Amount = _raw,
|
||||
Type = _type,
|
||||
Category = _category,
|
||||
Flags = _flags,
|
||||
Tags = _tags,
|
||||
SkillId = _skillId,
|
||||
SourceId = _sourceId,
|
||||
KnockbackDirection = _knockbackDirection,
|
||||
KnockbackForce = _knockbackForce,
|
||||
HitStunDuration = _hitStunDuration,
|
||||
FxType = _fxType,
|
||||
Break = _break,
|
||||
SourcePosition = _sourcePosition,
|
||||
SourceLayer = _sourceLayer,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ⚡ 零堆分配工厂(热路径首选)。直接从 DamageSourceSO 填入基础字段。
|
||||
/// KnockbackDirection / SourcePosition / SourceLayer 等运行时字段由调用方就地赋值。
|
||||
/// ⚡ 零堆分配工厂(热路径首选)。从 DamageSourceSO 填入所有静态字段;
|
||||
/// 可选传入运行时字段(knockbackDir、sourcePos、sourceLayer),
|
||||
/// 无需调用方事后就地赋值。
|
||||
/// </summary>
|
||||
public static DamageInfo From(DamageSourceSO so)
|
||||
public static DamageInfo From(
|
||||
DamageSourceSO so,
|
||||
Vector2 knockbackDir = default,
|
||||
Vector2 sourcePos = default,
|
||||
int sourceLayer = 0)
|
||||
{
|
||||
int baseAmt = Mathf.RoundToInt(so.BaseDamage * so.DamageMultiplier);
|
||||
return new DamageInfo
|
||||
{
|
||||
RawDamage = baseAmt,
|
||||
Amount = baseAmt,
|
||||
Type = so.Type,
|
||||
Category = so.Category,
|
||||
Flags = so.Flags,
|
||||
Tags = so.Tags,
|
||||
HitStunDuration = so.HitStunDuration,
|
||||
FxType = so.FxType,
|
||||
Break = so.BreakLevel,
|
||||
SourceId = so.sourceId,
|
||||
SkillId = so.skillId,
|
||||
RawDamage = baseAmt,
|
||||
Amount = baseAmt,
|
||||
Type = so.Type,
|
||||
Category = so.Category,
|
||||
Flags = so.Flags,
|
||||
Tags = so.Tags,
|
||||
HitStunDuration = so.HitStunDuration,
|
||||
FxType = so.FxType,
|
||||
Break = so.BreakLevel,
|
||||
SourceId = so.sourceId,
|
||||
SkillId = so.skillId,
|
||||
KnockbackDirection = knockbackDir,
|
||||
KnockbackForce = so.KnockbackForce,
|
||||
SourcePosition = sourcePos,
|
||||
SourceLayer = sourceLayer,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 攻击判定盒。挂载在武器 Prefab 或技能 HitBox Prefab 的子节点上。
|
||||
/// Phase 1 简化:直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。
|
||||
/// 直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。
|
||||
/// Collider2D 需设 IsTrigger = true,Layer = PlayerHitBox 或 EnemyHitBox。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
@@ -14,12 +15,38 @@ namespace BaseGames.Combat
|
||||
[SerializeField] private DamageSourceSO _defaultSource;
|
||||
[SerializeField] private float _hitCooldown = 0.1f;
|
||||
|
||||
/// <summary>
|
||||
/// HitBox 标识符,供 PlayerAnimationEvents / EnemyAnimationEvents 按名称精确激活特定判定盒。
|
||||
/// 留空表示"无 Id";事件 payload 为空时将操作所有 HitBox。
|
||||
/// </summary>
|
||||
[SerializeField] private string _id = "";
|
||||
public string Id => _id;
|
||||
|
||||
/// <summary>
|
||||
/// 对立阵营 HitBox 所在的 Layer 掩码(用于拼刀检测)。
|
||||
/// Inspector 中将 PlayerHitBox 与 EnemyHitBox 两个 Layer 均勾选。
|
||||
/// </summary>
|
||||
[SerializeField] private LayerMask _rivalHitBoxMask;
|
||||
|
||||
private DamageSourceSO _currentSource;
|
||||
private Transform _attackerTransform;
|
||||
private Rigidbody2D _ownerRigidbody;
|
||||
private bool _isActive;
|
||||
private IClashService _clashService;
|
||||
|
||||
/// <summary>HitBox 当前是否激活(供 ClashResolver 查询)。</summary>
|
||||
public bool IsActive => _isActive;
|
||||
|
||||
/// <summary>当前 Source 是否携带 CanClash 标记(供 ClashResolver 查询)。</summary>
|
||||
public bool CanClash => _currentSource != null && _currentSource.Flags.HasFlag(DamageFlags.CanClash);
|
||||
|
||||
/// <summary>宿主角色的 Rigidbody2D(用于拼刀弹开力计算)。</summary>
|
||||
public Rigidbody2D OwnerRigidbody => _ownerRigidbody;
|
||||
|
||||
// 拼刀检测所需的对立层掩码(Inspector 配置)
|
||||
|
||||
/// <summary>命中确认委托(PlayerCombat / EnemyCombat 订阅)。</summary>
|
||||
public System.Action<DamageInfo> OnHitConfirmed;
|
||||
public event System.Action<DamageInfo> OnHitConfirmed;
|
||||
|
||||
/// <summary>
|
||||
/// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。
|
||||
@@ -30,9 +57,25 @@ namespace BaseGames.Combat
|
||||
_currentSource = source ?? _defaultSource;
|
||||
_attackerTransform = attacker ?? transform;
|
||||
_isActive = true;
|
||||
// 缓存宿主 Rigidbody2D(沿父层级向上查找)
|
||||
_ownerRigidbody = _attackerTransform.GetComponentInParent<Rigidbody2D>();
|
||||
// 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标)
|
||||
_hitThisActivation.Clear();
|
||||
_hitCooldownTimers.Clear();
|
||||
}
|
||||
|
||||
public void Deactivate() => _isActive = false;
|
||||
public void Deactivate()
|
||||
{
|
||||
_isActive = false;
|
||||
_hitThisActivation.Clear();
|
||||
_hitCooldownTimers.Clear();
|
||||
}
|
||||
|
||||
/// <summary>仅替换当前 DamageSource(不改变激活状态,供 PlayerCombat 连击段切换)。</summary>
|
||||
public void SetDamageSource(DamageSourceSO source)
|
||||
{
|
||||
if (source != null) _currentSource = source;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -40,35 +83,60 @@ namespace BaseGames.Combat
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (!col.isTrigger)
|
||||
Debug.LogWarning($"[HitBox] {name}: Collider2D.isTrigger 应为 true。", this);
|
||||
// 缓存 IClashService:OnTriggerEnter2D 为物理热路径,避免每次调用 Dictionary 查找
|
||||
_clashService = ServiceLocator.GetOrDefault<IClashService>();
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_isActive = false;
|
||||
_hitThisActivation.Clear();
|
||||
_hitCooldownTimers.Clear();
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
private void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
// 目标离开判定区域时清除其冷却记录,防止持续激活的 HitBox(环境危险等)
|
||||
// 因有效目标持续流动而无限积累已离场对象。
|
||||
// 注意:_hitThisActivation 刻意保留,确保同一激活期内不重复命中。
|
||||
_hitCooldownTimers.Remove(other);
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other) {
|
||||
if (!_isActive) return;
|
||||
if (_currentSource == null)
|
||||
{
|
||||
Debug.LogWarning($"[HitBox] {name}: 无 DamageSourceSO,跳过命中。", this);
|
||||
return;
|
||||
}
|
||||
// 同一激活期防止对同一 Collider 重复命中(一次攻击每个目标至多命中一次)
|
||||
if (!_hitThisActivation.Add(other)) return;
|
||||
if (!CheckCooldown(other)) return;
|
||||
|
||||
Vector2 knockDir = ((Vector2)other.bounds.center
|
||||
- (Vector2)_attackerTransform.position).normalized;
|
||||
|
||||
// ⚡ 零 GC:struct 工厂,就地赋值运行时字段
|
||||
var info = DamageInfo.From(_currentSource);
|
||||
info.KnockbackDirection = knockDir;
|
||||
info.KnockbackForce = _currentSource.KnockbackForce;
|
||||
info.SourcePosition = _attackerTransform.position;
|
||||
info.SourceLayer = _attackerTransform.gameObject.layer;
|
||||
// ⚡ 零 GC:struct 工厂,运行时字段内联传入
|
||||
var info = DamageInfo.From(
|
||||
_currentSource,
|
||||
knockDir,
|
||||
_attackerTransform.position,
|
||||
_attackerTransform.gameObject.layer);
|
||||
|
||||
// ① 命中 HurtBox
|
||||
// ① 拼刀检测:当前 HitBox 携带 CanClash 标记,且碰到对立阵营的 HitBox 层
|
||||
int otherLayer = other.gameObject.layer;
|
||||
bool isRivalHitBoxLayer = (_rivalHitBoxMask.value & (1 << otherLayer)) != 0;
|
||||
if (isRivalHitBoxLayer && CanClash)
|
||||
{
|
||||
var rivalHitBox = other.GetComponent<HitBox>();
|
||||
if (rivalHitBox != null && rivalHitBox.IsActive && rivalHitBox.CanClash)
|
||||
{
|
||||
_clashService?.ResolveClash(this, rivalHitBox);
|
||||
return; // 拼刀,中止伤害流水线
|
||||
}
|
||||
}
|
||||
|
||||
// ② 命中 HurtBox
|
||||
var hurtBox = other.GetComponent<HurtBox>();
|
||||
if (hurtBox != null)
|
||||
{
|
||||
@@ -77,12 +145,14 @@ namespace BaseGames.Combat
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 命中 IBreakable(机关/障碍物)
|
||||
// ③ 命中 IBreakable(机关/障碍物)
|
||||
other.GetComponent<IBreakable>()?.TryInteract(info);
|
||||
}
|
||||
|
||||
// ── 同目标多帧命中冷却 ────────────────────────────────────────────────
|
||||
private readonly Dictionary<Collider2D, float> _hitCooldownTimers = new();
|
||||
// ── 当前激活期已命中目标集合(防止复合子 Collider 导致同帧多次命中)────────────
|
||||
private readonly HashSet<Collider2D> _hitThisActivation = new(8);
|
||||
// ── 同目标多帧命中冷却(防止 Trigger 处于重叠状态时重入等殊情导致的连击)──
|
||||
private readonly Dictionary<Collider2D, float> _hitCooldownTimers = new(8);
|
||||
|
||||
private bool CheckCooldown(Collider2D other)
|
||||
{
|
||||
@@ -92,5 +162,20 @@ namespace BaseGames.Combat
|
||||
_hitCooldownTimers[other] = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (col == null) return;
|
||||
// 激活时显示橙色判定框,非激活时显示极淡轮廓
|
||||
Gizmos.color = _isActive
|
||||
? new UnityEngine.Color(1f, 0.5f, 0f, 0.55f)
|
||||
: new UnityEngine.Color(1f, 0.5f, 0f, 0.1f);
|
||||
Gizmos.DrawCube(col.bounds.center, col.bounds.size);
|
||||
Gizmos.color = new UnityEngine.Color(1f, 0.5f, 0f, 0.9f);
|
||||
Gizmos.DrawWireCube(col.bounds.center, col.bounds.size);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
102
Assets/Scripts/Combat/HitStopManager.cs
Normal file
102
Assets/Scripts/Combat/HitStopManager.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 命中冻帧服务接口。
|
||||
/// </summary>
|
||||
public interface IHitStopService
|
||||
{
|
||||
/// <summary>冻帧 <paramref name="frames"/> 帧(以 fixedDeltaTime 换算为实际时长)。</summary>
|
||||
void FreezeFrames(int frames);
|
||||
/// <summary>冻帧指定时长(Unscaled 秒)。</summary>
|
||||
void FreezeDuration(float unscaledSeconds);
|
||||
/// <summary>游戏正常时间缩放(默认 1);子弹时间等功能修改此属性。</summary>
|
||||
float BaseTimeScale { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 命中冻帧服务(HitStop)(架构 06_CombatModule §16)。
|
||||
/// 通过短暂将 Time.timeScale 设为 0 实现"冻帧"效果,强化打击感。
|
||||
/// 常驻 Persistent 场景,由 GameManager 持有;通过 ServiceLocator 注册访问。
|
||||
///
|
||||
/// 设计说明:
|
||||
/// - 多次并发请求取最长持续时间(StopCoroutine + 重启)
|
||||
/// - 使用 WaitForSecondsRealtime 确保 timeScale=0 时协程仍能恢复
|
||||
/// - OnDestroy 强制还原 timeScale,防止异常退出导致游戏卡死
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-400)]
|
||||
public class HitStopManager : MonoBehaviour, IHitStopService
|
||||
{
|
||||
/// <summary>游戏正常时间缩放(默认 1);通过属性读取以便外部修改子弹时间时保留基准值。</summary>
|
||||
public float BaseTimeScale
|
||||
{
|
||||
get => _baseTimeScale;
|
||||
set => _baseTimeScale = Mathf.Clamp(value, 0.01f, 10f);
|
||||
}
|
||||
private float _baseTimeScale = 1f;
|
||||
|
||||
private Coroutine _activeRoutine;
|
||||
private float _freezeEndTime;
|
||||
|
||||
// ── 生命周期 ──────────────────────────────────────────────────────
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ServiceLocator.GetOrDefault<IHitStopService>() != null) { Destroy(gameObject); return; }
|
||||
ServiceLocator.Register<IHitStopService>(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
// 安全恢复:防止场景卸载/异常退出时 timeScale 永久为 0
|
||||
Time.timeScale = _baseTimeScale;
|
||||
ServiceLocator.Unregister<IHitStopService>(this);
|
||||
}
|
||||
|
||||
// ── 公共 API ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 冻帧 <paramref name="frames"/> 帧(以 fixedDeltaTime 换算为实际时长)。
|
||||
/// 若已有冻帧进行中,取两者中持续时间较长的(避免短请求截断较长的冻帧)。
|
||||
/// </summary>
|
||||
/// <param name="frames">冻帧帧数(fixedDeltaTime 单位)。0 或负数无效。</param>
|
||||
public void FreezeFrames(int frames)
|
||||
{
|
||||
if (frames <= 0) return;
|
||||
FreezeDuration(frames * Time.fixedDeltaTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 冻帧指定时长(Unscaled 实际秒数)。
|
||||
/// 若已有冻帧进行中,取两者中较长的。
|
||||
/// </summary>
|
||||
/// <param name="unscaledSeconds">实际时长(秒),不受 timeScale 影响。</param>
|
||||
public void FreezeDuration(float unscaledSeconds)
|
||||
{
|
||||
if (unscaledSeconds <= 0f) return;
|
||||
|
||||
float newEndTime = Time.unscaledTime + unscaledSeconds;
|
||||
// 已有更长的冻帧进行中,放弃短请求,避免截断
|
||||
if (_activeRoutine != null && newEndTime <= _freezeEndTime) return;
|
||||
|
||||
_freezeEndTime = newEndTime;
|
||||
if (_activeRoutine != null)
|
||||
StopCoroutine(_activeRoutine);
|
||||
|
||||
_activeRoutine = StartCoroutine(FreezeRoutine(unscaledSeconds));
|
||||
}
|
||||
|
||||
// ── 内部实现 ──────────────────────────────────────────────────────
|
||||
|
||||
private IEnumerator FreezeRoutine(float unscaledSeconds)
|
||||
{
|
||||
Time.timeScale = 0f;
|
||||
yield return new WaitForSecondsRealtime(unscaledSeconds);
|
||||
Time.timeScale = _baseTimeScale;
|
||||
_activeRoutine = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/HitStopManager.cs.meta
Normal file
11
Assets/Scripts/Combat/HitStopManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9aace2ae37bf9d0459a4cdeb34595885
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
40
Assets/Scripts/Combat/HomingProjectile.cs
Normal file
40
Assets/Scripts/Combat/HomingProjectile.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 追踪抛射物。初始以 Direction 发射后,每帧向目标转向,
|
||||
/// 转向力由 <see cref="ProjectileConfigSO.HomingStrength"/> 控制。
|
||||
/// </summary>
|
||||
public class HomingProjectile : Projectile
|
||||
{
|
||||
private Transform _target;
|
||||
|
||||
/// <summary>注入追踪目标(由 ProjectileManager 在生成时调用)。</summary>
|
||||
public void SetTarget(Transform t) => _target = t;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_rb.velocity = Direction * _config.Speed;
|
||||
}
|
||||
|
||||
protected override void Update()
|
||||
{
|
||||
base.Update();
|
||||
if (_target == null || _config == null) return;
|
||||
|
||||
Vector2 toTarget = ((Vector2)_target.position - _rb.position).normalized;
|
||||
_rb.velocity += toTarget * (_config.HomingStrength * Time.deltaTime);
|
||||
|
||||
float maxSpeed = _config.Speed * 1.5f;
|
||||
if (_rb.velocity.sqrMagnitude > maxSpeed * maxSpeed)
|
||||
_rb.velocity = _rb.velocity.normalized * maxSpeed;
|
||||
}
|
||||
|
||||
protected override void OnDisable()
|
||||
{
|
||||
base.OnDisable();
|
||||
_target = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/HomingProjectile.cs.meta
Normal file
11
Assets/Scripts/Combat/HomingProjectile.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cb4a2de7e8deb224db43f89dbe62748d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -12,10 +12,11 @@ namespace BaseGames.Combat
|
||||
public class HurtBox : MonoBehaviour
|
||||
{
|
||||
// ── 伤害接受方(Awake 注入)──────────────────────────────────────────
|
||||
private IDamageable _owner;
|
||||
private IShieldable _shieldable; // 由 PlayerController.Awake() 注入
|
||||
private ParrySystem _parrySystem; // Phase 2 由 PlayerController.Awake() 注入
|
||||
private IPoiseSource _poiseSource; // Phase 2 由 EnemyBase.Awake() 注入
|
||||
private IDamageable _owner;
|
||||
private IShieldable _shieldable; // 由 PlayerController.Awake() 注入
|
||||
private ParrySystem _parrySystem; // 由 PlayerController.Awake() 注入
|
||||
private IPoiseSource _poiseSource; // 由 EnemyBase.Awake() 注入
|
||||
private IStatusEffectable _statusEffectable; // Awake 缓存,避免每次受击调用 GetComponent
|
||||
|
||||
private bool _isHurtBoxInvincible;
|
||||
private bool _isActive = true;
|
||||
@@ -30,10 +31,18 @@ namespace BaseGames.Combat
|
||||
public void SetPoiseSource(IPoiseSource src) => _poiseSource = src;
|
||||
public void SetInvincible(bool value) => _isHurtBoxInvincible = value;
|
||||
public void SetActive(bool value) => _isActive = value;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// 付给编辑器的只读属性——避免反射并限制编辑器与运行时字段名耐合性。
|
||||
public object EditorOwner => _owner;
|
||||
public object EditorShieldable => _shieldable;
|
||||
public object EditorParrySystem => _parrySystem;
|
||||
public object EditorPoiseSource => _poiseSource;
|
||||
public object EditorStatusEffectable => _statusEffectable;
|
||||
#endif
|
||||
private void Awake()
|
||||
{
|
||||
_owner = GetComponentInParent<IDamageable>();
|
||||
_statusEffectable = GetComponentInParent<IStatusEffectable>();
|
||||
if (_owner == null)
|
||||
Debug.LogWarning($"[HurtBox] {name}: 父节点中未找到 IDamageable 实现。", this);
|
||||
}
|
||||
@@ -50,12 +59,12 @@ namespace BaseGames.Combat
|
||||
if ((_owner.IsInvincible || _isHurtBoxInvincible)
|
||||
&& !info.Flags.HasFlag(DamageFlags.IgnoreIFrame)) return;
|
||||
|
||||
// 2. 弹反检查(Phase 1 _parrySystem == null 跳过)
|
||||
// 2. 弹反检查(_parrySystem == null 时跳过)
|
||||
// ParrySystem 只暴露窗口状态,伤害数据留在 Combat 层,无跨程序集数据依赖。
|
||||
if (_parrySystem != null && info.Flags.HasFlag(DamageFlags.CanBeParried))
|
||||
if (_parrySystem.ConsumeParry()) return;
|
||||
|
||||
// 3. 霸体检查(Phase 1 _poiseSource == null 跳过)
|
||||
// 3. 霸体检查(_poiseSource == null 时跳过)
|
||||
if (!info.Flags.HasFlag(DamageFlags.ForceBreak) && _poiseSource != null)
|
||||
{
|
||||
PoiseLevel curPoise = _poiseSource.GetCurrentPoiseLevel();
|
||||
@@ -96,11 +105,23 @@ namespace BaseGames.Combat
|
||||
});
|
||||
|
||||
// 8. 状态效果触发(DoT — Fire / Poison)
|
||||
// 使用接口避免对 StatusEffects 程序集的直接依赖
|
||||
if (_owner is UnityEngine.MonoBehaviour mb)
|
||||
{
|
||||
mb.GetComponent<IStatusEffectable>()?.ApplyStatusEffect(info.Type);
|
||||
}
|
||||
// _statusEffectable 已在 Awake 中缓存,无需每次受击调用 GetComponent
|
||||
_statusEffectable?.ApplyStatusEffect(info.Type);
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (col == null) return;
|
||||
// 激活时红色不透明,无敌/非激活时半透明
|
||||
Gizmos.color = (_isActive && !_isHurtBoxInvincible)
|
||||
? new UnityEngine.Color(1f, 0f, 0f, 0.45f)
|
||||
: new UnityEngine.Color(1f, 0f, 0f, 0.1f);
|
||||
Gizmos.DrawCube(col.bounds.center, col.bounds.size);
|
||||
Gizmos.color = new UnityEngine.Color(1f, 0f, 0f, 0.9f);
|
||||
Gizmos.DrawWireCube(col.bounds.center, col.bounds.size);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
11
Assets/Scripts/Combat/IClashService.cs
Normal file
11
Assets/Scripts/Combat/IClashService.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
// Assets/Scripts/Combat/IClashService.cs
|
||||
// 拼刀服务接口,通过 ServiceLocator 注册与查询。
|
||||
// ClashResolver 实现此接口;HitBox 等调用方通过接口解耦。
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
public interface IClashService
|
||||
{
|
||||
void ResolveClash(HitBox hitBoxA, HitBox hitBoxB);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/IClashService.cs.meta
Normal file
11
Assets/Scripts/Combat/IClashService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7df722a95e7dc7f4b808256877e44af7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
17
Assets/Scripts/Combat/IProjectileService.cs
Normal file
17
Assets/Scripts/Combat/IProjectileService.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 抛射物服务接口。通过 ServiceLocator 注册,供敌人 AI 生成追踪弹使用。
|
||||
/// </summary>
|
||||
public interface IProjectileService
|
||||
{
|
||||
/// <summary>当前缓存的玩家 Transform,生成追踪弹时注入目标。</summary>
|
||||
Transform PlayerTransform { get; }
|
||||
|
||||
/// <summary>完整初始化一枚 HomingProjectile 并注入追踪目标。</summary>
|
||||
void LaunchHoming(HomingProjectile proj, Vector2 direction,
|
||||
ProjectileConfigSO config, DamageInfo damageInfo);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/IProjectileService.cs.meta
Normal file
11
Assets/Scripts/Combat/IProjectileService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b7d8b713a91da1d408f02c68e666f8fa
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
13
Assets/Scripts/Combat/LinearProjectile.cs
Normal file
13
Assets/Scripts/Combat/LinearProjectile.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 直线抛射物。以固定速度沿 Direction 方向飞行,无重力。
|
||||
/// </summary>
|
||||
public class LinearProjectile : Projectile
|
||||
{
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_rb.velocity = Direction * _config.Speed;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/LinearProjectile.cs.meta
Normal file
11
Assets/Scripts/Combat/LinearProjectile.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e7b0c1c571010c4c9f65f953274086d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
58
Assets/Scripts/Combat/ParryableProjectile.cs
Normal file
58
Assets/Scripts/Combat/ParryableProjectile.cs
Normal file
@@ -0,0 +1,58 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Parry;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 可被玩家弹反的抛射物。
|
||||
/// 触发时优先检测弹反窗口;若成功弹反则反向飞行并可切换伤害源;
|
||||
/// 否则走正常伤害流水线。
|
||||
/// </summary>
|
||||
public class ParryableProjectile : LinearProjectile
|
||||
{
|
||||
[SerializeField] private DamageSourceSO _reflectSource;
|
||||
|
||||
private bool _reflected;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// 禁用子 HitBox 的自动检测,改由本组件的 OnTriggerEnter2D 手动处理,
|
||||
// 以便在命中前插入弹反判断。
|
||||
_hitBox.Deactivate();
|
||||
_rb.velocity = Direction * _config.Speed;
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other)
|
||||
{
|
||||
// ── 弹反判断 ────────────────────────────────────────────────
|
||||
if (!_reflected)
|
||||
{
|
||||
var parrySystem = other.GetComponentInParent<ParrySystem>();
|
||||
if (parrySystem != null && parrySystem.IsParrying && parrySystem.ConsumeParry())
|
||||
{
|
||||
_reflected = true;
|
||||
Direction = -Direction;
|
||||
_rb.velocity = Direction * _config.Speed * _config.ParrySpeedMultiplier;
|
||||
|
||||
if (_reflectSource != null)
|
||||
DamageInfo = DamageInfo.From(_reflectSource);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 正常命中 ─────────────────────────────────────────────────
|
||||
var hurtBox = other.GetComponent<HurtBox>();
|
||||
if (hurtBox != null)
|
||||
{
|
||||
hurtBox.ReceiveDamage(DamageInfo);
|
||||
ReturnToPool();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDisable()
|
||||
{
|
||||
base.OnDisable();
|
||||
_reflected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/ParryableProjectile.cs.meta
Normal file
11
Assets/Scripts/Combat/ParryableProjectile.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 075db832266507d40bc71389d6d3f333
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
31
Assets/Scripts/Combat/PoiseWindowConfig.cs
Normal file
31
Assets/Scripts/Combat/PoiseWindowConfig.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 描述某个状态/技能在特定动画时间段内拥有的霸体等级(架构 06_CombatModule §13)。
|
||||
/// 在状态机的 Update() 或 AnimancerEvent 中与动画归一化时间对比,决定当前霸体等级。
|
||||
/// 使用示例:
|
||||
/// <code>
|
||||
/// [SerializeField] private PoiseWindowConfig[] _poiseWindows;
|
||||
/// public PoiseLevel GetCurrentPoiseLevel()
|
||||
/// {
|
||||
/// float t = _animancer.States.Current?.NormalizedTime ?? 0f;
|
||||
/// foreach (var w in _poiseWindows)
|
||||
/// if (t >= w.NormalizedStart && t <= w.NormalizedEnd)
|
||||
/// return w.Level;
|
||||
/// return PoiseLevel.None;
|
||||
/// }
|
||||
/// </code>
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public struct PoiseWindowConfig
|
||||
{
|
||||
/// <summary>此时间窗口期间的霸体等级。</summary>
|
||||
public PoiseLevel Level;
|
||||
/// <summary>动画归一化时间起点(0~1)。</summary>
|
||||
public float NormalizedStart;
|
||||
/// <summary>动画归一化时间终点(0~1)。</summary>
|
||||
public float NormalizedEnd;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/PoiseWindowConfig.cs.meta
Normal file
11
Assets/Scripts/Combat/PoiseWindowConfig.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aaa68ed270c340743958655059e74cfd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
67
Assets/Scripts/Combat/Projectile.cs
Normal file
67
Assets/Scripts/Combat/Projectile.cs
Normal file
@@ -0,0 +1,67 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Pool;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 抛射物基类。子类通过重写 <see cref="OnInitialized"/> 设定初速度。
|
||||
/// 依赖 <see cref="HitBox"/> 子组件进行碰撞伤害检测。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Rigidbody2D), typeof(HitBox))]
|
||||
public abstract class Projectile : MonoBehaviour
|
||||
{
|
||||
[HideInInspector] public DamageInfo DamageInfo;
|
||||
[HideInInspector] public Vector2 Direction;
|
||||
|
||||
protected ProjectileConfigSO _config;
|
||||
protected Rigidbody2D _rb;
|
||||
protected HitBox _hitBox;
|
||||
protected float _aliveTimer;
|
||||
|
||||
private PooledObject _pooledObject;
|
||||
|
||||
protected virtual void Awake()
|
||||
{
|
||||
_rb = GetComponent<Rigidbody2D>();
|
||||
_hitBox = GetComponent<HitBox>();
|
||||
_pooledObject = GetComponent<PooledObject>();
|
||||
}
|
||||
|
||||
/// <summary>从对象池取出后的初始化入口。</summary>
|
||||
public virtual void Initialize(ProjectileConfigSO config, DamageInfo damageInfo, Vector2 direction)
|
||||
{
|
||||
_config = config;
|
||||
DamageInfo = damageInfo;
|
||||
Direction = direction.normalized;
|
||||
_aliveTimer = 0f;
|
||||
|
||||
_hitBox.Activate(config.DamageSource);
|
||||
OnInitialized();
|
||||
}
|
||||
|
||||
/// <summary>子类在此设定初速度或附加初始化逻辑。</summary>
|
||||
protected virtual void OnInitialized() { }
|
||||
|
||||
protected virtual void Update()
|
||||
{
|
||||
_aliveTimer += Time.deltaTime;
|
||||
if (_config != null && _aliveTimer >= _config.Lifetime)
|
||||
ReturnToPool();
|
||||
}
|
||||
|
||||
/// <summary>停用并归还对象池。</summary>
|
||||
protected void ReturnToPool()
|
||||
{
|
||||
_hitBox.Deactivate();
|
||||
gameObject.SetActive(false);
|
||||
if (_pooledObject != null && _config != null)
|
||||
ServiceLocator.GetOrDefault<IObjectPoolService>()?.Despawn(_config.PoolKey, _pooledObject);
|
||||
}
|
||||
|
||||
protected virtual void OnDisable()
|
||||
{
|
||||
_aliveTimer = 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/Projectile.cs.meta
Normal file
11
Assets/Scripts/Combat/Projectile.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b3be0438f9175ab4f989693a8b01fdc7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
28
Assets/Scripts/Combat/ProjectileConfigSO.cs
Normal file
28
Assets/Scripts/Combat/ProjectileConfigSO.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 抛射物配置 ScriptableObject。描述一类抛射物的运动、伤害与对象池参数。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Combat/ProjectileConfig")]
|
||||
public class ProjectileConfigSO : ScriptableObject
|
||||
{
|
||||
[Header("伤害")]
|
||||
public DamageSourceSO DamageSource;
|
||||
|
||||
[Header("运动")]
|
||||
public float Speed = 12f;
|
||||
public float Lifetime = 5f;
|
||||
public float LaunchAngleDeg = 45f;
|
||||
public float GravityScale = 1f;
|
||||
public float HomingStrength = 4f;
|
||||
|
||||
[Header("对象池")]
|
||||
public string PoolKey;
|
||||
|
||||
[Header("弹反")]
|
||||
public float ParrySpeedMultiplier = 1.2f;
|
||||
public float ParryDamageMultiplier = 2.0f;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/ProjectileConfigSO.cs.meta
Normal file
11
Assets/Scripts/Combat/ProjectileConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 34d03fe23f5830b4e8abbe28bfbb5e52
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
55
Assets/Scripts/Combat/ProjectileManager.cs
Normal file
55
Assets/Scripts/Combat/ProjectileManager.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 抛射物管理器(单例 MonoBehaviour)。
|
||||
/// 缓存玩家 Transform,供追踪类抛射物注入目标引用。
|
||||
/// </summary>
|
||||
public class ProjectileManager : MonoBehaviour, IProjectileService
|
||||
{
|
||||
[SerializeField] private TransformEventChannelSO _onPlayerSpawned;
|
||||
|
||||
private Transform _playerTransform;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
/// <summary>当前缓存的玩家 Transform,生成追踪弹时使用。</summary>
|
||||
public Transform PlayerTransform => _playerTransform;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ServiceLocator.GetOrDefault<IProjectileService>() != null) { Destroy(gameObject); return; }
|
||||
ServiceLocator.Register<IProjectileService>(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<IProjectileService>(this);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_onPlayerSpawned?.Subscribe(OnPlayerSpawned).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
private void OnPlayerSpawned(Transform player) => _playerTransform = player;
|
||||
|
||||
/// <summary>
|
||||
/// 完整初始化一枚 <see cref="HomingProjectile"/> 并注入追踪目标。
|
||||
/// </summary>
|
||||
public void LaunchHoming(HomingProjectile proj, Vector2 direction,
|
||||
ProjectileConfigSO config, DamageInfo damageInfo)
|
||||
{
|
||||
if (proj == null || config == null) return;
|
||||
proj.Initialize(config, damageInfo, direction);
|
||||
proj.SetTarget(_playerTransform);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/ProjectileManager.cs.meta
Normal file
11
Assets/Scripts/Combat/ProjectileManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ac14f526cd2afcd4d8cdd44780805472
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,20 +1,137 @@
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 护盾组件(Phase 1 存根)。实现 IShieldable 接口供 HurtBox 注入。
|
||||
/// Phase 2 实现完整护盾逻辑(护盾值、再生、破盾事件)。
|
||||
/// 护盾组件。实现 IShieldable 接口供 HurtBox 注入。
|
||||
/// 护盾参数通过 ShieldConfigSO 集中配置。
|
||||
/// </summary>
|
||||
public class ShieldComponent : MonoBehaviour, IShieldable
|
||||
{
|
||||
public bool HasShield { get; private set; }
|
||||
[Header("配置资产")]
|
||||
[SerializeField] private ShieldConfigSO _config;
|
||||
|
||||
[Header("VFX 事件频道")]
|
||||
[SerializeField] private VoidEventChannelSO _onShieldBrokenChannel; // 护盾破碎 → 播放破碎 VFX
|
||||
[SerializeField] private VoidEventChannelSO _onShieldRestoredChannel; // 护盾恢复 → 播放恢复 VFX
|
||||
|
||||
// ── 运行时属性 ────────────────────────────────────────────────────────
|
||||
public int MaxShieldHP => _config.MaxShieldHP;
|
||||
public int CurrentShieldHP { get; private set; }
|
||||
/// <summary>当前是否能吸收伤害(护盾 HP > 0 且不在破碎惩罚期)。</summary>
|
||||
public bool HasShield => CurrentShieldHP > 0 && _brokenPenaltyTimer <= 0f;
|
||||
|
||||
private float AbsorptionRatio => _config.DamageAbsorptionRatio;
|
||||
private float RechargeDelay => _config.RechargeDelay;
|
||||
private float RechargeRate => _config.RechargeRate;
|
||||
private float BrokenPenaltyDur => _config.BrokenPenaltyDuration;
|
||||
private float ParryRestoreRatio => _config.ParryRestoreRatio;
|
||||
|
||||
public event Action<int, int> OnShieldChanged; // (current, max)
|
||||
public event Action OnShieldBroken;
|
||||
|
||||
private float _regenDelayTimer;
|
||||
private float _brokenPenaltyTimer;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Debug.Assert(_config != null, "[ShieldComponent] _config 未赋值,请在 Inspector 中指定 ShieldConfigSO。", this);
|
||||
CurrentShieldHP = MaxShieldHP;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
int maxHP = MaxShieldHP;
|
||||
if (maxHP <= 0) return;
|
||||
|
||||
// 破碎惩罚计时
|
||||
if (_brokenPenaltyTimer > 0f)
|
||||
{
|
||||
_brokenPenaltyTimer -= Time.deltaTime;
|
||||
// 破碎惩罚结束 → 广播护盾恢复事件(VFX 钩子)
|
||||
if (_brokenPenaltyTimer <= 0f)
|
||||
_onShieldRestoredChannel?.Raise();
|
||||
return;
|
||||
}
|
||||
|
||||
if (CurrentShieldHP >= maxHP) return;
|
||||
if (RechargeRate <= 0f) return;
|
||||
|
||||
if (_regenDelayTimer > 0f)
|
||||
{
|
||||
_regenDelayTimer -= Time.deltaTime;
|
||||
return;
|
||||
}
|
||||
|
||||
int prev = CurrentShieldHP;
|
||||
CurrentShieldHP = Mathf.Min(CurrentShieldHP + Mathf.CeilToInt(RechargeRate * Time.deltaTime), maxHP);
|
||||
if (CurrentShieldHP != prev)
|
||||
OnShieldChanged?.Invoke(CurrentShieldHP, maxHP);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 尝试以护盾吸收伤害。
|
||||
/// 返回穿透量(0 = 全部吸收,>0 = 穿透量继续走 TakeDamage 流程)。
|
||||
/// Phase 1:护盾不存在,全量穿透。
|
||||
/// 尝试以护盾吸收伤害。返回穿透量(0=全部吸收,>0=穿透量继续走 TakeDamage 流程)。
|
||||
/// </summary>
|
||||
public int AbsorbDamage(int amount) => amount;
|
||||
public int AbsorbDamage(int amount)
|
||||
{
|
||||
if (!HasShield) return amount;
|
||||
|
||||
_regenDelayTimer = RechargeDelay;
|
||||
|
||||
int maxHP = MaxShieldHP;
|
||||
// 按吸收比例计算实际由护盾承担的伤害
|
||||
int toAbsorb = Mathf.FloorToInt(amount * AbsorptionRatio);
|
||||
toAbsorb = Mathf.Min(toAbsorb, CurrentShieldHP);
|
||||
int passthrough = amount - toAbsorb;
|
||||
|
||||
CurrentShieldHP -= toAbsorb;
|
||||
OnShieldChanged?.Invoke(CurrentShieldHP, maxHP);
|
||||
|
||||
if (CurrentShieldHP <= 0)
|
||||
{
|
||||
CurrentShieldHP = 0;
|
||||
_brokenPenaltyTimer = BrokenPenaltyDur;
|
||||
OnShieldBroken?.Invoke();
|
||||
_onShieldBrokenChannel?.Raise(); // VFX 钩子:播放护盾破碎特效
|
||||
}
|
||||
|
||||
return passthrough;
|
||||
}
|
||||
|
||||
/// <summary>存档点 / 复活时调用:完全恢复护盾并清除惩罚状态。</summary>
|
||||
public void FullRecharge()
|
||||
{
|
||||
bool wasBroken = _brokenPenaltyTimer > 0f || CurrentShieldHP <= 0;
|
||||
_brokenPenaltyTimer = 0f;
|
||||
_regenDelayTimer = 0f;
|
||||
CurrentShieldHP = MaxShieldHP;
|
||||
OnShieldChanged?.Invoke(CurrentShieldHP, MaxShieldHP);
|
||||
if (wasBroken)
|
||||
_onShieldRestoredChannel?.Raise(); // VFX 钩子:护盾已满血恢复
|
||||
}
|
||||
|
||||
/// <summary>弹反成功时调用:按 ParryRestoreRatio 恢复护盾(会清除惩罚状态)。</summary>
|
||||
public void OnParrySuccess()
|
||||
{
|
||||
int maxHP = MaxShieldHP;
|
||||
if (maxHP <= 0) return;
|
||||
|
||||
_brokenPenaltyTimer = 0f;
|
||||
_regenDelayTimer = 0f;
|
||||
int restore = Mathf.CeilToInt(maxHP * ParryRestoreRatio);
|
||||
CurrentShieldHP = Mathf.Min(CurrentShieldHP + restore, maxHP);
|
||||
OnShieldChanged?.Invoke(CurrentShieldHP, maxHP);
|
||||
}
|
||||
|
||||
/// <summary>Inspector / 道具系统调用:设置最大护盾并重置当前值。</summary>
|
||||
public void SetMaxShieldHP(int max)
|
||||
{
|
||||
_maxShieldHP = Mathf.Max(0, max);
|
||||
_brokenPenaltyTimer = 0f;
|
||||
CurrentShieldHP = MaxShieldHP;
|
||||
OnShieldChanged?.Invoke(CurrentShieldHP, MaxShieldHP);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
29
Assets/Scripts/Combat/ShieldConfigSO.cs
Normal file
29
Assets/Scripts/Combat/ShieldConfigSO.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 护盾配置资产(架构 06_CombatModule §6 ShieldSystem)。
|
||||
/// 可通过 Assets/Create/Combat/ShieldConfig 创建。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Combat/ShieldConfig", fileName = "ShieldConfig")]
|
||||
public class ShieldConfigSO : ScriptableObject
|
||||
{
|
||||
[Header("护盾数值")]
|
||||
public int MaxShieldHP = 0; // 0 = 无护盾(默认禁用)
|
||||
[Range(0f, 1f)]
|
||||
public float DamageAbsorptionRatio = 1.0f; // 1.0 = 全吸收;<1.0 = 部分穿透
|
||||
public float RechargeDelay = 2.0f; // 受击后延迟多久才开始再生(秒)
|
||||
public float RechargeRate = 20f; // 每秒再生 HP
|
||||
|
||||
[Header("破碎惩罚")]
|
||||
public float BrokenPenaltyDuration = 3.0f; // 护盾破碎后不可再生的惩罚时长(秒)
|
||||
|
||||
[Header("存档点")]
|
||||
public bool FullRechargeOnSavePoint = true; // 抵达存档点时是否完全恢复护盾
|
||||
|
||||
[Header("弹反加成")]
|
||||
[Range(0f, 1f)]
|
||||
public float ParryRestoreRatio = 0.3f; // 弹反成功后恢复护盾比例(占 MaxShieldHP)
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/ShieldConfigSO.cs.meta
Normal file
11
Assets/Scripts/Combat/ShieldConfigSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b2e617a47cdc4b64383a6a907023b3e7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
48
Assets/Scripts/Combat/SkillHitBoxInstance.cs
Normal file
48
Assets/Scripts/Combat/SkillHitBoxInstance.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Combat;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 技能 HitBox 实例(架构 09_ProgressionModule §9 SkillHitBoxInstance)。
|
||||
/// 挂载于技能 HitBox Prefab 根节点。
|
||||
/// 命名规范: Assets/Prefabs/Skills/SKL_{skillId}_HitBox.prefab
|
||||
///
|
||||
/// Prefab 内部层级示例(近战 AoE 技能):
|
||||
/// [SKL_SkySlash_HitBox]
|
||||
/// └── [HitBox] ← 扇形/圆形 PolygonCollider2D
|
||||
/// └── HitBox.cs
|
||||
/// </summary>
|
||||
public class SkillHitBoxInstance : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private HitBox[] _hitBoxes; // 技能可有多个 HitBox(多段伤害)
|
||||
|
||||
public event System.Action<DamageInfo> OnHitConfirmed;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
foreach (var hb in _hitBoxes)
|
||||
{
|
||||
if (hb == null) continue;
|
||||
hb.OnHitConfirmed += info => OnHitConfirmed?.Invoke(info);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>激活所有 HitBox,传入伤害数据源和攻击者 Transform。</summary>
|
||||
public void Activate(DamageSourceSO source, Transform attacker)
|
||||
{
|
||||
foreach (var hb in _hitBoxes)
|
||||
hb?.Activate(source, attacker);
|
||||
}
|
||||
|
||||
/// <summary>duration 秒后自动销毁此 GameObject。</summary>
|
||||
public void AutoDestroyAfter(float duration)
|
||||
=> Destroy(gameObject, Mathf.Max(0f, duration));
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
foreach (var hb in _hitBoxes)
|
||||
hb?.Deactivate();
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/SkillHitBoxInstance.cs.meta
Normal file
11
Assets/Scripts/Combat/SkillHitBoxInstance.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 776f1908eabcca546937010169b91efc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -8,7 +8,8 @@
|
||||
"versionDefines": [],
|
||||
"rootNamespace": "BaseGames.Combat.StatusEffects",
|
||||
"references": [
|
||||
"BaseGames.Combat"
|
||||
"BaseGames.Combat",
|
||||
"BaseGames.Core.Events"
|
||||
],
|
||||
"autoReferenced": true,
|
||||
"overrideReferences": false,
|
||||
|
||||
45
Assets/Scripts/Combat/StatusEffects/FireEffect.cs
Normal file
45
Assets/Scripts/Combat/StatusEffects/FireEffect.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
namespace BaseGames.Combat.StatusEffects
|
||||
{
|
||||
/// <summary>
|
||||
/// 燃烧效果(架构 06_CombatModule §11)。
|
||||
/// 规则:不可叠加(MaxStacks = 1);重复施加刷新持续时间;每 0.5 秒造成 1 点 True 伤害。
|
||||
/// </summary>
|
||||
public class FireEffect : StatusEffect
|
||||
{
|
||||
private const float BaseDuration = 3.0f; // 持续 3 秒
|
||||
private const float DotInterval = 0.5f; // 每 0.5 秒一次
|
||||
|
||||
public override StatusEffectType EffectType => StatusEffectType.Fire;
|
||||
public override int MaxStacks => 1;
|
||||
|
||||
public FireEffect()
|
||||
{
|
||||
TickInterval = DotInterval;
|
||||
}
|
||||
|
||||
public override void OnApply(StatusEffectManager owner)
|
||||
{
|
||||
base.OnApply(owner);
|
||||
owner.SetShaderParam("_FireGlow", 1f);
|
||||
}
|
||||
|
||||
/// <summary>每次 DoT Tick 造成 1 点 True 伤害(绕过护盾,无敌帧不免疫)。</summary>
|
||||
public override void OnTick()
|
||||
{
|
||||
var info = new DamageInfo.Builder()
|
||||
.SetRaw(1)
|
||||
.SetType(DamageType.True)
|
||||
.SetFlags(DamageFlags.IgnoreIFrame)
|
||||
.Build();
|
||||
Owner?.ApplyDirectDamage(info);
|
||||
}
|
||||
|
||||
public override void OnExpire()
|
||||
{
|
||||
Owner?.SetShaderParam("_FireGlow", 0f);
|
||||
}
|
||||
|
||||
protected override float GetBaseDuration() => BaseDuration;
|
||||
public override string GetDisplayName() => "燃烧";
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/StatusEffects/FireEffect.cs.meta
Normal file
11
Assets/Scripts/Combat/StatusEffects/FireEffect.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2d692ae17737ac54bb0940c3487a59be
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
54
Assets/Scripts/Combat/StatusEffects/PoisonEffect.cs
Normal file
54
Assets/Scripts/Combat/StatusEffects/PoisonEffect.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
namespace BaseGames.Combat.StatusEffects
|
||||
{
|
||||
/// <summary>
|
||||
/// 中毒效果(架构 06_CombatModule §11)。
|
||||
/// 规则:最多叠加 3 层;每层叠加伤害 +1;每 1 秒造成 StackCount 点 True 伤害。
|
||||
/// </summary>
|
||||
public class PoisonEffect : StatusEffect
|
||||
{
|
||||
private const float BaseDuration = 5.0f; // 持续 5 秒
|
||||
private const float DotInterval = 1.0f; // 每 1 秒一次
|
||||
|
||||
public override StatusEffectType EffectType => StatusEffectType.Poison;
|
||||
public override int MaxStacks => 3;
|
||||
|
||||
public PoisonEffect()
|
||||
{
|
||||
TickInterval = DotInterval;
|
||||
}
|
||||
|
||||
public override void OnApply(StatusEffectManager owner)
|
||||
{
|
||||
base.OnApply(owner);
|
||||
UpdateShader();
|
||||
}
|
||||
|
||||
public override void OnStack()
|
||||
{
|
||||
base.OnStack();
|
||||
UpdateShader();
|
||||
}
|
||||
|
||||
public override void OnTick()
|
||||
{
|
||||
var info = new DamageInfo.Builder()
|
||||
.SetRaw(StackCount) // 叠层越多伤害越高
|
||||
.SetType(DamageType.True)
|
||||
.SetFlags(DamageFlags.IgnoreIFrame)
|
||||
.Build();
|
||||
Owner?.ApplyDirectDamage(info);
|
||||
}
|
||||
|
||||
public override void OnExpire()
|
||||
{
|
||||
StackCount = 0;
|
||||
Owner?.SetShaderParam("_PoisonGlow", 0f);
|
||||
}
|
||||
|
||||
private void UpdateShader()
|
||||
=> Owner?.SetShaderParam("_PoisonGlow", StackCount / (float)MaxStacks);
|
||||
|
||||
protected override float GetBaseDuration() => BaseDuration;
|
||||
public override string GetDisplayName() => $"中毒 ×{StackCount}";
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/StatusEffects/PoisonEffect.cs.meta
Normal file
11
Assets/Scripts/Combat/StatusEffects/PoisonEffect.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 97257dbfc13b78441a89c652f51310cf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
33
Assets/Scripts/Combat/StatusEffects/StaggerEffect.cs
Normal file
33
Assets/Scripts/Combat/StatusEffects/StaggerEffect.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace BaseGames.Combat.StatusEffects
|
||||
{
|
||||
/// <summary>
|
||||
/// 硬直效果(架构 06_CombatModule §11)。
|
||||
/// 规则:不可叠加(MaxStacks = 1);施加期间宿主无法执行动作。
|
||||
/// 外部查询:entity.GetComponent<StatusEffectManager>()?.HasEffect(StatusEffectType.Stagger)
|
||||
/// </summary>
|
||||
public class StaggerEffect : StatusEffect
|
||||
{
|
||||
private const float BaseDuration = 0.5f; // 默认硬直持续时间(秒)
|
||||
|
||||
private readonly float _overrideDuration;
|
||||
|
||||
/// <param name="duration">自定义持续时间(<= 0 使用默认值)。</param>
|
||||
public StaggerEffect(float duration = 0f)
|
||||
{
|
||||
_overrideDuration = duration > 0f ? duration : BaseDuration;
|
||||
TickInterval = 0f; // 无 DoT Tick
|
||||
}
|
||||
|
||||
public override StatusEffectType EffectType => StatusEffectType.Stagger;
|
||||
public override int MaxStacks => 1;
|
||||
|
||||
public override void OnApply(StatusEffectManager owner)
|
||||
{
|
||||
Owner = owner;
|
||||
Duration = _overrideDuration;
|
||||
}
|
||||
|
||||
protected override float GetBaseDuration() => _overrideDuration;
|
||||
public override string GetDisplayName() => "硬直";
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/StatusEffects/StaggerEffect.cs.meta
Normal file
11
Assets/Scripts/Combat/StatusEffects/StaggerEffect.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 23c67d6324b88a1468bd20baa4f109c3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
91
Assets/Scripts/Combat/StatusEffects/StatusEffect.cs
Normal file
91
Assets/Scripts/Combat/StatusEffects/StatusEffect.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Combat.StatusEffects
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态效果抽象基类(架构 06_CombatModule §11)。
|
||||
/// ⚠️ 类名为 StatusEffect(非 StatusEffectBase)。
|
||||
///
|
||||
/// 生命周期:
|
||||
/// OnApply(owner) → Update(delta) × N [内部调用 OnTick()] → OnExpire()
|
||||
///
|
||||
/// 叠加规则:同类型再次施加时调用 OnStack();Manager 保证每种类型只有一个实例。
|
||||
/// </summary>
|
||||
public abstract class StatusEffect
|
||||
{
|
||||
/// <summary>效果类型标识(用作 Dictionary key)。</summary>
|
||||
public abstract StatusEffectType EffectType { get; }
|
||||
|
||||
/// <summary>最大叠加层数(1 = 不可叠加,重复施加只刷新持续时间)。</summary>
|
||||
public abstract int MaxStacks { get; }
|
||||
|
||||
/// <summary>当前叠加层数。</summary>
|
||||
public int StackCount { get; protected set; } = 1;
|
||||
|
||||
/// <summary>当前剩余持续时间(秒)。</summary>
|
||||
public float Duration { get; protected set; }
|
||||
|
||||
/// <summary>每次 Tick 的间隔(秒)。</summary>
|
||||
public float TickInterval { get; protected set; }
|
||||
|
||||
/// <summary>是否已过期(由 Manager 每帧检查)。</summary>
|
||||
public virtual bool IsExpired => Duration <= 0f;
|
||||
|
||||
private float _tickTimer;
|
||||
|
||||
/// <summary>宿主 Manager(OnApply 时注入,OnTick/OnExpire 中可访问)。</summary>
|
||||
public StatusEffectManager Owner { get; protected set; }
|
||||
|
||||
// ── 生命周期回调(可重写)─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 效果施加时调用(Owner 在此注入)。
|
||||
/// ⚠️ 参数为 StatusEffectManager(非 IDamageable),架构 06 §11。
|
||||
/// </summary>
|
||||
public virtual void OnApply(StatusEffectManager owner)
|
||||
{
|
||||
Owner = owner;
|
||||
Duration = GetBaseDuration();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 同类型效果再次施加时调用(叠层 / 刷新持续时间)。
|
||||
/// 默认行为:刷新持续时间并叠加层数(若未达上限)。
|
||||
/// </summary>
|
||||
public virtual void OnStack()
|
||||
{
|
||||
Duration = GetBaseDuration();
|
||||
StackCount = Mathf.Min(StackCount + 1, MaxStacks);
|
||||
}
|
||||
|
||||
/// <summary>每个 TickInterval 秒调用一次(DoT 等周期效果)。</summary>
|
||||
public virtual void OnTick() { }
|
||||
|
||||
/// <summary>效果到期 / 被净化时调用。⚠️ 名称 OnExpire(非 OnRemove)。</summary>
|
||||
public virtual void OnExpire() { }
|
||||
|
||||
// ── 框架驱动(由 Manager.Update 调用,每帧执行)──────────────────
|
||||
|
||||
/// <summary>递减持续时间并在到达 Tick 间隔时触发 OnTick。</summary>
|
||||
public void Update(float delta)
|
||||
{
|
||||
Duration -= delta;
|
||||
if (TickInterval <= 0f) return;
|
||||
|
||||
_tickTimer += delta;
|
||||
if (_tickTimer >= TickInterval)
|
||||
{
|
||||
_tickTimer -= TickInterval;
|
||||
OnTick();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 子类必须实现 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>返回本类型效果的基础持续时间(秒)。</summary>
|
||||
protected abstract float GetBaseDuration();
|
||||
|
||||
/// <summary>返回本效果的本地化显示名称。</summary>
|
||||
public abstract string GetDisplayName();
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/StatusEffects/StatusEffect.cs.meta
Normal file
11
Assets/Scripts/Combat/StatusEffects/StatusEffect.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4490046c0c848054e9a6846dc272f9f6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,21 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Combat.StatusEffects
|
||||
{
|
||||
/// <summary>状态效果事件(应用 / 到期时广播,可用于 UI 更新)。</summary>
|
||||
public struct StatusEffectEvent
|
||||
{
|
||||
/// <summary>效果类型。</summary>
|
||||
public StatusEffectType EffectType;
|
||||
|
||||
/// <summary>当前叠加层数(到期时为 0)。</summary>
|
||||
public int StackCount;
|
||||
|
||||
/// <summary>剩余持续时间(到期时为 0)。</summary>
|
||||
public float RemainingDuration;
|
||||
}
|
||||
|
||||
[CreateAssetMenu(menuName = "Events/StatusEffect")]
|
||||
public class StatusEffectEventChannelSO : BaseEventChannelSO<StatusEffectEvent> { }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e2aa7161466b28442acab31ae092166b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,20 +1,176 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using BaseGames.Combat;
|
||||
|
||||
namespace BaseGames.Combat.StatusEffects
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态效果管理器(Phase 1 桩)。
|
||||
/// 实现 IStatusEffectable 接口,由 HurtBox 通过接口调用,避免程序集循环依赖。
|
||||
/// Phase 2 实现完整的效果叠加、持续时间、DoT 伤害计算。
|
||||
/// 状态效果管理器。
|
||||
///
|
||||
/// 架构 06_CombatModule §11:
|
||||
/// - 双结构:List(Update 遍历)+ Dictionary(O(1) 类型查找)
|
||||
/// - 实现 IStatusEffectable,接受来自 HurtBox 的 DamageType 并映射到具体效果
|
||||
/// - DealDotDamage:StatusEffect 子类通过 Owner 调用,绕过无敌帧造成 DoT
|
||||
/// - CleanseEffect:净化指定类型效果(道具/技能使用)
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(SpriteRenderer))]
|
||||
public class StatusEffectManager : MonoBehaviour, IStatusEffectable
|
||||
{
|
||||
// Phase 1:空实现
|
||||
public void ApplyStatusEffect(DamageType type) { }
|
||||
}
|
||||
[Header("事件频道(可选)")]
|
||||
[SerializeField] private StatusEffectEventChannelSO _onStatusEffectApplied;
|
||||
[SerializeField] private StatusEffectEventChannelSO _onStatusEffectExpired;
|
||||
|
||||
// ── Phase 1 占位效果类型 ──────────────────────────────────────────────────
|
||||
public class FireEffect { }
|
||||
public class PoisonEffect { }
|
||||
// ── 双结构 ─────────────────────────────────────────────────────────
|
||||
private readonly List<StatusEffect> _activeList = new();
|
||||
private readonly Dictionary<StatusEffectType, StatusEffect> _activeIndex = new();
|
||||
|
||||
// ── Shader 渲染(MaterialPropertyBlock,不修改共享材质)─────────
|
||||
private SpriteRenderer _renderer;
|
||||
private MaterialPropertyBlock _propBlock;
|
||||
|
||||
// ── DoT 伤害代理(由 StatusEffect.OnTick 通过 Owner 调用)──────────
|
||||
private IDamageable _damageable;
|
||||
|
||||
// ── 效果工厂字典(可在 Awake 后动态注册)─────────────────────
|
||||
private readonly Dictionary<DamageType, Func<StatusEffect>> _effectFactories = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_renderer = GetComponent<SpriteRenderer>();
|
||||
_propBlock = new MaterialPropertyBlock();
|
||||
_damageable = GetComponentInParent<IDamageable>();
|
||||
|
||||
// 默认标准效果注册(子类或外部模块可调用 RegisterEffectFactory 覆盖或扩展)
|
||||
RegisterEffectFactory(DamageType.Fire, () => new FireEffect());
|
||||
RegisterEffectFactory(DamageType.Poison, () => new PoisonEffect());
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
float delta = Time.deltaTime;
|
||||
|
||||
// 逆序遍历,避免移除时索引错位
|
||||
for (int i = _activeList.Count - 1; i >= 0; i--)
|
||||
{
|
||||
StatusEffect effect = _activeList[i];
|
||||
effect.Update(delta);
|
||||
|
||||
if (effect.IsExpired)
|
||||
RemoveAt(i, effect);
|
||||
}
|
||||
}
|
||||
|
||||
// ── IStatusEffectable 实现 ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// HurtBox 调用入口:将 DamageType 映射为具体 StatusEffect 实例并施加。
|
||||
/// </summary>
|
||||
public void ApplyStatusEffect(DamageType type)
|
||||
{
|
||||
StatusEffect effect = CreateEffect(type);
|
||||
if (effect != null)
|
||||
ApplyEffect(effect);
|
||||
}
|
||||
/// <summary>
|
||||
/// 注册或覆盖一个 DamageType 对应的效果工厂。
|
||||
/// Boss 或特殊游玩法式可在运行时注册自定义效果。
|
||||
/// </summary>
|
||||
public void RegisterEffectFactory(DamageType type, Func<StatusEffect> factory)
|
||||
=> _effectFactories[type] = factory;
|
||||
// ── 公开 API ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>直接施加一个具体效果(供技能/Boss 使用)。</summary>
|
||||
public void ApplyEffect(StatusEffect effect)
|
||||
{
|
||||
if (_activeIndex.TryGetValue(effect.EffectType, out StatusEffect existing))
|
||||
{
|
||||
existing.OnStack();
|
||||
BroadcastApplied(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
effect.OnApply(this);
|
||||
_activeList.Add(effect);
|
||||
_activeIndex[effect.EffectType] = effect;
|
||||
BroadcastApplied(effect);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>净化指定类型效果(净化道具/技能调用)。</summary>
|
||||
public void CleanseEffect(StatusEffectType type)
|
||||
{
|
||||
if (!_activeIndex.TryGetValue(type, out StatusEffect effect)) return;
|
||||
|
||||
effect.OnExpire();
|
||||
_activeIndex.Remove(type);
|
||||
_activeList.Remove(effect);
|
||||
BroadcastExpired(effect);
|
||||
}
|
||||
|
||||
/// <summary>查询是否存在指定类型效果(供状态机/UI 轮询)。</summary>
|
||||
public bool HasEffect(StatusEffectType type) => _activeIndex.ContainsKey(type);
|
||||
|
||||
/// <summary>获取指定类型效果(可为 null)。</summary>
|
||||
public StatusEffect GetEffect(StatusEffectType type)
|
||||
=> _activeIndex.TryGetValue(type, out var e) ? e : null;
|
||||
|
||||
/// <summary>
|
||||
/// DoT 伤害代理(架构 06 §10)。StatusEffect.OnTick() 调用此方法,传入已构建好的 DamageInfo。
|
||||
/// </summary>
|
||||
public void ApplyDirectDamage(DamageInfo info)
|
||||
{
|
||||
_damageable?.TakeDamage(info);
|
||||
}
|
||||
|
||||
/// <summary>设置 Sprite Shader 参数(MaterialPropertyBlock,不修改共享材质)。</summary>
|
||||
public void SetShaderParam(string param, float value)
|
||||
{
|
||||
if (_renderer == null) return;
|
||||
_renderer.GetPropertyBlock(_propBlock);
|
||||
_propBlock.SetFloat(param, value);
|
||||
_renderer.SetPropertyBlock(_propBlock);
|
||||
}
|
||||
|
||||
/// <summary>净化所有状态效果(存档点激活 / 返回城镇等调用)。</summary>
|
||||
public void CleanseAll()
|
||||
{
|
||||
foreach (var e in _activeList) e.OnExpire();
|
||||
_activeList.Clear();
|
||||
_activeIndex.Clear();
|
||||
}
|
||||
|
||||
// ── 私有辅助 ───────────────────────────────────────────────────────
|
||||
|
||||
private StatusEffect CreateEffect(DamageType type)
|
||||
=> _effectFactories.TryGetValue(type, out var factory) ? factory() : null;
|
||||
|
||||
private void RemoveAt(int index, StatusEffect effect)
|
||||
{
|
||||
effect.OnExpire();
|
||||
_activeList.RemoveAt(index);
|
||||
_activeIndex.Remove(effect.EffectType);
|
||||
BroadcastExpired(effect);
|
||||
}
|
||||
|
||||
private void BroadcastApplied(StatusEffect effect)
|
||||
{
|
||||
_onStatusEffectApplied?.Raise(new StatusEffectEvent
|
||||
{
|
||||
EffectType = effect.EffectType,
|
||||
StackCount = effect.StackCount,
|
||||
RemainingDuration = effect.Duration,
|
||||
});
|
||||
}
|
||||
|
||||
private void BroadcastExpired(StatusEffect effect)
|
||||
{
|
||||
_onStatusEffectExpired?.Raise(new StatusEffectEvent
|
||||
{
|
||||
EffectType = effect.EffectType,
|
||||
StackCount = 0,
|
||||
RemainingDuration = 0f,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
Assets/Scripts/Combat/StatusEffects/StatusEffectType.cs
Normal file
15
Assets/Scripts/Combat/StatusEffects/StatusEffectType.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
namespace BaseGames.Combat.StatusEffects
|
||||
{
|
||||
/// <summary>
|
||||
/// 状态效果类型枚举(架构 06_CombatModule §11)。
|
||||
/// 用于状态机索引(Dictionary key)和事件载荷,与 DamageType 相互独立。
|
||||
/// </summary>
|
||||
public enum StatusEffectType
|
||||
{
|
||||
Fire, // 燃烧(DoT,不可叠加,重复施加刷新持续时间)
|
||||
Poison, // 中毒(DoT,最多 3 层叠加)
|
||||
Stagger, // 硬直(无法行动 N 秒)
|
||||
Freeze, // 冻结(减速 / 固化)
|
||||
Stun, // 眩晕(无法行动)
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Combat/StatusEffects/StatusEffectType.cs.meta
Normal file
11
Assets/Scripts/Combat/StatusEffects/StatusEffectType.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a5502c061dc69b4b82113ed5b282740
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,3 +0,0 @@
|
||||
// Placeholder to prevent asmdef-no-scripts warning.
|
||||
namespace BaseGames.Combat.StatusEffects { }
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// Placeholder to prevent asmdef-no-scripts warning.
|
||||
namespace BaseGames.Combat { }
|
||||
|
||||
@@ -50,7 +50,7 @@ namespace BaseGames.Core.Assets
|
||||
|
||||
/// <summary>
|
||||
/// 解析 key,返回对应的 Addressable 地址字符串。
|
||||
/// 若 key 未注册则返回原 key(兼容直接使用静态常量的调用方)。
|
||||
/// 若 key 未注册,直接将 key 作为 Addressable 地址使用。
|
||||
/// </summary>
|
||||
public static string Resolve(string key)
|
||||
{
|
||||
|
||||
@@ -43,10 +43,16 @@ namespace BaseGames.Core.Assets
|
||||
// ── Config ScriptableObjects ─────────────────────────────────────
|
||||
public const string DataFootstepCatalog = "Config/FootstepCatalog";
|
||||
|
||||
// ── Labels(批量加载用)──────────────────────────────────────────
|
||||
public const string LabelEnemy = "Enemy";
|
||||
public const string LabelPoolable = "Poolable";
|
||||
public const string LabelBGM = "BGM";
|
||||
public const string LabelCharms = "Charms";
|
||||
/// <summary>
|
||||
/// Addressable 标签常量(用于批量加载)。
|
||||
/// 注意:这里是标签名称而非资产地址,不会被 AddressKeyValidator 校验。
|
||||
/// </summary>
|
||||
public static class Labels
|
||||
{
|
||||
public const string Enemy = "Enemy";
|
||||
public const string Poolable = "Poolable";
|
||||
public const string BGM = "BGM";
|
||||
public const string Charms = "Charms";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ namespace BaseGames.Core
|
||||
/// <summary>
|
||||
/// 资产释放跟踪器。
|
||||
/// 事件驱动:监听 EVT_SceneLoadRequest,在新场景加载前清理旧场景的对象池缓存。
|
||||
/// ⚠️ 不使用显式注册 API;GlobalObjectPool.ClearPool 在场景切换时批量清理。
|
||||
/// </summary>
|
||||
public class AssetReleaseTracker : MonoBehaviour
|
||||
{
|
||||
@@ -16,28 +15,21 @@ namespace BaseGames.Core
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
private string _lastLoadedScene;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onSceneLoadRequest != null)
|
||||
_onSceneLoadRequest.OnEventRaised += OnSceneLoadRequested;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onSceneLoadRequest != null)
|
||||
_onSceneLoadRequest.OnEventRaised -= OnSceneLoadRequested;
|
||||
}
|
||||
private void OnEnable() => _onSceneLoadRequest?.Subscribe(OnSceneLoadRequested).AddTo(_subs);
|
||||
private void OnDisable() => _subs.Clear();
|
||||
|
||||
private void OnSceneLoadRequested(SceneLoadRequest req)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_lastLoadedScene)) { _lastLoadedScene = req.SceneName; return; }
|
||||
|
||||
// 清除旧场景的敌人对象池缓存(按需扩展)
|
||||
if (GlobalObjectPool.Instance != null)
|
||||
var pool = ServiceLocator.GetOrDefault<IObjectPoolService>();
|
||||
if (pool != null)
|
||||
{
|
||||
GlobalObjectPool.Instance.ClearPool(AddressKeys.PrefabEnemyGrunt);
|
||||
GlobalObjectPool.Instance.ClearPool(AddressKeys.PrefabEnemySkullArch);
|
||||
pool.ClearPool(AddressKeys.PrefabEnemyGrunt);
|
||||
pool.ClearPool(AddressKeys.PrefabEnemySkullArch);
|
||||
}
|
||||
|
||||
_lastLoadedScene = req.SceneName;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
@@ -20,7 +21,7 @@ namespace BaseGames.Core
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 死亡/复活流程独立服务(Phase 0 骨架,Phase 1 完整实现)。
|
||||
/// 死亡/复活流程独立服务。
|
||||
/// </summary>
|
||||
public class DeathRespawnService : MonoBehaviour, IDeathRespawnService
|
||||
{
|
||||
@@ -30,49 +31,58 @@ namespace BaseGames.Core
|
||||
[SerializeField] private float _respawnFadeDuration = 0.4f;
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private VoidEventChannelSO _onRespawnStarted;
|
||||
[SerializeField] private VoidEventChannelSO _onRespawnCompleted;
|
||||
[SerializeField] private VoidEventChannelSO _onRespawnStarted;
|
||||
[SerializeField] private VoidEventChannelSO _onRespawnCompleted;
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
[Header("Event Channels - Listen")]
|
||||
[SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed;
|
||||
|
||||
private bool _deathConfirmed;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onDeathScreenConfirmed != null)
|
||||
_onDeathScreenConfirmed.OnEventRaised += HandleDeathScreenConfirmed;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onDeathScreenConfirmed != null)
|
||||
_onDeathScreenConfirmed.OnEventRaised -= HandleDeathScreenConfirmed;
|
||||
}
|
||||
|
||||
private void HandleDeathScreenConfirmed() => _deathConfirmed = true;
|
||||
|
||||
public IEnumerator StartDeathSequenceCoroutine()
|
||||
{
|
||||
yield return new WaitForSeconds(_deathAnimDuration);
|
||||
yield return new WaitForSeconds(_deathScreenDelay);
|
||||
_deathConfirmed = false;
|
||||
yield return new WaitUntil(() => _deathConfirmed);
|
||||
|
||||
// 局部订阅确认事件,不依赖类级 bool 字段
|
||||
bool confirmed = false;
|
||||
var sub = _onDeathScreenConfirmed?.Subscribe(() => confirmed = true);
|
||||
yield return new WaitUntil(() => confirmed);
|
||||
sub?.Dispose();
|
||||
}
|
||||
|
||||
public IEnumerator StartRespawnCoroutine()
|
||||
{
|
||||
_onRespawnStarted?.Raise();
|
||||
yield return new WaitForSeconds(_respawnFadeDuration);
|
||||
// Phase 1:加载存档场景(TODO)
|
||||
|
||||
// 通过 SceneLoadRequest 频道触发场景重载,复用 SceneService / RoomTransition 路径
|
||||
var sm = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
_onSceneLoadRequest?.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = sm?.LastCheckpointScene,
|
||||
EntryTransitionId = sm?.LastCheckpointSpawnId,
|
||||
ShowLoadingScreen = true,
|
||||
IsRespawn = true,
|
||||
});
|
||||
|
||||
yield return new WaitForSeconds(_respawnFadeDuration);
|
||||
_onRespawnCompleted?.Raise();
|
||||
}
|
||||
|
||||
public IEnumerator StartGameOverCoroutine()
|
||||
{
|
||||
// Phase 1:SteelSoul 清档并返回主菜单(TODO)
|
||||
yield return null;
|
||||
// 1. 删除当前存档槽
|
||||
var saveManager = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
if (saveManager != null)
|
||||
{
|
||||
var task = saveManager.DeleteSlotAsync(saveManager.ActiveSlot);
|
||||
yield return new WaitUntil(() => task.IsCompleted);
|
||||
}
|
||||
|
||||
// 2. 返回主菜单
|
||||
var sceneService = ServiceLocator.GetOrDefault<ISceneService>();
|
||||
if (sceneService != null)
|
||||
yield return sceneService.LoadMainMenuCoroutine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
Assets/Scripts/Core/Difficulty.meta
Normal file
8
Assets/Scripts/Core/Difficulty.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7982848c7ca0270419ab1c0a32fde9a0
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
79
Assets/Scripts/Core/Difficulty/DifficultyManager.cs
Normal file
79
Assets/Scripts/Core/Difficulty/DifficultyManager.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 难度管理器(单例 MonoBehaviour)。
|
||||
/// 统一管理当前难度档位,并通过事件频道广播变化。
|
||||
/// 订阅者通过 <see cref="CurrentScaler"/> 获取具体缩放数值。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-900)]
|
||||
public class DifficultyManager : MonoBehaviour, ISaveable, IDifficultyService
|
||||
{
|
||||
[SerializeField] private DifficultyScalerSO[] _allScalers;
|
||||
[SerializeField] private DifficultyChangedEventChannel _onDifficultyChanged;
|
||||
|
||||
public DifficultyLevel CurrentLevel { get; private set; } = DifficultyLevel.Normal;
|
||||
public DifficultyScalerSO CurrentScaler { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ServiceLocator.GetOrDefault<IDifficultyService>() != null) { Destroy(gameObject); return; }
|
||||
ServiceLocator.Register<IDifficultyService>(this);
|
||||
Apply(DifficultyLevel.Normal);
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<IDifficultyService>(this);
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在游戏流程中切换难度。
|
||||
/// SteelSoul 模式一旦选定,中途无法降级。
|
||||
/// </summary>
|
||||
public void ChangeDifficulty(DifficultyLevel level)
|
||||
{
|
||||
if (CurrentLevel == DifficultyLevel.SteelSoul && level != DifficultyLevel.SteelSoul)
|
||||
{
|
||||
Debug.LogWarning("[DifficultyManager] SteelSoul 模式无法在游戏中途降级。");
|
||||
return;
|
||||
}
|
||||
Apply(level);
|
||||
}
|
||||
|
||||
/// <summary>按档位查找对应的缩放器,未配置时返回 null。</summary>
|
||||
public DifficultyScalerSO GetScaler(DifficultyLevel level)
|
||||
{
|
||||
if (_allScalers == null) return null;
|
||||
foreach (var s in _allScalers)
|
||||
if (s != null && s.Level == level) return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
private void Apply(DifficultyLevel level)
|
||||
{
|
||||
CurrentLevel = level;
|
||||
CurrentScaler = GetScaler(level);
|
||||
_onDifficultyChanged?.Raise(CurrentLevel);
|
||||
}
|
||||
|
||||
// ── ISaveable ────────────────────────────────────────────────────────
|
||||
|
||||
public void OnSave(SaveData saveData)
|
||||
{
|
||||
if (saveData?.Meta != null)
|
||||
saveData.Meta.IsSteelSoul = CurrentLevel == DifficultyLevel.SteelSoul;
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData saveData)
|
||||
{
|
||||
if (saveData?.Meta != null && saveData.Meta.IsSteelSoul)
|
||||
Apply(DifficultyLevel.SteelSoul);
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Difficulty/DifficultyManager.cs.meta
Normal file
11
Assets/Scripts/Core/Difficulty/DifficultyManager.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7a810da0a9739024d90a4f7415aeb2a6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
41
Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs
Normal file
41
Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 难度缩放配置 ScriptableObject。每个难度档位对应一个实例。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Core/DifficultyScaler")]
|
||||
public class DifficultyScalerSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public DifficultyLevel Level;
|
||||
|
||||
[Header("玩家")]
|
||||
public float PlayerMaxHPMultiplier = 1.0f;
|
||||
public float PlayerDamageMultiplier = 1.0f;
|
||||
public float InvincibilityFrameScale = 1.0f;
|
||||
|
||||
[Header("敌人")]
|
||||
public float EnemyDamageMultiplier = 1.0f;
|
||||
public float EnemyHPMultiplier = 1.0f;
|
||||
public float BossDamageMultiplier = 1.0f;
|
||||
public float BossHPMultiplier = 1.0f;
|
||||
|
||||
[Header("经济")]
|
||||
public float ShopPriceMultiplier = 1.0f;
|
||||
public float GeoDropMultiplier = 1.0f;
|
||||
|
||||
[Header("机制")]
|
||||
public bool CanReviveWithGeoLoss = true;
|
||||
public bool InstantDeathOnZeroHP = false;
|
||||
public bool GeoPenaltyOnDeath = true;
|
||||
|
||||
[Header("AI")]
|
||||
public float EnemyAttackIntervalScale = 1.0f;
|
||||
public float EnemyAggroRangeScale = 1.0f;
|
||||
public float EnemyReactionTimeScale = 1.0f;
|
||||
public int EnemyAggressionLevel = 2;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs.meta
Normal file
11
Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d7e8ad48b35397348a800079849ee535
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
22
Assets/Scripts/Core/Difficulty/IDifficultyService.cs
Normal file
22
Assets/Scripts/Core/Difficulty/IDifficultyService.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 难度服务接口(架构 §DifficultyModule)。
|
||||
/// 提供当前难度档位及缩放参数,供跨程序集的游戏系统(战斗、商店、掉落等)查询,
|
||||
/// 无需直接依赖 <see cref="DifficultyManager"/> 具体类型。
|
||||
/// </summary>
|
||||
public interface IDifficultyService
|
||||
{
|
||||
/// <summary>当前难度档位。</summary>
|
||||
DifficultyLevel CurrentLevel { get; }
|
||||
|
||||
/// <summary>当前档位对应的缩放配置。未配置时返回 null。</summary>
|
||||
DifficultyScalerSO CurrentScaler { get; }
|
||||
|
||||
/// <summary>切换到指定难度。SteelSoul 模式一旦选定不可降级。</summary>
|
||||
void ChangeDifficulty(DifficultyLevel level);
|
||||
|
||||
/// <summary>按档位查找缩放器,未配置时返回 null。</summary>
|
||||
DifficultyScalerSO GetScaler(DifficultyLevel level);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Difficulty/IDifficultyService.cs.meta
Normal file
11
Assets/Scripts/Core/Difficulty/IDifficultyService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 20984324b3111c5489d9c4c3e55ec4f6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user