112 KiB
Phase 2 · 核心玩法扩展
周期:4–5 周(Week 5–9)
前置条件:Phase 1 全部完成标准通过(第三方库协作验证无误)
核心目标:完整的玩家战斗/移动系统、护盾/弹反、状态效果、难度系统、AudioMixer 快照、敌人扩展
产出物:玩家拥有所有移动能力(冲刺/登墙/游泳)、完整连击链、弹反机制;2–3 种敌人类型;AudioMixer 快照需求已整合
目录
- 实施顺序总览
- Week 5:玩家 FSM 完整扩展
- Week 6:护盾 + 弹反 + 战斗深化
- Week 7:状态效果 + 动画事件 + 完整 VFX
- Week 8:难度系统 + AudioMixer 快照
- Week 9:敌人扩展 + BossBase 骨架
- 完成标准检查清单
1. 实施顺序总览
Week 5: DashState → WallSlideState → AirAttackState → DownAttack/UpAttack
↓
FormController → WeaponManager → PlayerCombat 完整(含 SoulSkill 触发)
↓
PlayerStats 完整(SoulPower/SpiritPower/Geo/Ability 系统)
Week 6: ShieldComponent → HurtBox 修正(护盾管线)
↓
ParrySystem → ParryConfigSO → ParryState
↓
PoiseSystem(霸体)→ HurtState 更新(考虑霸体打断)
↓
连击链完整(3 段地面 + 2 段空中 + 下劈 + 上劈)
Week 7: AnimationEventType 枚举 → IAnimationEventHandler(接口,架构 24 §4)
↓
StatusEffect(抽象基类,架构 06 §11,非 StatusEffectBase)→ Poison / Burn / Stagger 具体实现
↓
StatusEffectManager → HurtBox 集成
↓
完整 VFX(ParryFlash / FormSwitch / StatusEffect 粒子)
Week 8: AudioMixer 快照配置(BGM形态切换/战斗/平静快照)
↓
DifficultyManager → DifficultyScalerSO × 4 → PlayerStats/EnemyStats 注入钩子
Week 9: EnemyBase 子类:RangedEnemy(远程)+ FlyingEnemy(飞行巡逻)
↓
LootResolver + LootTableSO
↓
BossBase 骨架(HP 分段、Phase 切换、Arena 锁定)
2. Week 5:玩家 FSM 完整扩展
参考文档:05_PlayerModule.md §2
2.1 新增 State 文件列表
Assets/Scripts/Player/States/
├── DashState.cs ← 地面冲刺(位移 + 无敌帧)
├── AerialDashState.cs ← 空中冲刺(架构 05 §12,独立状态,消耗 MaxAerialDashes 次数)⚠️ 与 DashState 分离
├── WallSlideState.cs ← 蹬墙(下滑 + 蹬墙跳触发)
├── WallJumpState.cs ← 蹬墙跳
├── AirAttackState.cs ← 空中攻击
├── DownAttackState.cs ← 下劈(Physics2D.OverlapPoint 踩踏检测)
├── UpAttackState.cs ← 上劈
├── HurtState.cs ← 受击硬直
├── DeadState.cs ← 死亡(冻结物理)
├── SpringState.cs ← 使用灵泉(治疗动画)
└── ParryState.cs ← 弹反预备(Week 6)
2.2 DashState 实现要点
public class DashState : PlayerStateBase
{
public override void OnStateEnter()
{
// 1. 消耗冲刺次数(Phase 2 先不加多段冲刺计数,一次冲刺)
// 2. 开启无敌帧 BeginInvincibility(_config.DashInvincibleDuration)
// 3. 施加冲刺速度 _player.Movement.Dash(facingDir, _config.DashSpeed)
// 4. 启动持续时间计时器 → 到期进入 Fall 或 Idle
// 5. 播放冲刺动画 + _player.Feedback.PlayDash()
}
// 冲刺期间忽略重力(SetGravityScale(0)),结束时恢复
}
2.3 FormController + WeaponManager
按 05_PlayerModule.md §6/§7 实现:
FormConfigSO.asset × 1 ← 单一资产,forms[0..2] = Sky/Earth/Death(Assets/Data/Player/Forms/) ⚠️ 非 3 个 FormConfigSO
FormSO.asset × 3 ← Sky / Earth / Death(存于 FormConfigSO.forms[] 数组中,架构 05 §18)
WeaponSO.asset × 3 ← 三形态对应武器数据(Assets/Data/Combat/Weapons/)
// ⚠️ 无 WeaponInstance Prefab;HitBox 直接挂载在 Player Prefab 上(PlayerCombat._hitBoxGround/Up/Down/Air),架构 05 §5/§7
FormController.SwitchForm() 实现(架构 05_PlayerModule §6):
- 更新
CurrentForm - 发布事件频道
_onFormChanged.Raise(_config.GetFormIndex(newForm))(UI / Save 用) - 触发 C# 事件
OnFormChanged?.Invoke()(⚠️ WeaponManager 在 OnEnable 自订阅,非直接调用 WeaponManager) - 调用
_paletteSwapSystem?.ApplyPalette(newForm.formType)(⚠️ 传FormType枚举值,架构 18_VFXFeedbackModule §10 定义ApplyPalette(FormType form)接受 FormType;架构 05 §6 代码片段错误地传了newForm.formAccentColor(Color)与架构 18 §10 不符,以架构 18 §10 为准;FormSO需补充public FormType formType字段,架构 05 §18 FormSO 定义中遗漏此字段) - 调用
_skillManager?.UpdateSkillSet(newForm)(⚠️ 传 FormSO 1 个参数,非 3 个 FormSkillSO,架构 05 §6) - 发布
_onSkillSetChanged?.Raise()(EVT_SkillSetChanged,通知 SkillHUD 刷新,架构 09_ProgressionModule §11)
PlayerCombat(按 05_PlayerModule.md §5 完整实现,Phase 1 §3.4 简化版补全):
// Assets/Scripts/Player/PlayerCombat.cs
// ⚠️ 架构 05_PlayerModule §5:HitBox 直接挂在 Player Prefab 上,不经过 WeaponInstance(无此类)
public class PlayerCombat : MonoBehaviour
{
[SerializeField] private WeaponManager _weaponManager;
[SerializeField] private SkillManager _skillManager;
// ── 玩家角色 Prefab 上的 HitBox(固定挂载,不依赖武器 Prefab)────────────
[SerializeField] private HitBox _hitBoxGround;
[SerializeField] private HitBox _hitBoxUp;
[SerializeField] private HitBox _hitBoxDown;
[SerializeField] private HitBox _hitBoxAir;
void OnEnable()
{
_weaponManager.OnWeaponChanged += RefreshWeaponData;
if (_weaponManager.ActiveWeapon != null)
RefreshWeaponData(_weaponManager.ActiveWeapon);
}
void OnDisable() => _weaponManager.OnWeaponChanged -= RefreshWeaponData;
void RefreshWeaponData(WeaponSO weapon) { /* 缓存动画片段,刷新 HitBox DamageSource */ }
public void SetComboSegmentSource(ComboState segment)
{
WeaponSO w = _weaponManager.ActiveWeapon;
DamageSourceSO src = segment switch
{
ComboState.Attack1 => w?.attack1Source,
ComboState.Attack2 => w?.attack2Source,
ComboState.Attack3 => w?.attack3Source,
ComboState.AirAttack => w?.airAttackSource,
ComboState.UpAttack => w?.upAttackSource,
ComboState.DownAttack => w?.downAttackSource,
_ => null,
};
if (src != null) _hitBoxGround.SetDamageSource(src);
}
// ── HitBox 激活(供 AttackState / AnimationEvent 调用)─────────────────
public void EnableWeaponHitBox(AttackDirection dir)
=> GetHitBox(dir)?.Activate(_weaponManager.ActiveWeapon?.GetSourceByDir(dir), transform);
public void DisableWeaponHitBox(AttackDirection dir)
=> GetHitBox(dir)?.Deactivate();
public void DisableAllWeaponHitBoxes()
{
_hitBoxGround?.Deactivate();
_hitBoxUp?.Deactivate();
_hitBoxDown?.Deactivate();
_hitBoxAir?.Deactivate();
}
// 命中回调 → 增加灵力
internal void OnHitConfirmed(DamageInfo info) { /* 增加灵力 */ }
private HitBox GetHitBox(AttackDirection dir) => dir switch
{
AttackDirection.Ground => _hitBoxGround,
AttackDirection.Up => _hitBoxUp,
AttackDirection.Down => _hitBoxDown,
AttackDirection.Air => _hitBoxAir,
_ => null,
};
}
WeaponSO(按 05_PlayerModule.md §7 完整实现):
// Assets/Scripts/Combat/WeaponSO.cs
// ⚠️ 纯数据 SO(不含 Prefab),架构 05 §7;HitBox 固定在 Player Prefab 上
[CreateAssetMenu(menuName = "Combat/Weapon")]
public class WeaponSO : ScriptableObject
{
[Header("基础信息")]
public string weaponId; // 全局唯一 ID,如 "Weapon_SkyBlade"
public string displayName; // "天裂刃"
public Sprite icon;
public WeaponType weaponType; // ⚠️ WeaponType 枚举(架构 05 §7)
[Header("连击动画(Animancer ClipTransition)")]
public ClipTransition attack1Clip;
public ClipTransition attack2Clip;
public ClipTransition attack3Clip;
public ClipTransition airAttackClip;
public ClipTransition upAttackClip;
public ClipTransition downAttackClip;
[Header("伤害来源(每段独立 DamageSourceSO)")]
public DamageSourceSO attack1Source;
public DamageSourceSO attack2Source;
public DamageSourceSO attack3Source;
public DamageSourceSO airAttackSource;
public DamageSourceSO upAttackSource;
public DamageSourceSO downAttackSource;
[Header("HitBox 配置")]
/// <summary>零向量 = 使用 PlayerCombat 中的默认尺寸</summary>
public Vector2 hitBoxSizeOverride;
public Vector2 hitBoxOffsetOverride;
[Header("武器特效")]
public WeaponVFXConfig vfxConfig; // ⚠️ 类型为 WeaponVFXConfig(非 MMF_Player),架构 05 §7
public DamageSourceSO GetSourceByDir(AttackDirection dir) => dir switch
{
AttackDirection.Ground => attack1Source,
AttackDirection.Up => upAttackSource,
AttackDirection.Down => downAttackSource,
AttackDirection.Air => airAttackSource,
_ => null,
};
}
// ⚠️ WeaponType 枚举(架构 05 §7)
public enum WeaponType
{
SkyBlade, // 天魂:裂空刃(高频轻击)
EarthHammer, // 地魂:地震锤(低频重击,范围大)
LifeScythe, // 命魂:命镰(穿透,直线斩)
Custom, // 护符替换或未来扩展武器
}
// ⚠️ WeaponVFXConfig:内嵌类([Serializable]),架构 05 §7;包含 FeedbackPresetSO 而非 MMF_Player
[Serializable]
public class WeaponVFXConfig
{
[Tooltip("切换到此武器时播放的特效(形态切换音效/粒子)")]
public FeedbackPresetSO onEquipFeedback; // ⚠️ FeedbackPresetSO(非 MMF_Player),架构 05 §7
[Tooltip("武器挥斩拖尾 Prefab(null = 不显示拖尾)")]
public GameObject weaponTrailPrefab;
public Color trailColor = Color.white;
[Tooltip("命中特效类型覆盖(null = 使用 DamageSourceSO.HitFxType)")]
public HitFxType? hitFxOverride;
}
WeaponManager(按 05_PlayerModule.md §7 完整实现,含护符 Override API):
// Assets/Scripts/Player/WeaponManager.cs
// ⚠️ 架构 05 §7:含护符替换武器的 Override 字典(_overrides),非简单直接切换
public class WeaponManager : MonoBehaviour
{
[SerializeField] private FormController _formController;
public WeaponSO ActiveWeapon { get; private set; }
public event Action<WeaponSO> OnWeaponChanged;
// ⚠️ 护符注入的武器覆盖:Key = FormSO.formId,Value = 替换武器(架构 05 §7)
readonly Dictionary<string, WeaponSO> _overrides = new();
void Awake()
{
if (_formController.CurrentForm != null)
ApplyWeapon(_formController.CurrentForm);
}
void OnEnable() => _formController.OnFormChanged += HandleFormChanged;
void OnDisable() => _formController.OnFormChanged -= HandleFormChanged;
void HandleFormChanged() => ApplyWeapon(_formController.CurrentForm);
void ApplyWeapon(FormSO form)
{
WeaponSO next = _overrides.TryGetValue(form.formId, out var ov)
? ov
: form.defaultWeapon;
if (next == ActiveWeapon) return;
ActiveWeapon = next;
OnWeaponChanged?.Invoke(next);
// ⚠️ vfxConfig.onEquipFeedback 为 FeedbackPresetSO(架构 05 §7),非 MMF_Player
next?.vfxConfig.onEquipFeedback?.PlayFeedbacks(gameObject);
}
// ─────────── 护符 Override API(由 WeaponOverrideEffect 调用,架构 05 §7)───────────
/// <summary>为指定形态设置武器覆盖。formId 为空 = 覆盖所有形态。</summary>
public void SetOverride(string formId, WeaponSO weapon)
{
if (string.IsNullOrEmpty(formId))
foreach (var f in _formController.AllForms) _overrides[f.formId] = weapon;
else
_overrides[formId] = weapon;
ApplyWeapon(_formController.CurrentForm);
}
/// <summary>移除覆盖,恢复默认武器。formId 为空 = 移除所有形态覆盖。</summary>
public void ClearOverride(string formId)
{
if (string.IsNullOrEmpty(formId))
foreach (var f in _formController.AllForms) _overrides.Remove(f.formId);
else
_overrides.Remove(formId);
ApplyWeapon(_formController.CurrentForm);
}
}
WeaponOverrideEffect(护符效果实现,按 05_PlayerModule.md §7):
// Assets/Scripts/Equipment/CharmEffects/WeaponOverrideEffect.cs
// ⚠️ 实现 ICharmEffect 接口(架构 05 §7),非直接继承 MonoBehaviour
namespace BaseGames.Equipment
{
[Serializable]
public class WeaponOverrideEffect : ICharmEffect
{
[Tooltip("目标形态 ID(留空 = 所有形态)")]
public string targetFormId;
[Tooltip("替换武器 SO")]
public WeaponSO replacementWeapon;
public void OnEquip(EquipmentContext ctx)
=> ctx.WeaponMgr.SetOverride(targetFormId, replacementWeapon);
public void OnUnequip(EquipmentContext ctx)
=> ctx.WeaponMgr.ClearOverride(targetFormId);
public string GetEffectDescription()
{
string formStr = string.IsNullOrEmpty(targetFormId) ? "所有形态" : targetFormId;
string wName = replacementWeapon != null ? replacementWeapon.displayName : "null";
return $"{formStr}的武器替换为 [{wName}]";
}
}
}
// EquipmentContext 需新增 WeaponManager WeaponMgr 字段(见 17_EquipmentSystem §3)
2.4 PlayerStats 完整实现
补充 Phase 1 未实现的部分:
| 功能 | 实现要点 |
|---|---|
SoulPower |
击杀/命中增加;使用魂技能消耗;上限 100 |
SpiritPower |
时间自动回复(SpiritRegenRate/秒);使用魄技能消耗 |
SpringCharges |
击杀积分达阈值增加;存档点恢复满值 |
Geo |
击杀掉落收集;购买消耗 |
UnlockedAbilities |
HashSet<AbilityType> 持久存储;HasAbility 供状态检查 |
⚠️
AddSoulvsAddSoulPower命名不一致:PlayerStats.csAPI(架构 05 §4)定义AddSoulPower(int amount),但ParrySystem.HandleSuccessfulParry(架构 06 §8)调用_playerStats.AddSoul(soulGain)。以架构 06 §8 为准,即AddSoul为正确方法名(可能是 SO 配置文件的旧名称);实现PlayerStats.cs时须确保两个调用名称一致。
PlayerStatsSO 完整定义(按 05_PlayerModule.md §16):
// Assets/Scripts/Player/PlayerStatsSO.cs
[CreateAssetMenu(menuName = "Player/Stats")]
public class PlayerStatsSO : ScriptableObject
{
[Header("HP")]
public int MaxHP = 5; // 初始心脏容器数 × 2(半格为1)
[Header("Soul Power")]
public int MaxSoulPower = 100;
[Header("Spirit Power")]
public int MaxSpiritPower = 100;
public int SpiritRegenRate = 5; // 每秒回复量
[Header("Spring")]
public int MaxSpringCharges = 3; // 初始灵泉次数上限
public int SpringHealAmount = 2; // ⚠️ 每次回复 HP 量(半格),架构 05 §16
public int SpringKillThreshold = 4; // ⚠️ 增加 1 次灵泉所需击杀点数,架构 05 §16
[Header("Invincibility")]
public float InvincibilityDuration = 0.6f;
[Header("Geo")]
public int InitialGeo = 0;
}
PlayerMovementConfigSO 完整定义(按 05_PlayerModule.md §15,含所有 Wall 参数):
// Assets/Scripts/Player/PlayerMovementConfigSO.cs
[CreateAssetMenu(menuName = "Player/MovementConfig")]
public class PlayerMovementConfigSO : ScriptableObject
{
[Header("Ground Movement")]
public float RunSpeed = 7f;
public float Acceleration = 50f;
public float Deceleration = 80f;
[Header("Jump")]
public float JumpForce = 18f;
public float CoyoteTime = 0.12f; // 秒
public float FallGravityMult = 2.5f; // 下落时额外重力倍率
public float MaxFallSpeed = 20f;
[Header("Dash")]
public float DashSpeed = 20f;
public float DashDuration = 0.18f; // 秒
public float DashCooldown = 0.4f; // 秒
public int MaxAerialDashes = 1; // ⚠️ 空中冲刺次数(AerialDashState 消耗此计数)
[Header("Wall")]
public float WallSlideSpeed = 2f;
public float WallJumpForceX = 12f;
public float WallJumpForceY = 16f;
[Header("Wall — 检测")]
public float WallRayLength = 0.55f; // 墙壁射线长度
public float WallRayOffsetY = 0.2f; // 上/下两根射线相对中心的 Y 偏移
[Header("Wall — WallGrab")]
public float WallGrabMaxHeightGain = 0.5f; // ⚠️ 允许的最大垂直高度增益(防无限蹬墙),架构 05 §15
public float WallGrabReleaseDelay = 0.08f; // ⚠️ 离开墙面后的延迟释放(避免误判),架构 05 §15
[Header("Wall — WallJump")]
public float WallJumpBackForceX = 14f; // ⚠️ 背墙跳(同向)水平速度,架构 05 §15
public float WallJumpAwayForceX = 10f; // ⚠️ 对墙跳(反向)水平速度,架构 05 §15
public float WallJumpAwayForceY = 18f; // ⚠️ 对墙跳垂直速度,架构 05 §15
public float WallJumpInputLockDuration = 0.15f; // ⚠️ 对墙跳后短暂锁定水平输入,架构 05 §15
[Header("General")]
public float DefaultGravityScale = 3f;
}
2.5 PlayerWallDetector + PlayerController IPoiseSource 扩展
PlayerWallDetector(独立组件,架构 05_PlayerModule.md §13):
// Assets/Scripts/Player/PlayerWallDetector.cs
// ⚠️ 独立组件,不嵌入 PlayerMovement(架构 05 §13)
// WallSlideState 通过 _owner.WallDetector.IsTouchingWall 访问
[RequireComponent(typeof(PlayerMovement))]
public class PlayerWallDetector : MonoBehaviour
{
[SerializeField] private PlayerMovementConfigSO _config;
public bool IsTouchingWall { get; private set; }
public int WallDirection { get; private set; } // +1 = 右墙,-1 = 左墙
private void FixedUpdate()
{
bool rightWall = CheckSide(Vector2.right);
bool leftWall = CheckSide(Vector2.left);
IsTouchingWall = rightWall || leftWall;
WallDirection = rightWall ? 1 : leftWall ? -1 : 0;
}
// ⚠️ 每侧发两根射线(TopRay + BottomRay),两根均命中才返回 true(防卡角误判)
private bool CheckSide(Vector2 dir)
{
Vector2 center = transform.position;
float len = _config.WallRayLength;
float oy = _config.WallRayOffsetY;
int layer = LayerMask.GetMask("Wall"); // Layer 10(见 06_CombatModule §12)
bool top = Physics2D.Raycast(center + Vector2.up * oy, dir, len, layer);
bool bot = Physics2D.Raycast(center + Vector2.down * oy, dir, len, layer);
return top && bot;
}
}
PlayerController 补充(架构 05_PlayerModule.md §13 + 06_CombatModule §13):
// PlayerController.cs 追加以下内容(与 Phase 1 骨架 merge)
// ⚠️ 须实现 IPoiseSource 接口(架构 06_CombatModule §13)
public partial class PlayerController : MonoBehaviour, IPoiseSource
{
[Header("Wall Detection")]
[SerializeField] private PlayerWallDetector _wallDetector; // ⚠️ 独立组件引用(架构 05 §13)
public PlayerWallDetector WallDetector => _wallDetector; // WallSlideState / WallJumpState 使用
// ── IPoiseSource 实现(架构 06_CombatModule §13)────────────────────────
private PoiseWindowConfig _currentPoiseWindow;
private AnimancerState _activeState; // Animancer 提供当前动画状态
public void SetPoiseWindow(PoiseWindowConfig window) // 由 AttackState / SkillState.OnStateEnter() 调用
=> _currentPoiseWindow = window;
public void ClearPoiseWindow() // 由 OnStateExit() 调用
=> _currentPoiseWindow = default;
/// <summary>IPoiseSource 实现:每帧查询当前霸体等级(攻击动画期间)</summary>
public PoiseLevel GetCurrentPoiseLevel()
{
if (_currentPoiseWindow.Level == PoiseLevel.None) return PoiseLevel.None;
if (_activeState == null) return PoiseLevel.None;
float t = _activeState.NormalizedTime % 1f;
bool inWindow = t >= _currentPoiseWindow.NormalizedStart
&& t <= _currentPoiseWindow.NormalizedEnd;
return inWindow ? _currentPoiseWindow.Level : PoiseLevel.None;
}
}
// 资产:Player Prefab Inspector 需挂载 PlayerWallDetector 组件并赋值到 _wallDetector
3. Week 6:护盾 + 弹反 + 战斗深化
参考文档:06_CombatModule.md §8-13、20_ShieldModule.md
3.0 护盾数据层 SO 与接口
// Assets/Scripts/Player/Shield/ShieldConfigSO.cs
// ⚠️ 按 Architecture 20_ShieldModule §3 实现
[CreateAssetMenu(menuName = "Player/ShieldConfig")]
public class ShieldConfigSO : ScriptableObject
{
[Header("基础参数")]
[Min(1)]
public int MaxShieldHP = 60;
[Range(0f, 1f)]
public float DamageAbsorptionRatio = 1.0f;
[Header("恢复")]
[Min(0f)]
public float RechargeDelay = 2.5f; // 最后受击后静默时间(秒)
[Min(0f)]
public float RechargeRate = 20f; // 每秒恢复量
[Header("破碎惩罚")]
[Min(0f)]
public float BrokenPenaltyDuration = 3.0f; // 护盾破碎后无法恢复的时间
[Header("存档点全量恢复")]
public bool FullRechargeOnSavePoint = true;
[Header("弹反加成(P1)")]
[Range(0f, 1f)]
public float ParryRestoreRatio = 0.3f; // 成功格挡时恢复护盾耐久比例
}
// Assets/Scripts/Player/Shield/IShieldable.cs
// ⚠️ 按 Architecture 20_ShieldModule §5 实现
namespace BaseGames.Player.Shield
{
public interface IShieldable
{
bool HasShield { get; }
int CurrentShieldHP { get; }
int MaxShieldHP { get; }
int AbsorbDamage(int incomingDamage); // ⚠️ 返回穿透伤害剩余量:0 = 全部被吸收,>0 = 穿透量(架构 20_ShieldModule §5)
void FullRecharge();
void OnParrySuccess();
}
}
3.1 ShieldComponent
按 20_ShieldModule.md 实现完整护盾系统:
// 关键实现点
public class ShieldComponent : MonoBehaviour, IShieldable
{
public bool HasShield => _currentShieldHP > 0 && !_isBroken; // ⚠️ 字段名 _currentShieldHP(架构 20_ShieldModule §4)
public int AbsorbDamage(int incomingDamage)
{
_timeSinceLastHit = 0f; // ⚠️ 重置静默计时(架构 20_ShieldModule §4)
int absorbed = Mathf.RoundToInt(incomingDamage * _config.DamageAbsorptionRatio);
int passThrough = incomingDamage - absorbed;
_currentShieldHP -= absorbed;
if (_currentShieldHP <= 0)
{
// 护盾破碎:多余伤害穿透(⚠️ 架构 20_ShieldModule §4)
passThrough += Mathf.Abs(_currentShieldHP);
_currentShieldHP = 0;
_isBroken = true;
_brokenTimer = 0f;
_onShieldBroken.Raise(); // ⚠️ Raise(),非 RaiseEvent()(架构 02 §2)
}
_onShieldHPChanged.Raise(_currentShieldHP); // ⚠️ 更新 ShieldBarUI(架构 20_ShieldModule §4)
return passThrough;
}
// ⚠️ 存档加载时恢复护盾状态,由 PlayerController.LoadFromSaveData() 调用(架构 20_ShieldModule §4)
public void SetShieldHP(int hp, bool isBroken)
{
_currentShieldHP = Mathf.Clamp(hp, 0, _config.MaxShieldHP);
_isBroken = isBroken;
_brokenTimer = 0f;
_timeSinceLastHit = 0f;
}
private void Update()
{
// 破碎冷却计时(递增到 BrokenPenaltyDuration)
if (_isBroken)
{
_brokenTimer += Time.deltaTime;
if (_brokenTimer >= _config.BrokenPenaltyDuration) // ⚠️ BrokenPenaltyDuration,非 BreakPenaltyDuration
{
_isBroken = false;
_brokenTimer = 0f;
}
return;
}
// 延迟恢复(_timeSinceLastHit 累计受击后静默时间,架构 20_ShieldModule §4)
if (_currentShieldHP < _config.MaxShieldHP)
{
_timeSinceLastHit += Time.deltaTime;
if (_timeSinceLastHit >= _config.RechargeDelay)
_currentShieldHP = Mathf.Min(
_config.MaxShieldHP,
_currentShieldHP + Mathf.RoundToInt(_config.RechargeRate * Time.deltaTime)
);
}
}
}
3.2 ParrySystem
按 06_CombatModule.md §8 实现 5 阶段状态机(Inactive → Startup → Active → EndLag/CounterWindow → Inactive):
Inactive → [按弹反键] → Startup(0.05s) → Active(0.28s) → [命中可弹反攻击] → ParrySuccess
↓(Startup/Active 超时) ↓
EndLag(0.10s) → Inactive CounterWindow(0.5s) → Inactive
// Assets/Scripts/Parry/ParrySystem.cs
// ⚠️ 5 阶段状态机;事件频道为 ParryInfoEventChannelSO(非 VoidEventChannelSO),架构 06 §8
/// <summary>弹反成功时通过 OnParrySuccess 频道广播的载荷(架构 06 §8)</summary>
public struct ParryInfo
{
public DamageInfo OriginalDamage; // 被弹反的原始攻击信息
public bool IsPerfect; // 是否为完美弹反
public Projectile HitProjectile; // 若弹反了投射物,此字段非 null
public DamageSourceSO ReflectDamageSource; // 反击伤害来源(ParryCounterMultiplier 倍率已应用)
}
/// <summary>弹反阶段枚举(架构 06 §8)</summary>
public enum ParryPhase { Inactive, Startup, Active, EndLag, CounterWindow }
public class ParrySystem : MonoBehaviour
{
[SerializeField] private InputReaderSO _inputReader;
[SerializeField] private PlayerController _controller;
[SerializeField] private ParryConfigSO _config;
[SerializeField] private HurtBox _playerHurtBox;
[SerializeField] private PlayerStats _playerStats;
[Header("Event Channels - Raise")]
// ⚠️ ParryInfoEventChannelSO 携带 ParryInfo 载荷(非 VoidEventChannelSO),架构 06 §8
[SerializeField] private ParryInfoEventChannelSO _onParrySuccess;
// ── 运行时状态 ────────────────────────────────────────────────
private ParryPhase _phase = ParryPhase.Inactive;
private float _phaseTimer;
private bool _isInCounterWindow;
public ParryPhase CurrentPhase => _phase;
public bool IsInCounterWindow => _isInCounterWindow;
private void OnEnable() => _inputReader.ParryEvent += TryActivateParry;
private void OnDisable() => _inputReader.ParryEvent -= TryActivateParry;
private void Update()
{
if (_phase == ParryPhase.Inactive) return;
// ⚠️ 使用 unscaledDeltaTime — 子弹时间期间计时不受影响(架构 06 §8)
_phaseTimer -= Time.unscaledDeltaTime;
if (_phaseTimer <= 0f) AdvancePhase(success: false);
}
// ── 外部调用入口 ──────────────────────────────────────────────
/// <summary>玩家按下弹反键时由 InputReader 事件触发</summary>
private void TryActivateParry()
{
if (_phase != ParryPhase.Inactive) return;
if (!_playerStats.HasAbility(AbilityType.Parry)) return;
EnterPhase(ParryPhase.Startup, _config.StartupDuration);
_controller.ForceState(PlayerStateType.Parry);
}
/// <summary>
/// HurtBox.ReceiveDamage 在 Active 阶段调用此方法判断是否弹反成功。
/// 返回 true 表示弹反成功,调用方应跳过普通受击流程。(架构 06 §8)
/// </summary>
public bool TryParryDamage(DamageInfo info)
{
if (_phase != ParryPhase.Active) return false;
if (info.Flags.HasFlag(DamageFlags.Unblockable)) return false;
if (!info.Flags.HasFlag(DamageFlags.CanBeParried)) return false;
if (info.Flags.HasFlag(DamageFlags.PerfectParryOnly) && !IsInPerfectWindow()) return false;
bool isPerfect = IsInPerfectWindow();
HandleSuccessfulParry(info, isPerfect);
return true;
}
// ── 私有实现 ──────────────────────────────────────────────────
private bool IsInPerfectWindow()
{
// Active 阶段开始后的前 PerfectParryThreshold 秒内命中为完美弹反
float activeElapsed = _config.WindowDuration - _phaseTimer;
return activeElapsed <= _config.PerfectParryThreshold;
}
/// <summary>弹反成功的统一处理入口(TryParryDamage 和 ParryableProjectile 均调用此处)</summary>
public void HandleSuccessfulParry(DamageInfo originalDamage, bool isPerfect,
Projectile hitProjectile = null)
{
// ⚠️ _playerStats.AddSoul()(架构 06 §8 使用 AddSoul,非 AddSoulPower)
int soulGain = isPerfect
? _config.SoulGainOnParry + _config.SoulGainOnPerfect
: _config.SoulGainOnParry;
_playerStats.AddSoul(soulGain);
// 护盾恢复
if (_controller.TryGetComponent<ShieldComponent>(out var shield))
shield.OnParrySuccess();
// 子弹时间(仅完美弹反)
if (isPerfect)
StartCoroutine(ApplyBulletTime());
// ⚠️ _onParrySuccess 为 ParryInfoEventChannelSO,携带 ParryInfo 载荷
var parryInfo = new ParryInfo
{
OriginalDamage = originalDamage,
IsPerfect = isPerfect,
HitProjectile = hitProjectile,
ReflectDamageSource = null, // 实际项目中设为预配置的 ReflectDamageSourceSO
};
_onParrySuccess.Raise(parryInfo);
// 进入 CounterWindow
EnterPhase(ParryPhase.CounterWindow, _config.CounterWindowDuration);
_isInCounterWindow = true;
}
private void AdvancePhase(bool success)
{
switch (_phase)
{
case ParryPhase.Startup:
EnterPhase(ParryPhase.Active, _config.WindowDuration);
break;
case ParryPhase.Active:
EnterPhase(ParryPhase.EndLag, _config.EndlagDuration);
break;
case ParryPhase.EndLag:
case ParryPhase.CounterWindow:
EnterPhase(ParryPhase.Inactive, 0f);
_isInCounterWindow = false;
break;
}
}
private void EnterPhase(ParryPhase phase, float duration)
{
_phase = phase;
_phaseTimer = duration;
}
private System.Collections.IEnumerator ApplyBulletTime()
{
Time.timeScale = _config.BulletTimeScale;
yield return new WaitForSecondsRealtime(_config.BulletTimeDuration);
Time.timeScale = 1f;
}
}
ParryConfigSO(按 06_CombatModule.md §9 完整字段):
// Assets/ScriptableObjects/Combat/ParryConfig.asset
[CreateAssetMenu(menuName = "Combat/ParryConfig")]
public class ParryConfigSO : ScriptableObject
{
[Header("阶段时长")]
public float StartupDuration = 0.05f; // ⚠️ 前摇(Startup)时长(秒),架构 06 §9
public float WindowDuration = 0.28f; // ⚠️ 弹反有效窗口(Active)时长(秒)
public float EndlagDuration = 0.10f; // ⚠️ 后摇(EndLag)时长(秒)
public float CounterWindowDuration = 0.5f; // ⚠️ 弹反成功后反击窗口时长(秒)
[Header("完美弹反判定")]
public float PerfectParryThreshold = 0.05f; // ⚠️ Active 阶段开始后的完美弹反窗口(秒)
[Header("冷却")]
public float ParryCooldown = 0.3f; // 弹反动作冷却(秒)
[Header("灵力奖励")]
public int SoulGainOnParry = 33; // ⚠️ 普通弹反获得灵力(架构 06 §9)
public int SoulGainOnPerfect = 50; // ⚠️ 完美弹反额外获得灵力(累计 +83)
[Header("反击伤害")]
public float ParryCounterMultiplier = 3.0f; // ⚠️ 弹反反击伤害倍率
[Header("子弹时间(完美弹反)")]
public float BulletTimeScale = 0.25f; // ⚠️ 完美弹反触发时的时间缩放
public float BulletTimeDuration = 0.2f; // ⚠️ 子弹时间持续时长(秒,实际时间)
[Header("硬直")]
public float StaggerDuration = 0.8f; // ⚠️ 被弹反敌人的受击硬直时长(秒)
}
ParryState 实现要点:
- 进入:
EnterPhase(Startup)已在TryActivateParry()完成,State 播放弹反预备动画 - Active 阶段:HurtBox 调用
TryParryDamage(info)— 5 阶段自动推进 - 成功:
HandleSuccessfulParry()→ CounterWindow → 可切换到 AttackState(反击) - 超时未命中:EndLag → Inactive → 返回
IdleState
3.3 PoiseSystem(霸体)
架构核心机制(
06_CombatModule.md §13):霸体使用等级比较,而非数值耐久条。
- 攻击方:
DamageInfo.Break(BreakLevel枚举)——此次攻击能打断多高的霸体- 承受方:
IPoiseSource.GetCurrentPoiseLevel()(PoiseLevel枚举)——当前帧拥有多高的霸体- 判定公式:
(int)info.Break >= (int)currentPoise→ 打断成功- 玩家和敌人均可拥有霸体(玩家在攻击/技能动画的特定帧,敌人在超甲状态)
// 实体实现此接口以提供当前霸体等级(玩家侧由 PlayerController 实现,敌人侧由 EnemyPoiseComponent 实现)
public interface IPoiseSource
{
/// <summary>返回当前帧的霸体等级(受时间窗口/状态机控制)</summary>
PoiseLevel GetCurrentPoiseLevel();
}
// 地址: Assets/Scripts/Combat/PoiseWindowConfig.cs
// 描述某个状态/技能在特定动画时间段内的霸体等级
[System.Serializable]
public struct PoiseWindowConfig
{
public PoiseLevel Level; // 此窗口期间的霸体等级
public float NormalizedStart; // 动画归一化时间起点(0~1)
public float NormalizedEnd; // 动画归一化时间终点(0~1)
}
// 地址: Assets/Scripts/Enemies/EnemyPoiseComponent.cs
// 敌人霸体组件:Awake() 自动注入到同节点 HurtBox(架构 06 §13)
// ⚠️ [RequireComponent(typeof(EnemyBase))](架构 06 §13)
[RequireComponent(typeof(EnemyBase))]
public class EnemyPoiseComponent : MonoBehaviour, IPoiseSource
{
[SerializeField] private PoiseLevel _defaultPoiseLevel = PoiseLevel.None;
private PoiseLevel _currentPoiseLevel;
private void Awake()
{
_currentPoiseLevel = _defaultPoiseLevel;
// ⚠️ Awake 自动注入到同节点 HurtBox(架构 06 §13)
if (TryGetComponent<HurtBox>(out var hurtBox))
hurtBox.SetPoiseSource(this);
}
// 超甲状态由 EnemyBase 或 BehaviorDesigner 任务直接调用
public void SetPoiseLevel(PoiseLevel level) => _currentPoiseLevel = level;
public void ResetPoiseLevel() => _currentPoiseLevel = _defaultPoiseLevel;
// IPoiseSource 实现
public PoiseLevel GetCurrentPoiseLevel() => _currentPoiseLevel;
}
HurtBox 霸体判定逻辑(已在 06_CombatModule §5 HurtBox.ReceiveDamage 中实现):
// 3. 霸体检查(BreakLevel vs PoiseLevel)
if (!info.Flags.HasFlag(DamageFlags.ForceBreak) && _poiseSource != null)
{
PoiseLevel curPoise = _poiseSource.GetCurrentPoiseLevel();
if (curPoise != PoiseLevel.None && curPoise == PoiseLevel.Unbreakable) return; // 完全无法打断
if ((int)info.Break < (int)curPoise)
{
// 打断等级不足:触发受击 VFX 但跳过伤害
_onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });
return;
}
}
PoiseOverrideTableSO(精细控制资产,按 06_CombatModule.md §13):
// Assets/ScriptableObjects/Combat/PoiseOverrideTable.asset
// ⚠️ 用于特殊规则:某个特定 sourceId 对某类目标的打断等级覆盖(架构 06 §13)
[CreateAssetMenu(menuName = "Combat/PoiseOverrideTable")]
public class PoiseOverrideTableSO : ScriptableObject
{
[Serializable]
public struct OverrideEntry
{
public string SourceId; // DamageSourceSO.sourceId(攻击来源)
public string TargetTag; // 目标 GameObject Tag(如 "Boss")
public BreakLevel OverrideBreak; // 覆盖使用的打断等级(忽略 DamageInfo.Break)
}
public List<OverrideEntry> Entries;
public bool TryGetOverride(string sourceId, string targetTag, out BreakLevel result)
{
foreach (var e in Entries)
{
if (e.SourceId == sourceId && e.TargetTag == targetTag)
{ result = e.OverrideBreak; return true; }
}
result = default;
return false;
}
}
3.4 完整连击链
| 输入 | 状态 | 说明 |
|---|---|---|
| 攻击 × 3(地面) | AttackState (combo 0/1/2) | 3段地面连击链 |
| 攻击(空中) | AirAttackState | 1段空中基础攻击 |
| 下 + 攻击(空中) | DownAttackState | 下劈(踩踏判定) |
| 上 + 攻击 | UpAttackState | 上劈(向上击飞) |
| 形态技能键 | SoulSkillState | 消耗灵力的魂技能(Phase 2) |
3.5 ClashResolver — 拼刀系统
参考文档:06_CombatModule.md §15
当玩家与敌人的近战 HitBox 同时激活并相互重叠时触发拼刀:双方武器碰撞,均不扣血,各自弹开,播放拼刀特效与音效。仅携带
CanClash标记(DamageFlags.CanClash)的 HitBox 才参与拼刀检测。
// Assets/Scripts/Combat/ClashResolver.cs
// ⚠️ 单例服务,常驻 Persistent 场景(由 GameManager 持有),架构 06 §15
[DefaultExecutionOrder(-500)]
public class ClashResolver : MonoBehaviour
{
public static ClashResolver Instance { get; private set; }
[SerializeField] VoidEventChannelSO _onNailClash;
[SerializeField] ClashConfigSO _config;
// 防止同一碰撞在同帧被双方 HitBox 各触发一次(去重)
readonly HashSet<int> _processedThisFrame = new();
void Awake()
{
if (Instance != null) { Destroy(gameObject); return; }
Instance = this;
}
void LateUpdate() => _processedThisFrame.Clear();
public void ResolveClash(HitBox playerHitBox, HitBox enemyHitBox)
{
int key = playerHitBox.GetInstanceID() ^ enemyHitBox.GetInstanceID();
if (!_processedThisFrame.Add(key)) return; // 本帧已处理,去重
// 1. 拼刀 HitStop
HitStopManager.FreezeFrames(_config.ClashFreezeFrames);
// 2. 双方弹开
ApplyClashKnockback(playerHitBox.OwnerRigidbody, enemyHitBox.transform.position);
ApplyClashKnockback(enemyHitBox.OwnerRigidbody, playerHitBox.transform.position);
// 3. 广播事件(VFX / Audio / CameraImpulse 订阅)
_onNailClash?.Raise();
}
void ApplyClashKnockback(Rigidbody2D rb, Vector2 oppositePos)
{
if (rb == null) return;
var dir = ((Vector2)rb.transform.position - oppositePos).normalized;
rb.AddForce(dir * _config.ClashKnockbackForce, ForceMode2D.Impulse);
}
}
// Assets/ScriptableObjects/Config/Combat/ClashConfig.asset
[CreateAssetMenu(menuName = "Combat/ClashConfig")]
public class ClashConfigSO : ScriptableObject
{
[Header("HitStop")]
public int ClashFreezeFrames = 1; // 拼刀冻帧(比命中的 2 帧更短)
[Header("弹开")]
public float ClashKnockbackForce = 6.0f; // 拼刀弹开力度
[Header("Camera Impulse")]
public float ClashImpulseStrength = 0.3f; // Cinemachine Impulse 强度(轻微)
}
拼刀判定规则:
| 情况 | 结果 |
|---|---|
双方 HitBox 均激活 + 均 CanClash = true |
触发拼刀,双方弹开,不扣血 |
| 仅玩家 HitBox 激活,敌人 HitBox 未激活 | 正常命中敌人 HurtBox |
敌人攻击标记为 CanClash = false(如 Boss 重击) |
不触发拼刀,正常伤害玩家 |
| 同帧多次碰撞 | HashSet 去重,每对 HitBox 每帧只触发一次 |
3.6 BreakConditionSO + IBreakable + BreakableProp — 机关/障碍物交互
参考文档:06_CombatModule.md §14
游戏中某些机关(晶石、封印门、毒液容器……)只能被特定类别/标签的攻击击碎。这类物体实现
IBreakable而非IDamageable,HitBox.OnTriggerEnter2D会把DamageInfo传给IBreakable.TryInteract(info)由物体本身决定是否响应。
// Assets/Scripts/Combat/BreakConditionSO.cs
// ⚠️ 数据驱动机关响应条件(架构 06 §14)
[CreateAssetMenu(menuName = "Combat/BreakCondition")]
public class BreakConditionSO : ScriptableObject
{
[Header("Category 白名单(空 = 不限类别)")]
public DamageCategory[] AllowedCategories;
[Header("Tags 必须位(AND 逻辑)")]
public DamageTags RequiredTags;
[Header("Tags 禁止位(AND NOT 逻辑)")]
public DamageTags ForbiddenTags;
[Header("DamageType 白名单(空 = 不限元素)")]
public DamageType[] AllowedTypes;
[Header("技能 ID 白名单(空 = 不限技能)")]
public string[] AllowedSkillIds;
public bool Evaluate(in DamageInfo info)
{
if (AllowedCategories is { Length: > 0 }
&& System.Array.IndexOf(AllowedCategories, info.Category) < 0) return false;
if (RequiredTags != DamageTags.None && (info.Tags & RequiredTags) != RequiredTags) return false;
if (ForbiddenTags != DamageTags.None && (info.Tags & ForbiddenTags) != 0) return false;
if (AllowedTypes is { Length: > 0 }
&& System.Array.IndexOf(AllowedTypes, info.Type) < 0) return false;
if (AllowedSkillIds is { Length: > 0 }
&& System.Array.IndexOf(AllowedSkillIds, info.SkillId) < 0) return false;
return true;
}
}
// Assets/Scripts/Combat/IBreakable.cs
// ⚠️ 机关、障碍物实现此接口(非 IDamageable),架构 06 §14
public interface IBreakable
{
bool TryInteract(in DamageInfo info);
}
// Assets/Scripts/Combat/BreakableProp.cs
// ⚠️ 通用可破坏/可交互物体(挂在机关 Prefab 上),架构 06 §14
public class BreakableProp : MonoBehaviour, IBreakable
{
[SerializeField] private BreakConditionSO _condition;
[SerializeField] private int _maxHp = 1; // 默认单次即碎
[SerializeField] private VoidEventChannelSO _onBroken; // 全局广播(开门、切换场景等)
[SerializeField] private FeedbackPresetSO _hitFeedback;
[SerializeField] private FeedbackPresetSO _breakFeedback;
[SerializeField] private FeedbackPresetSO _rejectFeedback; // 提示玩家"需要特定能力"
private int _currentHp;
private void Awake() => _currentHp = _maxHp;
public bool TryInteract(in DamageInfo info)
{
if (!_condition.Evaluate(info))
{
_rejectFeedback?.Play(transform.position);
return false;
}
_currentHp -= info.FinalDamage > 0 ? info.FinalDamage : 1;
_hitFeedback?.Play(transform.position);
if (_currentHp <= 0)
{
_breakFeedback?.Play(transform.position);
_onBroken?.Raise();
gameObject.SetActive(false);
}
return true;
}
}
机关配置示例:
| 机关 | AllowedCategories | RequiredTags | AllowedTypes | 说明 |
|---|---|---|---|---|
| 毒液晶石 | — | ElementPoison |
Poison |
任何毒属性攻击 |
| 封印门 | SoulSkill |
SkyFormOnly |
— | 仅天形魂技能 |
| 弱点晶球 | NormalAttack, SoulSkill |
AfterParry |
— | 弹反后才能击碎 |
| 普通木箱 | NormalAttack, SoulSkill, SpiritSkill |
MeleeHit |
— | 任意近战 |
4. Week 7:状态效果 + 动画事件 + 完整 VFX
参考文档:06_CombatModule.md §10-11、16_AnimationModule.md、18_VFXFeedbackModule.md
4.1 AnimationEventType 枚举 + AnimationEventBinder
参考文档:24_AnimEventModule.md §2-4
⚠️ 架构约束:
- 禁止使用 Unity 传统
AnimationEvent(字符串反射)- 必须使用 Animancer
ClipTransition.Events.SetCallback(normalizedTime, callback)- 所有时机值由
AnimationEventConfigSO管理(设计师可在 Inspector 调整)
文件:
Assets/Scripts/Animation/AnimationEventType.cs ← 枚举(与架构完全一致)
Assets/Scripts/Animation/AnimationEventConfigSO.cs ← SO 配置(每个 Clip 的事件时机)
Assets/Scripts/Animation/AnimationEventBinder.cs ← 静态工具类(注册回调)
Assets/Scripts/Animation/IAnimationEventHandler.cs ← 接口(⚠️ IAnimationEventHandler,架构 24 §4)
Assets/Scripts/Animation/PlayerAnimationEvents.cs ← 玩家侧实现
Assets/Scripts/Animation/EnemyAnimationEvents.cs ← 敌人侧实现
// ✅ 正确 — 完整枚举定义(对应架构 24_AnimEventModule §2)
namespace BaseGames.Animation
{
public enum AnimationEventType
{
// 战斗 - HitBox
EnableHitBox, // 开启 HitBox(payload 字段携带 HitBox 编号或名称)
DisableHitBox, // 关闭 HitBox
AttackImpact, // 攻击命中反馈(音效/特效时机)
// 战斗 - 弹反窗口(由 ParrySystem 监听)
EnableParryWindow, // 开启可弹反时间窗(ParrySystem.OpenWindow())
DisableParryWindow, // 关闭可弹反时间窗(ParrySystem.CloseWindow())
// 玩家
EnableIFrame, // 开启无敌帧(翻滚/受击恢复)
DisableIFrame, // 关闭无敌帧
Footstep, // 脚步落地(⚠️ 合并 FootstepLeft/Right,由 payload 区分面,架构 24 §2)
LandImpact, // 落地震动(落地音效/特效)
JumpLaunch, // 起跳(跳跃音效/特效)
EnableInteract, // 动画帧触发互动(如 NPC 握手时机)
// 反馈派发
TriggerFeedback, // 触发 MMF_Player 预设(payload = Feedback 名称)
PlaySFX, // 播放音效(payload = AudioEventSO Address key)
// 敌人
SpawnProjectile, // 生成弹幕(⚠️ SpawnProjectile,非 SummonProjectile,架构 24 §2)
RoarStart, // 怒吼开始(AI 警觉)
RoarEnd, // 怒吼结束
PhaseTwoStart, // 二阶段开始(Boss 过渡)
// 通用
CancelWindowOpen, // 可取消帧窗口开始(连击/取消窗口)
CancelWindowClose, // 可取消帧窗口结束
StateTransition, // 动画驱动状态机转移(payload = 目标状态名)
AnimationComplete, // 动画播完(一次性动画通知)
}
}
// AnimationEventConfigSO.cs — 每个 ClipTransition 的事件时机配置(设计师可在 Inspector 调整)
namespace BaseGames.Animation
{
[CreateAssetMenu(menuName = "Animation/EventConfig")]
public class AnimationEventConfigSO : ScriptableObject
{
[Serializable]
public struct EventEntry
{
public AnimationEventType eventType;
[Range(0f, 1f)]
public float normalizedTime; // 触发帧在整个动画中的归一化位置
[Tooltip("附加数据(可空):如 HitBox 编号、音频 key 等")]
public string data;
}
[Header("绑定的动画片段(类型安全引用,替代旧 clipName 字符串)")]
public AnimationClip targetClip; // ⚠️ AnimationClip(非 string clipName),架构 24_AnimEventModule §3
[Header("事件时机列表")]
public EventEntry[] events;
/// <summary>按时机顺序排序,方便 Binder 批量注册。</summary>
public IEnumerable<EventEntry> SortedEvents =>
events.OrderBy(e => e.normalizedTime);
/// <summary>查询指定事件类型的触发帧(工具/编辑器用)。⚠️ 架构 24 §3 定义</summary>
public float GetNormalizedTime(AnimationEventType eventType)
{
foreach (var e in events)
if (e.eventType == eventType) return e.normalizedTime;
return -1f; // 未找到
}
}
}
// AnimationEventBinder.cs — 静态工具,将 SO 配置绑定到 ClipTransition
namespace BaseGames.Animation
{
public static class AnimationEventBinder
{
public static void Bind(
ClipTransition clip,
AnimationEventConfigSO config,
IAnimationEventHandler receiver) // ⚠️ IAnimationEventHandler(架构 24 §4)
{
if (config == null) return;
foreach (var entry in config.SortedEvents)
{
var captured = entry;
clip.Events.SetCallback(captured.normalizedTime, () =>
receiver.HandleEvent(captured.eventType, captured.data)); // ⚠️ HandleEvent(架构 24 §4)
}
}
}
// ⚠️ 接口名为 IAnimationEventHandler,方法名为 HandleEvent(架构 24 §4,非 IAnimEventReceiver/OnAnimationEvent)
public interface IAnimationEventHandler
{
void HandleEvent(AnimationEventType type, string payload);
}
}
// PlayerAnimationEvents.cs — 挂在 [Animation] 子节点,是玩家所有动画事件的唯一派发入口
// ⚠️ 字段以架构 24_AnimEventModule §5 为准:独立组件引用,非 PlayerController 聚合
namespace BaseGames.Animation
{
// ⚠️ 实现 IAnimationEventHandler(架构 24 §5),方法名 HandleEvent
public class PlayerAnimationEvents : MonoBehaviour, IAnimationEventHandler
{
[Header("战斗组件")]
[SerializeField] HitBox[] _hitBoxes; // 玩家身上所有 HitBox
[SerializeField] HurtBox _hurtBox;
[Header("能力组件")]
[SerializeField] PlayerStats _playerStats;
[SerializeField] PlayerMovement _mover; // ⚠️ 类型为 PlayerMovement(架构 05_PlayerModule §3),非 PlayerMover
[SerializeField] ParrySystem _parrySystem; // ⚠️ 弹反窗口控制(架构 24 §5 / 20_ShieldModule §1)
[Header("特效/音效")]
[SerializeField] IFeedbackPlayer _feedback; // 通过 GetComponentInParent 注入
[Header("事件配置(与 ClipTransition 一一对应)")]
[SerializeField] EventBinding[] _bindings;
[Serializable]
struct EventBinding
{
public ClipTransition clip;
public AnimationEventConfigSO config;
}
void Awake()
{
_feedback = GetComponentInParent<IFeedbackPlayer>();
foreach (var b in _bindings)
AnimationEventBinder.Bind(b.clip, b.config, this);
}
public void HandleEvent(AnimationEventType type, string payload) // ⚠️ HandleEvent + payload(架构 24 §4)
{
switch (type)
{
case AnimationEventType.EnableHitBox:
EnableHitBoxById(payload); break;
case AnimationEventType.DisableHitBox:
DisableHitBoxById(payload); break;
case AnimationEventType.AttackImpact:
_feedback?.PlayAttackWhoosh(); break;
case AnimationEventType.EnableIFrame:
_hurtBox.SetInvincible(true); break; // ⚠️ HurtBox.SetInvincible,非 PlayerStats.BeginInvincibility
case AnimationEventType.DisableIFrame:
_hurtBox.SetInvincible(false); break;
case AnimationEventType.Footstep: // ⚠️ Footstep(合并左右脚,payload 区分)
FootstepSystem.Play(_mover.CurrentSurface, transform.position); break;
case AnimationEventType.LandImpact:
_feedback?.PlayLandImpact(); break;
case AnimationEventType.JumpLaunch:
_feedback?.PlayJumpLaunch(); break;
case AnimationEventType.EnableParryWindow:
_parrySystem?.OpenWindow(); break; // ⚠️ 架构 24 §5:ParrySystem.OpenWindow()
case AnimationEventType.DisableParryWindow:
_parrySystem?.CloseWindow(); break; // ⚠️ 架构 24 §5:ParrySystem.CloseWindow()
case AnimationEventType.CancelWindowOpen:
_mover.SetCancelWindowOpen(true); break; // ⚠️ PlayerMovement.SetCancelWindowOpen(架构 05_PlayerModule §3),非 AttackState 方法
case AnimationEventType.CancelWindowClose:
_mover.SetCancelWindowOpen(false); break;
case AnimationEventType.TriggerFeedback:
_feedback?.TriggerPreset(payload); break; // ⚠️ TriggerPreset(架构 18 §2 IFeedbackPlayer 接口,非 PlayFeedbackByName)
case AnimationEventType.PlaySFX:
_feedback?.PlaySFXById(payload); break; // ⚠️ PlaySFXById(架构 18 §2 IFeedbackPlayer 接口;AudioManager 无 PlaySFXByKey 方法)
}
}
void EnableHitBoxById(string id)
{
foreach (var hb in _hitBoxes)
if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Activate(); // ⚠️ HitBox.Activate(),无参数(架构 06 §4)
}
void DisableHitBoxById(string id)
{
foreach (var hb in _hitBoxes)
if (hb.Id == id || string.IsNullOrEmpty(id)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4)
}
}
}
// EnemyAnimationEvents.cs — 挂在 EnemyBase(或其 [Animation] 子节点),敌人侧动画事件派发入口
// ⚠️ 与 PlayerAnimationEvents 结构相同(见架构 24_AnimEventModule §6)
namespace BaseGames.Animation
{
public class EnemyAnimationEvents : MonoBehaviour, IAnimationEventHandler // ⚠️ IAnimationEventHandler(架构 24 §4,非 IAnimEventReceiver)
{
[SerializeField] HitBox[] _hitBoxes;
[SerializeField] EnemyFeedback _feedback;
[SerializeField] EnemyBase _enemy;
[SerializeField] EventBinding[] _bindings;
[Serializable]
struct EventBinding
{
public ClipTransition clip;
public AnimationEventConfigSO config;
}
void Awake()
{
foreach (var b in _bindings)
AnimationEventBinder.Bind(b.clip, b.config, this);
}
public void HandleEvent(AnimationEventType type, string payload) // ⚠️ HandleEvent(架构 24 §6,非 OnAnimationEvent)
{
switch (type)
{
case AnimationEventType.EnableHitBox:
foreach (var hb in _hitBoxes)
if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Activate(); // ⚠️ HitBox.Activate()(架构 06 §4)
break;
case AnimationEventType.DisableHitBox:
foreach (var hb in _hitBoxes)
if (hb.Id == payload || string.IsNullOrEmpty(payload)) hb.Deactivate(); // ⚠️ HitBox.Deactivate()(架构 06 §4)
break;
case AnimationEventType.SpawnProjectile: // ⚠️ SpawnProjectile(架构 24 §2,非旧版 SummonProjectile)
_enemy.SpawnProjectile(payload); break;
case AnimationEventType.RoarStart:
case AnimationEventType.RoarEnd:
_enemy.Blackboard.SetVariableValue("IsRoaring",
type == AnimationEventType.RoarStart);
break;
case AnimationEventType.PhaseTwoStart:
_enemy.TriggerPhaseTwo(); break;
case AnimationEventType.AnimationComplete:
_enemy.OnAnimationComplete(payload); break;
}
}
}
}
配置工作流:
- 为每个攻击动画创建
AnimationEventConfigSO(Inspector 设置 normalizedTime) - 在
PlayerAnimationEvents.Awake中调用AnimationEventBinder.Bind(clip, config, this) - 设计师调整时机无需修改代码,只改 SO 资产
4.1.1 EventConfigEditor — 动画事件时间轴可视化(⚠️ 架构 24 §10,Phase 2 优化)
路径:
Assets/Editor/Animation/EventConfigEditor.cs。[CustomEditor(typeof(AnimationEventConfigSO))],设计师可在 Inspector 中直观查看/调整所有 normalizedTime 标记点,无需手动核对帧序号。
// Assets/Editor/Animation/EventConfigEditor.cs
// 需在 AnimationEventConfigSO 补充隐藏字段:
// [HideInInspector] public float ExpectedClipLength = -1f; // Auto-detect 写入
[CustomEditor(typeof(AnimationEventConfigSO))]
public class EventConfigEditor : UnityEditor.Editor
{
// 事件类型颜色规则(架构 24 §10):
// HitBox=红 IFrame=绿 Footstep/SFX=蓝 ParryWindow=黄 TriggerFeedback=紫 CancelWindow=橙
public override void OnInspectorGUI()
{
var config = (AnimationEventConfigSO)target;
// ① 时间轴背景条(全宽 24px 高)+ 各事件 normalizedTime 竖线标记(按类型着色)
DrawTimeline(config);
// ② 默认 Inspector 字段
DrawDefaultInspector();
// ③ 越界保护:normalizedTime < 0 || > 1 → HelpBox Error
ValidateNormalizedTime(config);
// ④ Clip 漂移检测:|currentLen - ExpectedClipLength| > 5帧 → HelpBox Warning
if (config.ExpectedClipLength > 0 && config.TargetClip != null)
{
float frameDrift = Mathf.Abs(config.TargetClip.length - config.ExpectedClipLength)
* config.TargetClip.frameRate;
if (frameDrift > 5f)
EditorGUILayout.HelpBox(
$"Clip 长度偏差 {frameDrift:F1} 帧,请重新校验 normalizedTime 或点击 Auto-detect 更新基准。",
MessageType.Warning);
}
// ⑤ Auto-detect 按钮:将当前 Clip 长度写入 ExpectedClipLength
if (GUILayout.Button("Auto-detect Clip Length"))
{
if (config.TargetClip != null)
{
config.ExpectedClipLength = config.TargetClip.length;
EditorUtility.SetDirty(config);
}
}
}
private void DrawTimeline(AnimationEventConfigSO config) { /* ... */ }
private void ValidateNormalizedTime(AnimationEventConfigSO config) { /* ... */ }
}
4.1.2 FootstepSystem + FootstepCatalogSO + SurfaceType
参考文档:24_AnimEventModule.md §8
// Assets/Scripts/Animation/FootstepSystem.cs
// ⚠️ 静态工具类;_catalog 通过 Addressables 异步加载,禁止 Resources.Load
// BootstrapLoader 在游戏启动时调用 FootstepSystem.InitAsync()
namespace BaseGames.Animation
{
public static class FootstepSystem
{
static FootstepCatalogSO _catalog;
public static async UniTask InitAsync()
{
_catalog = await Addressables
.LoadAssetAsync<FootstepCatalogSO>(AddressKeys.SO_FootstepCatalog).Task;
// AddressKeys.SO_FootstepCatalog = "Config/FootstepCatalog"
}
public static void Play(SurfaceType surface, Vector3 position)
{
var entry = _catalog.GetEntry(surface);
if (entry == null) return;
// ⚠️ entry.audioClip 类型为 AudioClip(非 string key),对应架构 11_AudioModule §2 的 PlaySFXAtPosition(AudioClip, Vector2)
AudioManager.Instance.PlaySFXAtPosition(entry.audioClip, position);
if (entry.dustParticlePrefab.RuntimeKeyIsValid())
VFXPool.Instance.Play(entry.dustParticlePrefab, position);
}
}
// Assets/Scripts/Animation/FootstepCatalogSO.cs
[CreateAssetMenu(menuName = "Audio/FootstepCatalog")]
public class FootstepCatalogSO : ScriptableObject
{
[Serializable]
public struct FootstepEntry
{
public SurfaceType surface;
public AudioClip audioClip; // ⚠️ AudioClip 直接引用,非 string key
public AssetReferenceGameObject dustParticlePrefab;
}
[SerializeField] FootstepEntry[] _entries;
public FootstepEntry? GetEntry(SurfaceType surface)
{
foreach (var e in _entries)
if (e.surface == surface) return e;
return null;
}
}
// Assets/Scripts/Animation/SurfaceType.cs
public enum SurfaceType { Stone, Wood, Dirt, Water, Metal, Grass }
}
4.1.2 PlayerMovement Phase 2 扩展字段
参考文档:05_PlayerModule.md §3、24_AnimEventModule.md §9
Week 7 补充:在 PlayerMovement 已有方法基础上,追加以下字段供 AnimEvent 系统使用。
// PlayerMovement.cs 中新增(Phase 2 Week 7,对应架构 05_PlayerModule §3)
// ── 可取消帧窗口 ────────────────────────────────────────────────────
private bool _cancelWindowOpen = false;
public bool CancelWindowOpen => _cancelWindowOpen;
public void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open;
// 由 PlayerAnimationEvents.OnAnimationEvent(CancelWindowOpen/CancelWindowClose) 驱动
// ── 当前地面材质 ─────────────────────────────────────────────────────
public SurfaceType CurrentSurface { get; private set; } // private set,由 CheckGrounded() 检测 Tile 材质后赋值
// FootstepSystem.Play(_mover.CurrentSurface, ...) 依赖此属性
4.1.3 AttackState.GetNextState() CancelWindow 检查
参考文档:24_AnimEventModule.md §9
Week 7 补充:在 Plan 02 §3.5 AttackState 基础上,Phase 2 Week 7 追加
GetNextState()重写, 确保只有在CancelWindowOpen为 true 时才允许 FSM 接受新输入并切换状态。
// AttackState.cs 中追加(Phase 2 Week 7,对应架构 24_AnimEventModule §9)
// ⚠️ Phase 1 §3.5 使用 inline Events.SetCallback 的简化版;Week 7 改为由 PlayerAnimationEvents 驱动
public override PlayerStateBase GetNextState()
{
// 只有在 CancelWindowOpen 时才接受新输入转换状态
if (!_player.Movement.CancelWindowOpen) return null;
if (_player.Input.AttackPressed) return _nextAttackState;
if (_player.Input.DashPressed) return _player.DashState;
if (_player.Input.JumpPressed) return _player.JumpState;
return null;
}
流程(24_AnimEventModule.md §9 时序):
AttackClip 播放
├─ NormalizedTime = 0.6 → AnimationEventType.CancelWindowOpen → PlayerMovement._cancelWindowOpen = true
├─ 玩家可输入 → FSM.GetNextState() 允许取消进入其他状态
└─ NormalizedTime = 0.9 → AnimationEventType.CancelWindowClose → PlayerMovement._cancelWindowOpen = false
4.2 StatusEffect + 具体状态效果
// Assets/Scripts/Combat/StatusEffects/StatusEffect.cs
// ⚠️ 类名为 StatusEffect(非 StatusEffectBase),架构 06_CombatModule §11
namespace BaseGames.Combat.StatusEffects
{
public enum StatusEffectType { Fire, Poison, Freeze, Stun } // ⚠️ 枚举标识符,非 string EffectId
public abstract class StatusEffect
{
public abstract StatusEffectType EffectType { get; } // ⚠️ EffectType(非 EffectId string)
public abstract int MaxStacks { get; } // 最大叠加层数(1 = 不可叠加)
public int StackCount { get; protected set; } = 1; // ⚠️ 必须定义 StackCount(架构 06 §11)
public float Duration { get; protected set; } // 当前剩余持续时间
public float TickInterval { get; protected set; } // 每次 Tick 的间隔秒数
float _tickTimer;
protected StatusEffectManager Owner; // 宿主(由 OnApply 注入)
// ⚠️ OnApply 参数为 StatusEffectManager owner(非 IDamageable)
public virtual void OnApply(StatusEffectManager owner)
{
Owner = owner;
Duration = GetBaseDuration();
}
// ⚠️ OnStack(同类型再次施加时叠层/刷新)
public virtual void OnStack()
{
Duration = GetBaseDuration();
StackCount = Mathf.Min(StackCount + 1, MaxStacks);
}
// ⚠️ OnTick/OnExpire 无参数(用 Owner 字段引用宿主)
public virtual void OnTick() { }
public virtual void OnExpire() { } // ⚠️ OnExpire(非 OnRemove)
public virtual bool IsExpired => Duration <= 0f;
// ⚠️ Update(float delta)(非 Tick(IDamageable, float))
public void Update(float delta)
{
Duration -= delta;
_tickTimer += delta;
if (_tickTimer >= TickInterval)
{
_tickTimer -= TickInterval;
OnTick();
}
}
protected abstract float GetBaseDuration();
public abstract string GetDisplayName();
}
}
// Phase 2 实现以下 3 种(其余 Phase 3 补充) // Assets/Scripts/Combat/StatusEffects/FireEffect.cs — 燃烧(DOT,不可叠加,触发则刷新) // Assets/Scripts/Combat/StatusEffects/PoisonEffect.cs — 中毒(DOT,最多 3 层叠加) // Assets/Scripts/Combat/StatusEffects/StaggerEffect.cs — 硬直(无法行动 N 秒)
4.3 StatusEffectManager
// Assets/Scripts/Combat/StatusEffects/StatusEffectManager.cs
// ⚠️ [RequireComponent(typeof(SpriteRenderer))] — 需要 SpriteRenderer 支持 Shader 参数(架构 06 §10)
namespace BaseGames.Combat.StatusEffects
{
[RequireComponent(typeof(SpriteRenderer))]
public class StatusEffectManager : MonoBehaviour
{
[Header("事件频道")]
// ⚠️ StatusEffectEventChannelSO(非 VoidEventChannelSO),携带 StatusEffectType 载荷(架构 06 §10)
[SerializeField] StatusEffectEventChannelSO _onStatusEffectApplied;
[SerializeField] StatusEffectEventChannelSO _onStatusEffectExpired;
// ⚠️ 双结构:List 用于有序 Update 遍历;Dictionary 用于 O(1) 查找(架构 06 §10)
readonly List<StatusEffect> _activeList = new();
readonly Dictionary<StatusEffectType, StatusEffect> _activeIndex = new();
SpriteRenderer _renderer;
MaterialPropertyBlock _propBlock;
void Awake()
{
_renderer = GetComponent<SpriteRenderer>();
_propBlock = new MaterialPropertyBlock();
}
void Update()
{
for (int i = _activeList.Count - 1; i >= 0; i--)
{
var effect = _activeList[i];
effect.Update(Time.deltaTime); // ⚠️ Update(float)(非 Tick(IDamageable, float))
if (effect.IsExpired)
{
effect.OnExpire(); // ⚠️ OnExpire()(非 OnRemove(target))
_activeIndex.Remove(effect.EffectType);
_activeList.RemoveAt(i);
_onStatusEffectExpired?.Raise(effect.EffectType);
}
}
}
/// <summary>施加状态效果。已有相同类型时叠层/刷新(O(1) 查找)。</summary>
public void ApplyEffect(StatusEffect newEffect)
{
// ⚠️ 用 Dictionary 做 O(1) 查找(非 List.Find),架构 06 §10
if (_activeIndex.TryGetValue(newEffect.EffectType, out var existing))
{
existing.OnStack(); // ⚠️ OnStack()叠层或刷新
}
else
{
newEffect.OnApply(this); // ⚠️ 传 StatusEffectManager(非 IDamageable)
_activeList.Add(newEffect);
_activeIndex[newEffect.EffectType] = newEffect;
_onStatusEffectApplied?.Raise(newEffect.EffectType);
}
}
/// <summary>净化指定类型的状态效果(O(1) 查找)。⚠️ 方法名为 CleanseEffect(非 RemoveEffect),架构 06 §10</summary>
public void CleanseEffect(StatusEffectType type)
{
if (!_activeIndex.TryGetValue(type, out var effect)) return;
effect.OnExpire();
_activeIndex.Remove(type);
_activeList.Remove(effect);
_onStatusEffectExpired?.Raise(type);
}
/// <summary>净化所有状态效果。⚠️ 存档点激活时调用(架构 06 §10)</summary>
public void CleanseAll()
{
foreach (var e in _activeList) e.OnExpire();
_activeList.Clear();
_activeIndex.Clear();
}
/// <summary>由状态效果调用,直接扣 HP(True 伤害,绕过 HurtBox)。⚠️ 架构 06 §10</summary>
public void ApplyDirectDamage(DamageInfo info)
=> GetComponent<IDamageable>()?.TakeDamage(info);
/// <summary>由状态效果调用,设置 Shader 参数(MaterialPropertyBlock)。⚠️ 架构 06 §10</summary>
public void SetShaderParam(string param, float value)
{
_renderer.GetPropertyBlock(_propBlock);
_propBlock.SetFloat(param, value);
_renderer.SetPropertyBlock(_propBlock);
}
/// <summary>检查是否有指定类型的状态效果。⚠️ 架构 06 §10</summary>
public bool HasEffect(StatusEffectType type) => _activeIndex.ContainsKey(type);
}
}
HurtBox 收到含 DamageType.Poison/Fire 的 DamageInfo 时,自动调用 StatusEffectManager.ApplyEffect。
净化方式:进入水域调用 CleanseEffect(Fire);装备解毒护符自动净化 Poison;存档点调用 CleanseAll()(架构 06 §11.2)。
4.4 完整 VFX 反馈模块
参考文档:18_VFXFeedbackModule.md §2–§12
4.4.1 FeedbackConfigSO
// Assets/ScriptableObjects/Feedback/Feedback_Config.asset
// ⚠️ menuName = "Feedback/FeedbackConfig"(架构 18_VFXFeedbackModule §5)
namespace BaseGames.Feedback
{
[CreateAssetMenu(menuName = "Feedback/FeedbackConfig")]
public class FeedbackConfigSO : ScriptableObject
{
[Header("冻帧")]
[Range(0f, 0.2f)]
public float FreezeFrameDuration = 0.033f; // 默认 2 帧(60fps)
[Range(0f, 0.2f)]
public float ParryFreezeFrameDuration = 0.067f; // 弹反冻帧更长
[Header("子弹时间")]
[Range(0.01f, 1f)]
public float BulletTimeScale = 0.15f;
[Range(0f, 1f)]
public float BulletTimeDuration = 0.3f;
[Header("镜头震动强度")]
public float ShakeLightForce = 0.2f;
public float ShakeMediumForce = 0.5f;
public float ShakeHeavyForce = 1.0f;
public float ShakeParryForce = 0.8f;
[Header("受击白闪")]
public Color HurtFlashColor = Color.white;
[Range(0f, 0.5f)]
public float HurtFlashDuration = 0.15f;
}
}
4.4.2 PlayerFeedback(完整实现)
// Assets/Scripts/Feedback/PlayerFeedback.cs
// ⚠️ 挂在 Player Prefab 根节点下的 [Feedback] 子 GameObject 上(架构 18_VFXFeedbackModule §3)
// Player Prefab 层级:[Player] → [Feedback] ← PlayerFeedback.cs + MMF_Player × 10
namespace BaseGames.Feedback
{
public class PlayerFeedback : MonoBehaviour, IFeedbackPlayer
{
[Header("命中反馈")]
[SerializeField] MMF_Player _onHitLight;
[SerializeField] MMF_Player _onHitMedium;
[SerializeField] MMF_Player _onHitHeavy;
[Header("战斗反馈")]
[SerializeField] MMF_Player _onParrySuccess;
[SerializeField] MMF_Player _onTakeHit;
[SerializeField] MMF_Player _onDeath;
[Header("移动反馈")]
[SerializeField] MMF_Player _onHeal;
[SerializeField] MMF_Player _onLandImpact;
[SerializeField] MMF_Player _onAttackWhoosh;
[SerializeField] MMF_Player _onJumpLaunch;
[Header("配置")]
[SerializeField] FeedbackConfigSO _config;
// 预设字典(runtime,用于 TriggerPreset)
Dictionary<string, MMF_Player> _presetMap;
void Awake()
{
_presetMap = new Dictionary<string, MMF_Player>
{
{ "HitLight", _onHitLight },
{ "HitMedium", _onHitMedium },
{ "HitHeavy", _onHitHeavy },
{ "ParrySuccess", _onParrySuccess },
{ "TakeHit", _onTakeHit },
{ "Death", _onDeath },
{ "LandImpact", _onLandImpact },
};
}
public void PlayHit(HitWeight weight)
{
switch (weight)
{
case HitWeight.Light: _onHitLight?.PlayFeedbacks(); break;
case HitWeight.Medium: _onHitMedium?.PlayFeedbacks(); break;
case HitWeight.Heavy: _onHitHeavy?.PlayFeedbacks(); break;
}
}
public void PlayParrySuccess() => _onParrySuccess?.PlayFeedbacks();
public void PlayTakeHit() => _onTakeHit?.PlayFeedbacks();
public void PlayDeath() => _onDeath?.PlayFeedbacks();
public void PlayHeal() => _onHeal?.PlayFeedbacks();
public void PlayLandImpact() => _onLandImpact?.PlayFeedbacks();
public void PlayAttackWhoosh() => _onAttackWhoosh?.PlayFeedbacks();
public void PlayJumpLaunch() => _onJumpLaunch?.PlayFeedbacks();
public void TriggerPreset(string presetId)
{
if (_presetMap.TryGetValue(presetId, out var player))
player?.PlayFeedbacks();
}
public void PlaySFXById(string sfxId)
{
// sfxId → AudioClip 解析通过 SFXCatalogSO 查表后调用 AudioManager.Instance.PlaySFX(clip)
}
}
}
4.4.3 EnemyFeedback(完整实现)
// Assets/Scripts/Feedback/EnemyFeedback.cs
// ⚠️ 挂在 EnemyBase Prefab 下的 [Feedback] 子 GameObject 上(架构 18_VFXFeedbackModule §4)
// EnemyBase 通过 [SerializeField] EnemyFeedback _feedback 引用
namespace BaseGames.Feedback
{
public class EnemyFeedback : MonoBehaviour, IFeedbackPlayer
{
[SerializeField] MMF_Player _onTakeHit;
[SerializeField] MMF_Player _onDeath;
[SerializeField] MMF_Player _onHitLight;
[SerializeField] MMF_Player _onHitMedium;
[SerializeField] MMF_Player _onHitHeavy;
public void PlayHit(HitWeight weight)
{
switch (weight)
{
case HitWeight.Light: _onHitLight?.PlayFeedbacks(); break;
case HitWeight.Medium: _onHitMedium?.PlayFeedbacks(); break;
case HitWeight.Heavy: _onHitHeavy?.PlayFeedbacks(); break;
}
}
public void PlayTakeHit() => _onTakeHit?.PlayFeedbacks();
public void PlayDeath() => _onDeath?.PlayFeedbacks();
// 敌人未使用的接口方法(空实现)
public void PlayParrySuccess() { }
public void PlayHeal() { }
public void PlayLandImpact() { }
public void PlayAttackWhoosh() { }
public void PlayJumpLaunch() { }
public void TriggerPreset(string id) { }
public void PlaySFXById(string id) { }
}
}
4.4.4 PaletteSwapSystem + PaletteCatalogSO
// Assets/Scripts/VFX/PaletteSwapSystem.cs
// ⚠️ ApplyPalette(FormType form) 接受 FormType 枚举(架构 18_VFXFeedbackModule §10)
// FormController.SwitchForm 调用:_paletteSwapSystem?.ApplyPalette(newForm.formType) ← 正确(FormSO.formType 字段,架构 05 §18 遗漏,以架构 18 §10 为准)
namespace BaseGames.VFX
{
public class PaletteSwapSystem : MonoBehaviour
{
[SerializeField] SpriteRenderer _renderer;
[SerializeField] PaletteCatalogSO _catalog;
static readonly int PaletteTexID = Shader.PropertyToID("_PaletteTex");
MaterialPropertyBlock _block;
void Awake() => _block = new MaterialPropertyBlock();
/// <summary>切换到指定形态的调色板。由 FormController 调用。</summary>
public void ApplyPalette(FormType form) // ⚠️ 参数为 FormType 枚举(非 Color,非 FormSO)
{
if (!_catalog.TryGetPalette(form, out var tex)) return;
_renderer.GetPropertyBlock(_block);
_block.SetTexture(PaletteTexID, tex);
_renderer.SetPropertyBlock(_block);
}
}
// Assets/Scripts/VFX/PaletteCatalogSO.cs
[CreateAssetMenu(menuName = "VFX/PaletteCatalog")]
public class PaletteCatalogSO : ScriptableObject
{
public PaletteEntry[] entries;
public bool TryGetPalette(FormType form, out Texture2D tex)
{
foreach (var e in entries)
if (e.form == form) { tex = e.paletteLUT; return true; }
tex = null;
return false;
}
}
[Serializable]
public struct PaletteEntry
{
public FormType form;
public Texture2D paletteLUT; // 1D 查找表纹理(256×1 px)
}
}
4.4.5 PostProcessManager
// Assets/Scripts/VFX/PostProcessManager.cs
// ⚠️ 挂在 Persistent 场景 [PostProcess] GameObject(架构 18_VFXFeedbackModule §11)
// ⚠️ DOTween 规范:.SetAutoKill(true).SetLink(gameObject)(架构 §11)
namespace BaseGames.VFX
{
public class PostProcessManager : MonoBehaviour
{
[Header("Volume 引用(Persistent 场景内)")]
[SerializeField] Volume _underwaterVolume; // Priority=10
[SerializeField] Volume _bossArenaVolume; // Priority=10
[SerializeField] Volume _deathVolume; // Priority=20
[SerializeField] Volume _victoryVolume; // Priority=10
[Header("事件频道")]
[SerializeField] VoidEventChannelSO _onLiquidEntered;
[SerializeField] VoidEventChannelSO _onLiquidExited;
[SerializeField] VoidEventChannelSO _onBossFightStarted;
[SerializeField] VoidEventChannelSO _onBossFightEnded;
[SerializeField] VoidEventChannelSO _onPlayerDied;
[SerializeField] VoidEventChannelSO _onPlayerRespawned;
[SerializeField] VoidEventChannelSO _onBossDefeated;
[SerializeField] float _blendDuration = 0.4f;
private Volume[] _nonDefaultVolumes;
private void Awake()
{
_nonDefaultVolumes = new[] { _underwaterVolume, _bossArenaVolume, _deathVolume, _victoryVolume };
}
private void OnEnable()
{
_onLiquidEntered.OnEventRaised += () => BlendTo(_underwaterVolume);
_onLiquidExited.OnEventRaised += ResetAll;
_onBossFightStarted.OnEventRaised += () => BlendTo(_bossArenaVolume);
_onBossFightEnded.OnEventRaised += ResetAll;
_onPlayerDied.OnEventRaised += () => BlendTo(_deathVolume);
_onPlayerRespawned.OnEventRaised += ResetAll;
_onBossDefeated.OnEventRaised += () => BlendTo(_victoryVolume);
}
private void OnDisable()
{
_onLiquidEntered.OnEventRaised -= () => BlendTo(_underwaterVolume);
_onLiquidExited.OnEventRaised -= ResetAll;
_onBossFightStarted.OnEventRaised -= () => BlendTo(_bossArenaVolume);
_onBossFightEnded.OnEventRaised -= ResetAll;
_onPlayerDied.OnEventRaised -= () => BlendTo(_deathVolume);
_onPlayerRespawned.OnEventRaised -= ResetAll;
_onBossDefeated.OnEventRaised -= () => BlendTo(_victoryVolume);
}
private void BlendTo(Volume target)
{
foreach (var v in _nonDefaultVolumes)
DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
.SetAutoKill(true).SetLink(gameObject);
DOTween.To(() => target.weight, x => target.weight = x, 1f, _blendDuration)
.SetAutoKill(true).SetLink(gameObject);
}
private void ResetAll()
{
foreach (var v in _nonDefaultVolumes)
DOTween.To(() => v.weight, x => v.weight = x, 0f, _blendDuration)
.SetAutoKill(true).SetLink(gameObject);
}
}
}
Persistent 场景 [PostProcess] 子 Volume 层级:
[PostProcess]
├── Volume_Default Priority=0 Weight=1.0(始终生效)
├── Volume_Underwater Priority=10 Weight=0(进水时 blend 到 1.0)
├── Volume_BossArena Priority=10 Weight=0(Boss 战开始时 blend 到 1.0)
├── Volume_Death Priority=20 Weight=0(玩家死亡时 blend 到 1.0)
└── Volume_Victory Priority=10 Weight=0(Boss 击败时 blend 到 1.0)
| Volume | Bloom | Color Grading | Vignette | Chromatic Aberration |
|---|---|---|---|---|
| Default | 0.3 | 正常 | 0.2 | 关闭 |
| Underwater | 0.1 | 青绿 Filter -0.3 | 0.45 | 0.4 |
| BossArena | 0.5 | 饱和度 +20% | 0.35 | 0.15 |
| Death | 0 | 去饱和度 -80% | 0.7(黑色) | 0.8 |
| Victory | 0.8(白色) | 亮度 +0.4 | 0 | 0 |
4.4.6 RegionLightController + RegionLightCatalogSO
// Assets/Scripts/VFX/RegionLightController.cs
// ⚠️ 挂在 Persistent 场景 [Lighting] GameObject(架构 18_VFXFeedbackModule §12)
// 监听 EVT_RegionEntered(StringEventChannelSO regionId)平滑切换 Global Light 2D
namespace BaseGames.VFX
{
public class RegionLightController : MonoBehaviour
{
[SerializeField] Light2D _globalLight;
[SerializeField] RegionLightCatalogSO _catalog;
[SerializeField] StringEventChannelSO _onRegionEntered;
[SerializeField] float _transitionDuration = 1.5f;
private void OnEnable() => _onRegionEntered.OnEventRaised += OnRegionEntered;
private void OnDisable() => _onRegionEntered.OnEventRaised -= OnRegionEntered;
private void OnRegionEntered(string regionId)
{
if (!_catalog.TryGet(regionId, out var config)) return;
DOTween.To(() => _globalLight.color,
x => _globalLight.color = x,
config.Color, _transitionDuration)
.SetAutoKill(true).SetLink(gameObject);
DOTween.To(() => _globalLight.intensity,
x => _globalLight.intensity = x,
config.Intensity, _transitionDuration)
.SetAutoKill(true).SetLink(gameObject);
}
}
// Assets/Scripts/VFX/RegionLightCatalogSO.cs
[CreateAssetMenu(menuName = "VFX/RegionLightCatalog")]
public class RegionLightCatalogSO : ScriptableObject
{
[Serializable]
public struct RegionLightConfig
{
public string regionId;
public Color Color;
[Range(0f, 1f)] public float Intensity;
}
[SerializeField] RegionLightConfig[] _entries;
public bool TryGet(string regionId, out RegionLightConfig cfg)
{
foreach (var e in _entries)
if (e.regionId == regionId) { cfg = e; return true; }
cfg = default;
return false;
}
}
}
区域 Global Light 2D 参数速查:
| 区域 | 颜色 | 强度 |
|---|---|---|
| Forest(扎根森林) | #C8E8D0(淡绿) |
0.8 |
| Cave(腐蚀洞穴) | #1A0A2E(深紫) |
0.2 |
| Ruins(坍塌废墟) | #3D3028(暖褐) |
0.5 |
| Abyss(深渊裂隙) | #000820(极暗蓝) |
0.1 |
| Core(核心熔炉) | #4A1800(暗红橙) |
0.6 |
4.4.7 Phase 2 追加的 VFX 资产
| VFX Key | 触发时机 |
|---|---|
VFX_ParryFlash |
弹反成功瞬间 |
VFX_ShieldBreak |
护盾破碎 |
VFX_FormSwitch_Sky |
切换天魂形态 |
VFX_FormSwitch_Earth |
切换地魂形态 |
VFX_FormSwitch_Death |
切换命魂形态 |
VFX_Poison_Tick |
中毒每秒跳动 |
VFX_Burn_Tick |
燃烧 DOT |
VFX_DashTrail |
冲刺残影 |
VFX_WallSlide_Dust |
蹬墙粉尘 |
VFX_DownAttack_Land |
下劈落地冲击 |
5. Week 8:难度系统 + AudioMixer 快照
参考文档:11_AudioModule.md §3-5、19_DifficultyModule.md §4
⚠️ 架构说明:Phase 2 音频工作是完善 Unity AudioMixer 快照和 BGM 管理;架构无 FMOD 依赖,不需要 IAudioBackend 接口层。Phase 1 实现的 AudioManager 无需重构。
5.1 AudioMixer 快照系统
四个快照(在 Assets/Audio/MainMixer.mixer 中已创建):
| 快照 | 触发条件 | BGM 变化 |
|---|---|---|
Default |
正常游戏 | 正常音量 |
Paused |
PauseMenu 打开 | BGM 降低 + 低通滤波 |
Dead |
玩家死亡 | BGM 淡出 + 环境音压低 |
BossFight |
Boss 战开始 | BGM 切换 + SFX 提升 |
AudioManager 快照 API 完善(Week 8 补充到 Phase 1 的 AudioManager.cs):
// 已在 Phase 1 AudioManager 骨架中预留,Week 8 正式实现:
public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f) // ⚠️ TransitionToSnapshot(架构 11 §2)
{
var snapshot = _mixer.FindSnapshot(snapshotName);
snapshot?.TransitionTo(transitionTime);
}
GameManager 事件绑定:
EVT_PauseRequested→TransitionToSnapshot("Paused", 0.3f)EVT_BossFightToggled(true) →TransitionToSnapshot("BossFight", 1.0f)+ BGM 切换EVT_PlayerDied→TransitionToSnapshot("Dead", 0.5f)
5.1.1 AudioEventSO
参考文档:11_AudioModule.md §5
// Assets/Scripts/Audio/AudioEventSO.cs
// ⚠️ 字段全大写(Clips, VolumeMin/Max, PitchMin/Max),方法为 Play/PlayOneShot(架构 11 §5)
[CreateAssetMenu(menuName = "Audio/AudioEvent")]
public class AudioEventSO : ScriptableObject
{
public AudioClip[] Clips; // 随机挑选一个播放
[Range(0f, 1f)]
public float VolumeMin = 0.9f;
[Range(0f, 1f)]
public float VolumeMax = 1.0f;
[Range(0.5f, 2f)]
public float PitchMin = 0.95f;
[Range(0.5f, 2f)]
public float PitchMax = 1.05f;
public AudioMixerGroup MixerGroup; // ⚠️ 路由到子混音组(如 SFX_Player),架构 11 §5
public void Play(AudioSource source)
{
if (Clips == null || Clips.Length == 0) return;
source.outputAudioMixerGroup = MixerGroup; // ⚠️ 架构 11 §5
source.clip = Clips[Random.Range(0, Clips.Length)];
source.volume = Random.Range(VolumeMin, VolumeMax);
source.pitch = Random.Range(PitchMin, PitchMax);
source.Play();
}
public void PlayOneShot(AudioSource source)
{
if (Clips == null || Clips.Length == 0) return;
var clip = Clips[Random.Range(0, Clips.Length)];
source.outputAudioMixerGroup = MixerGroup; // ⚠️ 架构 11 §5
source.PlayOneShot(clip, Random.Range(VolumeMin, VolumeMax));
}
}
// 资产路径:Assets/ScriptableObjects/Audio/
// 命名规范:AUD_{Category}_{Name}.asset(例 AUD_Player_SwordSlash.asset)
5.1.2 GlobalSFXPlayer
参考文档:11_AudioModule.md §6
// Assets/Scripts/Audio/GlobalSFXPlayer.cs
// ⚠️ 静态入口 Play(AudioEventSO, Vector2?);Singleton 为 _instance(非 Instance,架构 11 §6)
public class GlobalSFXPlayer : MonoBehaviour
{
private static GlobalSFXPlayer _instance;
[SerializeField] private AudioMixerGroup _sfxGroup;
[SerializeField] private AudioSource _globalSFXSource;
private void Awake()
{
if (_instance != null) { Destroy(gameObject); return; }
_instance = this;
}
/// <summary>在任意地方播放 SFX。worldPos != null 时使用对象池创建空间音源。</summary>
public static void Play(AudioEventSO audioEvent, Vector2? worldPos = null)
{
if (worldPos.HasValue)
{
// 使用对象池获取临时 AudioSource GO,播放后自动归还
// (Phase 2 简化实现:直接 PlaySFXAtPosition 即可)
AudioManager.Instance.PlaySFXAtPosition(
audioEvent.Clips[Random.Range(0, audioEvent.Clips.Length)],
worldPos.Value,
Random.Range(audioEvent.VolumeMin, audioEvent.VolumeMax));
}
else
{
audioEvent.Play(_instance._globalSFXSource);
}
}
}
5.1.3 AudioConfigSO
参考文档:11_AudioModule.md §7
⚠️ Phase 1 的 BGMController 已引用
_config.VictoryStingBGM/GetBossBGM()/GetZoneBGM()等,此处补全类定义(P2-8 任务)。
// Assets/Scripts/Audio/AudioConfigSO.cs — 程序集 BaseGames.Audio
[CreateAssetMenu(menuName = "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;
}
public ZoneBGMEntry[] ZoneBGMs;
public BossBGMEntry[] BossBGMs;
public AudioClip MainMenuBGM;
public AudioClip GameOverSting; // 死亡时短音乐片段
public AudioClip VictoryStingBGM; // Boss 击败后胜利音乐片段
public float VictoryStingDuration = 4f; // 胜利音乐播放时长(秒)
public AudioClip GetZoneBGM(string zoneId)
{
foreach (var e in ZoneBGMs)
if (e.ZoneId == zoneId) return e.BGMClip;
return null;
}
public AudioClip GetBossBGM(string bossId)
{
foreach (var e in BossBGMs)
if (e.BossId == bossId) return e.BGMClip;
return null;
}
}
// 资产路径:Assets/ScriptableObjects/Audio/AUD_Config.asset
⚠️ Footstep + Underwater 音频:架构 11 §9-10 定义了
FootstepMaterialenum、FootstepAudioConfigSO、FootstepMaterialMarker和UnderwaterAudioController;这两个系统与玩家游泳(Phase 3 LiquidZone)及地面脚步相关,留至 Phase 3 Week 11(SwimState 完整实现时)集成实现。
按 19_DifficultyModule.md §3-4 实现:
// Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs
namespace BaseGames.Core
{
[CreateAssetMenu(menuName = "Core/DifficultyScaler")]
public class DifficultyScalerSO : ScriptableObject
{
[Header("标识")]
public DifficultyLevel level;
[Header("玩家属性缩放")]
[Range(0.1f, 3.0f)] public float PlayerMaxHPMultiplier = 1.0f;
[Range(0.1f, 3.0f)] public float PlayerDamageMultiplier = 1.0f;
[Range(0.0f, 2.0f)] public float InvincibilityFrameScale = 1.0f;
[Header("敌人属性缩放")]
[Range(0.1f, 3.0f)] public float EnemyDamageMultiplier = 1.0f;
[Range(0.1f, 3.0f)] public float EnemyHPMultiplier = 1.0f;
[Range(0.1f, 3.0f)] public float BossDamageMultiplier = 1.0f;
[Range(0.1f, 3.0f)] public float BossHPMultiplier = 1.0f;
[Header("商店价格")]
[Range(0.5f, 2.0f)] public float ShopPriceMultiplier = 1.0f;
[Header("游戏规则")]
public bool CanReviveWithGeoLoss = true;
public bool InstantDeathOnZeroHP = false; // SteelSoul 专用:HP 归零清档
public bool GeoPenaltyOnDeath = true; // false = Easy 无 Geo 损失
[Header("AI 行为(Behavior Designer 黑板变量名)")]
public float EnemyAttackIntervalScale = 1.0f; // 攻击间隔倍率(Hard < 1 = 更频繁)
public float EnemyAggroRangeScale = 1.0f; // 感知范围倍率
[Range(0.3f, 2.0f)]
public float EnemyReactionTimeScale = 1.0f; // ⚠️ 反应时间倍率(>1 = 更慢 = 更简单),架构 19 §3
[Range(0, 5)]
public int EnemyAggressionLevel = 2; // ⚠️ 0=被动 … 5=全力出击(影响 BT 决策权重),架构 19 §3
[Header("掉落与奖励")]
[Range(0.0f, 3.0f)]
public float GeoDropMultiplier = 1.0f; // ⚠️ Geo 掉落量倍率(Easy 可给更多),架构 19 §3
}
}
// Assets/Scripts/Core/Difficulty/DifficultyManager.cs
namespace BaseGames.Core
{
/// <summary>
/// 全局难度管理器,挂在 Persistent 场景 [GameManagers] 下。
/// 持有当前难度 ScalerSO,提供静态访问入口,广播难度变更事件。
/// </summary>
[DefaultExecutionOrder(-900)] // ⚠️ 架构 19_DifficultyModule §4:早于 PlayerStats/EnemyStats(-800) Awake 执行
public class DifficultyManager : MonoBehaviour
{
// ── Inspector ────────────────────────────────────────
[SerializeField] DifficultyScalerSO[] _allScalers; // 4 档资产
[SerializeField] DifficultyChangedEventChannel _onDifficultyChanged;
// ── Singleton ────────────────────────────────────────
public static DifficultyManager Instance { get; private set; }
// ── Runtime State ────────────────────────────────────
public DifficultyLevel CurrentLevel { get; private set; } = DifficultyLevel.Normal;
public DifficultyScalerSO CurrentScaler { get; private set; }
void Awake()
{
Instance = this;
// SaveData 加载后由 GameManager 调用 Apply,此处初始化为 Normal
Apply(DifficultyLevel.Normal);
}
/// <summary>
/// 应用难度。新游戏开始/读档时由 GameManager 调用。
/// </summary>
public void Apply(DifficultyLevel level)
{
// SteelSoul 不可降级
if (CurrentLevel == DifficultyLevel.SteelSoul && level != DifficultyLevel.SteelSoul)
{
Debug.LogWarning("[DifficultyManager] SteelSoul 模式不可降级");
return;
}
CurrentLevel = level;
CurrentScaler = GetScaler(level);
_onDifficultyChanged.Raise(CurrentScaler);
}
public DifficultyScalerSO GetScaler(DifficultyLevel level)
{
foreach (var s in _allScalers)
if (s.level == level) return s;
Debug.LogError($"[DifficultyManager] 找不到 {level} 的 ScalerSO");
return _allScalers[0]; // fallback
}
/// <summary>
/// 游戏进行中切换难度(仅允许 Easy ↔ Normal ↔ Hard,不可切换到 SteelSoul)。
/// </summary>
public void ChangeDifficulty(DifficultyLevel newLevel)
{
if (newLevel == DifficultyLevel.SteelSoul)
{
Debug.LogWarning("[DifficultyManager] 游戏进行中不可切换到 SteelSoul");
return;
}
Apply(newLevel);
}
}
}
四份 DifficultyScalerSO 资产(Assets/ScriptableObjects/Core/Difficulty/):
Difficulty_Easy.asset:玩家 HP ×1.5,敌人伤害 ×0.7,GeoPenaltyOnDeath = false,GeoDropMultiplier = 1.2,EnemyReactionTimeScale = 1.4,EnemyAggressionLevel = 1Difficulty_Normal.asset:全部 ×1.0(默认),EnemyAggressionLevel = 2Difficulty_Hard.asset:玩家 HP ×0.75,敌人伤害 ×1.3,敌人 HP ×1.2,EnemyAttackIntervalScale = 0.8,EnemyReactionTimeScale = 0.7,EnemyAggressionLevel = 3Difficulty_SteelSoul.asset:玩家 HP ×1.2,敌人伤害 ×1.5,InstantDeathOnZeroHP = true,EnemyReactionTimeScale = 0.6,EnemyAggressionLevel = 4
⚠️ 架构 19_DifficultyModule §3 完整字段见 DifficultyScalerSO 上方的资产预设表,以上为关键差异项。
各系统注入:
PlayerStats— 难度由DifficultyManager._onDifficultyChanged事件驱动(无 Initialize 参数)EnemyStats.Initialize(so)— 仅传入EnemyStatsSO(架构 07_EnemyModule §2);难度缩放由DifficultyManager.Apply广播DifficultyChangedEventChannel统一驱动
6. Week 9:敌人扩展 + BossBase 骨架
参考文档:07_EnemyModule.md §3-5
6.1 新增敌人类型
RangedEnemy(远程攻击):
- 继承
EnemyBase - BD BT:在攻击距离内静止 →
BeginAttack(AttackType.Ranged)→ 生成Projectile Projectile飞行中持有HitBox,命中玩家HurtBox后归还对象池
FlyingEnemy(飞行巡逻):
- 继承
EnemyBase - 不使用 PathBerserker2d(空中无需 NavSurface)
- 使用
Vector2.MoveTowards直线追踪玩家 - 碰撞攻击(
OnTriggerStay2D)
6.1.1 Projectile 弹射物系统
参考文档:06_CombatModule.md §7
// Assets/Scripts/Combat/ProjectileConfigSO.cs
// ⚠️ 弹射物静态配置(数据与运行时实例分离),架构 06 §7
[CreateAssetMenu(menuName = "Combat/ProjectileConfig")]
public class ProjectileConfigSO : ScriptableObject
{
public DamageSourceSO DamageSource; // 伤害来源
[Header("运动")]
public float speed = 12f; // 飞行速度 (m/s)
public float lifetime = 5f; // 生存时间 (s)
public float launchAngleDeg = 45f; // ArcProjectile 发射角(度)
public float gravityScale = 1f; // ArcProjectile 重力缩放
public float homingStrength = 4f; // HomingProjectile 追踪角速度(弧度/秒)
[Header("对象池")]
public string poolKey; // AddressKeys 常量,用于 GlobalObjectPool(⚠️ 类名为 GlobalObjectPool,非 ObjectPoolManager,架构 13_AssetPoolModule §3)
[Header("弹反")]
public float parrySpeedMultiplier = 1.2f; // 弹反后速度倍率
public float parryDamageMultiplier = 2.0f; // 弹反伤害倍率
}
// Assets/Scripts/Combat/Projectile.cs(基类)
// ⚠️ 直线 LinearProjectile、抛物线 ArcProjectile、追踪 HomingProjectile 均继承此基类(架构 06 §7)
[RequireComponent(typeof(Rigidbody2D), typeof(HitBox))]
public class Projectile : MonoBehaviour
{
[HideInInspector] public DamageInfo DamageInfo; // 由发射方注入
[HideInInspector] public Vector2 Direction; // 归一化发射方向
protected ProjectileConfigSO _config;
protected Rigidbody2D _rb;
protected HitBox _hitBox;
protected float _aliveTimer;
public virtual void Initialize(ProjectileConfigSO config, DamageInfo damageInfo, Vector2 direction)
{
_config = config;
DamageInfo = damageInfo;
Direction = direction.normalized;
_aliveTimer = 0f;
_rb = GetComponent<Rigidbody2D>();
_hitBox = GetComponent<HitBox>();
OnInitialized();
}
protected virtual void OnInitialized() { }
protected virtual void Update()
{
_aliveTimer += Time.deltaTime;
if (_aliveTimer >= _config.lifetime) ReturnToPool();
}
protected void ReturnToPool()
{
gameObject.SetActive(false);
GlobalObjectPool.Instance.Despawn(_config.poolKey, gameObject); // ⚠️ GlobalObjectPool(非 ObjectPoolManager),架构 13_AssetPoolModule §3
}
}
// ── 抛物线弹射物(投石、毒液弹)────────────────────────────────────────────────
// ⚠️ ArcProjectile 继承 Projectile(架构 06 §7)
public class ArcProjectile : Projectile
{
protected override void OnInitialized()
{
float angle = _config.launchAngleDeg * Mathf.Deg2Rad;
_rb.velocity = new Vector2(
Direction.x * _config.speed * Mathf.Cos(angle),
_config.speed * Mathf.Sin(angle)
);
_rb.gravityScale = _config.gravityScale;
}
}
// ── 追踪弹射物(Boss 阶段特殊弹、追踪蜂群)────────────────────────────────────
// ⚠️ HomingProjectile 通过 TransformEventChannelSO 注入目标(零耦合),架构 06 §7
public class HomingProjectile : Projectile
{
[SerializeField] TransformEventChannelSO _onPlayerSpawned;
Transform _target;
void OnEnable() => _onPlayerSpawned.OnEventRaised += t => _target = t;
void OnDisable() => _onPlayerSpawned.OnEventRaised -= t => _target = t;
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) return;
Vector2 toTarget = ((Vector2)_target.position - (Vector2)transform.position).normalized;
Vector2 newVel = Vector2.MoveTowards(
_rb.velocity.normalized, toTarget,
_config.homingStrength * Time.deltaTime) * _config.speed;
_rb.velocity = newVel;
}
}
// ── ProjectileManager(追踪弹辅助缓存,常驻 Persistent 场景)─────────────────
// ⚠️ 架构 06 §7;在发射追踪弹时注入已缓存的玩家 Transform
public class ProjectileManager : MonoBehaviour
{
[SerializeField] TransformEventChannelSO _onPlayerSpawned;
Transform _playerTransform;
void OnEnable() => _onPlayerSpawned.OnEventRaised += t => _playerTransform = t;
void OnDisable() => _onPlayerSpawned.OnEventRaised -= t => _playerTransform = t;
public Transform PlayerTransform => _playerTransform;
public void LaunchHoming(HomingProjectile proj, Vector2 origin, Vector2 direction,
ProjectileConfigSO config, DamageInfo damageInfo)
{
proj.Initialize(config, damageInfo, direction);
proj.SetTarget(_playerTransform); // 直接注入缓存值
}
}
// ⚠️ 字段名以架构 07_EnemyModule §14 为准(AddressKey→ItemId,DropChance→BaseWeight,GeoRange→GuaranteedGeoMin/Max)
[CreateAssetMenu(menuName = "Enemies/LootTable")]
public class LootTableSO : ScriptableObject
{
[Serializable]
public struct LootEntry
{
public string ItemId; // ⚠️ 非 AddressKey;"" 表示纯 Geo 掉落
public int GeoAmount; // ItemId 为空时使用
public float BaseWeight; // ⚠️ 非 DropChance;加权随机权重(相对值)
public bool ScaleWithDifficulty; // true → Hard 难度时权重 × 1.5
}
public LootEntry[] Entries;
public int GuaranteedGeoMin; // ⚠️ 非 GeoRange;死亡必掉最低 Geo
public int GuaranteedGeoMax;
}
public static class LootResolver
{
public static void Resolve(LootTableSO table, Vector2 worldPosition)
{
if (table == null) return;
// 1. 保底 Geo
int geo = Random.Range(table.GuaranteedGeoMin, table.GuaranteedGeoMax + 1);
if (geo > 0) CollectibleSpawner.SpawnGeo(worldPosition, geo);
// 2. 按 BaseWeight 加权随机(Hard 难度时 ScaleWithDifficulty=true 的条目权重 × 1.5)
float totalWeight = 0f;
var difficulty = DifficultyManager.Instance.CurrentLevel;
foreach (var entry in table.Entries)
{
float w = entry.BaseWeight;
if (entry.ScaleWithDifficulty && difficulty >= DifficultyLevel.Hard) w *= 1.5f;
totalWeight += w;
}
float roll = Random.Range(0f, totalWeight);
float cumulative = 0f;
foreach (var entry in table.Entries)
{
float w = entry.BaseWeight;
if (entry.ScaleWithDifficulty && difficulty >= DifficultyLevel.Hard) w *= 1.5f;
cumulative += w;
if (roll <= cumulative)
{
if (!string.IsNullOrEmpty(entry.ItemId))
CollectibleSpawner.SpawnItem(worldPosition, entry.ItemId);
break;
}
}
}
}
6.3 BossBase 骨架
// Assets/Scripts/Enemies/Boss/BossBase.cs
public class BossBase : EnemyBase
{
[SerializeField] protected BossOrchestrator _orchestrator; // ⚠️ 架构 07_EnemyModule §10
[SerializeField] protected TelegraphSystem _telegraph; // ⚠️ 架构 07_EnemyModule §10
[Header("Boss 识别")]
[SerializeField] private string _bossId; // ⚠️ 用于事件 payload(无 BossConfigSO)
[Header("Event Channels")]
[SerializeField] private BoolEventChannelSO _onBossFightEnded; // ⚠️ Raise 方:BossBase.Die()(架构 02 §4)
[SerializeField] private BossPhaseEventChannelSO _onBossPhaseChanged; // ⚠️ Raise 方:BossBase.EnterPhase()
public string BossId => _bossId;
protected int _currentPhase = 0;
// Boss 专属接口(由 BD_EnterPhase 任务调用)
// ⚠️ 方法名为 EnterPhase,非 TransitionToPhase(架构 07_EnemyModule §9)
public virtual void EnterPhase(int phase)
{
_currentPhase = phase;
// 1. 短暂无敌
// 2. 播放 Phase 过渡演出动画
_onBossPhaseChanged.Raise(new BossPhaseEvent { BossId = _bossId, Phase = phase });
}
// HP 阈值检查(BD_IsHPBelow 任务使用,无 CheckPhaseTransition MonoBehaviour 方法)
public bool IsHPBelow(float ratio) => (float)_stats.CurrentHP / _stats.MaxHP < ratio;
public override void Die()
{
base.Die();
_onBossFightEnded.Raise(true); // 广播胜利
// 触发死亡过场(EventChain)
}
}
6.3.1 CameraStateController 完整实现(补充 Boss/Death 相机)
参考文档:
17_CameraModule.md §3
Phase 1 仅创建了基础骨架(A/B 双机 + SwitchRoom + RegisterRoomCamera + TriggerImpulse)。Phase 2 添加 BossBase 时须同步完善CameraStateController,加入以下字段和处理方法:
// 补充到 CameraStateController(在 Phase 1 骨架基础上追加)
[Header("特殊状态相机(⚠️ 架构 17_CameraModule §3 要求)")]
[SerializeField] CinemachineCamera _vcamBoss; // Priority 30(Boss 战激活)
[SerializeField] CinemachineCamera _vcamDeath; // Priority 50(死亡时激活,1.0s EaseIn)
[SerializeField] CinemachineCamera _vcamCutscene; // Priority 40(Phase 4 Cutscene 时激活)
[Header("Phase 2 事件频道")]
[SerializeField] BoolEventChannelSO _onBossFightToggled; // true=开始 false=结束(⚠️ 与 BGMController 共用同一 SO)
[SerializeField] VoidEventChannelSO _onPlayerDied; // 死亡相机接管
[SerializeField] VoidEventChannelSO _onPlayerRespawned; // 死亡相机释放
// 补充事件订阅(在 OnEnable/OnDisable 中追加)
_onBossFightToggled.OnEventRaised += OnBossFightToggled;
_onPlayerDied.OnEventRaised += OnPlayerDied;
_onPlayerRespawned.OnEventRaised += OnPlayerRespawned;
// 补充处理方法
private void OnBossFightToggled(bool started)
{
_vcamBoss.Priority = started ? 30 : 0;
if (started) _brain.DefaultBlend =
new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 0.8f);
else _brain.DefaultBlend = new CinemachineBlendDefinition(
_defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration);
}
private void OnPlayerDied()
{
_vcamDeath.Priority = 50; // 死亡相机接管,1.0s EaseIn
_brain.DefaultBlend =
new CinemachineBlendDefinition(CinemachineBlendDefinition.Style.EaseIn, 1.0f);
}
private void OnPlayerRespawned()
{
_vcamDeath.Priority = 0;
_brain.DefaultBlend = new CinemachineBlendDefinition(
_defaultConfig.DefaultBlendStyle, _defaultConfig.DefaultBlendDuration);
}
Persistent 场景结构更新:在
[CameraRig]下新增[SpecialCameras]子组,包含VCam_Boss(Priority 0)、VCam_Death(Priority 0)、VCam_Cutscene(Priority 0)三个 CinemachineCamera。VCam_CutscenePhase 4 才正式使用,但 Phase 2 可先创建 GO 留空。
6.4 DeathShade(死亡遗骸)
文件:Assets/Scripts/World/DeathShade.cs(架构 07_EnemyModule §12)
玩家死亡时由
GameManager.HandlePlayerDied()在最后位置生成;与之交互可回收玩家死亡时持有的 Geo。
// ⚠️ 完整实现以架构 07_EnemyModule §12 为准
// ⚠️ IInteractable 定义在 BaseGames.World 命名空间(Architecture 08 §7 / 14 §1)
public class DeathShade : MonoBehaviour, IInteractable
{
[Header("Event Channel")]
[SerializeField] private StringEventChannelSO _onGeoRecovered; // ⚠️ payload: sceneId(通知存档)
private int _storedGeo;
private string _sceneId;
private Vector2 _worldPosition;
public bool CanInteract => true;
public string InteractPrompt => "回收遗骸";
/// <summary>由 GameManager(或 DeathShadeManager)在玩家死亡时调用。</summary>
public void Initialize(int geo, string sceneId, Vector2 pos)
{
_storedGeo = geo;
_sceneId = sceneId;
_worldPosition = pos;
transform.position = pos;
}
public void Interact(Transform player)
{
// ⚠️ PlayerController 无 Instance(Architecture 05 §2);通过 player 参数获取
var pc = player.GetComponent<PlayerController>();
if (pc != null) pc.Stats.AddGeo(_storedGeo);
_onGeoRecovered.Raise(_sceneId);
Destroy(gameObject); // 或归还对象池
}
public void OnPlayerEnterRange(Transform player) { }
public void OnPlayerExitRange() { }
// 存档集成(世界状态持久化,架构 07_EnemyModule §12)
public DeathShadeSaveData GetSaveData();
public void LoadSaveData(DeathShadeSaveData data);
}
GameManager 集成(Plan 02 §4.4 HandlePlayerDied 补充):
HandlePlayerDied()在切入GameState.Dead后,通过[SerializeField] DeathShade _deadShadePrefab+Instantiate在玩家当前位置生成 DeathShade,并调用Initialize(currentGeo, currentSceneName, playerPos)- 玩家死后 Geo 清零;新死亡覆盖旧遗骸(旧 DeathShade 自动销毁)
7. 完成标准检查清单
□ 冲刺:施加冲刺力 + 无敌帧期间不受伤 + 落地后正确切换 Idle
□ 蹬墙:接触墙壁减速下滑 + 蹬墙跳正确方向
□ 下劈:空中踩踏敌人时玩家弹起 + 地面撞击 VFX
□ 上劈:向上攻击命中敌人 + 上劈后进入 Fall 状态
□ 弹反:ParryWindow 内受攻击 → 弹反成功动画 + 敌人僵直
□ 护盾:受击时护盾优先吸收 + 护盾耐久条 UI 更新 + 破碎惩罚期间无护盾
□ 状态效果:Poison 每秒掉血 + StatusEffectManager 正确 Tick + 到期自动移除
□ AnimationEventType:攻击动画 HitBox 激活时机与动画帧完全同步
□ EventConfigEditor:AnimationEventConfigSO Inspector 时间轴可视化正常渲染,Clip 漂移警告触发(偏差>5帧)
□ FormController:三形态切换 + 各形态使用对应武器 + 调色板切换
□ AudioMixer:BGM 跨房间平滑切换(Snapshot Transition),SFX 无卡顿
□ DifficultyManager:Hard 模式敌人 HP 和伤害按 scaler 正确缩放
□ RangedEnemy:Projectile 飞行命中玩家,玩家 HP 减少
□ BossBase:Phase 切换(HP 降至 50%)动画演出正确触发
□ LootResolver:敌人死亡时随机掉落 Geo + 指定道具
□ Console 无 Error
Phase 2 完成后进入 Phase 3。