摄像机区域的架构改动

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

View File

@@ -0,0 +1,56 @@
using System;
namespace BaseGames.Player
{
/// <summary>
/// 玩家能力解锁标志位枚举([Flags] uint
/// 每个 bit 对应一项可解锁能力,支持组合查询。
/// 对应 Progression 模块中 AbilityManager 的解锁状态。
/// </summary>
[Flags]
public enum AbilityType : uint
{
None = 0,
// ── 移动能力 ──────────────────────────────────────────────────────
WallCling = 1u << 0, // 贴墙悬挂
WallJump = 1u << 1, // 墙跳
Dash = 1u << 2, // 地面冲刺
AirDash = 1u << 3, // 空中冲刺(二段冲刺)
DoubleJump = 1u << 4, // 二段跳
SuperJump = 1u << 5, // 超级跳(聚气跳)
Swim = 1u << 6, // 游泳(液体中自由移动)
Dive = 1u << 7, // 下劈(空中下突)
// ── 法术能力 ──────────────────────────────────────────────────────
Spell1 = 1u << 8, // 法术槽 1策划自定义
Spell2 = 1u << 9, // 法术槽 2
Spell3 = 1u << 10, // 法术槽 3
// ── 灵魄形态(泽灵特有)──────────────────────────────────────────
SpiritForm = 1u << 11, // 灵魄形态切换
SpiritDash = 1u << 12, // 灵魄冲刺(穿透地形)
// ── 战斗能力 ──────────────────────────────────────────────────────
Parry = 1u << 13, // 格挡/弹反
ChargeAttack = 1u << 14, // 蓄力攻击
DownSlash = 1u << 15, // 下斩
// ── 互动能力 ──────────────────────────────────────────────────────
Interact = 1u << 16, // 互动NPC/机关)
FastTravel = 1u << 17, // 快速旅行解锁
// ── 能力强化 ──────────────────────────────────────────────────────────
/// <summary>
/// 无敌冲刺强化(类比空洞骑士暗影披风)。
/// 仅持有 Dash 时:冲刺无无敌帧。
/// 解锁 InvincibleDash 后:冲刺期间完全无敌(地面 DashState + 空中 AerialDashState
/// </summary>
InvincibleDash = 1u << 18,
// ── 组合掩码 ─────────────────────────────────────────────────────────
AllMovement = WallCling | WallJump | Dash | AirDash | DoubleJump | SuperJump | Swim | Dive | InvincibleDash,
AllSpells = Spell1 | Spell2 | Spell3,
AllSpirit = SpiritForm | SpiritDash,
}
}

View File

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

View File

