摄像机区域的架构改动

This commit is contained in:
2026-05-15 14:47:24 +08:00
parent 1b37297585
commit f264329751
3591 changed files with 1687228 additions and 446503 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ee09651b4ef333d4d826baf6fc2d3963
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,31 @@
using Animancer;
namespace BaseGames.Animation
{
/// <summary>
/// 静态工具类:将 AnimationEventConfigSO 中声明的事件注入 Animancer ClipTransition。
/// 使用 Animancer Pro APIClipTransition.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));
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e9c16d7d7a6978a4c98a745261fbbf4f
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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 中创建此资产菜单BaseGames/Animation/EventConfig
/// 2. 配置 TargetClip 和 Events 列表。
/// 3. 在 MonoBehaviour.Awake 中AnimationEventBinder.Bind(clip, config, this)。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/BaseGames/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 被替换(长度漂移 &gt;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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3dc3625236c5f8747ae7352e78c01137
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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, // payloadFeedbackPreset 名称
PlaySFX, // payloadSFX Id
// ── 敌方专用 ──────────────────────────────────────────────────────
SpawnProjectile, // payload弹幕配置 Id
RoarStart,
RoarEnd,
PhaseTwoStart,
// ── 玩家取消窗口 ──────────────────────────────────────────────────
CancelWindowOpen,
CancelWindowClose,
// ── 状态机钩子 ────────────────────────────────────────────────────
StateTransition, // payload目标状态 Id
AnimationComplete, // payload上下文字符串可空
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 97412b5f50276484fa226d97b8c3769b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,22 @@
{
"excludePlatforms": [],
"allowUnsafeCode": false,
"precompiledReferences": [],
"name": "BaseGames.Animation",
"defineConstraints": [],
"noEngineReferences": false,
"versionDefines": [],
"rootNamespace": "BaseGames.Animation",
"references": [
"BaseGames.Core.Events",
"Kybernetik.Animancer",
"BaseGames.Combat",
"BaseGames.Parry",
"BaseGames.Feedback",
"BaseGames.Player",
"BaseGames.Enemies"
],
"autoReferenced": true,
"overrideReferences": false,
"includePlatforms": []
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: cc40540b9a6e12a4c81b5b0ee9132a3f
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 13003b40ed7bd164d8d90eb58f0548ec
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f2c57ff98093620448d1f7c8ce6d0511
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4e3a90eca47d12a49a07be7f38a376e8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 01d2e18a4097b5f408e821b493e20415
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,57 @@
using UnityEngine;
using UnityEngine.Audio;
namespace BaseGames.Audio
{
/// <summary>
/// 音频全局配置 SO区域 BGM 映射、Boss BGM 映射、特殊曲目。
/// 资产路径Assets/ScriptableObjects/Audio/AUD_Config.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Audio/AudioConfig")]
public class AudioConfigSO : ScriptableObject
{
[System.Serializable]
public struct ZoneBGMEntry
{
public string ZoneId;
public AudioClip BGMClip;
public float FadeDuration;
}
[System.Serializable]
public struct BossBGMEntry
{
public string BossId;
public AudioClip BGMClip;
}
[Header("区域 BGM 映射")]
public ZoneBGMEntry[] ZoneBGMs;
[Header("Boss BGM 映射")]
public BossBGMEntry[] BossBGMs;
[Header("特殊曲目")]
public AudioClip MainMenuBGM;
public AudioClip GameOverSting;
public AudioClip VictoryStingBGM;
[Min(0.1f)]
public float VictoryStingDuration = 4f;
public AudioClip GetZoneBGM(string zoneId)
{
if (ZoneBGMs == null) return null;
foreach (var e in ZoneBGMs)
if (e.ZoneId == zoneId) return e.BGMClip;
return null;
}
public AudioClip GetBossBGM(string bossId)
{
if (BossBGMs == null) return null;
foreach (var e in BossBGMs)
if (e.BossId == bossId) return e.BGMClip;
return null;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: af379c0184a345441914e3167376d39c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,52 @@
using UnityEngine;
using UnityEngine.Audio;
namespace BaseGames.Audio
{
/// <summary>
/// 音效事件 ScriptableObject。随机从 Clips 中选取一条播放,
/// 并在 Volume / Pitch 区间内随机变化,增强音效多样性。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/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());
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 356d306cd9d1eaa46b9d283761bcba54
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,249 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Audio;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.Audio
{
/// <summary>
/// 音频管理器。
/// 职责BGM 双 Source 交叉淡入淡出、SFX 多源轮转池、AudioMixer 快照切换、音量控制。
/// 挂在 Persistent 场景 [AudioManager] GameObject 上。
/// </summary>
[DefaultExecutionOrder(-500)]
public class AudioManager : MonoBehaviour, IAudioService
{
[Header("AudioMixer")]
[SerializeField] private AudioMixer _mixer;
[Header("BGM Sources双 Source 交叉淡入淡出)")]
[SerializeField] private AudioSource _bgmSourceA;
[SerializeField] private AudioSource _bgmSourceB;
[Header("SFX Pool建议 6 个,均路由到 SFX MixerGroup")]
[Tooltip("轮转池大小:同帧触发数超过此值时最旧的音效会被打断。建议 6~8 个。")]
[SerializeField] private AudioSource[] _sfxSources;
[Header("Audio ConfigBGM 映射)")]
[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;
private AudioSource _activeBGMSource;
private AudioSource _inactiveBGMSource;
private Coroutine _crossfadeCoroutine;
private int _sfxRoundRobin;
private Dictionary<string, AudioEventSO> _sfxLookup;
private readonly CompositeDisposable _subs = new();
private void Awake()
{
if (ServiceLocator.GetOrDefault<IAudioService>() != null) { Destroy(gameObject); return; }
Debug.Assert(_audioConfig != null, "[AudioManager] _audioConfig 未赋值,请在 Inspector 中指定 AudioConfigSO。", this);
_activeBGMSource = _bgmSourceA;
_inactiveBGMSource = _bgmSourceB;
ServiceLocator.Register<IAudioService>(this);
BuildSFXLookup();
Initialize();
}
private void OnEnable()
{
_onPlayerDied?.Subscribe(HandlePlayerDied).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
}
private void OnDestroy()
{
ServiceLocator.Unregister<IAudioService>(this);
}
// ── 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)
{
if (_sfxLookup != null && _sfxLookup.TryGetValue(key, out var evt))
{
evt?.PlayOneShot(NextSFXSource());
return;
}
Debug.LogWarning($"[AudioManager] SFX key '{key}' 未在注册表中找到。");
}
// ── 音量控制 ─────────────────────────────────────────────────────────────
/// <summary>
/// 将指定 Exposed Parameter 设置为 0-1 线性值(内部转换为 dB
/// 唯一音量写入入口(同时满足 IAudioService.SetVolume 接口)。
/// </summary>
public void SetVolume(string exposedParam, float linear)
=> _mixer.SetFloat(exposedParam, LinearToDecibel(linear));
/// <summary>读取 SettingsManager 已加载的设置数据并应用四路音量到 AudioMixer。</summary>
public void Initialize()
{
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 ──────────────────────────────────────────────────────────────────
/// <summary>播放 BGM使用双 AudioSource 交叉淡入淡出。</summary>
public void PlayBGM(AudioClip clip, float fadeOutDur = 1f, float fadeInDur = 1f)
{
if (clip == null) return;
if (_crossfadeCoroutine != null) StopCoroutine(_crossfadeCoroutine);
_crossfadeCoroutine = StartCoroutine(CrossfadeCoroutine(clip, fadeOutDur, fadeInDur));
}
/// <summary>停止 BGM带淡出。</summary>
public void StopBGM(float fadeDuration = 1f)
{
if (_crossfadeCoroutine != null) StopCoroutine(_crossfadeCoroutine);
_crossfadeCoroutine = StartCoroutine(FadeOutCoroutine(_activeBGMSource, fadeDuration));
}
// ── SFX ──────────────────────────────────────────────────────────────────
/// <summary>
/// 一次性播放 SFX使用轮转多源池避免高密度战斗时音效相互戳断。
/// </summary>
public void PlaySFX(AudioClip clip, float volumeScale = 1f)
{
if (clip == null) return;
var src = NextSFXSource();
if (src == null) return;
src.volume = volumeScale;
src.PlayOneShot(clip);
}
/// <summary>2D 游戏中位置无衰减,统一委托多源池播放。</summary>
public void PlaySFXAtPosition(AudioClip clip, Vector2 pos, float volumeScale = 1f)
{
if (clip == null) return;
AudioSource.PlayClipAtPoint(clip, pos, volumeScale);
}
// ── 快照切换 ─────────────────────────────────────────────────────────────
/// <summary>切换 AudioMixer 快照(如 Default / Paused / Dead / BossFight。</summary>
public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f)
{
var snapshot = _mixer.FindSnapshot(snapshotName);
if (snapshot != null)
snapshot.TransitionTo(transitionTime);
else
Debug.LogWarning($"[AudioManager] Snapshot '{snapshotName}' not found in mixer.");
}
// ── 内部实现 ─────────────────────────────────────────────────────────────
private void HandlePlayerDied()
{
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)
{
Debug.LogError("[AudioManager] SFX Source 池为空,请在 Inspector 中为 _sfxSources 赋值。");
return null;
}
return _sfxSources[_sfxRoundRobin++ % _sfxSources.Length];
}
private IEnumerator CrossfadeCoroutine(AudioClip newClip, float fadeOutDur, float fadeInDur)
{
// 淡出当前活跃 Source
float startVolume = _activeBGMSource.volume;
float elapsed = 0f;
while (elapsed < fadeOutDur)
{
elapsed += Time.unscaledDeltaTime;
_activeBGMSource.volume = Mathf.Lerp(startVolume, 0f, elapsed / fadeOutDur);
yield return null;
}
_activeBGMSource.Stop();
_activeBGMSource.volume = 0f;
// 切换到非活跃 Source 播放新曲目
var temp = _activeBGMSource;
_activeBGMSource = _inactiveBGMSource;
_inactiveBGMSource = temp;
_activeBGMSource.clip = newClip;
_activeBGMSource.volume = 0f;
_activeBGMSource.Play();
// 淡入新 Source
elapsed = 0f;
while (elapsed < fadeInDur)
{
elapsed += Time.unscaledDeltaTime;
_activeBGMSource.volume = Mathf.Lerp(0f, 1f, elapsed / fadeInDur);
yield return null;
}
_activeBGMSource.volume = 1f;
_crossfadeCoroutine = null;
}
private IEnumerator FadeOutCoroutine(AudioSource source, float duration)
{
float startVolume = source.volume;
float elapsed = 0f;
while (elapsed < duration)
{
elapsed += Time.unscaledDeltaTime;
source.volume = Mathf.Lerp(startVolume, 0f, elapsed / duration);
yield return null;
}
source.Stop();
source.volume = 0f;
_crossfadeCoroutine = null;
}
private static float LinearToDecibel(float linear)
=> linear > 0.0001f ? 20f * Mathf.Log10(linear) : -80f;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1230831ab62bdd84fbeb7df03e20c254
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: -500
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,15 @@
namespace BaseGames.Audio
{
/// <summary>
/// AudioMixer Exposed Parameter 字符串常量。
/// 与 Assets/Audio/MainMixer.mixer 中的 Exposed Parameters 保持同步。
/// 参数范围:-80 ~ 0 dB代码通过 LinearToDecibel 转换后写入)。
/// </summary>
public static class AudioMixerKeys
{
public const string Master = "MasterVolume";
public const string BGM = "BGMVolume";
public const string SFX = "SFXVolume";
public const string Ambient = "AmbientVolume";
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a8df8e995946cb24bb61b96c362bf58b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,24 @@
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Audio
{
/// <summary>
/// 区域音效触发器:玩家进入 Collider2D 时广播 _onRegionEntered 事件频道。
/// 挂在区域边界的 GameObject 上Collider2D 设置为 Is Trigger。
/// _zoneId 须与 AudioConfigSO.ZoneBGMs 中的 ZoneId 一致。
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class AudioZone : MonoBehaviour
{
[SerializeField] private string _zoneId;
[SerializeField] private StringEventChannelSO _onRegionEntered;
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
if (_onRegionEntered != null)
_onRegionEntered.Raise(_zoneId);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 11367ad39af3b9649af572aaa55891ec
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,115 @@
using System.Collections;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.Audio
{
/// <summary>BGM 状态机内部状态。</summary>
public enum MusicState
{
Exploration, // 默认:区域探索 BGM
Boss, // Boss 战Boss 主题 BGM
Victory, // Boss 击败后短暂胜利音乐
None, // 过场/死亡/主菜单时由 BGMController 直接切换
}
/// <summary>
/// BGM 控制器:订阅世界/Boss/游戏状态事件,指挥 AudioManager 切换 BGM 和快照。
/// 挂在 Persistent 场景 [AudioManager] 子对象上。
/// </summary>
public class BGMController : MonoBehaviour
{
[SerializeField] private AudioManager _audioManager;
[SerializeField] private AudioConfigSO _config;
[Header("Event Channels - Subscribe")]
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
[SerializeField] private BoolEventChannelSO _onBossFightToggled; // true=开始, false=结束
[SerializeField] private StringEventChannelSO _onRegionEntered;
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()
{
_onBossFightToggled?.Subscribe(OnBossFightToggled).AddTo(_subscriptions);
_onRegionEntered?.Subscribe(OnRegionEntered).AddTo(_subscriptions);
_onGameStateChanged?.Subscribe(HandleStateChanged).AddTo(_subscriptions);
}
private void OnDisable()
{
_subscriptions.Clear();
}
private void OnBossFightToggled(bool started)
{
if (started)
{
_musicState = MusicState.Boss;
var clip = _config.GetBossBGM(_currentRegion);
if (clip == null)
{
Debug.LogWarning($"[BGMController] 区域 '{_currentRegion}' 未配置 Boss BGM将保持当前音乐。", this);
}
else
{
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 0.5f);
}
_audioManager.TransitionToSnapshot("BossFight", 0.5f);
}
else
{
StartCoroutine(PlayVictoryThenRestore());
}
}
private IEnumerator PlayVictoryThenRestore()
{
_musicState = MusicState.Victory;
_audioManager.PlayBGM(_config.VictoryStingBGM,
fadeOutDur: 0.3f, fadeInDur: 0.1f);
float dur = _config.VictoryStingDuration;
yield return new WaitForSecondsRealtime(dur);
_musicState = MusicState.Exploration;
OnRegionEntered(_currentRegion);
_audioManager.TransitionToSnapshot("Default", 1.0f);
}
private void OnRegionEntered(string regionId)
{
if (regionId == _currentRegion) return;
_currentRegion = regionId;
if (_musicState == MusicState.Exploration)
{
var clip = _config.GetZoneBGM(regionId);
if (clip == null)
{
Debug.LogWarning($"[BGMController] 区域 '{regionId}' 未配置 BGM将保持当前音乐。", this);
return;
}
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 1f);
}
}
private void HandleStateChanged(GameStateId state)
{
if (state == GameStates.MainMenu)
_audioManager.PlayBGM(_config.MainMenuBGM,
fadeOutDur: 0.5f, fadeInDur: 1.0f);
else if (state == GameStates.Paused)
_audioManager.TransitionToSnapshot("Paused", 0.2f);
else if (state == GameStates.Dead)
_audioManager.TransitionToSnapshot("Dead", 1.5f);
else if (state == GameStates.Gameplay)
_audioManager.TransitionToSnapshot("Default", 0.3f);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: eed9bb7d17336ae4c8e2636fbeeef6bd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,18 @@
{
"excludePlatforms": [],
"allowUnsafeCode": false,
"precompiledReferences": [],
"name": "BaseGames.Audio",
"defineConstraints": [],
"noEngineReferences": false,
"versionDefines": [],
"rootNamespace": "BaseGames.Audio",
"references": [
"BaseGames.Core.Events",
"BaseGames.Core",
"BaseGames.Combat"
],
"autoReferenced": true,
"overrideReferences": false,
"includePlatforms": []
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: bdbbbb51c06a54142b8bf1f9966fc408
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,81 @@
using UnityEngine;
using BaseGames.Core.Events;
using BaseGames.Combat;
namespace BaseGames.Audio
{
/// <summary>
/// 订阅战斗/死亡事件,通过 GlobalSFXPlayer 播放对应 AudioEventSO 音效。
/// 挂载在 Persistent 场景的 [Systems] GameObject 上。
/// 使用 AudioEventSO 替代裸 AudioClip支持随机音量 / 音调 / 多片段。
/// </summary>
public class CombatSFXController : MonoBehaviour
{
[Header("Event Channels")]
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed;
[SerializeField] private VoidEventChannelSO _onPlayerDied;
[Header("Default Hit SFX")]
[SerializeField] private AudioEventSO _defaultHitSFX;
[Header("Per-Type Hit SFX (optional, overrides default)")]
[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 AudioEventSO _playerDeathSFX;
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_onHitConfirmed?.Subscribe(HandleHit).AddTo(_subs);
_onPlayerDied?.Subscribe(HandlePlayerDied).AddTo(_subs);
}
private void OnDisable()
{
_subs.Clear();
}
private void HandleHit(HitInfo info)
{
AudioEventSO sfx = ResolveHitSFX(info.DamageInfo.FxType);
if (sfx == null) return;
GlobalSFXPlayer.Play(sfx, info.HitPoint);
}
private void HandlePlayerDied()
{
if (_playerDeathSFX == null) return;
GlobalSFXPlayer.Play(_playerDeathSFX);
}
private AudioEventSO ResolveHitSFX(HitFxType fxType)
{
AudioEventSO perType = fxType switch
{
HitFxType.Spark => _sparkHitSFX,
HitFxType.Slash => _slashHitSFX,
HitFxType.Blood => _bloodHitSFX,
HitFxType.Magic => _magicHitSFX,
HitFxType.Heavy => _heavyHitSFX,
HitFxType.Crit => _critHitSFX,
HitFxType.Parry => _parryHitSFX,
HitFxType.Fire => _fireHitSFX,
HitFxType.Ice => _iceHitSFX,
_ => null
};
return perType != null ? perType : _defaultHitSFX;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c9ab0a72bbabda44888a0bec8186bc27
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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/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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 902dc34e76d63a041a7e9a564b89a5e0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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, // 洞穴(回响加强)
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3dc331b476a760a49b238b6645aae052
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
// Assets/Scripts/Audio/FootstepMaterialMarker.cs
// 挂载到地面碰撞体所在 GameObject标记该地面的脚步声材质
// Architecture 21_LiquidPuzzleModule §3.3
using UnityEngine;
namespace BaseGames.Audio
{
/// <summary>
/// 挂载到地面碰撞体所在 GameObjectTilemap 图层 or 单体地形 Prefab
/// 若玩家脚下 GameObject 无此组件,默认使用 FootstepMaterial.Stone。
/// </summary>
public class FootstepMaterialMarker : MonoBehaviour
{
public FootstepMaterial material = FootstepMaterial.Stone;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9bed4b6cdf4d7064793fe22fefccce43
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8275d76dd985c4c419f4c477b9275de3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,58 @@
// Assets/Scripts/Audio/UnderwaterAudioController.cs
// 进入 LiquidZone 时切换水下 DSP 处理Architecture 21_LiquidPuzzleModule §3.4
using UnityEngine;
using UnityEngine.Audio;
using BaseGames.Core.Events;
namespace BaseGames.Audio
{
/// <summary>
/// 挂载于 PlayerController 所在 GameObject。
/// 订阅 EVT_LiquidEntered / EVT_LiquidExited 事件频道(与 WaterDangerState、UnderwaterPostProcessingController 一致)。
/// 切换 AudioMixer Snapshot 以应用/解除水下 DSP 处理。
/// 仅响应 Water 类型液体Acid / Lava 不切换水下音频。
/// </summary>
public class UnderwaterAudioController : MonoBehaviour
{
[SerializeField] private AudioMixer _mixer;
[SerializeField] private float _transitionDuration = 0.3f;
[Header("Event Channels")]
[SerializeField] private LiquidEventChannelSO _onLiquidEntered; // EVT_LiquidEntered
[SerializeField] private LiquidEventChannelSO _onLiquidExited; // EVT_LiquidExited
private readonly CompositeDisposable _subs = new();
private void OnEnable()
{
_onLiquidEntered?.Subscribe(OnLiquidEntered).AddTo(_subs);
_onLiquidExited?.Subscribe(OnLiquidExited).AddTo(_subs);
}
private void OnDisable() => _subs.Clear();
private void OnLiquidEntered(LiquidEvent evt)
{
if (evt.LiquidType == LiquidType.Water)
EnterWater();
}
private void OnLiquidExited(LiquidEvent evt)
{
if (evt.LiquidType == LiquidType.Water)
ExitWater();
}
/// <summary>切换至水下 AudioMixer Snapshot。</summary>
public void EnterWater()
{
_mixer?.FindSnapshot("Underwater")?.TransitionTo(_transitionDuration);
}
/// <summary>切换回默认 AudioMixer Snapshot。</summary>
public void ExitWater()
{
_mixer?.FindSnapshot("Default")?.TransitionTo(_transitionDuration);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9d32189c7a2ecbe4caf2bf3d8aa174f2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 0461abd63ce9f1a4cbe77b584ad89dbe
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,17 @@
{
"excludePlatforms": [],
"allowUnsafeCode": false,
"precompiledReferences": [],
"name": "BaseGames.Camera",
"defineConstraints": [],
"noEngineReferences": false,
"versionDefines": [],
"rootNamespace": "BaseGames.Camera",
"references": [
"BaseGames.Core.Events",
"Unity.Cinemachine"
],
"autoReferenced": true,
"overrideReferences": false,
"includePlatforms": []
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5b9cbc0f2e569d64a862f3b7f417c7b6
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,90 @@
using UnityEngine;
using Unity.Cinemachine;
namespace BaseGames.Camera
{
/// <summary>
/// 相机区域数据组件。一个房间场景内可放置任意数量的 CameraArea
/// 每个区域独立定义限位范围、可视边界与混合配置。
///
/// 运行时由 <see cref="CameraStateController"/> 管理:
/// - <c>_dedicatedCamera</c> 为空 → 使用 Persistent 场景中的两台全局 VCam 交替承接,
/// 减少场景内 VCam 数量,相机参数统一由全局 VCam 配置。
/// - <c>_dedicatedCamera</c> 不为空 → 激活该专有 VCam优先级高于全局 VCam
/// 适用于需要独特相机参数FOV / Offset / 阻尼)的特殊区域。
/// </summary>
public class CameraArea : MonoBehaviour
{
[Header("限位区域")]
[Tooltip("定义相机移动边界的 PolygonCollider2D通常挂载在子节点 AreaBoundary 上)。\n" +
"会被赋给全局 VCam 的 CinemachineConfiner2D.BoundingShape2D。")]
[SerializeField] private PolygonCollider2D _confinerCollider;
[Header("可视区域(透视相机)")]
[Tooltip("摄像机应显示的最大可视矩形(世界坐标)。\n" +
"Scene 视图中可直接拖拽四条边编辑,然后点击 Inspector 中的\n" +
"「从可视区域更新限位区域(透视)」按钮将其换算为限位多边形。")]
[SerializeField] private Rect _visibleBounds = new Rect(-12f, -6f, 24f, 12f);
[Tooltip("摄像机到场景平面Z = 0的垂直距离用于透视视口尺寸计算。\n" +
"留 0 时自动取 transform.position.z 的绝对值(推荐)。")]
[SerializeField] private float _cameraDepth = 0f;
[Header("混合配置")]
[SerializeField] private CameraBlendProfileSO _blendProfile;
[Header("专有虚拟相机(可选)")]
[Tooltip("为空时由全局双 VCam 交替过渡(推荐,节省 VCam 数量)。\n" +
"不为空时激活此专有 CinemachineCamera优先级高于全局 VCam。\n" +
"适用于需要独特 FOV / Noise / LookAt 等参数的特殊区域。")]
[SerializeField] private CinemachineCamera _dedicatedCamera;
[Tooltip("专有 VCam 激活时使用的优先级,须高于全局 VCam 的 _globalActivePriority默认 10。")]
[SerializeField] private int _dedicatedPriority = 20;
// ── 公开属性 ──────────────────────────────────────────────────────────
public PolygonCollider2D ConfinerCollider => _confinerCollider;
public CameraBlendProfileSO BlendProfile => _blendProfile;
public Rect VisibleBounds => _visibleBounds;
public bool HasDedicated => _dedicatedCamera != null;
public CinemachineCamera DedicatedCamera => _dedicatedCamera;
public int DedicatedPriority => _dedicatedPriority;
/// <summary>
/// 摄像机到场景平面的有效深度(用于透视视口换算)。
/// _cameraDepth &gt; 0 时使用配置值,否则自动读取 |transform.position.z|,再兜底 10。
/// </summary>
public float CameraDepth
{
get
{
if (_cameraDepth > 0f) return _cameraDepth;
float z = Mathf.Abs(transform.position.z);
return z > 0.01f ? z : 10f;
}
}
// ── Gizmo ────────────────────────────────────────────────────────────
private void OnDrawGizmosSelected()
{
// 黄色:可视区域
Vector3 center = new Vector3(_visibleBounds.center.x, _visibleBounds.center.y, 0f);
Vector3 size = new Vector3(_visibleBounds.width, _visibleBounds.height, 0.01f);
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.10f);
Gizmos.DrawCube(center, size);
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.90f);
Gizmos.DrawWireCube(center, size);
// 青色:专有 VCam 指示线
if (_dedicatedCamera != null)
{
Gizmos.color = new Color(0.2f, 1f, 0.8f, 0.8f);
Gizmos.DrawLine(transform.position,
_dedicatedCamera.transform.position);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8ebf3921efccfad429f451251738375e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,25 @@
using UnityEngine;
using Unity.Cinemachine;
namespace BaseGames.Camera
{
[CreateAssetMenu(menuName = "BaseGames/Camera/BlendProfile")]
public class CameraBlendProfileSO : ScriptableObject
{
public CinemachineBlendDefinition.Styles Style = CinemachineBlendDefinition.Styles.EaseInOut;
public float BlendTime = 0.5f;
[Tooltip("Style = Custom 时使用")]
public AnimationCurve CustomCurve = AnimationCurve.EaseInOut(0f, 0f, 1f, 1f);
/// <summary>转换为 Cinemachine 混合定义。</summary>
public CinemachineBlendDefinition ToBlendDefinition()
{
return new CinemachineBlendDefinition
{
Style = this.Style,
Time = this.BlendTime,
CustomCurve = this.Style == CinemachineBlendDefinition.Styles.Custom ? CustomCurve : null
};
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 04f7b183b6d364d4ea85283d30339db7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,22 @@
using UnityEngine;
namespace BaseGames.Camera
{
[CreateAssetMenu(menuName = "BaseGames/Camera/CameraConfig")]
public class CameraConfigSO : ScriptableObject
{
[Header("跟随")]
public float FollowDamping = 0.15f;
public float LookAheadTime = 0.3f;
public float LookAheadSmoothing = 0.1f;
public Vector2 DeadZoneSize = new Vector2(1f, 0.5f);
public Vector2 SoftZoneSize = new Vector2(2.5f, 2f);
[Header("偏移")]
public float LookDownOffset = -1.5f;
public float LookUpOffset = 1.5f;
[Header("画面抖动默认强度")]
public float DefaultImpulseStrength = 0.3f;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b358a30ac16c6a34fb673ede0a288e48
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,180 @@
using UnityEngine;
using Unity.Cinemachine;
using BaseGames.Core;
namespace BaseGames.Camera
{
/// <summary>
/// 相机状态单例控制器。须放置在 Persistent 场景中。
///
/// 支持两种相机切换模式:
/// 1. 全局双 VCam 模式(推荐):<see cref="SwitchArea"/>
/// 两台全局 CinemachineCamera<c>_vcamA</c> / <c>_vcamB</c>)交替承接各区域,
/// 通过优先级 ping-pong 触发 Cinemachine 混合过渡。场景内无需每个区域都放置 VCam。
///
/// 2. 专有 VCam 模式:<see cref="SwitchArea"/>(区域含 dedicatedCamera 时自动使用)
/// 激活该区域专属的 CinemachineCamera优先级 > 全局 VCam
/// 适用于需要独特相机参数的特殊区域。
/// </summary>
[DefaultExecutionOrder(-100)]
public class CameraStateController : MonoBehaviour, ICameraService
{
[Header("引用")]
[SerializeField] private CinemachineBrain _brain;
[SerializeField] private CinemachineImpulseSource _impulseSource;
[Header("全局双 VCamPersistent 场景中放置两台通用虚拟相机)")]
[Tooltip("两台 VCam 交替承接各相机区域,通过优先级 ping-pong 触发混合过渡。\n" +
"须各自挂载 CinemachineCamera + CinemachineConfiner2D\n" +
"Follow 指向 Player/CameraFollowTarget或运行时调用 SetFollowTarget 赋值)。")]
[SerializeField] private CinemachineCamera _vcamA;
[SerializeField] private CinemachineCamera _vcamB;
[Tooltip("全局 VCam 激活时的优先级(非活跃时为 0。专有 VCam 的 _dedicatedPriority 须高于此值。")]
[SerializeField] private int _globalActivePriority = 10;
[Header("默认混合配置")]
[SerializeField] private CameraBlendProfileSO _defaultBlendProfile;
// ── 状态 ──────────────────────────────────────────────────────────────
private int _activeSlot = -1; // -1 = 未初始化0 = A1 = B
private CinemachineCamera _activeDedicatedCam;
private CinemachineConfiner2D _confinerA;
private CinemachineConfiner2D _confinerB;
// ── Lifecycle ────────────────────────────────────────────────────────
private void Awake()
{
if (ServiceLocator.GetOrDefault<ICameraService>() != null) { Destroy(gameObject); return; }
ServiceLocator.Register<ICameraService>(this);
// 缓存 Confiner 引用
if (_vcamA != null) _confinerA = _vcamA.GetComponent<CinemachineConfiner2D>();
if (_vcamB != null) _confinerB = _vcamB.GetComponent<CinemachineConfiner2D>();
// 初始两台 VCam 均处于非活跃优先级
if (_vcamA != null) _vcamA.Priority = 0;
if (_vcamB != null) _vcamB.Priority = 0;
}
private void OnDestroy()
{
ServiceLocator.Unregister<ICameraService>(this);
}
// ── 公开 API ──────────────────────────────────────────────────────────
/// <summary>
/// 切换到目标相机区域。
/// <list type="bullet">
/// <item>区域有专有 VCam → 激活它(高优先级),全局 VCam 保持当前状态。</item>
/// <item>区域无专有 VCam → 配置非活跃全局 VCamping-pong 切换优先级触发混合。</item>
/// </list>
/// </summary>
public void SwitchArea(CameraArea targetArea)
{
if (targetArea == null) return;
ApplyBlendProfile(targetArea.BlendProfile ?? _defaultBlendProfile);
if (targetArea.HasDedicated)
ActivateDedicated(targetArea);
else
ActivateGlobalSlot(targetArea);
}
/// <summary>
/// 运行时为两台全局 VCam 统一设置跟随目标(如 Player/CameraFollowTarget
/// 可在 Player 生成后由任意系统调用。
/// </summary>
public void SetFollowTarget(Transform followTarget)
{
if (_vcamA != null) _vcamA.Follow = followTarget;
if (_vcamB != null) _vcamB.Follow = followTarget;
}
/// <summary>触发屏幕抖动。</summary>
public void TriggerImpulse(Vector3 velocity)
{
if (_impulseSource != null) _impulseSource.GenerateImpulse(velocity);
}
/// <summary>以默认强度触发屏幕抖动。</summary>
public void TriggerImpulse(float strength = 0.3f)
=> TriggerImpulse(Vector3.down * strength);
// ── 内部方法 ──────────────────────────────────────────────────────────
/// <summary>激活区域的专有 VCam高优先级。</summary>
private void ActivateDedicated(CameraArea area)
{
// 降低前一个专有 VCam若与新的不同
if (_activeDedicatedCam != null && _activeDedicatedCam != area.DedicatedCamera)
_activeDedicatedCam.Priority = 0;
_activeDedicatedCam = area.DedicatedCamera;
_activeDedicatedCam.Priority = area.DedicatedPriority;
}
/// <summary>
/// 使用全局 VCam ping-pong 切换到新区域。
/// 配置非活跃 VCam 的 Confiner → 提升其优先级 → 降低旧 VCam 优先级。
/// Cinemachine Brain 检测到优先级变化后自动触发混合。
/// </summary>
private void ActivateGlobalSlot(CameraArea area)
{
// 收回专有 VCam
if (_activeDedicatedCam != null)
{
_activeDedicatedCam.Priority = 0;
_activeDedicatedCam = null;
}
bool noVCams = _vcamA == null && _vcamB == null;
if (noVCams)
{
Debug.LogWarning("[CameraStateController] 全局 VCam A / B 均未绑定,无法切换相机区域。");
return;
}
// 首次调用:直接激活 VCamA场景淡入阶段无需混合动画
if (_activeSlot < 0)
{
var cam = _vcamA ?? _vcamB;
var confiner = _vcamA != null ? _confinerA : _confinerB;
ConfigureSlot(cam, confiner, area);
cam.Priority = _globalActivePriority;
_activeSlot = _vcamA != null ? 0 : 1;
return;
}
// Ping-pong配置非活跃槽 → 升级其优先级 → 降低活跃槽优先级
bool nextIsA = _activeSlot != 0;
var inactiveCam = nextIsA ? _vcamA : _vcamB;
var activeCam = nextIsA ? _vcamB : _vcamA;
var inactiveConfiner = nextIsA ? _confinerA : _confinerB;
// 只有一台 VCam 时降级处理(仍能工作,但无混合动画)
if (inactiveCam == null) inactiveCam = activeCam;
ConfigureSlot(inactiveCam, inactiveConfiner, area);
inactiveCam.Priority = _globalActivePriority;
activeCam.Priority = 0;
_activeSlot = nextIsA ? 0 : 1;
}
private static void ConfigureSlot(
CinemachineCamera vcam, CinemachineConfiner2D confiner, CameraArea area)
{
if (confiner != null && area.ConfinerCollider != null)
confiner.BoundingShape2D = area.ConfinerCollider;
}
private void ApplyBlendProfile(CameraBlendProfileSO profile)
{
if (_brain != null && profile != null)
_brain.DefaultBlend = profile.ToBlendDefinition();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 49f718c655d71394ea13e312a2dd9eed
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,46 @@
using UnityEngine;
using BaseGames.Core;
namespace BaseGames.Camera
{
/// <summary>
/// 相机区域切换触发器。玩家进入时通知 <see cref="CameraStateController"/> 切换到目标 <see cref="CameraArea"/>。
/// </summary>
[ExecuteAlways]
[RequireComponent(typeof(BoxCollider2D))]
public class CameraTriggerZone : MonoBehaviour
{
[SerializeField] private CameraArea _targetArea;
[SerializeField] private string _playerTag = "Player";
private BoxCollider2D _collider;
private void Awake()
{
_collider = GetComponent<BoxCollider2D>();
_collider.isTrigger = true;
}
private void OnTriggerEnter2D(Collider2D other)
{
if (!Application.isPlaying) return;
if (!other.CompareTag(_playerTag)) return;
var service = ServiceLocator.GetOrDefault<ICameraService>();
if (service == null) return;
if (_targetArea != null)
service.SwitchArea(_targetArea);
}
private void OnDrawGizmos()
{
if (_collider == null) _collider = GetComponent<BoxCollider2D>();
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.25f);
Gizmos.matrix = transform.localToWorldMatrix;
Gizmos.DrawCube(_collider.offset, _collider.size);
Gizmos.color = new Color(0.2f, 0.8f, 1f, 0.8f);
Gizmos.DrawWireCube(_collider.offset, _collider.size);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 108d2b73047255f44a823dbcdea4a7fa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,15 @@
namespace BaseGames.Camera
{
/// <summary>
/// 相机服务接口。供 CameraTriggerZone 等调用,
/// 通过 ServiceLocator.Get&lt;ICameraService&gt;() 访问,无需直接依赖 CameraStateController。
/// </summary>
public interface ICameraService
{
/// <summary>
/// 切换到目标相机区域。
/// 区域有专有 VCam 时激活它(高优先级);无专有 VCam 时由全局双 VCam 交替承接。
/// </summary>
void SwitchArea(CameraArea targetArea);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 94f7141340a22a54aa504d7c6a09eeb3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,71 @@
using UnityEngine;
using Unity.Cinemachine;
namespace BaseGames.Camera
{
/// <summary>
/// 单房间虚拟相机。激活时提升优先级,停用时降为 0。
/// 挂载在每个房间的 CinemachineCamera GameObject 上。
/// </summary>
[RequireComponent(typeof(CinemachineCamera))]
public class RoomCamera : MonoBehaviour
{
[Header("房间设置")]
[SerializeField] private RoomVisibleArea _visibleArea;
[SerializeField] private Vector2 _cameraOffset = Vector2.zero;
[SerializeField] private CameraBlendProfileSO _blendProfile;
[SerializeField] private int _activePriority = 15;
[Header("可视区域(透视相机)")]
[Tooltip("摄像机应显示的最大可视矩形(世界坐标)。\n" +
"在 Scene 视图中可直接拖拽四条边编辑,然后点击 Inspector 中的\n" +
"「从可视区域更新限位区域」按钮将其换算为 CinemachineConfiner2D 所需的限位多边形。")]
[SerializeField] private Rect _visibleBounds = new Rect(-12f, -6f, 24f, 12f);
[Tooltip("摄像机到场景平面Z = 0的垂直距离用于透视视口尺寸计算。\n" +
"留 0 时自动取 transform.position.z 的绝对值(推荐)。")]
[SerializeField] private float _cameraDepth = 0f;
private CinemachineCamera _vcam;
private void Awake() => _vcam = GetComponent<CinemachineCamera>();
private void OnEnable() => _vcam.Priority = _activePriority;
private void OnDisable() => _vcam.Priority = 0;
public PolygonCollider2D ConfinerCollider => _visibleArea?.Collider;
public Vector2 CameraOffset => _cameraOffset;
public CameraBlendProfileSO BlendProfile => _blendProfile;
public Rect VisibleBounds => _visibleBounds;
/// <summary>
/// 摄像机到场景平面的有效深度。
/// _cameraDepth > 0 时使用配置值,否则自动读取 |transform.position.z|,再兜底 10。
/// </summary>
public float CameraDepth
{
get
{
if (_cameraDepth > 0f) return _cameraDepth;
float z = Mathf.Abs(transform.position.z);
return z > 0.01f ? z : 10f;
}
}
/// <summary>在 CameraStateController 管理的激活流程中调用。</summary>
public void Activate() => gameObject.SetActive(true);
public void Deactivate() => gameObject.SetActive(false);
// ── Gizmo ──────────────────────────────────────────────────────────────
private void OnDrawGizmosSelected()
{
// 黄色:可视区域(设计意图——玩家在此房间内的最大可见范围)
Vector3 center = new Vector3(_visibleBounds.center.x, _visibleBounds.center.y, 0f);
Vector3 size = new Vector3(_visibleBounds.width, _visibleBounds.height, 0.01f);
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.10f);
Gizmos.DrawCube(center, size);
Gizmos.color = new Color(1f, 0.85f, 0.15f, 0.90f);
Gizmos.DrawWireCube(center, size);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: af7e12583264b8c4da8dcd69df274793
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,30 @@
using UnityEngine;
namespace BaseGames.Camera
{
/// <summary>
/// 标记房间的可见区域(多边形)。供 CinemachineConfiner2D 使用。
/// [ExecuteAlways] 确保编辑器中碰撞体立即更新。
/// </summary>
[ExecuteAlways]
[RequireComponent(typeof(PolygonCollider2D))]
public class RoomVisibleArea : MonoBehaviour
{
private PolygonCollider2D _collider;
private void Awake()
{
_collider = GetComponent<PolygonCollider2D>();
_collider.isTrigger = true;
}
public PolygonCollider2D Collider
{
get
{
if (_collider == null) _collider = GetComponent<PolygonCollider2D>();
return _collider;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 38af2eabab7039c4a919181e4c507d12
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 878e5c61f6bb064449bf2399b70026b9
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f43e5039135c2f84682862a9249e2688
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,18 @@
{
"excludePlatforms": [],
"allowUnsafeCode": false,
"precompiledReferences": [],
"name": "BaseGames.Combat",
"defineConstraints": [],
"noEngineReferences": false,
"versionDefines": [],
"rootNamespace": "BaseGames.Combat",
"references": [
"BaseGames.Core",
"BaseGames.Core.Events",
"BaseGames.Parry"
],
"autoReferenced": true,
"overrideReferences": false,
"includePlatforms": []
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8746e0f9f33d5d84ea0b598962cc36ae
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,24 @@
using UnityEngine;
namespace BaseGames.Combat
{
/// <summary>
/// 拼刀系统参数配置(架构 06_CombatModule §15
/// 资产路径: Assets/ScriptableObjects/Config/Combat/ClashConfig.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/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;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d2dce4002cf4f99429388b2038fa2f60
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df7a93b79e446e24dbb27d2971c85f39
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,82 @@
using System;
namespace BaseGames.Combat
{
// ── 元素/物理属性 ───────────────────────────────────────────────────────
public enum DamageType { Normal, True, Fire, Poison, Ice, Lightning, Void }
// ── 来源分类 ────────────────────────────────────────────────────────────
public enum DamageCategory
{
NormalAttack = 0,
SoulSkill = 1,
SpiritSkill = 2,
Projectile = 3,
EnvironmentTrap = 4,
StatusEffect = 5,
FallDamage = 6,
Reflected = 7,
}
// ── 行为标志 ────────────────────────────────────────────────────────────
[Flags]
public enum DamageFlags
{
None = 0,
Unblockable = 1 << 0,
CanBeParried = 1 << 1,
IgnoreIFrame = 1 << 2,
PerfectParryOnly = 1 << 3,
IsProjectile = 1 << 4,
CanClash = 1 << 5,
ForceBreak = 1 << 6,
NoKnockback = 1 << 7,
}
// ── 交互标签 ────────────────────────────────────────────────────────────
[Flags]
public enum DamageTags : uint
{
None = 0,
MeleeHit = 1 << 0,
RangedHit = 1 << 1,
SkillHit = 1 << 2,
ElementFire = 1 << 3,
ElementPoison = 1 << 4,
ElementVoid = 1 << 5,
AfterParry = 1 << 6,
ChargedAttack = 1 << 7,
SkyFormOnly = 1 << 8,
EarthFormOnly = 1 << 9,
DeathFormOnly = 1 << 10,
BreakLight = 1 << 11,
BreakMedium = 1 << 12,
BreakHeavy = 1 << 13,
BreakBreaker = 1 << 14,
}
public enum HitFxType { Spark, Slash, Blood, Magic, Heavy, Crit, Void, Heal, Parry, Fire, Ice }
// ── 攻击方打断等级 ──────────────────────────────────────────────────────
public enum BreakLevel
{
None = 0,
Light = 1,
Medium = 2,
Heavy = 3,
Breaker = 4,
}
// ── 承受方霸体等级 ──────────────────────────────────────────────────────
public enum PoiseLevel
{
None = 0,
Light = 1,
Medium = 2,
Heavy = 3,
Unbreakable = 100,
}
// ── 攻击方向PlayerCombat / WeaponSO 使用)────────────────────────────
public enum AttackDirection { Ground, Up, Down, Air }
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ca3858c58d2156f4fbc2d295c444bd40
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,49 @@
namespace BaseGames.Combat
{
/// <summary>
/// 可受击实体接口。PlayerController 和 EnemyBase 实现此接口。
/// HurtBox.Awake 通过 GetComponentInParent&lt;IDamageable&gt;() 注入。
/// </summary>
public interface IDamageable
{
bool IsAlive { get; }
bool IsInvincible { get; }
int Defense { get; }
void TakeDamage(DamageInfo info);
}
/// <summary>
/// 可持有霸体的实体接口。HurtBox 在 ReceiveDamage 中做等级比较。
/// </summary>
public interface IPoiseSource
{
PoiseLevel GetCurrentPoiseLevel();
}
/// <summary>
/// 护盾接口(玩家专属)。由 PlayerController.Awake() 注入 HurtBox。
/// AbsorbDamage 返回穿透量0 = 全部吸收,>0 = 穿透量继续走 TakeDamage 流程)。
/// </summary>
public interface IShieldable
{
bool HasShield { get; }
int AbsorbDamage(int amount);
}
/// <summary>
/// 可破坏机关/障碍物接口。HitBox 在命中非 HurtBox 对象时尝试调用。
/// </summary>
public interface IBreakable
{
void TryInteract(DamageInfo info);
}
/// <summary>
/// 可施加状态效果的实体接口(避免 Combat 直接引用 StatusEffects 程序集)。
/// StatusEffectManager 实现此接口HurtBox.ReceiveDamage 步骤 8 通过此接口调用。
/// </summary>
public interface IStatusEffectable
{
void ApplyStatusEffect(DamageType type);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7881dd1e194f2944a87ec5d50686740e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,125 @@
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Combat
{
/// <summary>
/// 单次伤害信息。流水线RawDamage → Amount护盾修改→ FinalDamage防御减免后
/// ⚠️ 保留为可变 structHurtBox 流水线需要在方法内修改本地副本的 Amount / FinalDamage。
/// Builder 通过独立字段构造,不直接修改 DamageInfo 实例。
/// </summary>
[System.Serializable]
public struct DamageInfo
{
public int RawDamage; // HitBox 设定的原始值(工厂/Builder 写入一次)
public int Amount; // 流水线中被护盾/防御修改
public int FinalDamage; // HurtBox 写入,最终 HP 扣除量
public Vector2 KnockbackDirection;
public float KnockbackForce;
public float HitStunDuration;
public DamageType Type;
public DamageCategory Category;
public DamageFlags Flags;
public DamageTags Tags;
public Vector2 SourcePosition;
public int SourceLayer;
public HitFxType FxType;
public BreakLevel Break;
public string SourceId;
public string SkillId;
// ── Builder ──────────────────────────────────────────────────────────
/// <summary>
/// 通过独立字段构造 DamageInfo避免直接持有可变 DamageInfo 实例。
/// </summary>
public class Builder
{
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 同步初始化 AmountAmount 始终以 RawDamage 为起点)
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 填入所有静态字段;
/// 可选传入运行时字段knockbackDir、sourcePos、sourceLayer
/// 无需调用方事后就地赋值。
/// </summary>
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,
KnockbackDirection = knockbackDir,
KnockbackForce = so.KnockbackForce,
SourcePosition = sourcePos,
SourceLayer = sourceLayer,
};
}
}
/// <summary>伤害事件频道EVT_DamageDealt。</summary>
[UnityEngine.CreateAssetMenu(menuName = "Events/DamageDealt")]
public class DamageInfoEventChannelSO : BaseEventChannelSO<DamageInfo> { }
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 78a4c2420f838e74aa97697d5da09b72
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,52 @@
using UnityEngine;
namespace BaseGames.Combat
{
/// <summary>
/// 攻击数据源 SO。描述单次攻击的基础伤害参数。
/// ⚡ 热路径使用零分配工厂DamageInfo.From(sourceSO)。
/// 仅需链式覆盖多字段时才使用 CreateBuilder()。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Combat/DamageSource")]
public class DamageSourceSO : ScriptableObject
{
[Header("Identity")]
public string sourceId;
public string skillId;
[Header("Base")]
public int BaseDamage = 10;
public float DamageMultiplier = 1.0f;
public DamageType Type = DamageType.Normal;
public DamageCategory Category = DamageCategory.NormalAttack;
public DamageFlags Flags = DamageFlags.CanBeParried;
public DamageTags Tags = DamageTags.MeleeHit;
[Header("Physics")]
public float KnockbackForce = 5f;
public float HitStunDuration = 0.1f;
public BreakLevel BreakLevel = BreakLevel.Light;
[Header("FX")]
public HitFxType FxType = HitFxType.Slash;
[Header("Combo")]
public float ComboWindowDuration = 0.4f;
public float CancelWindowEnd = 0.5f;
/// <summary>
/// 链式 Builder特殊场景使用热路径改用 DamageInfo.From(this))。
/// </summary>
public DamageInfo.Builder CreateBuilder() => new DamageInfo.Builder()
.SetRaw(Mathf.RoundToInt(BaseDamage * DamageMultiplier))
.SetType(Type)
.SetCategory(Category)
.SetFlags(Flags)
.SetTags(Tags)
.SetStun(HitStunDuration)
.SetFx(FxType)
.SetBreak(BreakLevel)
.SetSourceId(sourceId)
.SetSkillId(skillId);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 96b10c11e6173394a8fa8d9c614b0035
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,290 @@
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
namespace BaseGames.Combat
{
/// <summary>
/// 攻击判定盒。挂载在武器 Prefab 或技能 HitBox Prefab 的子节点上。
/// 直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。
/// Collider2D 需设 IsTrigger = trueLayer = PlayerHitBox 或 EnemyHitBox。
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class HitBox : MonoBehaviour
{
[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;
private Collider2D _collider;
/// <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 event System.Action<DamageInfo> OnHitConfirmed;
/// <summary>
/// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。
/// ⚠️ 不存在 Activate(float duration) 重载。
/// </summary>
public void Activate(DamageSourceSO source = null, Transform attacker = null)
{
_currentSource = source ?? _defaultSource;
_attackerTransform = attacker ?? transform;
_isActive = true;
// 缓存宿主 Rigidbody2D沿父层级向上查找
_ownerRigidbody = _attackerTransform.GetComponentInParent<Rigidbody2D>();
// 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标)
_hitThisActivation.Clear();
_hitCooldownTimers.Clear();
}
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()
{
// 确保 Collider2D 是 Trigger
_collider = GetComponent<Collider2D>();
if (!_collider.isTrigger)
Debug.LogWarning($"[HitBox] {name}: Collider2D.isTrigger 应为 true。", this);
// 缓存 IClashServiceOnTriggerEnter2D 为物理热路径,避免每次调用 Dictionary 查找
_clashService = ServiceLocator.GetOrDefault<IClashService>();
}
private void OnDisable()
{
_isActive = false;
_hitThisActivation.Clear();
_hitCooldownTimers.Clear();
}
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;
// 排除自身:攻击方与受击方属于同一根 GameObject 时跳过(防止近战 EnemyHitBox 命中同一敌人的 EnemyHurtBox
if (other.transform.root == _attackerTransform.root) return;
Vector2 knockDir = ((Vector2)other.bounds.center
- (Vector2)_attackerTransform.position).normalized;
// ⚡ 零 GCstruct 工厂,运行时字段内联传入
var info = DamageInfo.From(
_currentSource,
knockDir,
_attackerTransform.position,
_attackerTransform.gameObject.layer);
// ① 拼刀检测:当前 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)
{
// 用 HitBox 自身碰撞盒中心在 HurtBox 表面上的最近点作为受击位置。
// 对大体积/长条形受击体(如地刺),此点远比 HurtBox 节点中心更准确。
Vector3 hitPoint = other.ClosestPoint(_collider.bounds.center);
hurtBox.ReceiveDamage(info, hitPoint);
OnHitConfirmed?.Invoke(info);
return;
}
// ③ 命中 IBreakable机关/障碍物)
other.GetComponent<IBreakable>()?.TryInteract(info);
}
// ── 当前激活期已命中目标集合(防止复合子 Collider 导致同帧多次命中)────────────
private readonly HashSet<Collider2D> _hitThisActivation = new(8);
// ── 同目标多帧命中冷却(防止 Trigger 处于重叠状态时重入等殊情导致的连击)──
private readonly Dictionary<Collider2D, float> _hitCooldownTimers = new(8);
private bool CheckCooldown(Collider2D other)
{
float now = Time.time;
if (_hitCooldownTimers.TryGetValue(other, out float last) && now - last < _hitCooldown)
return false;
_hitCooldownTimers[other] = now;
return true;
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
var col = GetComponent<Collider2D>();
if (col == null) return;
// HitBox激活 = 鲜红,非激活 = 极淡红轮廓
Gizmos.color = _isActive
? new Color(1f, 0.15f, 0.15f, 1f)
: new Color(1f, 0.15f, 0.15f, 0.2f);
DrawCollider2DWire(col);
}
// ────────────────────────────────────────────────────
// Gizmo 辅助(内联,不依赖外部工具类)
// ────────────────────────────────────────────────────
/// <summary>
/// 根据 Collider2D 类型绘制正确的 wire 轮廓。
/// 使用 <see cref="Gizmos.matrix"/> 统一应用 Transform 的移动/旋转/缩放。
/// </summary>
public static void DrawCollider2DWire(Collider2D col)
{
var prev = Gizmos.matrix;
Gizmos.matrix = col.transform.localToWorldMatrix;
switch (col)
{
case BoxCollider2D box:
DrawWireRect2D(box.offset, box.size);
break;
case CapsuleCollider2D caps:
DrawWireCapsule2D(caps.offset, caps.size, caps.direction);
break;
case PolygonCollider2D poly:
for (int p = 0; p < poly.pathCount; p++)
{
var pts = poly.GetPath(p);
for (int i = 0; i < pts.Length; i++)
Gizmos.DrawLine(pts[i], pts[(i + 1) % pts.Length]);
}
break;
case CircleCollider2D circle:
DrawWireCircle2D(circle.offset, circle.radius);
break;
default:
// 其他类型回退到 bounds 近似(恢复矩阵后在世界空间绘制)
Gizmos.matrix = prev;
DrawWireRect2D(col.bounds.center, col.bounds.size);
return;
}
Gizmos.matrix = prev;
}
/// <summary>在当前 Gizmos 坐标系中绘制轴对齐矩形2D 线框,兼容透视相机)。</summary>
public static void DrawWireRect2D(Vector2 center, Vector2 size)
{
float hx = size.x * 0.5f, hy = size.y * 0.5f;
Vector3 tl = new Vector3(center.x - hx, center.y + hy, 0f);
Vector3 tr = new Vector3(center.x + hx, center.y + hy, 0f);
Vector3 br = new Vector3(center.x + hx, center.y - hy, 0f);
Vector3 bl = new Vector3(center.x - hx, center.y - hy, 0f);
Gizmos.DrawLine(tl, tr);
Gizmos.DrawLine(tr, br);
Gizmos.DrawLine(br, bl);
Gizmos.DrawLine(bl, tl);
}
/// <summary>用线段近似绘制 2D 圆周(不使用 DrawWireSphere兼容透视相机。</summary>
public static void DrawWireCircle2D(Vector3 center, float radius, int segs = 32)
{
float step = 360f / segs;
Vector3 prevPt = center + new Vector3(radius, 0f, 0f);
for (int i = 1; i <= segs; i++)
{
float a = i * step * Mathf.Deg2Rad;
Vector3 nextPt = center + new Vector3(Mathf.Cos(a) * radius, Mathf.Sin(a) * radius, 0f);
Gizmos.DrawLine(prevPt, nextPt);
prevPt = nextPt;
}
}
/// <summary>绘制 2D 胶囊轮廓(在 col.transform 局部坐标系中)。</summary>
private static void DrawWireCapsule2D(Vector2 offset, Vector2 size, CapsuleDirection2D dir)
{
bool vert = dir == CapsuleDirection2D.Vertical;
float radius = vert ? size.x * 0.5f : size.y * 0.5f;
float half = Mathf.Max(0f, (vert ? size.y : size.x) * 0.5f - radius);
Vector2 axis = vert ? Vector2.up : Vector2.right;
Vector2 perp = vert ? Vector2.right : Vector2.up;
Vector2 capA = offset + axis * half;
Vector2 capB = offset - axis * half;
Gizmos.DrawLine(capA + perp * radius, capB + perp * radius);
Gizmos.DrawLine(capA - perp * radius, capB - perp * radius);
DrawArc2D(capA, radius, vert ? 0f : -90f, 180f);
DrawArc2D(capB, radius, vert ? 180f : 90f, 180f);
}
/// <summary>用多段直线近似绘制 2D 圆弧。</summary>
private static void DrawArc2D(Vector2 c, float r, float startDeg, float spanDeg, int segs = 20)
{
float step = spanDeg / segs;
var prev = c + new Vector2(Mathf.Cos(startDeg * Mathf.Deg2Rad) * r,
Mathf.Sin(startDeg * Mathf.Deg2Rad) * r);
for (int i = 1; i <= segs; i++)
{
float a = (startDeg + step * i) * Mathf.Deg2Rad;
var next = c + new Vector2(Mathf.Cos(a) * r, Mathf.Sin(a) * r);
Gizmos.DrawLine(prev, next);
prev = next;
}
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a655e2461396a8348a32a13144438e8e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
using BaseGames.Core.Events;
using UnityEngine;
namespace BaseGames.Combat
{
[CreateAssetMenu(menuName = "BaseGames/Events/HitConfirmed")]
public class HitConfirmedEventChannelSO : BaseEventChannelSO<HitInfo> { }
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 86e5ffa3ce0537845b1b601c267d76ef
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,17 @@
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Combat
{
/// <summary>
/// 命中信息HurtBox.ReceiveDamage 广播给 VFX/Audio/Feedback
/// </summary>
public struct HitInfo
{
public DamageInfo DamageInfo;
public Vector3 HitPoint;
public Vector3 HitNormal;
public Transform HitTransform;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b5933018bd81bef48be815337eef02af
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9aace2ae37bf9d0459a4cdeb34595885
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cb4a2de7e8deb224db43f89dbe62748d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,129 @@
using UnityEngine;
using BaseGames.Parry;
namespace BaseGames.Combat
{
/// <summary>
/// 受击盒组件。实现完整 8 步伤害流水线(架构 06_CombatModule §5
/// 挂载在角色根节点或指定子节点上Collider2D 需设 IsTrigger = true
/// Layer = PlayerHurtBox 或 EnemyHurtBox。
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class HurtBox : MonoBehaviour
{
// ── 伤害接受方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;
// ── 事件频道 ──────────────────────────────────────────────────────────
[SerializeField] private DamageInfoEventChannelSO _onDamageDealt;
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed;
// ── 注入接口 ──────────────────────────────────────────────────────────
public void SetShieldable(IShieldable shieldable) => _shieldable = shieldable;
public void SetParrySystem(ParrySystem ps) => _parrySystem = ps;
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);
}
/// <summary>
/// 接受伤害(由 HitBox.OnTriggerEnter2D 直接调用)。
/// <param name="hitPoint">弹击点世界坐标;不传则默认使用 HurtBox 节点中心。</param>
/// ⚠️ 方法名必须为 ReceiveDamage。
/// </summary>
public void ReceiveDamage(DamageInfo info, Vector3? hitPoint = null)
{
Vector3 resolvedHitPoint = hitPoint ?? transform.position;
if (!_isActive || _owner == null) return;
// 1. 无敌帧检查
if ((_owner.IsInvincible || _isHurtBoxInvincible)
&& !info.Flags.HasFlag(DamageFlags.IgnoreIFrame)) return;
// 2. 弹反检查_parrySystem == null 时跳过)
// ParrySystem 只暴露窗口状态,伤害数据留在 Combat 层,无跨程序集数据依赖。
if (_parrySystem != null && info.Flags.HasFlag(DamageFlags.CanBeParried))
if (_parrySystem.ConsumeParry()) return;
// 3. 霸体检查_poiseSource == null 时跳过)
if (!info.Flags.HasFlag(DamageFlags.ForceBreak) && _poiseSource != null)
{
PoiseLevel curPoise = _poiseSource.GetCurrentPoiseLevel();
if (curPoise == PoiseLevel.Unbreakable) return;
if ((int)info.Break < (int)curPoise)
{
_onHitConfirmed?.Raise(new HitInfo
{
DamageInfo = info,
HitPoint = resolvedHitPoint,
});
return;
}
}
// 4. 护盾层拦截(玩家专属,在防御减免前)
if (_shieldable != null && _shieldable.HasShield)
{
int passThrough = _shieldable.AbsorbDamage(info.Amount);
if (passThrough <= 0) return;
info.Amount = passThrough;
}
// 5. 计算 FinalDamage防御减免最低 1
int finalDamage = UnityEngine.Mathf.Max(1, info.Amount - _owner.Defense);
info.Amount = finalDamage;
info.FinalDamage = finalDamage;
// 6. 调用 _owner.TakeDamage
_owner.TakeDamage(info);
// 7. 全局广播
_onDamageDealt?.Raise(info);
_onHitConfirmed?.Raise(new HitInfo
{
DamageInfo = info,
HitPoint = resolvedHitPoint,
});
// 8. 状态效果触发DoT — Fire / Poison
// _statusEffectable 已在 Awake 中缓存,无需每次受击调用 GetComponent
_statusEffectable?.ApplyStatusEffect(info.Type);
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
var col = GetComponent<Collider2D>();
if (col == null) return;
// HurtBox正常激活 = 青色,无敌 = 黄色,非激活 = 极淡青
Gizmos.color = !_isActive
? new Color(0f, 0.85f, 1f, 0.15f)
: _isHurtBoxInvincible
? new Color(1f, 1f, 0f, 1f )
: new Color(0f, 0.85f, 1f, 1f );
HitBox.DrawCollider2DWire(col);
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d7b7a233d7f70aa4f86b473412b826de
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7df722a95e7dc7f4b808256877e44af7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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, int ownerLayer = 0);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b7d8b713a91da1d408f02c68e666f8fa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,105 @@
using BaseGames.Core.Events;
using UnityEngine;
namespace BaseGames.Combat
{
/// <summary>
/// 致命陷阱(地刺、深渊等)——对齐空洞骑士地刺行为:
///
/// ① 玩家 HurtBox 触碰时,通过 HurtBox.ReceiveDamage() 造成一次伤害
/// 并携带 IgnoreIFrame 标记(无视翻滚/受击无敌帧,与空洞骑士一致)。
/// ② 若伤害导致玩家死亡 → PlayerController.TakeDamage 已 Raise EVT_PlayerDied
/// 走完整死亡流程(死亡动画 → 重载场景)。
/// ③ 若玩家存活 → 本组件 Raise EVT_PlayerDied强制返回最近检查点
/// 同样触发 DeathRespawnService无死亡演出只是重新加载
///
/// 可下劈Pogo
/// - 将 _canPogo = true并在地刺顶部添加子 GameObject
/// · 挂 HurtBox 组件EnemyHurtBox 层)
/// · 父链上无 IDamageable → ReceiveDamage 无副作用HP 不扣除)
/// · 玩家下劈 HitBox 命中该 HurtBox → HitBox.OnHitConfirmed →
/// WeaponHitBoxInstance.OnDownHitConfirmed → DownAttackState.OnDownHitConfirmed
/// → Move.Jump() 完成弹跳,无需额外代码。
/// - _canPogo = false 则不添加顶部 HurtBox下劈时玩家仍受地刺伤害。
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class LethalTrap : MonoBehaviour
{
[Header("伤害")]
[Tooltip("每次触碰造成的伤害量(对齐空洞骑士 = 1 格血)")]
[SerializeField] private int _damage = 1;
[Header("检测")]
[Tooltip("勾选 PlayerHurtBox 层")]
[SerializeField] private LayerMask _playerLayers;
[Header("事件")]
[Tooltip("EVT_PlayerDied — 触发后由 DeathRespawnService 接管回到检查点")]
[SerializeField] private VoidEventChannelSO _onPlayerDied;
[Header("设计")]
[Tooltip("true = 顶部有 HurtBox 子节点可下劈弹跳(需手动添加 Pogo Surface 子节点)")]
[SerializeField] private bool _canPogo = true;
private bool _triggered;
private void Awake()
{
var col = GetComponent<Collider2D>();
if (!col.isTrigger)
{
col.isTrigger = true;
Debug.LogWarning($"[LethalTrap] {name}: Collider2D.isTrigger 已自动设为 true。", this);
}
}
private void OnEnable() => _triggered = false;
private void OnTriggerEnter2D(Collider2D other)
{
if (_triggered) return;
if ((_playerLayers.value & (1 << other.gameObject.layer)) == 0) return;
_triggered = true;
// ── 1. 通过 HurtBox 流水线造成伤害(含防御计算,携带 IgnoreIFrame────────
bool playerDiedFromDamage = false;
if (_damage > 0)
{
var hurtBox = other.GetComponent<HurtBox>();
if (hurtBox != null)
{
var info = new DamageInfo.Builder()
.SetRaw(_damage)
.SetFlags(DamageFlags.IgnoreIFrame) // 无视无敌帧(翻滚/受击)
.SetSourceId("lethal_trap")
.Build();
hurtBox.ReceiveDamage(info);
// 检测伤害后玩家存活状态:
// 若已死亡PlayerController.TakeDamage 已在同帧内 Raise EVT_PlayerDied
// 若存活,需由本组件 Raise 使其回到检查点。
var damageable = other.transform.root.GetComponentInParent<IDamageable>();
playerDiedFromDamage = damageable != null && !damageable.IsAlive;
}
}
// ── 2. 若玩家存活(或无 HurtBox / damage=0强制触发检查点回溯 ───────────
if (!playerDiedFromDamage)
_onPlayerDied?.Raise();
}
#if UNITY_EDITOR
[UnityEngine.ContextMenu("打印 Pogo 提示")]
private void PrintPogoInfo()
{
Debug.Log(
_canPogo
? $"[LethalTrap] {name}: _canPogo=true。请在顶部添加子 GO + HurtBox(EnemyHurtBox 层) + Collider2D(isTrigger=true),父链上无 IDamageable。"
: $"[LethalTrap] {name}: _canPogo=false无下劈弹跳。",
this);
}
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 03c34fc042c310942a02cfa075dcd981
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,13 @@
namespace BaseGames.Combat
{
/// <summary>
/// 直线抛射物。以固定速度沿 Direction 方向飞行,无重力。
/// </summary>
public class LinearProjectile : Projectile
{
protected override void OnInitialized()
{
_rb.velocity = Direction * _config.Speed;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8e7b0c1c571010c4c9f65f953274086d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 075db832266507d40bc71389d6d3f333
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