55 KiB
Phase 1 · 垂直切片 MVP
周期:3–4 周(Week 1-4)
前置条件:Phase 0 全部完成标准通过
核心目标:一个可玩的测试房间;验证所有第三方库(Animancer / PathBerserker2d / BD / Cinemachine / Feel / Addressables)协作无冲突
产出物:玩家能移动/跳跃/攻击 → 敌人寻路并反击 → 死亡 → 存档复活,VFX/SFX 全流程响应
进度更新(2026-05-07):Week 1–3 代码层完成,Week 4 未开始。
Week 1 完成项:InputReaderSO、InputBuffer、PlayerMovement、PlayerStats、PlayerController(Idle/Run/Jump/Fall)、CameraStateController、RoomVisibleArea、CameraTriggerZone、CameraConfigSO、CameraBlendProfileSO。
Week 2 完成项:DamageInfo/DamageType/DamageFlags/DamageTags/DamageCategory/HitFxType/BreakLevel、HitBox、HurtBox、DamageSourceSO、WeaponManager、PlayerCombat、AttackState、EnemyBase、EnemyStats、EnemyNavAgent、BD 基础行为树任务(BD_Patrol / BD_MoveToPlayer / BD_Attack / BD_IsPlayerInRange)。
Week 3 完成项:SaveManager(完整 SaveAsync/LoadAsync/Checksum/SlotSummary)、ISaveable、SaveData、LocalFileStorage、SaveMigrator、IInteractable、SavePoint(含 ISaveable 集成)、EmergencySaveService、GameManager(DeathFlow + RequestTransition)、DeathRespawnService、UIManager、HUDController(HP/灵力/Geo/弹簧/形态/交互提示)、DeathScreenController。PlayerStats.FullHeal() 已补全;BaseGames.World.asmdef 已加 Player.States/Core.Events/Core.Save 引用;BaseGames.UI.asmdef 已加 BaseGames.Core 引用。
目录
- 实施顺序总览
- Week 1:输入 + 玩家骨架 + 相机
- Week 2:战斗基础 + 敌人骨架
- Week 3:存档完善 + 死亡复活流 + UI
- Week 4:VFX/音效 + 集成验证
- 第三方集成检查点
- 完成标准检查清单
1. 实施顺序总览
Week 1:
InputReaderSO + InputBuffer
↓
PlayerMovement + PlayerStats(Rigidbody2D 物理验证)
↓
PlayerController(仅 Idle/Run/Jump/Fall 状态)
↓
CameraStateController + CinemachineConfiner2D + PixelPerfect 验证
Week 2:
DamageInfo + DamageType 枚举 + DamageCategory/Flags/Tags
↓
HitBox + HurtBox + IDamageable
↓
AttackState(Animancer 状态机 首次集成)
↓
EnemyBase + EnemyStats + EnemyNavAgent(PathBerserker2d 首次集成)
↓
Behavior Designer 基础 BT(Patrol→Chase→Attack)
Week 3:
SaveManager 完整实现 + SavePoint + ISaveable + EmergencySaveService
↓
GameManager 死亡/复活流完整 Coroutine
↓
UIManager + HUDController(HP 条、灵力条)+ DeathScreenController(⚠️ 非 HUDPanel/DeathPanel,架构 10 §3/§6)
Week 4:
VFXPool + HitFXSpawner + HurtFlashController + VFXCatalogSO
↓
AudioManager(Unity AudioMixer + 双 AudioSource 交叉淡入淡出)
↓
IFeedbackPlayer + PlayerFeedback / EnemyFeedback(Feel MMF_Player)
↓
集成验证(Phase 1 全链路)
2. Week 1:输入 + 玩家骨架 + 相机
参考文档:04_InputModule.md、05_PlayerModule.md §1-4、17_CameraModule.md
2.1 InputReaderSO
文件:Assets/Scripts/Input/InputReaderSO.cs
创建要点:
IInputActionCollection2接口(通过 Input Actions 代码生成类自动满足)OnEnable/OnDisable中 Enable / Disable ActionMap- 所有 Action 委托在
OnEnable中绑定到内部_actions上,OnDisable取消绑定 - 区分两个 ActionMap:
Gameplay、UI,通过专用方法切换(⚠️ 方法名以架构04_InputModule §2为准,不存在SetActionMap(ActionMapType);对话中由 DialogueManager 调用EnableUIInput(),无独立 Dialogue Map)
// 最小可验证 API(Phase 1 需要)
public event Action<Vector2> MoveEvent;
public event Action JumpStartedEvent;
public event Action JumpCancelledEvent;
public event Action AttackEvent;
public event Action PauseEvent;
public void EnableGameplayInput(); // 启用 Gameplay Map,禁用 UI Map
public void EnableUIInput(); // 启用 UI Map,禁用 Gameplay Map
public void DisableAllInput(); // 全部禁用(过场/加载中)
资产:Assets/Data/Player/PLY_InputReader.asset — 在 Inspector 中 Create Asset,供全局使用。
PlayerInputActions.inputactions:在 Assets/Settings/Input/ 创建 Input Actions 资产,配置:
| ActionMap | Action | Binding |
|---|---|---|
| Gameplay | Move | WASD / 左摇杆 |
| Gameplay | Jump | Space / 南键 |
| Gameplay | Attack | Z / 西键 |
| Gameplay | Pause | Escape / Start |
| UI | Navigate | 方向键 / 左摇杆 |
| UI | Submit | Return / 南键 |
| UI | Cancel | Escape / 东键 |
2.2 InputBuffer
文件:Assets/Scripts/Input/InputBuffer.cs
// 挂在 Player Prefab 上,与 InputReaderSO 配合
public class InputBuffer : MonoBehaviour
{
[SerializeField] private InputReaderSO _inputReader; // ⚠️ 非 _input(架构 04_InputModule §4)
[SerializeField] private float _jumpBufferDuration = 0.15f; // 跳跃宻d容窗口(架构 04_InputModule §4)
[SerializeField] private float _attackBufferDuration = 0.12f; // 攻击接续连段
[SerializeField] private float _dashBufferDuration = 0.10f; // 冲刺小量容错
private float _jumpBuffer;
private float _attackBuffer;
private float _dashBuffer;
// 查询接口(消费型,查到即清除)
public bool ConsumeJump();
public bool ConsumeAttack();
public bool ConsumeDash();
// 内部:OnEnable 订阅 InputReader 事件,设定定时清除
}
2.3 PlayerMovement
文件:Assets/Scripts/Player/PlayerMovement.cs
Phase 1 实现范围(不含 Dash/WallSlide/Swim):
| 方法 | Phase 1 实现 |
|---|---|
Move(float speedX) |
✅ 完整 |
Jump(bool isVariable) |
✅ 完整(CoyoteTime + VariableJump) |
CutJump() |
✅ 完整 |
ApplyKnockback |
✅ 完整 |
CheckGrounded() |
✅ Physics2D.OverlapBox |
CheckWalls() |
✅ Raycast |
Dash(Vector2 dir, float speed) |
⏳ Phase 2 |
DropThroughPlatform() |
⏳ Phase 2 |
2.4 PlayerStats
文件:Assets/Scripts/Player/PlayerStats.cs
Phase 1 仅实现:
public int MaxHP { get; private set; }
public int CurrentHP { get; private set; }
public int MaxSpringCharges { get; private set; } // ⚠️ 非 MaxSpring(架构 05 §4)
public int CurrentSpringCharges { get; private set; } // ⚠️ 非 CurrentSpring
public void TakeDamage(int amount); // 减 HP → 发布 EVT_HPChanged;=0 时发布 EVT_PlayerDied
public void HealHP(int amount); // ⚠️ 非 Heal;回血(上限 MaxHP,架构 05 §4)
// ⚠️ 无 Initialize(PlayerStatsSO) 方法;PlayerStatsSO _config 通过 Inspector [SerializeField] 设置
// 存档恢复使用 LoadSaveData(PlayerSaveData data)(架构 05 §4)
资产:Assets/Data/Player/PLY_Stats_Normal.asset(PlayerStatsSO)
2.5 PlayerController + 4 个基础状态
文件:
Assets/Scripts/Player/PlayerController.cs(主控制器,Phase 1 骨架)Assets/Scripts/Player/States/PlayerStateBase.cs(抽象基类)Assets/Scripts/Player/States/IdleState.csAssets/Scripts/Player/States/RunState.csAssets/Scripts/Player/States/JumpState.csAssets/Scripts/Player/States/FallState.cs
Animancer 集成要点:
AnimancerComponent挂在 Player Prefab 根节点- 每个 State 的
AnimationClip定义在PlayerAnimationConfigSO里,Phase 1 直接引用(⚠️ 字段类型为 AnimationClip,非 ClipTransition,架构 05_PlayerModule §17) - 进入 State 时调用
_player.Animancer.Play(_config.Idle)(⚠️ 字段名为 Idle,非 IdleClip,架构 05_PlayerModule §17) - 验证点:切换状态时无
MissingReferenceException,动画无卡顿
// PlayerController.Awake 最小初始化(Phase 1)
void Awake()
{
_movement = GetComponent<PlayerMovement>();
_stats = GetComponent<PlayerStats>();
_inputBuffer = GetComponent<InputBuffer>();
_animancer = GetComponent<AnimancerComponent>();
_idleState = new IdleState(this);
_runState = new RunState(this);
_jumpState = new JumpState(this);
_fallState = new FallState(this);
_attackState = new AttackState(this); // Week 2
TryTransitionState(_idleState);
}
2.6 CameraStateController
参考文档:17_CameraModule.md §2–§10
文件:Assets/Scripts/Camera/CameraStateController.cs
Phase 1 实现范围:
// 全局双机 A/B 切换(架构 17_CameraModule §3)
namespace BaseGames.Camera
{
public class CameraStateController : MonoBehaviour
{
[SerializeField] CinemachineCamera _vcamA;
[SerializeField] CinemachineCamera _vcamB;
[SerializeField] CinemachineBrain _brain;
[SerializeField] CameraConfigSO _defaultConfig;
CinemachineCamera _activeCam; // runtime
CinemachineCamera _inactiveCam;
RoomCamera _currentRoomCam;
public static CameraStateController Instance { get; private set; }
void Awake()
{
Instance = this;
_activeCam = _vcamA;
_inactiveCam = _vcamB;
}
// 由 CameraTriggerZone.OnTriggerEnter2D 调用
public void SwitchRoom(RoomCameraData data);
// 由 RoomCamera.OnEnable 调用
public void RegisterRoomCamera(RoomCamera rc);
// 由 RoomCamera.OnDisable 调用
public void UnregisterRoomCamera(RoomCamera rc);
// 由 IFeedbackPlayer 调用(架构 17_CameraModule §9)
public void TriggerImpulse(CameraShakePreset preset);
}
public struct RoomCameraData
{
public Collider2D ConfinerCollider;
public Vector3 CameraOffset;
public CameraBlendProfileSO BlendProfile;
}
}
// Persistent 场景 CameraRig Prefab 组装
// ├── Main Camera (CinemachineBrain + PixelPerfectCamera + CinemachinePixelPerfect)
// ├── VCam_Global_A(CinemachineCamera + CinemachinePositionComposer + CinemachineConfiner2D
// │ + CinemachineImpulseListener + CinemachinePixelPerfect,Priority: 10,active)
// ├── VCam_Global_B(同上,Priority: 9,standby)
// └── CameraStateController.cs(挂在根节点)
2.6.1 RoomVisibleArea
文件:Assets/Scripts/Camera/RoomVisibleArea.cs(架构 17_CameraModule §4)
[ExecuteAlways]
[RequireComponent(typeof(PolygonCollider2D))]
public class RoomVisibleArea : MonoBehaviour
{
[SerializeField] private Vector2 _roomSize = new(20f, 11.25f);
[SerializeField] private Vector2 _viewportSize = new(20f, 11.25f);
/// <summary>roomSize ≈ viewportSize 时为固定镜头(无滚动)。</summary>
public bool IsFixedCamera { get; }
/// <summary>返回 PolygonCollider2D,供 CameraTriggerZone 传入 CinemachineConfiner2D。</summary>
public Collider2D Collider { get; private set; }
private void Awake() => Collider = GetComponent<PolygonCollider2D>();
// 每次 Inspector 变更重算 Confiner = Max(0, roomSize - viewportSize)
private void OnValidate() => RebuildCollider();
private void RebuildCollider() { /* 重建 PolygonCollider2D 形状 */ }
}
// + RoomVisibleAreaEditor(自定义 Inspector 拖拽句柄,[ExecuteAlways])
2.6.2 CameraTriggerZone
文件:Assets/Scripts/Camera/CameraTriggerZone.cs(架构 17_CameraModule §5)
[ExecuteAlways]
[RequireComponent(typeof(BoxCollider2D))]
public class CameraTriggerZone : MonoBehaviour
{
[SerializeField] private Vector2 _center = Vector2.zero;
[SerializeField] private Vector2 _size = new(0.5f, 4f);
[SerializeField] private RoomVisibleArea _targetRoom; // 目标房间的 RoomVisibleArea
[SerializeField] private Vector3 _cameraOffset;
[SerializeField] private CameraBlendProfileSO _blendOverride; // null = 使用默认
[SerializeField] private bool _triggerOnce = false;
private bool _triggered;
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
if (_triggerOnce && _triggered) return;
_triggered = true;
CameraStateController.Instance.SwitchRoom(new RoomCameraData
{
ConfinerCollider = _targetRoom.Collider,
CameraOffset = _cameraOffset,
BlendProfile = _blendOverride,
});
}
}
// + CameraTriggerZoneEditor(场景拖拽句柄,_center/_size)
2.6.3 CameraConfigSO
文件:Assets/Scripts/Camera/CameraConfigSO.cs(架构 17_CameraModule §7)
[CreateAssetMenu(menuName = "Camera/CameraConfig")]
public class CameraConfigSO : ScriptableObject
{
[Header("跟随")]
public Vector3 DefaultFollowOffset = new(0f, 1f, -10f);
public float HorizontalDamping = 0.5f;
public float VerticalDamping = 0.3f;
[Header("默认混合")]
public float DefaultBlendDuration = 0.5f;
public CinemachineBlendDefinition.Style DefaultBlendStyle
= CinemachineBlendDefinition.Style.EaseInOut;
[Header("Pixel Perfect")]
public int ReferenceResolutionX = 320;
public int ReferenceResolutionY = 180;
public int PixelsPerUnit = 16;
public bool CropFrameX = false;
public bool CropFrameY = false;
/// <summary>相机视口世界单位尺寸(320×180 / 16PPU = 20×11.25)。</summary>
public Vector2 ViewportSizeInWorldUnits
=> new Vector2((float)ReferenceResolutionX / PixelsPerUnit,
(float)ReferenceResolutionY / PixelsPerUnit);
}
// 资产路径:Assets/ScriptableObjects/Camera/Camera_Config.asset
2.6.4 CameraBlendProfileSO
文件:Assets/Scripts/Camera/CameraBlendProfileSO.cs(架构 17_CameraModule §8)
[CreateAssetMenu(menuName = "Camera/BlendProfile")]
public class CameraBlendProfileSO : ScriptableObject
{
public float Duration = 0.5f;
public CinemachineBlendDefinition.Style Style
= CinemachineBlendDefinition.Style.EaseInOut;
public CinemachineBlendDefinition ToBlendDefinition()
=> new CinemachineBlendDefinition(Style, Duration);
}
// 内置预设(Assets/ScriptableObjects/Camera/Blends/):
// Blend_Default.asset(0.5s)、Blend_Instant.asset(0s)、
// Blend_Slow.asset(1.0s)、Blend_BossExit.asset(0.8s)
2.6.5 RoomCamera(可选)
文件:Assets/Scripts/Camera/RoomCamera.cs(架构 17_CameraModule §6,优先级 15 的房间专属机位)
namespace BaseGames.Camera
{
public class RoomCamera : MonoBehaviour
{
[SerializeField] CinemachineCamera _vcam;
[SerializeField] RoomVisibleArea _visibleArea;
[SerializeField] CameraBlendProfileSO _enterBlend; // 可留空
void OnEnable()
{
_vcam.Priority = 15;
CameraStateController.Instance.RegisterRoomCamera(this);
}
void OnDisable()
{
_vcam.Priority = 0;
CameraStateController.Instance.UnregisterRoomCamera(this);
}
public CinemachineCamera Vcam => _vcam;
public RoomVisibleArea VisibleArea => _visibleArea;
public CameraBlendProfileSO EnterBlend => _enterBlend;
}
}
Pixel Perfect 验证:Play Mode 下检查 PixelPerfectCamera.upscaleRT = true,角色移动时像素网格对齐无抖动。
3. Week 2:战斗基础 + 敌人骨架
参考文档:06_CombatModule.md、07_EnemyModule.md
3.1 战斗数据结构
按顺序创建(后者依赖前者):
DamageType.cs ← 枚举(Normal/Fire/Poison/Ice/Lightning/Void/True)
DamageCategory.cs ← 枚举(NormalAttack/SoulSkill/...)
DamageFlags.cs ← [Flags] 枚举(Unblockable/CanBeParried/...)
DamageTags.cs ← [Flags] 枚举(MeleeHit/RangedHit/SkillHit/ElementFire/ElementPoison/ElementVoid/AfterParry/ChargedAttack/SkyFormOnly/EarthFormOnly/DeathFormOnly/BreakLight/BreakMedium/BreakHeavy/BreakBreaker,架构 06_CombatModule §2)
HitFxType.cs ← 枚举(Spark/Slash/Blood/Magic/Heavy/Crit/Void/Heal/Parry/Fire/Ice,架构 06_CombatModule §2)
BreakLevel.cs ← 枚举(None=0/Light=1/Medium=2/Heavy=3/Breaker=4,架构 06_CombatModule §2)
DamageInfo.cs ← struct + Builder(⚠️ 非 readonly struct;Builder 需就地赋値字段,架构 06_CombatModule §1)
DamageSourceSO.cs ← [CreateAssetMenu] SO 包装器
IDamageable.cs ← 接口(bool IsInvincible / int Defense / void TakeDamage(DamageInfo info),三成员,架构 06_CombatModule §5)
3.2 HitBox
文件:Assets/Scripts/Combat/HitBox.cs
// 挂在武器 / 攻击判定子节点上(Collider2D 设 IsTrigger=true,Layer=PlayerHitBox 或 EnemyHitBox)
// ⚠️ 完整实现以架构 06_CombatModule §4 为准
[RequireComponent(typeof(Collider2D))]
public class HitBox : MonoBehaviour
{
[SerializeField] private DamageSourceSO _defaultSource; // Inspector 默认值
[SerializeField] private float _hitCooldown = 0.1f; // 同目标多帧冷却
// 运行时注入(AttackState / Projectile 覆盖默认 SO)
private DamageSourceSO _currentSource;
private Transform _attackerTransform;
private bool _isActive;
// 命中确认委托(PlayerCombat / EnemyCombat 订阅)
public System.Action<DamageInfo> OnHitConfirmed;
// ⚠️ 参数 source/attacker 均可选(架构 06_CombatModule §4);不存在 Activate(float duration)
public void Activate(DamageSourceSO source = null, Transform attacker = null)
{
_currentSource = source ?? _defaultSource;
_attackerTransform = attacker ?? transform;
_isActive = true;
}
public void Deactivate() => _isActive = false;
private void OnTriggerEnter2D(Collider2D other)
{
if (!_isActive) return;
if (!CheckCooldown(other)) return;
var knockDir = ((Vector2)other.bounds.center - (Vector2)_attackerTransform.position).normalized;
var info = _currentSource.CreateBuilder()
.SetKnockback(knockDir, _currentSource.KnockbackForce)
.SetSourcePos(_attackerTransform.position)
.SetLayer(_attackerTransform.gameObject.layer)
.Build();
// ① 命中 HurtBox(敌人/玩家受击)
var hurtBox = other.GetComponent<HurtBox>();
if (hurtBox != null)
{
hurtBox.ReceiveDamage(info); // ⚠️ 必须用 ReceiveDamage(见架构 06_CombatModule §5)
OnHitConfirmed?.Invoke(info);
return;
}
// ② 命中 IBreakable(机关/障碍物)
var breakable = other.GetComponent<IBreakable>();
breakable?.TryInteract(info);
}
private Dictionary<Collider2D, float> _hitCooldownTimers = new();
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;
}
}
Layer 设置(Physics 2D Layer Collision Matrix):
PlayerHitBox与EnemyHurtBox碰撞EnemyHitBox与PlayerHurtBox碰撞- 其他组合全部关闭
3.3 HurtBox
文件:Assets/Scripts/Combat/HurtBox.cs
[RequireComponent(typeof(Collider2D))]
public class HurtBox : MonoBehaviour
{
private IDamageable _owner; // 由 Awake 的 GetComponentInParent 注入
private IShieldable _shieldable; // 由 PlayerController.Awake() 注入
private bool _isHurtBoxInvincible; // 动画事件 EnableIFrame/DisableIFrame 控制
private bool _isActive = true; // HurtBox 整体开关(⚠️ 架构 06_CombatModule §5)
// 弹反系统(仅玩家侧注入;Phase 2 由 PlayerController.Awake() 调用 SetParrySystem)
private ParrySystem _parrySystem; // null = 无弹反(Phase 1 默认 null,Phase 2 注入)
// 霸体来源(EnemyPoiseComponent / PlayerController 实现;Phase 2 注入)
private IPoiseSource _poiseSource; // null = 无霸体检查(Phase 1 默认 null,Phase 2 注入)
[SerializeField] private DamageInfoEventChannelSO _onDamageDealt; // EVT_DamageDealt(AnalyticsManager)
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed; // EVT_HitConfirmed(VFX/Audio/Feedback,架构 06_CombatModule §5)
public void SetShieldable(IShieldable shieldable) => _shieldable = shieldable; // ⚠️ 参数为 IShieldable 接口(非 ShieldComponent 具体类型)
public void SetInvincible(bool value) => _isHurtBoxInvincible = value; // 由 PlayerAnimationEvents 的 EnableIFrame/DisableIFrame 事件调用
public void SetParrySystem(ParrySystem ps) => _parrySystem = ps; // 由 PlayerController.Awake() 注入(Phase 2)
public void SetPoiseSource(IPoiseSource src) => _poiseSource = src; // 由 EnemyBase.Awake() 注入(Phase 2)
public void SetActive(bool value) => _isActive = value; // 整体开关(架构 06_CombatModule §5)
// ⚠️ 方法名必须与架构 06_CombatModule §5 完全一致;全 8 步流水线
public void ReceiveDamage(DamageInfo info)
{
if (!_isActive) return; // HurtBox 被禁用时忽略
// 1. 无敌帧检查(架构:_owner.IsInvincible || _isHurtBoxInvincible)
if ((_owner.IsInvincible || _isHurtBoxInvincible) && !info.Flags.HasFlag(DamageFlags.IgnoreIFrame)) return;
// 2. 弹反检查(须在伤害计算前;仅玩家侧 HurtBox 注入了 _parrySystem;Phase 1 skip)
if (_parrySystem != null && info.Flags.HasFlag(DamageFlags.CanBeParried))
if (_parrySystem.TryParryDamage(info)) return;
// 3. 霸体检查(BreakLevel vs PoiseLevel;Phase 1 _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 = transform.position });
return;
}
}
// 4. 护盾层拦截(玩家专属,在防御减免前)
if (_shieldable != null && _shieldable.HasShield)
{
int passThrough = _shieldable.AbsorbDamage(info.Amount); // ⚠️ 返回穿透量(int),非 ref 参数(架构 20_ShieldModule §5)
if (passThrough <= 0) return; // 全部被护盾吸收
info.Amount = passThrough; // 穿透量继续走防御减免流程
}
// 5. 计算 FinalDamage(防御减免,最低 1)
int finalDamage = Mathf.Max(1, info.Amount - _owner.Defense);
info.Amount = finalDamage;
info.FinalDamage = finalDamage; // ⚠️ 同步写入 FinalDamage(架构 06_CombatModule §5 步骤 5)
// 6. 调用 _owner.TakeDamage
_owner.TakeDamage(info);
// 7. 全局广播(两个频道,架构 06_CombatModule §5)
_onDamageDealt.Raise(info);
_onHitConfirmed.Raise(new HitInfo { DamageInfo = info, HitPoint = transform.position });
// 8. 状态效果触发(DoT — Fire/Poison;Phase 1 StatusEffectManager 可能尚未挂载)
if (_owner is MonoBehaviour mb)
{
var sem = mb.GetComponent<StatusEffectManager>();
if (sem != null)
{
if (info.Type == DamageType.Fire) sem.ApplyEffect(new FireEffect());
else if (info.Type == DamageType.Poison) sem.ApplyEffect(new PoisonEffect());
}
}
}
}
3.4 WeaponManager + PlayerCombat(Week 2 新增)
架构约束(05_PlayerModule.md §5/§7):HitBox 挂载在 Player Prefab 本体上(由
PlayerCombat管理),不使用独立武器 Prefab。WeaponSO 是纯数据 SO,不含 Prefab 引用。⚠️WeaponInstance类不存在。
文件:
Assets/Scripts/Player/WeaponManager.csAssets/Scripts/Player/PlayerCombat.cs(Phase 1 简化版;完整版见 Phase 2 §2.3)
// WeaponManager.cs — 挂在 PlayerController 上
// ⚠️ 架构 05_PlayerModule §7:ActiveWeapon(WeaponSO),OnWeaponChanged 事件,无 Equip() 方法,无 WeaponInstance
// Phase 1: _startingWeapon 在 Inspector 直接指定;Phase 2 §2.3 接入 FormController.OnFormChanged
public class WeaponManager : MonoBehaviour
{
[SerializeField] private WeaponSO _startingWeapon; // Phase 1 直接指定,Phase 2 改为由 FormController 驱动
public WeaponSO ActiveWeapon { get; private set; }
public event Action<WeaponSO> OnWeaponChanged;
void Start()
{
if (_startingWeapon != null)
{
ActiveWeapon = _startingWeapon;
OnWeaponChanged?.Invoke(ActiveWeapon);
}
}
// Phase 2 §2.3 补全:SetOverride / ClearOverride + FormController 订阅(架构 05_PlayerModule §7)
}
// PlayerCombat.cs — Phase 1 简化版(只含 HitBox 激活,无 RefreshWeaponData / SetComboSegmentSource)
// ⚠️ 架构 05_PlayerModule §5:HitBox 直接挂在 Player Prefab 上,不经过 WeaponInstance
public class PlayerCombat : MonoBehaviour
{
[SerializeField] private WeaponManager _weaponManager;
// ── 玩家角色 Prefab 上的 HitBox(固定挂载,不依赖武器 Prefab)────────────
[SerializeField] private HitBox _hitBoxGround;
[SerializeField] private HitBox _hitBoxUp;
[SerializeField] private HitBox _hitBoxDown;
[SerializeField] private HitBox _hitBoxAir;
// ── 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();
}
// 命中回调 → 增加灵力(Phase 2 §2.3 补全)
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
};
}
Player Prefab 关键子节点(Week 2 补充):
[Player]
├── ... (其余同架构层级)
├── [HitBoxGround] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxGround)
├── [HitBoxUp] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxUp)
├── [HitBoxDown] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxDown)
├── [HitBoxAir] ← 碰撞体,挂 HitBox 组件(PlayerCombat._hitBoxAir)
└── [SkillSocket] ← 空 Transform,技能 HitBox 挂点(Phase 2 技能系统使用)
AttackState 说明:AttackState 通过 _player.Combat.EnableWeaponHitBox(dir) / DisableAllWeaponHitBoxes() 激活/关闭 HitBox(见 §3.5)。⚠️ 不存在 WeaponInstance 或 ActiveWeaponInstance API。
3.5 AttackState(Animancer 集成关键)
文件:Assets/Scripts/Player/States/AttackState.cs
// Animancer 状态机集成要点
public class AttackState : PlayerStateBase
{
// PlayerAnimationConfigSO 中定义 AnimationClip[] GroundAttacks(3段连击,⚠️ 字段名 GroundAttacks,非 AttackChainClips,架构 05_PlayerModule §17)
private int _comboIndex;
public override void OnStateEnter()
{
_comboIndex = 0;
PlayAttackClip();
_player.Input.AttackEvent += OnAttackInput; // 监听连击输入
}
private void PlayAttackClip()
{
var clip = _player.AnimConfig.GroundAttacks[_comboIndex]; // ⚠️ 字段名 GroundAttacks(架构 05_PlayerModule §17)
var state = _player.Animancer.Play(clip);
state.Events.OnEnd = OnClipEnd;
// HitBox 激活由 Animancer 帧事件驱动(架构 05_PlayerModule §5)
state.Events.SetCallback(0.3f, () => _player.Combat.EnableWeaponHitBox(AttackDirection.Ground));
state.Events.SetCallback(0.6f, () => _player.Combat.DisableAllWeaponHitBoxes());
}
private void OnClipEnd()
{
_player.Input.AttackEvent -= OnAttackInput;
_player.Combat.DisableAllWeaponHitBoxes(); // 确保关闭
_player.TryTransitionState(_player.IdleState);
}
// 连击窗口内再次按攻击键 → 进入下一段
private void OnAttackInput()
{
if (_comboIndex < 2) { _comboIndex++; PlayAttackClip(); }
}
}
验证点:3 段连击动画顺序播放无帧跳,最终段结束后正确回到 Idle。PlayerCombat HitBox 在正确帧激活/关闭。
3.5 EnemyBase + EnemyStats + EnemyNavAgent
文件:
Assets/Scripts/Enemies/EnemyBase.csAssets/Scripts/Enemies/EnemyStats.csAssets/Scripts/Enemies/Navigation/EnemyNavAgent.cs
Phase 1 实现范围:
| 组件 | Phase 1 实现 |
|---|---|
EnemyBase |
Awake、TakeDamage、Die(播放动画+延迟销毁) |
EnemyStats |
MaxHP/CurrentHP、TakeDamage、DistanceToPlayer(每帧更新) |
EnemyNavAgent |
SetDestination、Stop、PathBerserker2d NavAgent 封装 |
PathBerserker2d 集成要点:
- 在测试房间 Tilemap 上烘焙
NavSurface2D EnemyNavAgent持有PathBerserker2d.NavAgent组件,SetDestination直接调用其 API- 验证点:敌人能在平台间寻路,不陷入地面,停止时无滑动
3.6 Behavior Designer 基础行为树
文件:Assets/Scripts/Enemies/AI/BasicEnemyBT.cs(BD 外部行为树,非 MonoBehaviour 直接编写)
Phase 1 行为树结构(BD Editor 中组装):
Selector
├── Sequence (Chase & Attack)
│ ├── Conditional: IsPlayerInAttackRange(1.5f)
│ └── Action: MeleeAttack → BeginAttack(AttackType.Melee)
│
├── Sequence (Chase)
│ ├── Conditional: IsPlayerVisible
│ └── Action: MoveToPlayer → MoveTo(playerPos)
│
└── Action: Patrol → MoveInDirection(patrolDir) + FlipOnBoundary
BD + Animancer 冲突验证:BD 调用 EnemyBase.BeginAttack → Animancer 切换攻击动画,确认无 MissingAnimancerComponent 异常。
4. Week 3:存档完善 + 死亡复活流 + UI
参考文档:12_SaveModule.md、03_CoreModule.md §4-6、10_UIModule.md
4.1 SaveManager 完整实现
Phase 0 仅实现骨架,Week 3 补全:
// 补充实现(⚠️ 返回类型为 Task,与架构 12_SaveModule §4 一致;非 UniTask)
public async Task SaveAsync(int slot = -1)
{
// 1. 遍历 _saveables 列表,调用 s.OnSave(_current) 收集数据
// (直接迭代,非事件广播)
// 2. LocalFileStorage.WriteAsync(slot, json)
// (⚠️ 类名为 LocalFileStorage,非 LocalFileSaveStorage)
// 3. 计算 Checksum、更新 LastCheckpointScene / LastCheckpointSpawnId
}
public async Task<bool> LoadAsync(int slot)
{
// 1. LocalFileStorage.ReadAsync(slot) → 反序列化 SaveData
// 2. ValidateChecksum → 失败返回 false
// 3. SaveMigrator.Migrate(data)
// 4. 遍历 _saveables 列表,调用 s.OnLoad(_current) 恢复状态
}
ISaveable 接口:
// Assets/Scripts/Core/Save/ISaveable.cs
// ⚠️ 方法名必须与架构 12_SaveModule §3 完全一致
public interface ISaveable
{
void OnSave(SaveData data); // 收集并填充对应字段(存档时调用)
void OnLoad(SaveData data); // 从存档数据恢复自身状态(读档时调用)
}
PlayerController 实现 ISaveable:
OnSave:填充data.Player.PosX/PosY/CurrentHP/CurrentSpringOnLoad:设置 Position、HP、Spring
4.2 SavePoint
文件:Assets/Scripts/World/SavePoint.cs
// ⚠️ Interact 参数为 Transform(架构 14_NarrativeModule §1 IInteractable 定义)
public class SavePoint : MonoBehaviour, IInteractable
{
[SerializeField] private string _savePointId; // 全局唯一 ID
[SerializeField] private bool _restoreSpring = true;
[SerializeField] private StringEventChannelSO _onSavePointActivated;
[SerializeField] private VoidEventChannelSO _onFastTravelOpen; // 快速旅行 UI
private bool _isActivated = false;
public bool CanInteract => true;
public string InteractPrompt => _isActivated ? "休息" : "激活";
public void Interact(Transform player) // 参数为 Transform(架构 14_NarrativeModule §1 / 08_WorldModule §7 IInteractable 定义)
{
// ⚠️ PlayerController 无 Instance(Architecture 05 §2);通过 player 参数获取
var pc = player.GetComponent<PlayerController>();
_isActivated = true;
if (_restoreSpring && pc != null) pc.Stats.RestoreSpringCharges();
_onSavePointActivated.Raise(_savePointId); // → GameManager 调用 SaveManager.SaveAsync
_onFastTravelOpen.Raise();
PlayActivateAnimation();
}
// ISaveable.OnLoad:从 WorldSaveData.ActivatedSavePoints 恢复激活状态
public void OnSave(SaveData data) { if (_isActivated) data.World.ActivatedSavePoints.Add(_savePointId); }
public void OnLoad(SaveData data) { _isActivated = data.World.ActivatedSavePoints.Contains(_savePointId); }
public void OnPlayerEnterRange(Transform player) { }
public void OnPlayerExitRange() { }
}
4.3 EmergencySaveService
// Assets/Scripts/Core/Save/EmergencySaveService.cs
// 定期自动保存到独立紧急存档槽(slot 99),用于崩溃/异常退出后恢复进度
// ⚠️ 与架构 12_SaveModule §9 完全对应;非"死亡时保存"机制
public class EmergencySaveService : MonoBehaviour
{
private const int EmergencySlot = 99; // 独立槽,不显示在存档选择界面
[SerializeField] private float _intervalSeconds = 120f; // 每 2 分钟
[SerializeField] private SaveManager _saveManager;
[SerializeField] private BoolEventChannelSO _onGameplayActive; // 仅 Gameplay 状态下保存
private bool _gameplayActive;
private float _timer;
private void OnEnable() => _onGameplayActive.OnEventRaised += v => _gameplayActive = v;
private void OnDisable() => _onGameplayActive.OnEventRaised -= v => _gameplayActive = v;
private void Update()
{
if (!_gameplayActive) return;
_timer += Time.deltaTime;
if (_timer >= _intervalSeconds)
{
_timer = 0f;
_ = _saveManager.SaveAsync(EmergencySlot); // fire-and-forget
}
}
// 判断是否存在未读的紧急存档(启动时由 MainMenuController 检查)
public bool HasEmergencySave() => _saveManager.SlotExists(EmergencySlot);
// 将紧急存档提升为指定主存档槽(玩家确认恢复后调用)
public async Task PromoteToSlot(int targetSlot)
{
// 读取 slot 99 → 写入 targetSlot → 删除 slot 99
// 由 LocalFileStorage(ISaveStorage 实现)执行
}
}
4.4 GameManager 死亡/复活完整流
补充 03_CoreModule.md §8 中定义的死亡/复活流程:
// ⚠️ Architecture 03 §8:HandlePlayerDied() 在协程启动前先调用 TransitionTo(GameState.Dead)
// private void HandlePlayerDied()
// {
// TransitionTo(GameState.Dead); ← 协程外,HandlePlayerDied() 中直接调用
// StartCoroutine(DeathSequenceCoroutine());
// }
private IEnumerator DeathSequenceCoroutine()
{
// 1. 等待死亡动画完成(约 1.5s)
yield return new WaitForSeconds(1.5f);
// 2. DeathScreenController 已通过 EVT_PlayerDied 订阅并延迟 1.5s 自动显示;GameManager 无需直接调用 UI
// ⚠️ 不调用 UIManager.ShowPanel("DeathScreen"):Architecture 10 §2 UIManager 无该方法(API 为 OpenPanel(GameObject))
// 3. 等待玩家按下"重试"(监听 EVT_DeathScreenConfirmed)
}
private IEnumerator RespawnCoroutine()
{
// 1. TransitionTo(GameState.LoadingScene)
// 2. _sceneLoader.RequestLoad(new SceneLoadRequest
// {
// SceneName = _currentSceneName,
// EntryTransitionId = _lastSavePointId,
// ShowLoadingScreen = false,
// IsRespawn = true
// });
// → SceneLoader 内部依次:淡出 → 卸载 → 加载 → SaveManager 恢复 → 淡入
// ⚠️ GameManager 不额外调用 SaveManager.LoadAsync(),恢复由 SceneLoader 流程内部处理
// 3. TransitionTo(GameState.Gameplay)
// 4. _onPlayerRespawned.Raise()
yield break;
}
4.5 HUDController(Phase 1 最小集)+ DeathScreenController
文件:
Assets/Scripts/UI/UIManager.cs(Phase 1 仅占位;完整实现见 Plan 05 §4.1)Assets/Scripts/UI/HUD/HUDController.cs⚠️ 路径/类名以 Architecture 10 §3 为准(非 HUDPanel / UI/Panels/)Assets/Scripts/UI/Menus/DeathScreenController.cs⚠️ 路径/类名以 Architecture 10 §6 为准(非 DeathPanel / UI/Panels/)
Phase 1 HUDController 最小集(完整字段见 Architecture 10 §3):
// Assets/Scripts/UI/HUD/HUDController.cs
// Phase 1 仅实现 HP + Soul;其余字段 Phase 2+ 填充
public class HUDController : MonoBehaviour
{
[Header("HP")]
[SerializeField] private Transform _hpContainer;
[SerializeField] private GameObject _hpCellPrefab;
[Header("Gauges")]
[SerializeField] private Image _soulGaugeFill;
[SerializeField] private TMP_Text _geoText;
[Header("Event Channels - Subscribe")]
[SerializeField] private IntEventChannelSO _onHPChanged;
[SerializeField] private IntEventChannelSO _onMaxHPChanged;
[SerializeField] private IntEventChannelSO _onSoulPowerChanged;
[SerializeField] private IntEventChannelSO _onGeoChanged;
private void OnEnable()
{
_onHPChanged.OnEventRaised += UpdateHP;
_onMaxHPChanged.OnEventRaised += RebuildHPCells;
_onSoulPowerChanged.OnEventRaised += val => _soulGaugeFill.fillAmount = val / 100f;
_onGeoChanged.OnEventRaised += val => _geoText.text = val.ToString();
}
private void OnDisable()
{
_onHPChanged.OnEventRaised -= UpdateHP;
_onMaxHPChanged.OnEventRaised -= RebuildHPCells;
_onSoulPowerChanged.OnEventRaised -= val => _soulGaugeFill.fillAmount = val / 100f;
_onGeoChanged.OnEventRaised -= val => _geoText.text = val.ToString();
}
private void UpdateHP(int current); // HP 格子激活/灰化
private void RebuildHPCells(int max); // MaxHP 改变时重建格子列表
}
DeathScreenController(完整见 Architecture 10 §6):
- 文件:
Assets/Scripts/UI/Menus/DeathScreenController.cs - 订阅
EVT_PlayerDied,WaitForSeconds(1.5f)后调用Show() - "继续"按钮点击后调用
_onDeathScreenConfirmed.Raise()
5. Week 4:VFX/音效 + 集成验证
参考文档:18_VFXFeedbackModule.md
5.1 VFXPool
// Assets/Scripts/VFX/VFXPool.cs
// ❗ API 以架构 18_VFXFeedbackModule §6 为准,不使用 SpawnAt
public class VFXPool : MonoBehaviour
{
public static VFXPool Instance { get; private set; }
// 在世界坐标播放一次特效。Fire-and-forget(UniTask 自动回池)
// ❗ vfxRef 类型为 AssetReferenceGameObject(非 string key)
public async UniTaskVoid Play(AssetReferenceGameObject vfxRef,
Vector3 position,
Quaternion rotation = default);
// 预热:预先创建若干实例避免首次播放媁顿
public async UniTask WarmupAsync(AssetReferenceGameObject vfxRef, int count);
}
5.2 VFXCatalogSO
// ❗ 结构名、字段名、数组名以架构 18_VFXFeedbackModule §7 为准
[CreateAssetMenu(menuName = "VFX/VFXCatalog")]
public class VFXCatalogSO : ScriptableObject
{
[Header("命中特效映射")]
public VFXEntry[] hitEffects; // ❗ 数组名 hitEffects(非 Entries);HitFxType → VFX Prefab
[Header("预热配置")]
public VFXWarmupEntry[] warmups; // ❗ 预热配置数组(不可缺少)
// ❗ 方法名为 TryGetHitFX(非 GetKey);返回 bool + out(架构 18_VFXFeedbackModule §7)
public bool TryGetHitFX(HitFxType type, out AssetReferenceGameObject vfxRef);
}
[Serializable]
public struct VFXEntry
{
public HitFxType type; // ❗ 字段名 type(非 FxType),小写开头
public AssetReferenceGameObject vfxRef; // ❗ 字段名 vfxRef(非 VfxRef),camelCase
}
[Serializable]
public struct VFXWarmupEntry
{
public AssetReferenceGameObject vfxRef;
[Min(1)] public int warmupCount; // 建议 3~5
}
Phase 1 需要的 VFX 资产(最小集):
VFX_HitSlashLight:轻攻击命中VFX_HitSlashHeavy:重攻击命中VFX_PlayerDust:落地/跑步尘土VFX_EnemyDie:敌人死亡
5.3 HitFXSpawner
// Assets/Scripts/VFX/HitFXSpawner.cs
// 挂在 Persistent 场景,监听 EVT_HitConfirmed 事件频道,驱动 VFXPool
// ❗ 完整实现以架构 18_VFXFeedbackModule §8 为准
public class HitFXSpawner : MonoBehaviour
{
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed;
[SerializeField] private VFXCatalogSO _catalog; // ❗ 字段为 _catalog(非 _vfxPool)
private void OnEnable() => _onHitConfirmed.OnEventRaised += HandleHit;
private void OnDisable() => _onHitConfirmed.OnEventRaised -= HandleHit;
private void HandleHit(HitInfo info) // ❗ 参数类型 HitInfo(非 HitEventData)
{
// ⚠️ DamageInfo 中字段名为 FxType(架构 06_CombatModule §1),非 HitFxType
if (_catalog.TryGetHitFX(info.DamageInfo.FxType, out var vfxRef))
VFXPool.Instance.Play(vfxRef, info.HitPoint).Forget();
}
}
❗ HitInfo(DamageInfo + HitPoint)和 HitConfirmedEventChannelSO 均已在 Day 2 §3.1 创建;VFXCatalogSO 中的 Entry.VfxRef 为 AssetReferenceGameObject。DamageInfo.FxType 为字段名(HitFxType 为类型名,字段名是 FxType,架构 06_CombatModule §1)。
5.4 HurtFlashController
// Assets/Scripts/VFX/HurtFlashController.cs
// 挂在 Player / Enemy SpriteRenderer 同一 GameObject 上
// ❗ 字段与架构 18_VFXFeedbackModule §9 对齐:单个 _renderer + FeedbackConfigSO _config(非数组+裸float)
// ❗ Flash() 为 async UniTaskVoid(非 Coroutine),使用 UniTask.Delay(架构 18 §9)
public class HurtFlashController : MonoBehaviour
{
[SerializeField] SpriteRenderer _renderer; // ❗ 单个(非 SpriteRenderer[] _renderers)
[SerializeField] FeedbackConfigSO _config; // ❗ 时长/颜色来自 FeedbackConfigSO(非 float _flashDuration)
static readonly int FlashColorID = Shader.PropertyToID("_FlashColor"); // ❗ 颜色属性(不可缺少)
static readonly int FlashAmountID = Shader.PropertyToID("_FlashAmount"); // 混合强度
MaterialPropertyBlock _block;
void Awake() => _block = new MaterialPropertyBlock();
/// <summary>
/// 触发一次受击白闪;由 IFeedbackPlayer.PlayTakeHit 调用。
/// ⚠️ 返回 async UniTaskVoid(非 IEnumerator Coroutine),使用 UniTask.Delay(架构 18_VFXFeedbackModule §9)
/// </summary>
public async UniTaskVoid Flash(CancellationToken ct = default)
{
_renderer.GetPropertyBlock(_block);
_block.SetColor(FlashColorID, _config.HurtFlashColor);
_block.SetFloat(FlashAmountID, 1f);
_renderer.SetPropertyBlock(_block);
await UniTask.Delay(
TimeSpan.FromSeconds(_config.HurtFlashDuration),
cancellationToken: ct);
_block.SetFloat(FlashAmountID, 0f);
_renderer.SetPropertyBlock(_block);
}
// Shader 需支持 _FlashColor(Color)和 _FlashAmount(float 0~1)(URP Sprite-Lit-Flash 变体)
}
5.5 AudioManager + BGMController + AudioZone
参考文档:11_AudioModule.md
⚠️ 架构说明:架构使用 Unity AudioMixer + 双 AudioSource 交叉淡入,无 FMOD 依赖。Phase 2 无需"切换后端"。
文件:
Assets/Scripts/Audio/AudioManager.csAssets/Scripts/Audio/BGMController.csAssets/Scripts/Audio/AudioZone.csAssets/Scripts/Audio/AudioMixerKeys.csAssets/Scripts/Audio/AudioConfigSO.cs
// Assets/Scripts/Audio/AudioMixerKeys.cs
public static class AudioMixerKeys
{
public const string Master = "MasterVolume";
public const string BGM = "BGMVolume";
public const string SFX = "SFXVolume";
public const string Ambient = "AmbientVolume";
}
// Assets/Scripts/Audio/AudioManager.cs
[DefaultExecutionOrder(-500)]
public class AudioManager : MonoBehaviour
{
[Header("AudioMixer")]
[SerializeField] private AudioMixer _mixer;
[Header("BGM Sources(双 Source 交叉淡入淡出)")]
[SerializeField] private AudioSource _bgmSourceA;
[SerializeField] private AudioSource _bgmSourceB;
[Header("SFX")]
[SerializeField] private AudioSource _globalSFXSource;
[Header("Event Channels - Subscribe")]
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
[SerializeField] private StringEventChannelSO _onBossFightStarted; // ⚠️ bossId: string(架构 11 §2)
// 私有状态(双 Source 交叉淡入淡出)
private AudioSource _activeBGMSource;
private AudioSource _inactiveBGMSource;
private Coroutine _crossfadeCoroutine;
// BGM 播放(独立淡出/淡入时长)
public void PlayBGM(AudioClip clip, float fadeOutDur = 1f, float fadeInDur = 1f); // ⚠️ 两个独立参数(架构 11 §2)
public void StopBGM(float fadeDuration = 1f);
// SFX 一次性播放
public void PlaySFX(AudioClip clip, float volumeScale = 1f);
public void PlaySFXAtPosition(AudioClip clip, Vector2 pos, float volumeScale = 1f); // 空间音效
// 音量设置(SettingsManager 调用)
public void Initialize(); // 读取 GlobalSettings 并应用音量
/// <summary>唯一音量写入入口。exposedParam = AudioMixerKeys.* 常量。</summary>
public void SetVolume(string exposedParam, float linear)
=> _mixer.SetFloat(exposedParam, LinearToDecibel(linear));
// 快照切换(Pause/BossFight 状态)
public void TransitionToSnapshot(string snapshotName, float transitionTime = 0.5f); // ⚠️ TransitionToSnapshot(架构 11 §2)
private IEnumerator CrossfadeCoroutine(AudioClip newClip, float fadeOutDur, float fadeInDur); // ⚠️ 两个 float 参数
private static float LinearToDecibel(float linear)
=> linear > 0.0001f ? 20f * Mathf.Log10(linear) : -80f;
}
// BGMController.cs — 订阅 Boss/Zone 事件,驱动 AudioManager 切换 BGM
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=结束(架构 11 §3)
[SerializeField] private StringEventChannelSO _onRegionEntered; // ⚠️ _onRegionEntered(非 _onAudioZoneEntered)
private MusicState _musicState = MusicState.Exploration;
private string _currentRegion = "Forest";
private void OnEnable()
{
_onBossFightToggled.OnEventRaised += OnBossFightToggled;
_onRegionEntered.OnEventRaised += OnRegionEntered;
_onGameStateChanged.OnEventRaised += HandleStateChanged;
}
private void OnDisable()
{
_onBossFightToggled.OnEventRaised -= OnBossFightToggled;
_onRegionEntered.OnEventRaised -= OnRegionEntered;
_onGameStateChanged.OnEventRaised -= HandleStateChanged;
}
private void OnBossFightToggled(bool started) // ⚠️ OnBossFightToggled,非 StartBossBGM/EndBossBGM 两个方法
{
if (started)
{
_musicState = MusicState.Boss;
var clip = _config.GetBossBGM(_currentRegion);
_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);
yield return new WaitForSecondsRealtime(_config.VictoryStingDuration);
_musicState = MusicState.Exploration;
OnRegionEntered(_currentRegion);
_audioManager.TransitionToSnapshot("Default", 1.0f);
}
private void OnRegionEntered(string regionId) // ⚠️ OnRegionEntered,非 SwitchZoneBGM
{
if (regionId == _currentRegion) return;
_currentRegion = regionId;
if (_musicState == MusicState.Exploration)
{
var clip = _config.GetZoneBGM(regionId);
_audioManager.PlayBGM(clip, fadeOutDur: 1f, fadeInDur: 1f);
}
}
private void HandleStateChanged(GameStateId state)
{
// ⚠️ GameStateId 是 struct,不能用 switch;使用 if/else + GameStates 常量(架构 03_CoreModule §2)
if (state == GameStates.MainMenu)
_audioManager.PlayBGM(_config.MainMenuBGM, fadeOutDur: 0.5f, fadeInDur: 1.0f); // ⚠️ 架构 11 §3
else if (state == GameStates.Paused)
_audioManager.TransitionToSnapshot("Paused", 0.2f);
else if (state == GameStates.Dead)
_audioManager.TransitionToSnapshot("Dead", 1.5f); // ⚠️ 1.5f(架构 11 §3),非 0.5f
else if (state == GameStates.Gameplay)
_audioManager.TransitionToSnapshot("Default", 0.3f); // ⚠️ 0.3f(架构 11 §3),非 0.2f
}
}
Phase 1 资产:Assets/Audio/MainMixer.mixer(Master/BGM/SFX/Ambient 四组 + Default/Paused/Dead/BossFight 快照)
5.6 IFeedbackPlayer + PlayerFeedback + EnemyFeedback
参考文档:18_VFXFeedbackModule.md §2–§4
// Assets/Scripts/Feedback/IFeedbackPlayer.cs
// ⚠️ Phase 1 必须定义此接口;GameLogic 依赖接口不依赖 Feel
public interface IFeedbackPlayer
{
void PlayHit(HitWeight weight); // 命中反馈(Light/Medium/Heavy)
void PlayParrySuccess(); // 弹反成功
void PlayTakeHit(); // 受击
void PlayDeath(); // 死亡演出
void PlayHeal(); // 治疗
void PlayLandImpact(); // 落地冲击
void PlayAttackWhoosh(); // 攻击挥动音效
void PlayJumpLaunch(); // 起跳
void TriggerPreset(string presetId); // 通过 ID 触发预设(AnimEvent 用)
void PlaySFXById(string sfxId); // 通过 ID 播放音效(AnimEvent 用)
}
public enum HitWeight { Light, Medium, Heavy }
/// <summary>空实现,用于测试和无反馈需求场景。</summary>
public class NullFeedbackPlayer : IFeedbackPlayer { /* 全部空实现 */ }
// PlayerFeedback 聚合以下 MMF_Player 实例(Inspector 连线):
// ⚠️ 字段名以架构 18_VFXFeedbackModule §3 为准(_on* 前缀)
// ├── _onHitLight → MMF_Player(CameraShake Light + HitStop 0.05s)
// ├── _onHitMedium → MMF_Player(CameraShake Medium)
// ├── _onHitHeavy → MMF_Player(CameraShake Heavy + HitStop 0.1s)
// ├── _onParrySuccess → MMF_Player(弹反成功)
// ├── _onTakeHit → MMF_Player(HurtFlash + Haptic Light)
// ├── _onDeath → MMF_Player(SlowMotion 0.3s + SFX)
// ├── _onHeal → MMF_Player(ParticleSystem spawn + SFX)
// ├── _onLandImpact → MMF_Player(落地震动)
// ├── _onAttackWhoosh → MMF_Player(攻击挥动音效)
// └── _onJumpLaunch → MMF_Player(起跳)
Phase 1 最小实现:仅连线 _onHitLight、_onHitHeavy、_onTakeHit、_onDeath、_onHeal,其余 Phase 2 补全。
Feel 集成验证:命中敌人时屏幕轻震 + 0.05s 冻帧;玩家受击时白闪 + 手柄震动(若有)。
6. 第三方集成检查点
所有检查点必须在 Phase 1 结束前通过:
| 库 | 检查项 | 通过标准 |
|---|---|---|
| Animancer Pro | 玩家 FSM 状态切换(Idle/Run/Jump/Attack) | 无 MissingReferenceException;动画无帧跳 |
| Animancer Pro | Animancer + BD 同时运行(敌人既用 Animancer 又有 BD) | 无竞争报错,敌人攻击动画正常播放 |
| PathBerserker2d | 敌人在 NavSurface2D 上寻路 | 能到达玩家附近;无陷入地面;停止时无滑动 |
| Behavior Designer | 基础 BT(Patrol→Chase→Attack)运行 | 状态切换符合预期;BT 条件读取 EnemyStats 数据正确 |
| Cinemachine 3 | 双机 A/B 切换 + Confiner2D | 无相机跳变;切换时有混合过渡 |
| PixelPerfect Camera | 角色移动时像素对齐 | 无亚像素抖动(放大观察精灵边缘) |
| Addressables | 场景加载/卸载 + 资产加载/释放 | 无内存泄漏(Profiler 观察 GC Alloc);场景切换时 AssetReleaseTracker 自动清理旧场景对象池 |
| Feel / MMF_Player | 命中冻帧 + 相机震动 | Time.timeScale 短暂降低;相机有位移 |
| NewtonSoft.Json | SaveData 序列化/反序列化 | 存档文件内容字段完整;加载后数据与保存前一致 |
7. 完成标准检查清单
□ 玩家移动/跳跃/攻击无 Animancer 卡顿(测试:快速连续输入10次)
□ 3 段连击动画顺序播放,最终段后回到 Idle
□ 敌人在 NavSurface2D 上寻路到玩家位置(直线距离和跨平台)
□ BD 行为树 Patrol→Chase→Attack 切换正确
□ 玩家攻击命中敌人:HP 减少 + HitFX 播放 + Feel 冻帧 0.05s
□ 敌人攻击命中玩家:HP 减少 + HurtFlash + HUD HP Bar 更新
□ 玩家 HP ≤ 0 → 死亡动画 → DeathScreenController 显示(⚠️ 非 DeathPanel,架构 10 §6)
□ 按确认键 → FadeOut → LoadAsync 恢复存档 → FadeIn → 玩家在 SavePoint 位置复活
□ 相机跟随玩家,像素对齐无抖动
□ 相机进入触发区域后切换到目标 Confiner 边界(测试用两个 Confiner 区域)
□ SavePoint 激活 → 自动存档 → 关闭 Play Mode → 重开 → 数据与存档一致
□ Addressables 场景加载/卸载后 Profiler 无持续增长内存
□ Console 无 Error(Warning 可记录 TODO 但不阻塞)
Phase 1 完成后进入 Phase 2。