@@ -0,0 +1,15 @@
using BaseGames.Core.Events;
using UnityEngine;
namespace BaseGames.Player
{
/// <summary>
/// 能力解锁事件频道(内部类型保留)。
/// ⚠️ EVT_AbilityUnlocked 资产实际使用 StringEventChannelSOabilityId 字符串),
/// 本频道供需要类型安全枚举的内部系统(如 FormController使用。
/// 发布AbilityManager.UnlockAbility()
/// 订阅PlayerController刷新可用动作集、HUDController更新技能面板
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Events/Player/AbilityType")]
public class AbilityTypeEventChannelSO : BaseEventChannelSO<AbilityType> { }
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,34 @@
using UnityEngine;
namespace BaseGames.Player
{
/// <summary>
/// 形态切换配置 ScriptableObject。
/// 单一资产forms[0..2] = Sky / Earth / Death架构 05 §18
/// ⚠️ 只有 1 个 FormConfigSO 资产FormSO × 3 存于 forms[] 数组中,不分开建立。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Player/FormConfig")]
public class FormConfigSO : ScriptableObject
{
[Header("形态列表 (forms[0]=Sky, forms[1]=Earth, forms[2]=Death)")]
public FormSO[] forms;
/// <summary>按形态类型查找对应 FormSO找不到返回 null。</summary>
public FormSO GetFormByType(FormType type)
{
if (forms == null) return null;
foreach (var f in forms)
if (f != null && f.formType == type) return f;
return null;
}
/// <summary>返回指定 FormSO 在 forms 数组中的索引;找不到返回 -1。</summary>
public int GetFormIndex(FormSO form)
{
if (forms == null || form == null) return -1;
for (int i = 0; i < forms.Length; i++)
if (forms[i] == form) return i;
return -1;
}
}
}

View File

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

View File

@@ -0,0 +1,71 @@
using System;
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Player
{
/// <summary>
/// 形态控制器。
/// 管理天魂/地魂/命魂三形态切换,依次触发:
/// 1. _onFormChanged SO 事件UI/Save 用)
/// 2. OnFormChanged C# 事件WeaponManager 订阅)
/// 3. _onSkillSetChanged SO 事件SkillHUD 刷新)
/// 架构 05_PlayerModule §6。
/// </summary>
public class FormController : MonoBehaviour
{
[Header("配置")]
[SerializeField] private FormConfigSO _config;
[Header("事件频道")]
[SerializeField] private IntEventChannelSO _onFormChanged; // 广播当前形态索引UI/Save
[SerializeField] private VoidEventChannelSO _onSkillSetChanged; // 通知 SkillHUD 刷新
// ── 运行时 ─────────────────────────────────────────────────────────────
public FormSO CurrentForm { get; private set; }
public FormSO[] AllForms => _config.forms;
/// <summary>C# 事件WeaponManager 在 OnEnable 自订阅(架构 05 §6。</summary>
public event Action OnFormChanged;
private void Awake()
{
Debug.Assert(_config != null, "[FormController] _config 未赋值,请在 Inspector 中指定 FormConfigSO。", this);
}
private void Start()
{
if (_config.forms != null && _config.forms.Length > 0)
CurrentForm = _config.forms[0];
}
// ── 公共 API ────────────────────────────────────────────────────────────
/// <summary>切换到指定形态类型。若已在目标形态则不操作。</summary>
public void SwitchForm(FormType newFormType)
{
FormSO newForm = _config.GetFormByType(newFormType);
if (newForm == null || newForm == CurrentForm) return;
CurrentForm = newForm;
// 1. SO 事件广播索引UI/Save
_onFormChanged?.Raise(_config.GetFormIndex(newForm));
// 2. C# 事件WeaponManager 等订阅者)
OnFormChanged?.Invoke();
// 3. SkillHUD 刷新事件
_onSkillSetChanged?.Raise();
}
/// <summary>通过数组索引切换形态。</summary>
public void SwitchToFormByIndex(int index)
{
if (_config?.forms == null || index < 0 || index >= _config.forms.Length) return;
var form = _config.forms[index];
if (form != null)
SwitchForm(form.formType);
}
}
}

View File

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

View File

@@ -0,0 +1,24 @@
using UnityEngine;
namespace BaseGames.Player
{
/// <summary>
/// 单一形态数据 ScriptableObject架构 05_PlayerModule §18
/// 存储于 FormConfigSO.forms[] 数组中,不单独作为 FormConfigSO 资产。
/// ⚠️ 补充 formType 字段(架构 05 §18 遗漏,以架构 18 §10 ApplyPalette(FormType) 为准)。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Player/Form")]
public class FormSO : ScriptableObject
{
[Header("基础信息")]
public string formId; // 全局唯一 ID如 "Form_Sky"
public string displayName; // 显示名,如 "天魂"
public FormType formType; // 对应枚举值,供 ApplyPalette / 条件判断使用
[Header("武器")]
public WeaponSO defaultWeapon; // 此形态的默认武器(护符可通过 Override 覆盖)
[Header("外观")]
public Color formAccentColor = Color.white; // 调色盘主色仅供参考VFX 以 formType 枚举为准)
}
}

View File

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

View File

@@ -0,0 +1,13 @@
namespace BaseGames.Player
{
/// <summary>
/// 玩家形态枚举。对应三形态切换系统(天魂/地魂/命魂)。
/// ApplyPalette(FormType) 以此为参数(架构 18_VFXFeedbackModule §10
/// </summary>
public enum FormType
{
TianHun = 0, // 天魂形态:裂空刃,高频轻击
DiHun = 1, // 地魂形态:地震锤,低频重击
MingHun = 2, // 命魂形态:命镰,穿透直线斩
}
}

View File

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

View File

@@ -0,0 +1,64 @@
using UnityEngine;
namespace BaseGames.Player
{
[CreateAssetMenu(menuName = "BaseGames/Player/AnimationConfig")]
public class PlayerAnimationConfigSO : ScriptableObject
{
[Header("移动")]
public AnimationClip Idle;
public AnimationClip Run;
public AnimationClip Jump;
public AnimationClip Fall;
public AnimationClip Dash;
[Header("墙")]
public AnimationClip WallSlide;
[Header("受伤 / 死亡")]
public AnimationClip Hurt;
public AnimationClip Dead;
[Tooltip("受击硬直最短持续时间(秒),动画早于此时间结束也不会提前退出)")]
public float HurtDuration = 0.4f;
[Header("弹簧")]
public AnimationClip UseSpring;
[Header("地面攻击(连招序列)")]
public AnimationClip[] GroundAttacks;
/// <summary>每段连击的 HitBox 开启/关闭时间点(归一化 0-1与 GroundAttacks 索引对应。</summary>
[System.Serializable]
public struct AttackTimings
{
[Tooltip("HitBox 开启时间点(归一化 0-1")]
[UnityEngine.Range(0f, 1f)] public float HitBoxEnter;
[Tooltip("HitBox 关闭时间点(归一化 0-1")]
[UnityEngine.Range(0f, 1f)] public float HitBoxExit;
}
[Header("地面攻击 HitBox 激活窗口(归一化时间 0-1")]
[Tooltip("每段连击 HitBox 开启/关闭时间点,与 GroundAttacks 索引对应")]
public AttackTimings[] GroundAttackTimings = { new AttackTimings { HitBoxEnter = 0.3f, HitBoxExit = 0.6f } };
[Header("空中攻击")]
public AnimationClip AirAttack;
public AnimationClip UpAttack;
public AnimationClip DownAttack; // 戳击 (Pogo)
[Header("弹反")]
public AnimationClip ParryStart;
public AnimationClip ParrySuccess;
[Header("游泳")]
public AnimationClip SwimIdle;
public AnimationClip SwimMove;
/// <summary>按连招步骤取地面攻击动画,越界自动取最后一个。</summary>
public AnimationClip GetAttackClip(int step)
{
if (GroundAttacks == null || GroundAttacks.Length == 0) return null;
return step < GroundAttacks.Length ? GroundAttacks[step] : GroundAttacks[^1];
}
}
}

View File

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

View File

@@ -0,0 +1,94 @@
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Player
{
/// <summary>
/// 玩家战斗组件。
/// HitBox 随装备武器动态实例化到 [WeaponSocket] 子节点,通过 WeaponManager.ActiveHitBoxInstance 访问。
/// </summary>
public class PlayerCombat : MonoBehaviour
{
[SerializeField] private WeaponManager _weaponManager;
private PlayerStats _stats;
private WeaponHitBoxInstance _currentHitBoxInstance;
/// <summary>下劈 HitBox 命中确认事件(供 DownAttackState 订阅 pogo 弹跳逻辑)。</summary>
public event System.Action<DamageInfo> OnDownHitConfirmed;
private void Awake()
{
_stats = GetComponentInParent<PlayerStats>();
}
private void OnEnable()
{
if (_weaponManager != null)
_weaponManager.OnWeaponChanged += HandleWeaponChanged;
}
private void OnDisable()
{
if (_weaponManager != null)
_weaponManager.OnWeaponChanged -= HandleWeaponChanged;
UnsubscribeDownHit();
}
private void HandleWeaponChanged(WeaponSO _)
{
UnsubscribeDownHit();
_currentHitBoxInstance = _weaponManager.ActiveHitBoxInstance;
if (_currentHitBoxInstance != null)
_currentHitBoxInstance.OnDownHitConfirmed += HandleDownHitConfirmed;
}
private void UnsubscribeDownHit()
{
if (_currentHitBoxInstance == null) return;
_currentHitBoxInstance.OnDownHitConfirmed -= HandleDownHitConfirmed;
_currentHitBoxInstance = null;
}
private void HandleDownHitConfirmed(DamageInfo info) => OnDownHitConfirmed?.Invoke(info);
// ── 连击段伤害来源切换 ────────────────────────────────────────────────
/// <summary>
/// 根据当前连招段切换 HitBox 的 DamageSource由 AttackState 在每段开始时调用)。
/// </summary>
public void SetComboSegmentSource(int comboIndex)
{
WeaponSO w = _weaponManager?.ActiveWeapon;
if (w == null) return;
DamageSourceSO src = comboIndex switch
{
0 => w.attack1Source,
1 => w.attack2Source,
2 => w.attack3Source,
_ => w.attack1Source,
};
_weaponManager.ActiveHitBoxInstance?.SetDamageSource(AttackDirection.Ground, src);
}
// ── HitBox 激活(由 State / AnimationEvent 调用)─────────────────────
public void EnableWeaponHitBox(AttackDirection dir)
{
var source = _weaponManager?.ActiveWeapon?.GetSourceByDir(dir);
_weaponManager?.ActiveHitBoxInstance?.Activate(dir, source, transform);
}
public void DisableWeaponHitBox(AttackDirection dir)
=> _weaponManager?.ActiveHitBoxInstance?.Deactivate(dir);
public void DisableAllWeaponHitBoxes()
=> _weaponManager?.ActiveHitBoxInstance?.DeactivateAll();
/// <summary>命中确认回调:增加灵力(由 HurtBox.ReceiveDamage 步骤 7 的 HitConfirmed 事件订阅)。</summary>
public void OnHitConfirmed(DamageInfo info)
{
int gain = _weaponManager?.ActiveWeapon?.soulPowerGain ?? 10;
_stats?.AddSoulPower(gain);
}
}
}

View File

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

View File

@@ -0,0 +1,271 @@
using UnityEngine;
namespace BaseGames.Player
{
/// <summary>
/// 玩家物理移动组件。封装 Rigidbody2D 操作,提供跑动、跳跃、击退等接口。
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public class PlayerMovement : MonoBehaviour
{
[Header("配置")]
[SerializeField] private PlayerMovementConfigSO _config;
[Header("地面检测")]
[SerializeField] private Transform _groundCheck;
[SerializeField] private Vector2 _groundCheckSize = new Vector2(0.8f, 0.05f);
[SerializeField] private LayerMask _groundLayer;
[Header("朝向")]
[SerializeField] private SpriteRenderer _spriteRenderer;
// ── 运行时状态 ────────────────────────────────────────────────────────
private Rigidbody2D _rb;
private float _coyoteTimer;
private bool _isGrounded;
private bool _isWallLeft;
private bool _isWallRight;
private bool _onOneWayPlatform;
private int _facingDirection = 1;
private bool _cancelWindowOpen;
private SurfaceType _currentSurface = SurfaceType.Ground;
private readonly Collider2D[] _groundBuffer = new Collider2D[4];
public bool IsGrounded => _isGrounded;
public bool HasCoyoteTime => _coyoteTimer > 0f;
public bool IsWallLeft => _isWallLeft;
public bool IsWallRight => _isWallRight;
public bool OnOneWayPlatform => _onOneWayPlatform;
public int FacingDirection => _facingDirection;
public Rigidbody2D Rb => _rb;
public bool CancelWindowOpen => _cancelWindowOpen;
public SurfaceType CurrentSurface => _currentSurface;
/// <summary>垂直速度大于零(处于上升弧段)。供 WallJumpState 判断起跳是否结束。</summary>
public bool IsRising => _rb.velocity.y > 0f;
private void Awake()
{
Debug.Assert(_config != null, "[PlayerMovement] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this);
_rb = GetComponent<Rigidbody2D>();
if (_spriteRenderer == null)
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
}
private void FixedUpdate()
{
CheckGrounded();
CheckWalls();
if (_isGrounded)
_coyoteTimer = _config.CoyoteTime;
else
_coyoteTimer = Mathf.Max(0f, _coyoteTimer - Time.fixedDeltaTime);
}
// ── 移动 ──────────────────────────────────────────────────────────────
public void Move(float speedX)
{
float target = speedX;
float current = _rb.velocity.x;
float accel = Mathf.Abs(speedX) > 0.01f ? _config.Acceleration : _config.Deceleration;
float newX = Mathf.MoveTowards(current, target, accel * Time.fixedDeltaTime);
_rb.velocity = new Vector2(newX, _rb.velocity.y);
}
// ── 跳跃 ──────────────────────────────────────────────────────────────
public void Jump(bool isVariable = true)
{
_rb.velocity = new Vector2(_rb.velocity.x, _config.JumpForce);
_coyoteTimer = 0f;
}
public void CutJump()
{
if (_rb.velocity.y > 0f)
_rb.velocity = new Vector2(_rb.velocity.x, _rb.velocity.y * _config.JumpCutMultiplier);
}
/// <summary>
/// 二段跳Monarch Wings 等效)。覆盖当前垂直速度为 DoubleJumpForce。
/// FallState / JumpState 在检测到 HasAbility(DoubleJump) && AirJumpsLeft > 0 时调用。
/// </summary>
public void DoubleJump()
{
_rb.velocity = new Vector2(_rb.velocity.x, _config.DoubleJumpForce);
_coyoteTimer = 0f;
}
// ── 重力 ──────────────────────────────────────────────────────────────
public void SetGravityScale(float scale) => _rb.gravityScale = scale;
// ── 击退 ──────────────────────────────────────────────────────────────
public void ApplyKnockback(Vector2 direction, float force)
=> _rb.velocity = direction.normalized * force;
// ── 速度控制 ──────────────────────────────────────────────────────────
public void ZeroVelocity() => _rb.velocity = Vector2.zero;
public void ZeroHorizontalVelocity() => _rb.velocity = new Vector2(0f, _rb.velocity.y);
// ── 朝向 ──────────────────────────────────────────────────────────────
public void UpdateFacing()
{
float vx = _rb.velocity.x;
if (Mathf.Abs(vx) < 0.1f) return;
int dir = vx > 0f ? 1 : -1;
if (dir == _facingDirection) return;
_facingDirection = dir;
if (_spriteRenderer != null)
_spriteRenderer.flipX = dir < 0;
}
// ── 取消窗口 ──────────────────────────────────────────────────────────
public void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open;
// ── 冲刺 ──────────────────────────────────────────────────────────────
/// <summary>
/// 施加冲刺速度DashState/AerialDashState 调用)。
/// direction 应为归一化水平向量±1, 0
/// 冲刺期间调用 SetGravityScale(0) 可关闭重力;结束后恢复默认重力。
/// </summary>
public void Dash(Vector2 direction, float speed)
{
_rb.velocity = new Vector2(direction.x * speed, 0f);
}
/// <summary>
/// 壁滑:将垂直速度限制为 -WallSlideSpeed向下缓慢滑动
/// WallSlideState.OnStateFixedUpdate 每帧调用。
/// </summary>
public void ApplyWallSlide()
{
float targetY = -_config.WallSlideSpeed;
if (_rb.velocity.y < targetY)
_rb.velocity = new Vector2(_rb.velocity.x, targetY);
}
/// <summary>
/// 蹬墙跳:对墙方向施加相反水平力 + 向上力。
/// wallDir = +1 (右墙) 或 -1 (左墙),跳跃方向与之相反。
/// </summary>
public void WallJump(int wallDir)
{
float forceX = -wallDir * _config.WallJumpForceX;
float forceY = _config.WallJumpForceY;
_rb.velocity = new Vector2(forceX, forceY);
_coyoteTimer = 0f;
}
/// <summary>单向平台穿透(输入下行 + 跳跃键时触发)。</summary>
public void DropThroughPlatform() { }
// ── Physics 检测 ──────────────────────────────────────────────────────
private void CheckGrounded()
{
bool wasGrounded = _isGrounded;
Vector2 origin = _groundCheck != null
? (Vector2)_groundCheck.position
: (Vector2)transform.position + Vector2.down * 0.5f;
_isGrounded = Physics2D.OverlapBoxNonAlloc(origin, _groundCheckSize, 0f, _groundBuffer, _groundLayer) > 0;
if (_isGrounded && !wasGrounded)
_coyoteTimer = _config.CoyoteTime;
}
private void CheckWalls()
{
float len = _config.WallRayLength;
float offY = _config.WallRayOffsetY;
Vector2 pos = (Vector2)transform.position + Vector2.up * offY;
_isWallLeft = Physics2D.Raycast(pos, Vector2.left, len, _groundLayer);
_isWallRight = Physics2D.Raycast(pos, Vector2.right, len, _groundLayer);
}
private void OnDrawGizmos()
{
#if UNITY_EDITOR
// ── 1. 角色物理轮廓(浅绿,始终可见)────────────────────────────
Gizmos.color = new Color(0.4f, 1f, 0.4f, 0.65f);
foreach (var col in GetComponents<Collider2D>())
{
if (col.isTrigger) continue;
BaseGames.Combat.HitBox.DrawCollider2DWire(col);
}
// ── 2. 朝向箭头(金黄)──────────────────────────────────────────
Vector3 center = transform.position;
Vector3 arrowEnd = center + new Vector3(_facingDirection * 0.55f, 0f, 0f);
DrawArrow2D(center, arrowEnd, new Color(1f, 0.85f, 0.1f, 0.95f));
// ── 3. 站立检测框(落地亮绿 / 未落地淡绿)──────────────────────
Vector2 gOrigin = _groundCheck != null
? (Vector2)_groundCheck.position
: (Vector2)transform.position + Vector2.down * 0.5f;
bool grounded = Application.isPlaying && _isGrounded;
Gizmos.color = grounded
? new Color(0.1f, 1f, 0.3f, 0.9f)
: new Color(0.4f, 0.85f, 0.4f, 0.35f);
BaseGames.Combat.HitBox.DrawWireRect2D(gOrigin, _groundCheckSize);
// ── 4. 靠墙检测射线(碰墙橙色 / 未碰淡黄,带端点圆点)─────────
if (_config == null) return;
float wLen = _config.WallRayLength;
float wOffY = _config.WallRayOffsetY;
Vector2 wallPos = (Vector2)transform.position + Vector2.up * wOffY;
bool lHit = Application.isPlaying && _isWallLeft;
bool rHit = Application.isPlaying && _isWallRight;
Gizmos.color = lHit ? new Color(1f, 0.45f, 0f, 1f) : new Color(1f, 0.85f, 0.3f, 0.4f);
Gizmos.DrawRay(wallPos, Vector2.left * wLen);
BaseGames.Combat.HitBox.DrawWireCircle2D((Vector3)(wallPos + Vector2.left * wLen), 0.04f, 8);
Gizmos.color = rHit ? new Color(1f, 0.45f, 0f, 1f) : new Color(1f, 0.85f, 0.3f, 0.4f);
Gizmos.DrawRay(wallPos, Vector2.right * wLen);
BaseGames.Combat.HitBox.DrawWireCircle2D((Vector3)(wallPos + Vector2.right * wLen), 0.04f, 8);
#endif
}
private void OnDrawGizmosSelected()
{
#if UNITY_EDITOR
// 运行时:用青色箭头显示速度向量(仅选中时,按比例缩放)
if (!Application.isPlaying || _rb == null) return;
Vector2 vel = _rb.velocity;
if (vel.sqrMagnitude < 0.01f) return;
DrawArrow2D(transform.position, transform.position + (Vector3)(vel * 0.12f),
new Color(0.2f, 0.9f, 1f, 0.9f), 0.1f);
#endif
}
// 在 Gizmos 空间绘制带箭头的 2D 有向线段
private static void DrawArrow2D(Vector3 from, Vector3 to, Color color, float headLen = 0.15f)
{
Vector3 dir = to - from;
if (dir.sqrMagnitude < 0.0001f) return;
dir = dir.normalized;
Gizmos.color = color;
Gizmos.DrawLine(from, to);
// 将 -dir 分别旋转 ±35° 得到箭头两翼
float cos = 0.8192f; // Mathf.Cos(35° in radians)
float sin = 0.5736f; // Mathf.Sin(35°)
float bx = -dir.x, by = -dir.y;
Vector3 wing1 = new Vector3(bx * cos - by * sin, bx * sin + by * cos, 0f) * headLen;
Vector3 wing2 = new Vector3(bx * cos + by * sin, -bx * sin + by * cos, 0f) * headLen;
Gizmos.DrawLine(to, to + wing1);
Gizmos.DrawLine(to, to + wing2);
}
}
/// <summary>当前所在地面类型(用于脚步声等反馈)。</summary>
public enum SurfaceType
{
Ground,
OneWayPlatform,
Slope,
Ice,
}
}

View File

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

View File

@@ -0,0 +1,62 @@
using UnityEngine;
namespace BaseGames.Player
{
[CreateAssetMenu(menuName = "BaseGames/Player/MovementConfig")]
public class PlayerMovementConfigSO : ScriptableObject
{
[Header("地面移动")]
public float RunSpeed = 7f;
public float Acceleration = 50f;
public float Deceleration = 80f;
[Header("跳跃(对齐空洞骑士手感)")]
[Tooltip("一段跳初速度。HK 约 18-20对应 ~4-5 格高度。")]
public float JumpForce = 19f;
[Tooltip("按住跳跃键可保持的郊狼时间。HK ~0.12s。")]
public float CoyoteTime = 0.12f;
[Tooltip("下落阶段额外重力倍率。HK ~3.5,使下落比上升更快、手感更紧实。")]
public float FallGravityMult = 3.5f;
[Tooltip("最大下落速度终端速度。HK ~22。")]
public float MaxFallSpeed = 22f;
[Tooltip("松开跳跃键时速度保留比例变高跳。HK ~0.45,越小跳跃越低。")]
[Range(0f, 1f)]
public float JumpCutMultiplier = 0.45f;
[Header("二段跳Monarch Wings 等效)")]
[Tooltip("二段跳初速度。设为与 JumpForce 相同可获得等高二段跳HK 风格)。")]
public float DoubleJumpForce = 19f;
[Header("冲刺(对齐空洞骑士 Mothwing Cloak 手感)")]
[Tooltip("冲刺速度(单位/秒。HK ~25在 0.35s 内约穿越 7-8 格。")]
public float DashSpeed = 25f;
[Tooltip("冲刺持续时长。HK ~0.35s。")]
public float DashDuration = 0.35f;
[Tooltip("冲刺冷却时长。HK ~0.6s,落地后才可再次冲刺。")]
public float DashCooldown = 0.6f;
[Tooltip("每次腾空可使用的最大空中冲刺次数。HK = 1Mothwing Cloak。")]
public int MaxAerialDashes = 1;
[Header("冲刺无敌帧(对齐空洞骑士:窗口 < 冲刺时长,且有独立 CD")]
[Tooltip("冲刺无敌窗口时长。仅为冲刺前段窗口结束后即使仍在冲刺中也可受伤被打断HK ~0.20s)。")]
public float DashInvincibilityDuration = 0.20f;
[Tooltip("无敌的独立冷却。CD 内再次冲刺不会获得无敌帧防止连冲变相持续无敌HK ~0.9s)。")]
public float DashInvincibilityCooldown = 0.9f;
[Header("蹬墙 / 壁滑")]
public float WallSlideSpeed = 2f;
public float WallJumpForceX = 12f;
public float WallJumpForceY = 16f;
public float WallRayLength = 0.55f;
public float WallRayOffsetY = 0.2f;
public float WallGrabMaxHeightGain = 0.5f;
public float WallGrabReleaseDelay = 0.08f;
public float WallJumpBackForceX = 14f;
public float WallJumpAwayForceX = 10f;
public float WallJumpAwayForceY = 18f;
public float WallJumpInputLockDuration = 0.15f;
[Header("重力")]
public float DefaultGravityScale = 3f;
}
}

View File

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

View File

@@ -0,0 +1,314 @@
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
namespace BaseGames.Player
{
/// <summary>
/// 玩家数值管理组件。负责 HP、灵魂、灵气、弹簧充能、LingZhu、能力解锁与存档读写。
/// 实现 <see cref="IRewardTarget"/> 供 RewardSO 使用,避免 Quest 程序集直接依赖 Player 程序集。
/// </summary>
public class PlayerStats : MonoBehaviour, ISaveable, BaseGames.Core.IRestoreOnSave, IRewardTarget
{
[Header("配置")]
[SerializeField] private PlayerStatsSO _config;
[Header("事件频道")]
[SerializeField] private IntEventChannelSO _onHPChanged;
[SerializeField] private IntEventChannelSO _onMaxHPChanged;
[SerializeField] private IntEventChannelSO _onSoulPowerChanged;
[SerializeField] private IntEventChannelSO _onSpiritPowerChanged;
[SerializeField] private IntEventChannelSO _onSpringChargesChanged;
[SerializeField] private IntEventChannelSO _onLingZhuChanged;
[SerializeField] private AbilityTypeEventChannelSO _onAbilityUnlocked;
[SerializeField] private DifficultyChangedEventChannel _onDifficultyChanged;
// ── 运行时数值 ─────────────────────────────────────────────────────────
public int CurrentHP { get; private set; }
public int MaxHP { get; private set; }
public int CurrentSoulPower { get; private set; }
public int MaxSoulPower { get; private set; }
public int CurrentSpiritPower { get; private set; }
public int MaxSpiritPower { get; private set; }
public int CurrentSpringCharges { get; private set; }
public int MaxSpringCharges { get; private set; }
public int SpringKillPoints { get; private set; }
public int CurrentLingZhu { get; private set; }
public bool IsInvincible => _invincibleTimer > 0f;
public bool IsAlive => CurrentHP > 0;
private float _invincibleTimer;
private float _spiritRegenTimer;
private AbilityType _unlockedAbilities = AbilityType.None;
private bool _isGodMode;
private readonly CompositeDisposable _subs = new();
// ── 护符属性修改器 ─────────────────────────────────────────────────────────
private readonly Dictionary<StatType, float> _flatModifiers = new();
private readonly Dictionary<StatType, float> _percentModifiers = new();
private float _animatorSpeedBonus = 0f;
private int _soulCostReduction = 0;
/// <summary>动画速度倍率AttackSpeedEffect 调节,初始 1.0)。</summary>
public float AnimatorSpeedMultiplier => 1f + _animatorSpeedBonus;
/// <summary>法术灵力消耗减免SoulSpellEffect 叠加)。</summary>
public int SoulCostReduction => _soulCostReduction;
private void Awake()
{
Debug.Assert(_config != null, "[PlayerStats] _config 未赋值,请在 Inspector 中指定 PlayerStatsSO。", this);
MaxHP = _config.MaxHP;
CurrentHP = MaxHP;
MaxSoulPower = _config.MaxSoulPower;
MaxSpiritPower = _config.MaxSpiritPower;
MaxSpringCharges = _config.MaxSpringCharges;
CurrentSpringCharges = MaxSpringCharges;
CurrentLingZhu = _config.InitialLingZhu;
}
private void OnEnable() => _onDifficultyChanged?.Subscribe(HandleDifficultyChanged).AddTo(_subs);
private void OnDisable() => _subs.Clear();
private void HandleDifficultyChanged(DifficultyLevel _)
{
var scaler = ServiceLocator.GetOrDefault<IDifficultyService>()?.CurrentScaler;
if (scaler == null) return;
// 按比例缩放当前 HP架构 19 §5难度切换时保持 HP 比例)
float hpRatio = MaxHP > 0 ? (float)CurrentHP / MaxHP : 1f;
MaxHP = Mathf.Max(1, Mathf.RoundToInt(_config.MaxHP * scaler.PlayerMaxHPMultiplier));
CurrentHP = Mathf.Clamp(Mathf.RoundToInt(MaxHP * hpRatio), IsAlive ? 1 : 0, MaxHP);
_onMaxHPChanged?.Raise(MaxHP);
_onHPChanged?.Raise(CurrentHP);
}
private void Update()
{
float dt = Time.deltaTime;
if (_invincibleTimer > 0f)
_invincibleTimer -= dt;
if (_config.SpiritRegenRate > 0)
{
_spiritRegenTimer += dt;
if (_spiritRegenTimer >= 1f)
{
_spiritRegenTimer -= 1f;
AddSpiritPower(_config.SpiritRegenRate);
}
}
}
// ── 护符修改器 API ─────────────────────────────────────────────────────
/// <summary>叠加属性修改器(护符效果装备时调用)。</summary>
public void AddModifier(StatType stat, float flat, float percent)
{
_flatModifiers[stat] = _flatModifiers.GetValueOrDefault(stat) + flat;
_percentModifiers[stat] = _percentModifiers.GetValueOrDefault(stat) + percent;
if (stat == StatType.MaxHP) RecalcMaxHP();
}
/// <summary>移除属性修改器(护符效果卸下时调用)。</summary>
public void RemoveModifier(StatType stat, float flat, float percent)
{
_flatModifiers[stat] = _flatModifiers.GetValueOrDefault(stat) - flat;
_percentModifiers[stat] = _percentModifiers.GetValueOrDefault(stat) - percent;
if (stat == StatType.MaxHP) RecalcMaxHP();
}
/// <summary>查询指定属性的固定加成(由 PlayerMovement、战斗等外部系统查询。</summary>
public float GetFlatModifier(StatType stat) => _flatModifiers.GetValueOrDefault(stat);
/// <summary>查询指定属性的百分比加成。</summary>
public float GetPercentModifier(StatType stat) => _percentModifiers.GetValueOrDefault(stat);
/// <summary>加载动画速度加成AttackSpeedEffect 装备时调用)。</summary>
public void AddAnimatorSpeedBonus(float delta) => _animatorSpeedBonus += delta;
/// <summary>移除动画速度加成AttackSpeedEffect 卸下时调用)。</summary>
public void RemoveAnimatorSpeedBonus(float delta) => _animatorSpeedBonus -= delta;
/// <summary>增加灵力消耗减免SoulSpellEffect 装备时调用)。</summary>
public void AddSoulCostReduction(int amount) => _soulCostReduction += amount;
/// <summary>移除灵力消耗减免SoulSpellEffect 卸下时调用)。</summary>
public void RemoveSoulCostReduction(int amount) => _soulCostReduction = Mathf.Max(0, _soulCostReduction - amount);
private void RecalcMaxHP()
{
float flat = _flatModifiers.GetValueOrDefault(StatType.MaxHP);
float percent = _percentModifiers.GetValueOrDefault(StatType.MaxHP);
int newMax = Mathf.Max(1, Mathf.RoundToInt(_config.MaxHP * (1f + percent) + flat));
SetMaxHP(newMax);
}
// ── HP ────────────────────────────────────────────────────────────────
/// <summary>受到伤害时触发(已扣血,不包含无敌/死亡情况)。</summary>
public event System.Action OnDamaged;
/// <summary>Debug开启/关闭无敌模式(不计入无敌帧,永久生效直至关闭)。</summary>
public void SetGodMode(bool v) { _isGodMode = v; }
public void TakeDamage(int amount)
{
if (_isGodMode || IsInvincible || !IsAlive || amount <= 0) return;
CurrentHP = Mathf.Max(0, CurrentHP - amount);
_onHPChanged?.Raise(CurrentHP);
OnDamaged?.Invoke();
}
public void FullHeal()
{
if (!IsAlive) return;
CurrentHP = MaxHP;
_onHPChanged?.Raise(CurrentHP);
}
// ── IRestoreOnSave ────────────────────────────────────────────────────
void BaseGames.Core.IRestoreOnSave.FullRestore() => FullHeal();
void BaseGames.Core.IRestoreOnSave.RestoreSpring() => RestoreSpringCharges();
public void HealHP(int amount)
{
if (!IsAlive || amount <= 0) return;
CurrentHP = Mathf.Min(MaxHP, CurrentHP + amount);
_onHPChanged?.Raise(CurrentHP);
}
public void SetMaxHP(int newMax)
{
MaxHP = Mathf.Max(1, newMax);
CurrentHP = Mathf.Min(CurrentHP, MaxHP);
_onMaxHPChanged?.Raise(MaxHP);
_onHPChanged?.Raise(CurrentHP);
}
// ── Soul Power ────────────────────────────────────────────────────────
public void AddSoulPower(int amount)
{
if (amount <= 0) return;
CurrentSoulPower = Mathf.Min(MaxSoulPower, CurrentSoulPower + amount);
_onSoulPowerChanged?.Raise(CurrentSoulPower);
}
/// <summary>AddSoul 是 AddSoulPower 的别名(架构 06_CombatModule §8 ParrySystem 使用此名称)。</summary>
public void AddSoul(int amount) => AddSoulPower(amount);
public bool ConsumeSoulPower(int amount)
{
if (CurrentSoulPower < amount) return false;
CurrentSoulPower -= amount;
_onSoulPowerChanged?.Raise(CurrentSoulPower);
return true;
}
// ── Spirit Power ──────────────────────────────────────────────────────
public void AddSpiritPower(int amount)
{
if (amount <= 0) return;
CurrentSpiritPower = Mathf.Min(MaxSpiritPower, CurrentSpiritPower + amount);
_onSpiritPowerChanged?.Raise(CurrentSpiritPower);
}
public bool ConsumeSpiritPower(int amount)
{
if (CurrentSpiritPower < amount) return false;
CurrentSpiritPower -= amount;
_onSpiritPowerChanged?.Raise(CurrentSpiritPower);
return true;
}
// ── Spring ────────────────────────────────────────────────────────────
public bool UseSpring()
{
if (CurrentSpringCharges <= 0) return false;
CurrentSpringCharges--;
_onSpringChargesChanged?.Raise(CurrentSpringCharges);
HealHP(_config.SpringHealAmount);
return true;
}
public void RestoreSpringCharges(int amount = -1)
{
if (amount < 0) amount = MaxSpringCharges;
CurrentSpringCharges = Mathf.Min(MaxSpringCharges, CurrentSpringCharges + amount);
_onSpringChargesChanged?.Raise(CurrentSpringCharges);
}
public void AddKillPoints(int points = 1)
{
SpringKillPoints += points;
if (SpringKillPoints >= _config.SpringKillThreshold)
{
SpringKillPoints = 0;
RestoreSpringCharges(1);
}
}
// ── LingZhu ─────────────────────────────────────────────────────────────────────
public void AddLingZhu(int amount)
{
if (amount <= 0) return;
CurrentLingZhu += amount;
_onLingZhuChanged?.Raise(CurrentLingZhu);
}
public bool SpendLingZhu(int amount)
{
if (CurrentLingZhu < amount) return false;
CurrentLingZhu -= amount;
_onLingZhuChanged?.Raise(CurrentLingZhu);
return true;
}
// ── Invincibility ─────────────────────────────────────────────────────
public void BeginInvincibility(float duration = -1f)
{
float d = duration >= 0f ? duration : _config.InvincibilityDuration;
_invincibleTimer = Mathf.Max(_invincibleTimer, d);
}
// ── Abilities ─────────────────────────────────────────────────────────
public bool HasAbility(AbilityType ability)
=> (_unlockedAbilities & ability) == ability;
public void UnlockAbility(AbilityType ability)
{
if (HasAbility(ability)) return;
_unlockedAbilities |= ability;
_onAbilityUnlocked?.Raise(ability);
}
/// <summary>IRewardTarget 实现:以 uint 位掩码解锁能力(避免跨程序集枚举引用)。</summary>
void IRewardTarget.UnlockAbilityFlag(uint abilityFlag) => UnlockAbility((AbilityType)abilityFlag);
public void LockAbility(AbilityType ability)
=> _unlockedAbilities &= ~ability;
// ── ISaveable ─────────────────────────────────────────────────────────
public void OnSave(SaveData saveData)
{
var p = saveData.Player;
p.CurrentHP = CurrentHP;
p.MaxHP = MaxHP;
p.CurrentLingZhu = CurrentLingZhu;
p.AbilityFlags = (uint)_unlockedAbilities;
}
public void OnLoad(SaveData saveData)
{
var p = saveData.Player;
MaxHP = p.MaxHP;
CurrentHP = Mathf.Clamp(p.CurrentHP, 0, MaxHP);
CurrentLingZhu = p.CurrentLingZhu;
_unlockedAbilities = (AbilityType)p.AbilityFlags;
_onHPChanged?.Raise(CurrentHP);
_onMaxHPChanged?.Raise(MaxHP);
_onLingZhuChanged?.Raise(CurrentLingZhu);
}
}
}

View File

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

View File

@@ -0,0 +1,29 @@
using UnityEngine;
namespace BaseGames.Player
{
[CreateAssetMenu(menuName = "BaseGames/Player/Stats")]
public class PlayerStatsSO : ScriptableObject
{
[Header("HP")]
public int MaxHP = 5;
[Header("Soul")]
public int MaxSoulPower = 100;
[Header("Spirit")]
public int MaxSpiritPower = 100;
public int SpiritRegenRate = 5; // 每秒回复量
[Header("Spring (治愈弹簧)")]
public int MaxSpringCharges = 3;
public int SpringHealAmount = 2;
public int SpringKillThreshold = 4; // 击杀数触发弹簧恢复
[Header("无敌帧")]
public float InvincibilityDuration = 0.6f;
[Header("初始货币")]
public int InitialLingZhu = 0;
}
}

View File

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

View File

@@ -0,0 +1,70 @@
using UnityEngine;
namespace BaseGames.Player
{
/// <summary>
/// 独立墙壁检测组件(架构 05_PlayerModule §13
/// ⚠️ 不嵌入 PlayerMovement以保持单一职责。
/// 每侧发两根射线Top + Bottom两根均命中才视为接触墙壁防卡角误判
/// WallSlideState / WallJumpState 通过 PlayerController.WallDetector 访问。
/// </summary>
[RequireComponent(typeof(PlayerMovement))]
public class PlayerWallDetector : MonoBehaviour
{
[SerializeField] private PlayerMovementConfigSO _config;
[Header("墙壁 Layer默认使用 \"Wall\" + \"Ground\"")]
[SerializeField] private LayerMask _wallLayer;
/// <summary>当前是否正在触碰墙壁。</summary>
public bool IsTouchingWall { get; private set; }
/// <summary>触碰到的墙壁方向:+1 = 右墙,-1 = 左墙0 = 无墙。</summary>
public int WallDirection { get; private set; }
private void Awake()
{
Debug.Assert(_config != null, "[PlayerWallDetector] _config 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this);
}
private void FixedUpdate()
{
bool rightWall = CheckSide(Vector2.right);
bool leftWall = CheckSide(Vector2.left);
IsTouchingWall = rightWall || leftWall;
WallDirection = rightWall ? 1 : (leftWall ? -1 : 0);
}
/// <summary>
/// 每侧发两根射线TopRay + BottomRay两根均命中才返回 true。
/// </summary>
private bool CheckSide(Vector2 dir)
{
Vector2 center = transform.position;
float len = _config.WallRayLength;
float oy = _config.WallRayOffsetY;
int layer = _wallLayer != 0 ? (int)_wallLayer : LayerMask.GetMask("Wall", "Ground");
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;
}
private void OnDrawGizmosSelected()
{
if (_config == null) return;
float len = _config.WallRayLength;
float oy = _config.WallRayOffsetY;
Vector2 center = transform.position;
Gizmos.color = IsTouchingWall ? Color.red : Color.cyan;
// 右侧两根射线
Gizmos.DrawRay(center + Vector2.up * oy, Vector2.right * len);
Gizmos.DrawRay(center + Vector2.down * oy, Vector2.right * len);
// 左侧两根射线
Gizmos.DrawRay(center + Vector2.up * oy, Vector2.left * len);
Gizmos.DrawRay(center + Vector2.down * oy, Vector2.left * len);
}
}
}

View File

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

View File

@@ -0,0 +1,2 @@
// SkillManager 定义位于 Assets/Scripts/Skills/SkillManager.cs (BaseGames.Skills)。
namespace BaseGames.Player { }

View File

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

View File

@@ -0,0 +1,14 @@
using UnityEngine;
namespace BaseGames.Player
{
/// <summary>
/// 治愈弹簧系统(架构 05_PlayerModule §7
/// PlayerStats 中已预留 CurrentSpringCharges / MaxSpringCharges / SpringKillPoints 字段。
/// TODO: 实现弹簧充能逻辑:
/// - 击杀敌人时调用 PlayerStats.AddSpringKillPoint() 积累充能
/// - 按下治愈键时消耗充能槽并恢复玩家 HP
/// - 充能满格时触发特殊强化状态(视设计而定)
/// </summary>
public class SpringSystem : MonoBehaviour { }
}

View File

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

View File

@@ -0,0 +1,16 @@
namespace BaseGames.Player
{
/// <summary>
/// 玩家属性类型(护符效果 StatModifierEffect 使用)。
/// 存放于 BaseGames.Player 程序集,供 PlayerStats 与 Equipment 共同引用。
/// </summary>
public enum StatType
{
MaxHP,
AttackDamage,
MoveSpeed,
JumpHeight,
SoulGain,
Defense
}
}

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>
/// 空中冲刺状态(架构 05_PlayerModule §12
/// 与地面 DashState 独立,消耗 MaxAerialDashes 次数;
/// 空中冲刺可向任意方向(使用移动输入方向,无输入则使用朝向)。
/// </summary>
public class AerialDashState : PlayerStateBase
{
private float _timer;
private int _aerialDashesLeft;
private int _facingDir;
public bool HasAerialDash => _aerialDashesLeft > 0;
// ── IsInvincible 不再在状态层硬编码,与 DashState 保持一致:
// 实际无敌用 Stats.BeginInvincibility(DashInvincibilityDuration) 面题。
// PlayerController.TakeDamage 已将 Stats.IsInvincible 纳入硬直判断。
public AerialDashState(PlayerController owner) : base(owner)
{
_aerialDashesLeft = 1;
}
public override void OnStateEnter()
{
_aerialDashesLeft = Mathf.Max(0, _aerialDashesLeft - 1);
_facingDir = Owner.FacingDirection;
_timer = Cfg.DashDuration;
// 无敌帧:与地面冲刺共享同一无敌 CDDashState._invincibilityCooldownTimer
// 条件 1已解锁 InvincibleDash
// 条件 2共享无敌冷却已就绪
var dashState = Owner.GetState<DashState>();
if (Stats != null && Stats.HasAbility(AbilityType.InvincibleDash)
&& dashState != null && dashState.CanGrantInvincibility)
{
Stats.BeginInvincibility(Cfg.DashInvincibilityDuration);
dashState.ResetInvincibilityCooldown(Cfg.DashInvincibilityCooldown);
}
// 关闭重力,施加冲刺速度(空中冲刺不改变垂直速度)
Move?.SetGravityScale(0f);
float dir = Input.MoveInput.x != 0 ? Mathf.Sign(Input.MoveInput.x) : _facingDir;
Move?.Dash(new Vector2(dir, 0f), Cfg.DashSpeed);
// 播放冲刺动画(复用地面冲刺动画)
if (AnimCfg?.Dash != null) Anim?.Play(AnimCfg.Dash);
}
public override void OnStateUpdate()
{
_timer -= Time.deltaTime;
if (_timer <= 0f)
{
Move?.SetGravityScale(Cfg.DefaultGravityScale);
Owner.TransitionTo(Owner.GetState<FallState>());
}
}
public override void OnStateExit()
{
Move?.SetGravityScale(Cfg.DefaultGravityScale);
}
public override void OnStateFixedUpdate()
{
// 冲刺期间锁定速度
if (_timer > 0f)
{
float dir = Input.MoveInput.x != 0 ? Mathf.Sign(Input.MoveInput.x) : _facingDir;
Move?.Dash(new Vector2(dir, 0f), Cfg.DashSpeed);
}
}
/// <summary>着地时重置空中冲刺次数(由 PlayerController 在着地时调用)。</summary>
public void ResetAerialDashes()
{
_aerialDashesLeft = Cfg.MaxAerialDashes;
}
}
}

View File

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

View File

@@ -0,0 +1,46 @@
using BaseGames.Combat;
namespace BaseGames.Player.States
{
/// <summary>
/// 空中攻击状态(架构 05_PlayerModule §2
/// 由 FallState / JumpState 接收攻击输入后转入;
/// Animancer 帧事件驱动 HitBoxAir 激活;结束后回到 FallState。
/// </summary>
public class AirAttackState : PlayerStateBase
{
public AirAttackState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
Owner.Combat?.SetComboSegmentSource(0);
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Air);
var clip = Owner.Weapon?.ActiveWeapon?.airAttackClip;
if (clip != null && clip.Clip != null)
{
var state = Anim.Play(clip);
state.Events(this).OnEnd = OnClipEnd;
}
else if (AnimCfg?.AirAttack != null)
{
var state = Anim.Play(AnimCfg.AirAttack);
state.Events(this).OnEnd = OnClipEnd;
}
else
{
OnClipEnd();
}
}
public override void OnStateExit()
{
Owner.Combat?.DisableAllWeaponHitBoxes();
}
private void OnClipEnd()
{
Owner.TransitionTo(Owner.GetState<FallState>());
}
}
}

View File

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

View File

@@ -0,0 +1,69 @@
using BaseGames.Combat;
namespace BaseGames.Player.States
{
/// <summary>
/// 地面攻击状态3 段连击)。
/// 由 PlayerController 实例化AttackEvent 触发切换。
/// 通过 Animancer 帧事件驱动 HitBox 激活/关闭。
/// </summary>
public class AttackState : PlayerStateBase
{
private int _comboIndex;
public AttackState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
_comboIndex = 0;
PlayAttackClip();
Input.AttackEvent += OnAttackInput;
}
public override void OnStateExit()
{
Input.AttackEvent -= OnAttackInput;
Owner.Combat?.DisableAllWeaponHitBoxes();
}
public override void OnStateUpdate() { }
public override void OnStateFixedUpdate() { }
// ── 内部 ──────────────────────────────────────────────────────────────
private void PlayAttackClip()
{
// ⚠️ 字段名 GroundAttacks非 AttackChainClips
var clip = AnimCfg.GroundAttacks[_comboIndex];
var state = Anim.Play(clip);
var events = state.Events(this);
events.OnEnd = OnClipEnd;
// HitBox 由 Animancer 归一化时间事件驱动(时间点配置于 PlayerAnimationConfigSO
var timings = AnimCfg?.GroundAttackTimings;
float enterTime = timings != null && _comboIndex < timings.Length
? timings[_comboIndex].HitBoxEnter : 0.3f;
float exitTime = timings != null && _comboIndex < timings.Length
? timings[_comboIndex].HitBoxExit : 0.6f;
events.Add(enterTime, () => Owner.Combat?.EnableWeaponHitBox(AttackDirection.Ground));
events.Add(exitTime, () => Owner.Combat?.DisableAllWeaponHitBoxes());
}
private void OnClipEnd()
{
Owner.Combat?.DisableAllWeaponHitBoxes();
Owner.TransitionTo(Owner.GetState<IdleState>());
}
private void OnAttackInput()
{
// 连击段数从配置 SO 动态读取,无须修改代码即可支持任意段数连击
int maxCombo = AnimCfg?.GroundAttacks?.Length ?? 1;
if (_comboIndex < maxCombo - 1)
{
_comboIndex++;
PlayAttackClip();
}
}
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,101 @@
using UnityEngine;
using System.Collections;
namespace BaseGames.Player.States
{
/// <summary>
/// 地面冲刺状态(架构 05_PlayerModule §2 + §12
/// 施加水平位移 + 无敌帧;冲刺期间重力归零,结束后恢复;
/// 需解锁 AbilityType.Dash 才能进入PlayerController 负责条件检查)。
/// </summary>
public class DashState : PlayerStateBase
{
private float _timer;
private float _cooldownTimer;
/// <summary>
/// 无敌的独立冷却计时器。由 TickCooldown 每帧减少;
/// 地面冲刺和空中冲刺共享此计时器AerialDashState 通过 GetState&lt;DashState&gt;() 读写)。
/// </summary>
private float _invincibilityCooldownTimer;
private int _facingDir;
public bool CanDash => _cooldownTimer <= 0f;
/// <summary>
/// 无敌帧是否已冷却,即本次冲刺可以获得无敌。
/// DashState 和 AerialDashState 共享同一实例计时器实现 "spam 冲刺不能持续无敌" 机制。
/// </summary>
public bool CanGrantInvincibility => _invincibilityCooldownTimer <= 0f;
/// <summary>重置无敌冷却DashState/AerialDashState 驾用无敌后调用)。</summary>
public void ResetInvincibilityCooldown(float cd) => _invincibilityCooldownTimer = cd;
// ── IsInvincible 不再在状态层硬编码,改为全面依赖 Stats._invincibleTimer ────────────
// 冲刺无敌窗口 = BeginInvincibility(DashInvincibilityDuration) 设定的时间限 Stats._invincibleTimer
// PlayerController.TakeDamage 已改为将 Stats.IsInvincible 纳入硬直判断,居间无敌窗口自然失效。
// 不再需要 override IsInvincible。
public DashState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
Debug.Assert(Cfg != null, "[DashState] MovementConfig 未配置,请在 PlayerController Inspector 中绑定 MovementConfig SO。", _owner);
_facingDir = Owner.FacingDirection;
_timer = Cfg.DashDuration;
// 无敌帧:
// 条件 1已解锁 InvincibleDash
// 条件 2无敌冷却已就绪防止 spam 冲刺连序无敌)
// 窗口时长 = DashInvincibilityDuration < DashDuration冲刺后段无保护对齐 HK
if (Stats != null && Stats.HasAbility(AbilityType.InvincibleDash) && CanGrantInvincibility)
{
Stats.BeginInvincibility(Cfg.DashInvincibilityDuration);
_invincibilityCooldownTimer = Cfg.DashInvincibilityCooldown;
}
// 关闭重力,施加冲刺速度
Move?.SetGravityScale(0f);
Move?.Dash(new Vector2(_facingDir, 0f), Cfg.DashSpeed);
// 播放冲刺动画
if (AnimCfg?.Dash != null) Anim?.Play(AnimCfg.Dash);
}
public override void OnStateUpdate()
{
_timer -= Time.deltaTime;
if (_timer <= 0f)
EndDash();
}
public override void OnStateExit()
{
// 恢复默认重力
Move?.SetGravityScale(Cfg.DefaultGravityScale);
_cooldownTimer = Cfg.DashCooldown;
}
public override void OnStateFixedUpdate()
{
// 冲刺期间保持冲刺速度(防止摩擦力减速)
if (_timer > 0f)
Move?.Dash(new Vector2(_facingDir, 0f), Cfg.DashSpeed);
}
private void EndDash()
{
if (Move != null && Move.IsGrounded)
Owner.TransitionTo(Owner.GetState<IdleState>());
else
Owner.TransitionTo(Owner.GetState<FallState>());
}
/// <summary>每帧减少冒小帮冷却(由 PlayerController.Update 调用)。</summary>
public void TickCooldown(float dt)
{
if (_cooldownTimer > 0f) _cooldownTimer -= dt;
if (_invincibilityCooldownTimer > 0f) _invincibilityCooldownTimer -= dt;
}
}
}

View File

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

View File

@@ -0,0 +1,39 @@
namespace BaseGames.Player.States
{
/// <summary>
/// 死亡状态(架构 05_PlayerModule §2
/// 冻结物理ZeroVelocity + 关闭重力),播放 Dead 动画;
/// 不自动转出 — 由 DeathRespawnSystem 通过 EVT_PlayerRespawned 触发重置。
/// </summary>
public class DeadState : PlayerStateBase
{
public DeadState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
// 冻结物理
Move?.ZeroVelocity();
Move?.SetGravityScale(0f);
// 禁用 HurtBox防止重复受击
if (Owner.HurtBox != null)
Owner.HurtBox.SetActive(false);
// 播放死亡动画
if (AnimCfg?.Dead != null)
Anim?.Play(AnimCfg.Dead);
}
public override void OnStateExit()
{
// 复活时恢复重力和 HurtBox
Move?.SetGravityScale(Owner.MovConfig.DefaultGravityScale);
if (Owner.HurtBox != null)
Owner.HurtBox.SetActive(true);
}
// 死亡状态不接受任何输入或状态转换
public override void OnStateUpdate() { }
public override void OnStateFixedUpdate() { }
}
}

View File

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

View File

@@ -0,0 +1,83 @@
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Player.States
{
/// <summary>
/// 下劈(踩踏 Pogo状态架构 05_PlayerModule §2
/// 需解锁 AbilityType.DownSlash 才能进入;
/// 激活 HitBoxDown着地或命中后弹跳。
/// </summary>
public class DownAttackState : PlayerStateBase
{
private bool _hasHitEnemy;
private bool _exited;
public DownAttackState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
_hasHitEnemy = false;
_exited = false;
if (Owner.Combat != null)
Owner.Combat.OnDownHitConfirmed += OnDownHitConfirmed;
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Down);
var clip = Owner.Weapon?.ActiveWeapon?.downAttackClip;
if (clip != null && clip.Clip != null)
{
var state = Anim.Play(clip);
state.Events(this).OnEnd = OnClipEnd;
}
else if (AnimCfg?.DownAttack != null)
{
var state = Anim.Play(AnimCfg.DownAttack);
state.Events(this).OnEnd = OnClipEnd;
}
else
{
OnClipEnd();
}
// 施加向下的速度(下劈冲击)
if (Move?.Rb != null)
Move.Rb.velocity = new Vector2(Move.Rb.velocity.x, -18f);
}
public override void OnStateExit()
{
_exited = true;
if (Owner.Combat != null)
Owner.Combat.OnDownHitConfirmed -= OnDownHitConfirmed;
Owner.Combat?.DisableAllWeaponHitBoxes();
}
private void OnDownHitConfirmed(BaseGames.Combat.DamageInfo _)
{
if (_hasHitEnemy) return;
_hasHitEnemy = true;
// Pogo 弹跳:命中敌人后向上弹起
Move.Jump();
}
public override void OnStateUpdate()
{
// 着地时回到 Idle / Run
if (Move.IsGrounded)
{
Owner.TransitionTo(Owner.GetState<IdleState>());
}
}
private void OnClipEnd()
{
if (_exited) return;
if (!Move.IsGrounded)
Owner.TransitionTo(Owner.GetState<FallState>());
else
Owner.TransitionTo(Owner.GetState<IdleState>());
}
}
}

View File

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

View File

@@ -0,0 +1,84 @@
using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>
/// 下落状态(对齐空洞骑士手感)。
/// - 郊狼跳CoyoteTimer > 0 时按跳跃 → 一段跳JumpState使用 JumpForce
/// - 二段跳CoyoteTimer 耗尽后按跳跃且 AirJumpsLeft > 0 → JumpState使用 DoubleJumpForce
/// - 空中冲刺HasAbility(AirDash) && HasAerialDash → AerialDashState。
/// - 增强下落重力FallGravityMult确保下落快于上升手感紧实。
/// </summary>
public class FallState : PlayerStateBase
{
public FallState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
if (AnimCfg?.Fall != null)
Anim.Play(AnimCfg.Fall);
}
public override void OnStateUpdate()
{
// ── 跳跃输入(郊狼跳 / 二段跳)────────────────────────────────────
if (Buffer.ConsumeJump())
{
if (Move.HasCoyoteTime)
{
// 郊狼跳:一段跳,使用 JumpForce
_owner.TransitionTo(_owner.GetState<JumpState>());
return;
}
if (Owner.AirJumpsLeft > 0)
{
// 二段跳:通过 SetDoubleJump 标记 JumpState 使用 DoubleJumpForce
Owner.UseAirJump();
Owner.GetState<JumpState>()?.SetDoubleJump(true);
_owner.TransitionTo(_owner.GetState<JumpState>());
return;
}
// 无跳跃机会输入已消耗静默忽略HK 相同行为)
}
// ── 空中冲刺────────────────────────────────────────────────────────
if (Buffer.ConsumeDash()
&& Stats != null && Stats.HasAbility(AbilityType.AirDash))
{
var aerialDash = Owner.GetState<AerialDashState>();
if (aerialDash != null && aerialDash.HasAerialDash)
{
_owner.TransitionTo(aerialDash);
return;
}
}
// ── 着地──────────────────────────────────────────────────────────
if (Move.IsGrounded)
{
Move.ZeroVelocity();
if (Mathf.Abs(Input.MoveInput.x) > 0.1f)
_owner.TransitionTo(_owner.GetState<RunState>());
else
_owner.TransitionTo(_owner.GetState<IdleState>());
return;
}
// 空中水平移动
if (Mathf.Abs(Input.MoveInput.x) > 0.01f)
Move.Move(Input.MoveInput.x * Cfg.RunSpeed);
}
public override void OnStateFixedUpdate()
{
// 增强下落重力FallGravityMult 对齐 HK下落比上升更快
if (Move.Rb.velocity.y < 0f)
{
float extraGrav = Physics2D.gravity.y * (Cfg.FallGravityMult - 1f) * Time.fixedDeltaTime;
Move.Rb.velocity = new Vector2(
Move.Rb.velocity.x,
Mathf.Max(Move.Rb.velocity.y + extraGrav, -Cfg.MaxFallSpeed));
}
}
}
}

View File

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

View File

@@ -0,0 +1,63 @@
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Player.States
{
/// <summary>
/// 受击硬直状态(架构 05_PlayerModule §2
/// 由 PlayerController.TakeDamage 在玩家非无敌时触发;
/// 播放 Hurt 动画,施加击退,结束后回到 Idle 或 Fall。
/// </summary>
public class HurtState : PlayerStateBase
{
private float _timer;
private bool _ended;
public HurtState(PlayerController owner) : base(owner) { }
public void Initialize(BaseGames.Combat.DamageInfo info)
{
// 由 PlayerController.TakeDamage 传入伤害信息
if (info.KnockbackForce > 0.01f)
Move?.ApplyKnockback(info.KnockbackDirection, info.KnockbackForce);
}
public override void OnStateEnter()
{
// 持续时长从 PlayerAnimationConfigSO 读取,不同攻击可设置不同硬直
_timer = Owner.AnimConfig?.HurtDuration ?? 0.4f;
_ended = false;
Stats?.BeginInvincibility();
if (AnimCfg?.Hurt != null)
{
var state = Anim?.Play(AnimCfg.Hurt);
if (state != null)
state.Events(this).OnEnd = OnHurtEnd;
}
}
public override void OnStateUpdate()
{
_timer -= Time.deltaTime;
if (_timer <= 0f)
OnHurtEnd();
}
private void OnHurtEnd()
{
if (_ended) return;
_ended = true;
if (Stats != null && !Stats.IsAlive)
{
Owner.TransitionTo(Owner.GetState<DeadState>());
return;
}
if (Move.IsGrounded)
Owner.TransitionTo(Owner.GetState<IdleState>());
else
Owner.TransitionTo(Owner.GetState<FallState>());
}
}
}

View File

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

View File

@@ -0,0 +1,46 @@
using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>闲置状态。默认入口状态,播放 Idle 动画。</summary>
public class IdleState : PlayerStateBase
{
public IdleState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
if (AnimCfg?.Idle != null)
Anim.Play(AnimCfg.Idle);
Move?.ZeroHorizontalVelocity();
// 落地时重置空中能力计数器
Owner.GetState<AerialDashState>()?.ResetAerialDashes();
Owner.ResetAirJumps();
}
public override void OnStateUpdate()
{
if (!Move.IsGrounded)
{
_owner.TransitionTo(_owner.GetState<FallState>());
return;
}
if (Buffer.ConsumeJump())
{
_owner.TransitionTo(_owner.GetState<JumpState>());
return;
}
// 地面冲刺:需解锁 Dash 能力,且冲刺不在冷却
if (Buffer.ConsumeDash()
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Owner.GetState<DashState>() is { CanDash: true } dashState)
{
_owner.TransitionTo(dashState);
return;
}
if (Mathf.Abs(Input.MoveInput.x) > 0.1f)
{
_owner.TransitionTo(_owner.GetState<RunState>());
}
}
}
}

View File

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

View File

@@ -0,0 +1,84 @@
using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>
/// 跳跃状态(对齐空洞骑士手感)。
/// - 一段跳 / 郊狼跳OnStateEnter 时调用 Move.Jump()。
/// - 二段跳Monarch Wings 等效):上升或下落途中再按跳跃且 AirJumpsLeft > 0
/// 调用 Move.DoubleJump(),重播跳跃动画,不离开本状态(保持速度截断逻辑)。
/// - 空中冲刺:上升途中按冲刺且 HasAbility(AirDash) → AerialDashState。
/// - 变高跳:松开跳跃键触发 JumpCancelledEvent → CutJump()(系数 = JumpCutMultiplier
/// - _isDoubleJump由 FallState 在转换前通过 SetDoubleJump(true) 预设,
/// 使 OnStateEnter 对二段跳调用 Move.DoubleJump() 而非 Move.Jump()。
/// </summary>
public class JumpState : PlayerStateBase
{
private bool _isDoubleJump;
public JumpState(PlayerController owner) : base(owner) { }
/// <summary>
/// 由 FallState 在转换到 JumpState 前调用,标记本次进入为二段跳,
/// 以便 OnStateEnter 使用 DoubleJumpForce 而非 JumpForce。
/// </summary>
public void SetDoubleJump(bool isDouble) => _isDoubleJump = isDouble;
public override void OnStateEnter()
{
if (AnimCfg?.Jump != null)
Anim.Play(AnimCfg.Jump);
if (_isDoubleJump)
Move.DoubleJump();
else
Move.Jump();
_isDoubleJump = false; // 消耗标记
Input.JumpCancelledEvent += OnJumpCancelled;
}
public override void OnStateUpdate()
{
// 上升结束时转为下落
if (Move.Rb.velocity.y <= 0f)
{
_owner.TransitionTo(_owner.GetState<FallState>());
return;
}
// 空中冲刺(优先于二段跳:冲刺可保存二段跳机会)
if (Buffer.ConsumeDash()
&& Stats != null && Stats.HasAbility(AbilityType.AirDash))
{
var aerialDash = Owner.GetState<AerialDashState>();
if (aerialDash != null && aerialDash.HasAerialDash)
{
_owner.TransitionTo(aerialDash);
return;
}
}
// 二段跳:上升阶段即可触发(类比 HK Monarch Wings随时可二段跳
if (Buffer.ConsumeJump() && Owner.AirJumpsLeft > 0)
{
Owner.UseAirJump();
Move.DoubleJump();
// 留在 JumpState速度截断JumpCancelledEvent和落地检测逻辑继续生效
if (AnimCfg?.Jump != null) Anim?.Play(AnimCfg.Jump);
return;
}
// 水平移动HK 空中控制:与跑步同速)
if (Mathf.Abs(Input.MoveInput.x) > 0.01f)
Move.Move(Input.MoveInput.x * Cfg.RunSpeed);
}
public override void OnStateExit()
{
Input.JumpCancelledEvent -= OnJumpCancelled;
}
private void OnJumpCancelled() => Move.CutJump();
}
}

View File

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

View File

@@ -0,0 +1,46 @@
namespace BaseGames.Player.States
{
/// <summary>
/// 弹反预备状态(架构 05_PlayerModule §2完整实现在 Week 6
/// 开启 ParrySystem 弹反窗口,播放 ParryStart 动画;
/// 成功弹反后 ParrySystem.ConsumeParry() 返回 trueHurtBox 不处理该次伤害。
/// </summary>
public class ParryState : PlayerStateBase
{
public ParryState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
// 开启弹反窗口HurtBox.ReceiveDamage 步骤 2 查询)
Owner.Parry?.OpenParryWindow();
// 停止移动
Move?.ZeroHorizontalVelocity();
// 播放弹反预备动画
if (AnimCfg?.ParryStart != null)
{
var state = Anim?.Play(AnimCfg.ParryStart);
if (state != null)
{
state.Events(this).OnEnd = OnParryEnd;
return;
}
}
// 无动画则立即结束
OnParryEnd();
}
public override void OnStateExit()
{
// 确保弹反窗口关闭(无论是否成功)
Owner.Parry?.CloseParryWindow();
}
private void OnParryEnd()
{
Owner.TransitionTo(Owner.GetState<IdleState>());
}
}
}

View File

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

View File

@@ -0,0 +1,343 @@
using UnityEngine;
using System.Linq;
using Animancer;
using BaseGames.Core.Events;
using BaseGames.Input;
using BaseGames.Combat;
using BaseGames.Parry;
using BaseGames.Skills;
namespace BaseGames.Player.States
{
/// <summary>
/// 玩家主控制器(协调器)。位于 Player/States/ 程序集,以便引用所有具体状态类型。
/// 实现 IDamageable + IPoiseSource架构 06_CombatModule §13
/// 依赖注入:同节点组件由 RequireComponent + Awake 自动获取;跨节点引用通过 [SerializeField] 绑定。
/// </summary>
[DefaultExecutionOrder(-100)]
[RequireComponent(typeof(InputBuffer))]
[RequireComponent(typeof(PlayerMovement))]
[RequireComponent(typeof(PlayerStats))]
[RequireComponent(typeof(AnimancerComponent))]
public class PlayerController : MonoBehaviour, IDamageable, IPoiseSource
{
// ── 同节点组件(由 RequireComponent 保证存在Awake 中自动获取)─────
private PlayerMovement _movement;
private PlayerStats _stats;
private AnimancerComponent _animancer;
// ── 配置 SO ───────────────────────────────────────────────────────────
[Header("配置")]
[SerializeField] private PlayerMovementConfigSO _movementConfig;
[SerializeField] private PlayerAnimationConfigSO _animConfig;
[SerializeField] private InputReaderSO _inputReader;
[SerializeField] private FormConfigSO _formConfig;
// ── 战斗组件 ──────────────────────────────────────────────────────────
[Header("战斗")]
[SerializeField] private PlayerCombat _combat;
[SerializeField] private FormController _formController;
[SerializeField] private WeaponManager _weaponManager;
[SerializeField] private SkillManager _skillManager;
[SerializeField] private SpringSystem _springSystem;
[SerializeField] private ParrySystem _parrySystem;
[SerializeField] private HurtBox _hurtBox;
[SerializeField] private ShieldComponent _shield;
[SerializeField] private PlayerWallDetector _wallDetector;
// ── 事件频道 ──────────────────────────────────────────────────────────
[Header("事件频道")]
[SerializeField] private VoidEventChannelSO _onPlayerDied;
/// <summary>
/// Start() 时广播玩家 TransformEnemyBase / ProjectileManager 等订阅此频道)。
/// 替代每个敌人在 Awake 中独立 FindWithTag 的 O(n) 全场景扫描。
/// </summary>
[SerializeField] private TransformEventChannelSO _onPlayerSpawned;
// ── 运行时 ────────────────────────────────────────────────────────────
private InputBuffer _inputBuffer;
private bool _missingDependencyLogged;
private bool _dependenciesReady;
/// <summary>
/// 当前腾空可用的额外跳跃次数(二段跳)。
/// 由 IdleState/RunState.OnStateEnter 落地时通过 ResetAirJumps() 重置;
/// JumpState/FallState 判断 HasAbility(DoubleJump) 后消耗。
/// </summary>
private int _airJumpsLeft;
#if UNITY_EDITOR
[Header("调试")]
[SerializeField] private bool _debugValidateTransitions = true;
#endif
// Overlay LayerLayer 1供 SpringState / SoulSkill 等叠加层动画使用
// Base LayerLayer 0移动/攻击/受伤/死亡等全身状态动画
private AnimancerLayer _overlayLayer;
// ── 状态实例 ──────────────────────────────────────────────────────────
private PlayerStateBase _currentState;
private readonly System.Collections.Generic.Dictionary<System.Type, PlayerStateBase> _states = new();
// ── IDamageable 实现 ──────────────────────────────────────────────────
public bool IsAlive => _stats != null && _stats.IsAlive;
public bool IsInvincible => _stats != null && _stats.IsInvincible;
public int Defense => 0;
public void TakeDamage(DamageInfo info)
{
if (_stats == null) return;
_stats.TakeDamage(info.FinalDamage);
// 当前状态标记为无敌(如旧版冲刺状态),或 Stats 层无敌窗口仍激活
// (冲刺无敌帧 DashInvincibilityDuration 内:跳过受击硬直;窗口过期后可被打断)
if (_currentState?.IsInvincible == true || (_stats != null && _stats.IsInvincible)) return;
if (_stats.IsAlive)
{
GetState<HurtState>()?.Initialize(info);
TransitionTo(GetState<HurtState>());
}
else
{
TransitionTo(GetState<DeadState>());
_onPlayerDied?.Raise();
}
}
// ── IPoiseSource 实现(架构 06_CombatModule §13─────────────────────
/// <summary>
/// 玩家不拥有霸体,始终返回 <see cref="PoiseLevel.None"/>。
/// 设计决策:类似 Hollow Knight玩家依靠走位和弹反规避伤害而非硬吃。
/// 若未来需要临时霸体(如特定技能动作),请通过独立的覆盖标记实现,
/// 而非在此处引入状态,以保持接口语义清晰。
/// </summary>
public PoiseLevel GetCurrentPoiseLevel() => PoiseLevel.None;
// ── 公开属性(供状态类访问)──────────────────────────────────────────
public PlayerMovement Movement => _movement;
public PlayerStats Stats => _stats;
public AnimancerComponent Animancer => _animancer;
public PlayerMovementConfigSO MovConfig => _movementConfig;
public PlayerAnimationConfigSO AnimConfig => _animConfig;
public InputReaderSO Input => _inputReader;
public InputBuffer Buffer => _inputBuffer;
public PlayerCombat Combat => _combat;
public FormController Form => _formController;
public WeaponManager Weapon => _weaponManager;
public SkillManager Skill => _skillManager;
public SpringSystem Spring => _springSystem;
public ParrySystem Parry => _parrySystem;
public HurtBox HurtBox => _hurtBox;
public ShieldComponent Shield => _shield;
public PlayerWallDetector WallDetector => _wallDetector;
public bool IsGrounded => _movement != null && _movement.IsGrounded;
public int FacingDirection => _movement != null ? _movement.FacingDirection : 1;
// ── 空中跳跃 API二段跳────────────────────────────────────────────
public int AirJumpsLeft => _airJumpsLeft;
public void UseAirJump() => _airJumpsLeft = Mathf.Max(0, _airJumpsLeft - 1);
/// <summary>
/// 落地时重置空中跳跃次数(由 IdleState/RunState.OnStateEnter 调用)。
/// 若解锁 DoubleJump 则重置为 1否则为 0。
/// </summary>
public void ResetAirJumps() =>
_airJumpsLeft = _stats != null && _stats.HasAbility(AbilityType.DoubleJump) ? 1 : 0;
// ── Overlay Layer API供 SpringState / SoulSkill 等叠加动画使用)─────
/// <summary>
/// 在 Overlay LayerLayer 1播放动画叠加于当前 Base Layer 动画之上。
/// 适用于灵泉使用、魂技能等需要与移动动画并行的上半身动作。
/// </summary>
public AnimancerState PlayOnOverlay(AnimationClip clip)
=> _overlayLayer?.Play(clip);
/// <summary>停止 Overlay Layer 动画,淡出回 Base Layer。</summary>
public void StopOverlay(float fadeDuration = 0.1f)
=> _overlayLayer?.StartFade(0f, fadeDuration);
// ── Unity Lifecycle ───────────────────────────────────────────────────
private void Awake()
{
Debug.Assert(_movementConfig != null, "[PlayerController] _movementConfig 未赋值,请在 Inspector 中指定 PlayerMovementConfigSO。", this);
ResolveDependencies();
// 初始化 Animancer 双层动画Layer 0 = Base全身状态Layer 1 = Overlay叠加层
if (_animancer != null)
{
// 确保 Layer 1 存在AnimancerComponent 默认只有 Layer 0
while (_animancer.Layers.Count <= 1)
_animancer.Layers.Add();
_overlayLayer = _animancer.Layers[1];
}
// 注入 HurtBox 依赖
if (_hurtBox != null)
{
if (_shield != null) _hurtBox.SetShieldable(_shield);
if (_parrySystem != null) _hurtBox.SetParrySystem(_parrySystem);
_hurtBox.SetPoiseSource(this);
}
// 将唯一配置点_inputReader注入到 ParrySystem。
// ParrySystem 不再需要在 Inspector 单独配置 InputReaderSO。
if (_parrySystem != null)
_parrySystem.SetInputReader(_inputReader);
InitializeStates();
// 订阅 ParrySystem C# 事件
if (_parrySystem != null)
{
_parrySystem.OnParryActivated += OnParryActivated;
_parrySystem.OnParryConsumed += OnParryConsumedHandler;
}
}
private void OnDestroy()
{
if (_parrySystem != null)
{
_parrySystem.OnParryActivated -= OnParryActivated;
_parrySystem.OnParryConsumed -= OnParryConsumedHandler;
}
}
/// <summary>弹反输入激活时由 ParrySystem 触发 → 转换到 ParryState。</summary>
private void OnParryActivated()
{
if (_states.ContainsKey(typeof(ParryState)))
TransitionTo(GetState<ParryState>());
}
/// <summary>弹反命中成功时由 ParrySystem 触发 → 发放灵力并恢复护盾。</summary>
private void OnParryConsumedHandler(BaseGames.Parry.ParryInfo info)
{
_stats?.AddSoul(info.SoulGained);
_shield?.OnParrySuccess();
}
private void Start()
{
if (!HasRequiredStateDependencies())
return;
// 广播玩家 TransformEnemyBase / ProjectileManager 等订阅者将通过事件接收引用
// (必须在 Start 中调用,确保所有 Awake/OnEnable 订阅已就绪)
_onPlayerSpawned?.Raise(transform);
TransitionTo(GetState<IdleState>());
}
private void Update()
{
if (!HasRequiredStateDependencies())
return;
// 冲刺冷却计时
GetState<DashState>()?.TickCooldown(Time.deltaTime);
_currentState?.OnStateUpdate();
}
private void FixedUpdate()
{
_currentState?.OnStateFixedUpdate();
}
private void LateUpdate()
{
_movement?.UpdateFacing();
}
// ── 状态机 ────────────────────────────────────────────────────────────
public void TransitionTo(PlayerStateBase newState)
{
#if UNITY_EDITOR
if (_debugValidateTransitions && _currentState != null && newState != null)
{
var allowed = _currentState.ValidTransitions;
if (allowed.Count > 0 && !allowed.Contains(newState.GetType()))
Debug.LogWarning(
$"[PlayerController] 非预期转换: {_currentState.GetType().Name} → {newState.GetType().Name}",
this);
}
#endif
_currentState?.OnStateExit();
_currentState = newState;
_currentState?.OnStateEnter();
}
private void InitializeStates()
{
_states[typeof(IdleState)] = new IdleState(this);
_states[typeof(RunState)] = new RunState(this);
_states[typeof(JumpState)] = new JumpState(this);
_states[typeof(FallState)] = new FallState(this);
_states[typeof(AttackState)] = new AttackState(this);
_states[typeof(DashState)] = new DashState(this);
_states[typeof(AerialDashState)] = new AerialDashState(this);
_states[typeof(WallSlideState)] = new WallSlideState(this);
_states[typeof(WallJumpState)] = new WallJumpState(this);
_states[typeof(AirAttackState)] = new AirAttackState(this);
_states[typeof(DownAttackState)] = new DownAttackState(this);
_states[typeof(UpAttackState)] = new UpAttackState(this);
_states[typeof(HurtState)] = new HurtState(this);
_states[typeof(DeadState)] = new DeadState(this);
_states[typeof(SpringState)] = new SpringState(this);
_states[typeof(ParryState)] = new ParryState(this);
_states[typeof(SwimState)] = new SwimState(this);
}
/// <summary>
/// 按类型获取状态实例。未注册时返回 null供可选状态调用方安全使用
/// </summary>
public T GetState<T>() where T : PlayerStateBase
=> _states.TryGetValue(typeof(T), out var s) ? (T)s : null;
private void ResolveDependencies()
{
if (_movement == null)
_movement = GetComponent<PlayerMovement>();
if (_stats == null)
_stats = GetComponent<PlayerStats>();
if (_animancer == null)
_animancer = GetComponent<AnimancerComponent>();
if (_inputBuffer == null)
_inputBuffer = GetComponent<InputBuffer>();
// 将唯一配置点_inputReader注入到同一 GameObject 上的 InputBuffer。
// InputBuffer 不再需要在 Inspector 单独配置 InputReaderSO。
if (_inputBuffer != null)
_inputBuffer.Init(_inputReader);
}
private bool HasRequiredStateDependencies()
{
if (_dependenciesReady) return true;
bool ok = _movement != null && _animancer != null && _inputBuffer != null && _inputReader != null;
if (ok)
{
_dependenciesReady = true;
_missingDependencyLogged = false;
return true;
}
if (!_missingDependencyLogged)
{
Debug.LogError($"[PlayerController] Missing required dependencies. " +
$"Movement={(_movement != null ? "OK" : "NULL")}, " +
$"Animancer={(_animancer != null ? "OK" : "NULL")}, " +
$"InputBuffer={(_inputBuffer != null ? "OK" : "NULL")}, " +
$"InputReader={(_inputReader != null ? "OK" : "NULL")}");
_missingDependencyLogged = true;
}
return false;
}
// ── 状态访问器 ────────────────────────────────────────────────────────
// 使用 GetState<T>() 按类型获取任意状态实例,不再暴露具名属性。
}
}

View File

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

View File

@@ -0,0 +1,48 @@
using Animancer;
using BaseGames.Input;
using BaseGames.Player;
namespace BaseGames.Player.States
{
/// <summary>
/// 所有玩家状态的抽象基类。持有 PlayerController 引用并提供便捷属性访问。
/// 状态不继承 MonoBehaviour生命周期由 PlayerController 驱动。
/// </summary>
public abstract class PlayerStateBase
{
protected PlayerController _owner;
protected PlayerStateBase(PlayerController owner) => _owner = owner;
public virtual void OnStateEnter() { }
public virtual void OnStateUpdate() { }
public virtual void OnStateFixedUpdate() { }
public virtual void OnStateExit() { }
public virtual PlayerStateBase GetNextState() => null;
/// <summary>
/// 此状态期间是否应视为无敌(忽略伤害)。
/// 冲刺等状态 override 为 truePlayerController.TakeDamage 据此判断是否进入受击。
/// </summary>
public virtual bool IsInvincible => false;
#if UNITY_EDITOR
/// <summary>
/// 此状态允许转换到的目标类型白名单(仅 Editor 调试用)。
/// 返回空列表表示不限制任何转换。子状态按需 override 来声明合法出口。
/// </summary>
public virtual System.Collections.Generic.IReadOnlyList<System.Type> ValidTransitions
=> System.Array.Empty<System.Type>();
#endif
// ── 便捷属性 ──────────────────────────────────────────────────────────
protected PlayerController Owner => _owner;
protected InputReaderSO Input => _owner.Input;
protected InputBuffer Buffer => _owner.Buffer;
protected PlayerMovement Move => _owner.Movement;
protected PlayerStats Stats => _owner.Stats;
protected AnimancerComponent Anim => _owner.Animancer;
protected PlayerMovementConfigSO Cfg => _owner.MovConfig;
protected PlayerAnimationConfigSO AnimCfg => _owner.AnimConfig;
}
}

View File

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

View File

@@ -0,0 +1,51 @@
using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>跑步状态。播放 Run 动画并驱动水平移动。</summary>
public class RunState : PlayerStateBase
{
public RunState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
if (AnimCfg?.Run != null)
Anim.Play(AnimCfg.Run);
// 落地时重置空中能力计数器(绝大多数情况被 IdleState 覆盖,但水平落地直接进入 RunState 时也需要)
Owner.GetState<AerialDashState>()?.ResetAerialDashes();
Owner.ResetAirJumps();
}
public override void OnStateUpdate()
{
if (!Move.IsGrounded)
{
_owner.TransitionTo(_owner.GetState<FallState>());
return;
}
if (Buffer.ConsumeJump())
{
_owner.TransitionTo(_owner.GetState<JumpState>());
return;
}
// 地面冲刺:需解锁 Dash 能力,且冲刺不在冷却
if (Buffer.ConsumeDash()
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Owner.GetState<DashState>() is { CanDash: true } dashState)
{
_owner.TransitionTo(dashState);
return;
}
if (Mathf.Abs(Input.MoveInput.x) < 0.1f)
{
_owner.TransitionTo(_owner.GetState<IdleState>());
return;
}
}
public override void OnStateFixedUpdate()
{
Move.Move(Input.MoveInput.x * Cfg.RunSpeed);
}
}
}

View File

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

View File

@@ -0,0 +1,55 @@
namespace BaseGames.Player.States
{
/// <summary>
/// 使用灵泉(治疗)状态(架构 05_PlayerModule §2
/// 消耗 SpringCharge播放治愈动画动画结束后回到 Idle。
/// 需在地面且有充能才能进入PlayerController 负责条件检查)。
///
/// 动画在 Overlay LayerLayer 1播放叠加于 Base Layer 的 Idle/Run 之上,
/// 符合架构 05_PlayerModule §14 双层动画设计。
/// </summary>
public class SpringState : PlayerStateBase
{
public SpringState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
// 消耗灵泉充能并治疗PlayerStats.UseSpring 内部回复 HP
bool used = Stats?.UseSpring() ?? false;
if (!used)
{
// 无充能时立即退出
Owner.TransitionTo(Owner.GetState<IdleState>());
return;
}
// 停止移动
Move?.ZeroHorizontalVelocity();
// 在 Overlay LayerLayer 1播放灵泉动画叠加于 Base Layer 的 Idle 之上
if (AnimCfg?.UseSpring != null)
{
var state = Owner.PlayOnOverlay(AnimCfg.UseSpring);
if (state != null)
{
state.Events(this).OnEnd = OnSpringEnd;
return;
}
}
// 无动画则直接结束
OnSpringEnd();
}
public override void OnStateExit()
{
// 淡出叠加层,恢复纯 Base Layer 动画
Owner.StopOverlay(fadeDuration: 0.1f);
}
private void OnSpringEnd()
{
Owner.TransitionTo(Owner.GetState<IdleState>());
}
}
}

View File

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

View File

@@ -0,0 +1,85 @@
// Assets/Scripts/Player/States/SwimState.cs
// 游泳状态玩家在液体中时使用Architecture 21_LiquidPuzzleModule §5
// ⚠️ 遵循 PlayerStateBase 构造函数注入模式(非 MonoBehaviour
// ⚠️ 输入通过 Input.MoveInput / Input.JumpStartedEvent 访问(项目实际 API
using BaseGames.Player;
using BaseGames.World.Liquid;
using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>
/// 游泳状态:玩家在液体中时使用。
/// 需要 AbilityType.Swim 已解锁;若未解锁则由 WaterDangerState 触发溺水流程。
/// 由 PlayerController 在收到 EVT_LiquidEntered 后切换进入。
/// </summary>
public class SwimState : PlayerStateBase
{
private LiquidPhysicsConfigSO _currentPhysics;
private float _originalGravity;
public SwimState(PlayerController owner) : base(owner) { }
/// <summary>由 PlayerController 在切换状态前调用,注入当前液体区域的物理配置。</summary>
public void SetPhysicsConfig(LiquidPhysicsConfigSO config) => _currentPhysics = config;
public override void OnStateEnter()
{
_originalGravity = Move.Rb.gravityScale;
Move.SetGravityScale(_currentPhysics?.GravityScale ?? 0.3f);
if (AnimCfg?.SwimIdle != null)
Anim?.Play(AnimCfg.SwimIdle);
Input.JumpStartedEvent += OnJumpStarted;
}
public override void OnStateExit()
{
Input.JumpStartedEvent -= OnJumpStarted;
Move.SetGravityScale(_originalGravity);
}
public override void OnStateUpdate()
{
var input = Input.MoveInput;
var maxSpeed = _currentPhysics?.MaxSwimSpeed ?? 4f;
var accel = _currentPhysics?.SwimAcceleration ?? 8f;
var drag = _currentPhysics?.DragCoefficient ?? 3f;
var rb = Move.Rb;
if (input != Vector2.zero)
{
var targetVel = input * maxSpeed;
rb.velocity = Vector2.MoveTowards(rb.velocity, targetVel, accel * Time.deltaTime);
if (AnimCfg?.SwimMove != null)
Anim?.Play(AnimCfg.SwimMove);
}
else
{
// 水下浮力(持续向上的微弱力)
rb.AddForce(Vector2.up * (_currentPhysics?.BuoyancyForce ?? 0.5f), ForceMode2D.Force);
if (AnimCfg?.SwimIdle != null)
Anim?.Play(AnimCfg.SwimIdle);
}
// 施加水阻
rb.velocity *= 1f - drag * Time.deltaTime;
}
public override PlayerStateBase GetNextState()
{
// 离开液体区域由 PlayerController 订阅 EVT_LiquidExited 后切换到 FallState
return null;
}
private void OnJumpStarted()
{
// 跳跃键 = 跃出水面冲量
Move.Rb.AddForce(Vector2.up * (_currentPhysics?.SurfaceExitSpeed ?? 5f),
ForceMode2D.Impulse);
}
}
}

View File

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

View File

@@ -0,0 +1,47 @@
using BaseGames.Combat;
namespace BaseGames.Player.States
{
/// <summary>
/// 上劈状态(架构 05_PlayerModule §2
/// 激活 HitBoxUp结束后回到 Idle地面或 FallState空中
/// </summary>
public class UpAttackState : PlayerStateBase
{
public UpAttackState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
Owner.Combat?.EnableWeaponHitBox(AttackDirection.Up);
var clip = Owner.Weapon?.ActiveWeapon?.upAttackClip;
if (clip != null && clip.Clip != null)
{
var state = Anim.Play(clip);
state.Events(this).OnEnd = OnClipEnd;
}
else if (AnimCfg?.UpAttack != null)
{
var state = Anim.Play(AnimCfg.UpAttack);
state.Events(this).OnEnd = OnClipEnd;
}
else
{
OnClipEnd();
}
}
public override void OnStateExit()
{
Owner.Combat?.DisableAllWeaponHitBoxes();
}
private void OnClipEnd()
{
if (Move.IsGrounded)
Owner.TransitionTo(Owner.GetState<IdleState>());
else
Owner.TransitionTo(Owner.GetState<FallState>());
}
}
}

View File

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

View File

@@ -0,0 +1,58 @@
using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>
/// 蹬墙跳状态(架构 05_PlayerModule §2
/// 从 WallSlideState 进入;施加背墙方向的水平 + 垂直速度;
/// 短暂锁定水平输入后转为 FallState。
/// </summary>
public class WallJumpState : PlayerStateBase
{
private float _inputLockTimer;
private int _wallDir;
public WallJumpState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
// 记录墙壁方向(跳跃反向)
_wallDir = Owner.WallDetector != null ? Owner.WallDetector.WallDirection : 0;
if (_wallDir == 0) _wallDir = -Owner.FacingDirection;
// 施加蹬墙跳速度
Move?.WallJump(_wallDir);
// 锁定水平输入
_inputLockTimer = Cfg.WallJumpInputLockDuration;
// 播放跳跃动画(复用跳跃动画)
if (AnimCfg?.Jump != null) Anim?.Play(AnimCfg.Jump);
Input.JumpCancelledEvent += OnJumpCancelled;
}
public override void OnStateExit()
{
Input.JumpCancelledEvent -= OnJumpCancelled;
}
public override void OnStateUpdate()
{
_inputLockTimer -= Time.deltaTime;
// 上升结束 → 下落
if (!Move.IsRising)
{
Owner.TransitionTo(Owner.GetState<FallState>());
return;
}
// 输入锁结束后允许水平控制
if (_inputLockTimer <= 0f && Mathf.Abs(Input.MoveInput.x) > 0.01f)
Move?.Move(Input.MoveInput.x * Cfg.RunSpeed);
}
private void OnJumpCancelled() => Move?.CutJump();
}
}

View File

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

View File

@@ -0,0 +1,55 @@
using UnityEngine;
namespace BaseGames.Player.States
{
/// <summary>
/// 壁滑状态(架构 05_PlayerModule §2
/// 需有 PlayerWallDetector.IsTouchingWall == true 才能进入;
/// 限制下落速度为 WallSlideSpeed按跳跃则切换到 WallJumpState。
/// </summary>
public class WallSlideState : PlayerStateBase
{
public WallSlideState(PlayerController owner) : base(owner) { }
public override void OnStateEnter()
{
if (AnimCfg?.WallSlide != null)
Anim?.Play(AnimCfg.WallSlide);
Input.JumpStartedEvent += OnJumpPressed;
}
public override void OnStateExit()
{
Input.JumpStartedEvent -= OnJumpPressed;
}
public override void OnStateUpdate()
{
// 离开墙壁 → 下落
if (Owner.WallDetector == null || !Owner.WallDetector.IsTouchingWall)
{
Owner.TransitionTo(Owner.GetState<FallState>());
return;
}
// 着地 → 闲置
if (Move.IsGrounded)
{
Owner.TransitionTo(Owner.GetState<IdleState>());
return;
}
}
public override void OnStateFixedUpdate()
{
// 限制下落速度(壁滑缓慢下落)
Move?.ApplyWallSlide();
}
private void OnJumpPressed()
{
Owner.TransitionTo(Owner.GetState<WallJumpState>());
}
}
}

View File

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

View File

@@ -0,0 +1,69 @@
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Player
{
/// <summary>
/// 武器 HitBox 实例。
/// 由 WeaponManager 在切换武器时实例化到角色的 [WeaponSocket] 子节点下,
/// 武器卸下(切换)时销毁。
///
/// Prefab 内部层级示例:
/// [WPN_SkyBlade_HitBox]
/// ├── [HitBox_Ground] ← BoxCollider2D, Layer = PlayerHitBox
/// ├── [HitBox_Up]
/// ├── [HitBox_Down]
/// └── [HitBox_Air]
///
/// 命名规范Assets/Prefabs/Weapons/WPN_{weaponId}_HitBox.prefab
/// </summary>
public class WeaponHitBoxInstance : MonoBehaviour
{
[SerializeField] private HitBox _hitBoxGround;
[SerializeField] private HitBox _hitBoxUp;
[SerializeField] private HitBox _hitBoxDown;
[SerializeField] private HitBox _hitBoxAir;
/// <summary>下劈 HitBox 命中确认事件(供 PlayerCombat 转发给 DownAttackState pogo 逻辑)。</summary>
public event System.Action<DamageInfo> OnDownHitConfirmed;
private void Awake()
{
if (_hitBoxDown != null)
_hitBoxDown.OnHitConfirmed += info => OnDownHitConfirmed?.Invoke(info);
}
// ── 公共 API ──────────────────────────────────────────────────────────
/// <summary>激活指定方向的 HitBox。</summary>
public void Activate(AttackDirection dir, DamageSourceSO source, Transform attacker)
=> GetHitBox(dir)?.Activate(source, attacker);
/// <summary>停用指定方向的 HitBox。</summary>
public void Deactivate(AttackDirection dir)
=> GetHitBox(dir)?.Deactivate();
/// <summary>停用所有方向的 HitBox。</summary>
public void DeactivateAll()
{
_hitBoxGround?.Deactivate();
_hitBoxUp?.Deactivate();
_hitBoxDown?.Deactivate();
_hitBoxAir?.Deactivate();
}
/// <summary>切换连击段伤害源(不改变激活状态,供 PlayerCombat.SetComboSegmentSource 调用)。</summary>
public void SetDamageSource(AttackDirection dir, DamageSourceSO source)
=> GetHitBox(dir)?.SetDamageSource(source);
/// <summary>按方向查询对应 HitBox 组件。</summary>
public HitBox GetHitBox(AttackDirection dir) => dir switch
{
AttackDirection.Ground => _hitBoxGround,
AttackDirection.Up => _hitBoxUp,
AttackDirection.Down => _hitBoxDown,
AttackDirection.Air => _hitBoxAir,
_ => null,
};
}
}

View File

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

View File

@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace BaseGames.Player
{
/// <summary>
/// 武器管理器。
/// 监听 FormController.OnFormChanged依据当前形态切换 ActiveWeapon。
/// 支持护符 Override护符调用 SetOverride() 临时替换特定形态的武器。
/// 架构 05_PlayerModule §7。
/// </summary>
public class WeaponManager : MonoBehaviour
{
[SerializeField] private FormController _formController;
[SerializeField] private WeaponSO _startingWeapon; // 无 FormController 时的回退武器
[Header("HitBox 挂载点")]
[Tooltip("武器 HitBox Prefab 实例化的父节点,应为 Player 层级下的 [WeaponSocket] ")]
[SerializeField] private Transform _weaponSocket;
public WeaponSO ActiveWeapon { get; private set; }
public WeaponHitBoxInstance ActiveHitBoxInstance { get; private set; }
public event Action<WeaponSO> OnWeaponChanged;
// 护符注入的武器覆盖Key = FormSO.formIdValue = 替换武器(架构 05 §7
private readonly Dictionary<string, WeaponSO> _overrides = new();
private void Awake()
{
if (_formController != null && _formController.CurrentForm != null)
ApplyWeapon(_formController.CurrentForm);
else if (_startingWeapon != null)
SetDirectWeapon(_startingWeapon);
}
private void OnEnable()
{
if (_formController != null)
_formController.OnFormChanged += HandleFormChanged;
}
private void OnDisable()
{
if (_formController != null)
_formController.OnFormChanged -= HandleFormChanged;
}
private void Start()
{
// 若 FormController 在 Awake 时 CurrentForm 尚未初始化,在 Start 重试
if (ActiveWeapon == null && _formController != null && _formController.CurrentForm != null)
ApplyWeapon(_formController.CurrentForm);
else if (ActiveWeapon == null && _startingWeapon != null)
SetDirectWeapon(_startingWeapon);
}
// ── 内部切换 ───────────────────────────────────────────────────────────
private void HandleFormChanged() => ApplyWeapon(_formController.CurrentForm);
private void ApplyWeapon(FormSO form)
{
if (form == null) return;
WeaponSO next = _overrides.TryGetValue(form.formId, out var ov) ? ov : form.defaultWeapon;
if (next == ActiveWeapon) return;
SetDirectWeapon(next);
}
private void SetDirectWeapon(WeaponSO weapon)
{
var oldInstance = ActiveHitBoxInstance;
ActiveWeapon = weapon;
ActiveHitBoxInstance = null;
if (weapon?.hitBoxPrefab != null && _weaponSocket != null)
{
var go = Instantiate(weapon.hitBoxPrefab, _weaponSocket);
ActiveHitBoxInstance = go.GetComponent<WeaponHitBoxInstance>();
}
// 先通知订阅者(使其有机会取消对旧实例的事件订阅),再销毁旧实例
OnWeaponChanged?.Invoke(weapon);
if (oldInstance != null)
Destroy(oldInstance.gameObject);
}
// ── 护符 Override API由 WeaponOverrideEffect 调用,架构 05 §7──────
/// <summary>为指定形态设置武器覆盖。formId 为空 = 覆盖所有形态。</summary>
public void SetOverride(string formId, WeaponSO weapon)
{
if (string.IsNullOrEmpty(formId))
{
if (_formController != null)
foreach (var f in _formController.AllForms)
_overrides[f.formId] = weapon;
}
else
{
_overrides[formId] = weapon;
}
if (_formController?.CurrentForm != null)
ApplyWeapon(_formController.CurrentForm);
}
/// <summary>移除覆盖恢复默认武器。formId 为空 = 移除所有形态覆盖。</summary>
public void ClearOverride(string formId)
{
if (string.IsNullOrEmpty(formId))
{
_overrides.Clear();
}
else
{
_overrides.Remove(formId);
}
if (_formController?.CurrentForm != null)
ApplyWeapon(_formController.CurrentForm);
}
}
}

View File

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

View File

@@ -0,0 +1,91 @@
using System;
using UnityEngine;
using Animancer;
using BaseGames.Combat;
using BaseGames.Feedback;
namespace BaseGames.Player
{
/// <summary>
/// 武器数据 SO纯数据不含 Prefab 引用)。
/// 每个攻击方向对应一个 DamageSourceSO 和 ClipTransition。
/// HitBox 直接挂载在 Player Prefab 上(架构 05_PlayerModule §7
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Player/Weapon")]
public class WeaponSO : ScriptableObject
{
[Header("基础信息")]
public string weaponId; // 全局唯一 ID如 "Weapon_SkyBlade"
public string displayName; // 显示名,如 "天裂刃"
public Sprite icon;
public WeaponType weaponType;
[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 Prefab")]
[Tooltip("武器专属 HitBox Prefab内含 WeaponHitBoxInstance。\nWeaponManager 在切换武器时实例化于 [WeaponSocket] \n命名规范Assets/Prefabs/Weapons/WPN_{weaponId}_HitBox.prefab")]
public GameObject hitBoxPrefab;
[Header("武器特效")]
public WeaponVFXConfig vfxConfig;
[Header("战斗参数")]
[Tooltip("命中确认时增加的灵力值(覆盖 PlayerCombat 默认值 10")]
[Min(0)]
public int soulPowerGain = 10;
// ── 方向查询 ──────────────────────────────────────────────────────────
public DamageSourceSO GetSourceByDir(AttackDirection dir) => dir switch
{
AttackDirection.Ground => attack1Source,
AttackDirection.Up => upAttackSource,
AttackDirection.Down => downAttackSource,
AttackDirection.Air => airAttackSource,
_ => attack1Source,
};
public ClipTransition GetClipByCombo(int comboIndex) => comboIndex switch
{
0 => attack1Clip,
1 => attack2Clip,
2 => attack3Clip,
_ => attack1Clip,
};
}
// ── 武器类型枚举(架构 05 §7──────────────────────────────────────────────
public enum WeaponType
{
SkyBlade, // 天魂:裂空刃(高频轻击)
EarthHammer, // 地魂:地震锤(低频重击,范围大)
LifeScythe, // 命魂:命镰(穿透,直线斩)
Custom, // 护符替换或未来扩展武器
}
// ── 武器特效配置([Serializable] 内嵌,架构 05 §7────────────────────────
[Serializable]
public class WeaponVFXConfig
{
[Tooltip("切换到此武器时播放的特效预设 ID对应 IFeedbackPlayer.TriggerPreset")]
public string onEquipPresetId;
[Tooltip("武器挥斩拖尾 Prefabnull = 不显示拖尾)")]
public GameObject weaponTrailPrefab;
public Color trailColor = Color.white;
}
}

View File

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