修复内容:

PlayerMovement:新增 _facingLocked 字段 + LockFacing(bool) 方法;UpdateFacing() 锁定时直接返回
WallSlideState:OnStateEnter 调用 LockFacing(true) + FlipFacing(_wallDir);OnStateExit 调用 LockFacing(false) 解锁
WallJumpState:OnStateEnter 保险性再调一次 LockFacing(false);WallJumpAway/Toward 同步写入 _inputVelocityX,确保解锁后 UpdateFacing 朝向正确(背墙跳 = 离墙方向,对墙跳 = 朝墙方向)
This commit is contained in:
2026-05-22 10:48:52 +08:00
parent 285ac46e31
commit 68d4c699ae
15 changed files with 235 additions and 418 deletions

View File

@@ -775,9 +775,13 @@ namespace BaseGames.Editor
{
var report = new List<string>();
GameObject go = new GameObject("MovingPlatform");
Undo.RegisterCreatedObjectUndo(go, "Place Moving Platform");
go.transform.position = GetDropPosition();
// 根节点:平台实体 + 路径点都挂在此节点下,路径点不随平台本体移动
GameObject root = new GameObject("MovingPlatform_Root");
Undo.RegisterCreatedObjectUndo(root, "Place Moving Platform");
root.transform.position = GetDropPosition();
// 平台实体:作为 root 子节点
GameObject go = GetOrCreateChild(root.transform, "MovingPlatform").gameObject;
SetLayer(go, "Platform", report);
Rigidbody2D rb = GetOrAddComponent<Rigidbody2D>(go);
@@ -796,22 +800,23 @@ namespace BaseGames.Editor
sensorCol.size = new Vector2(3.8f, 0.25f);
sensorCol.offset = new Vector2(0f, 0.33f);
// Waypoint markers (LinearAB mode end points)
Transform wpA = GetOrCreateChild(go.transform, "WaypointA");
Transform wpB = GetOrCreateChild(go.transform, "WaypointB");
wpA.localPosition = new Vector3(-3f, 0f, 0f);
wpB.localPosition = new Vector3(3f, 0f, 0f);
// 路径点:挂在 root 下而非平台下,平台移动时路径点位置不变
Transform wpA = GetOrCreateChild(root.transform, "WaypointA");
Transform wpB = GetOrCreateChild(root.transform, "WaypointB");
wpA.position = root.transform.position + new Vector3(-3f, 0f, 0f);
wpB.position = root.transform.position + new Vector3( 3f, 0f, 0f);
MovingPlatform platform = GetOrAddComponent<MovingPlatform>(go);
AssignReference(platform, "_passengerSensor", sensorCol, report);
AssignLayerMask(platform, "_passengerLayer", new[] { "Player", "Enemy" }, report);
AssignObjectArray(platform, "_wayPoints", new Object[] { wpA, wpB }, report);
report.Add("WaypointA / WaypointB 为移动端点,可将其拖出平台并在场景中调整位置。");
report.Add("WaypointA / WaypointB 已挂在 MovingPlatform_Root 下(非平台子节点),平台移动时路径点保持原位。");
report.Add("在场景中调整 WaypointA / WaypointB 的世界位置即可设置移动端点。");
report.Add("如需触发激活,改 _moveType = TriggeredLinear 并将 VoidEventChannelSO 拖入 _activationChannel。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Moving Platform", go, report);
Selection.activeGameObject = root;
MarkDirtyAndLog("Moving Platform", root, report);
}
[MenuItem("BaseGames/Scene/Place/Tilemap Ground", priority = 160)]

View File

@@ -30,6 +30,7 @@ namespace BaseGames.Input
public event Action SpiritSkill2CancelledEvent;
public event Action SpellCastEvent;
public event Action InteractEvent;
public event Action InteractCancelledEvent;
// ── UI Events ─────────────────────────────────────────────────────────
public event Action PauseEvent;
@@ -177,7 +178,8 @@ namespace BaseGames.Input
BindStarted(_gameplay, "SpiritSkill2", () => SpiritSkill2StartedEvent?.Invoke());
BindCanceled(_gameplay, "SpiritSkill2", () => SpiritSkill2CancelledEvent?.Invoke());
BindStarted(_gameplay, "Spell", () => SpellCastEvent?.Invoke());
BindStarted(_gameplay, "Interact", () => InteractEvent?.Invoke());
BindStarted(_gameplay, "Interact", () => InteractEvent?.Invoke());
BindCanceled(_gameplay, "Interact", () => InteractCancelledEvent?.Invoke());
BindStarted(_gameplay, "Pause", HandlePause);

View File

@@ -43,6 +43,7 @@ namespace BaseGames.Player
private bool _isWallRight;
private bool _onOneWayPlatform;
private int _facingDirection = 1;
private bool _facingLocked; // 为 true 时 UpdateFacing() 不覆盖朝向
private bool _cancelWindowOpen;
private SurfaceType _currentSurface = SurfaceType.Ground;
private readonly Collider2D[] _groundBuffer = new Collider2D[4];
@@ -213,6 +214,7 @@ namespace BaseGames.Player
// ── 朝向 ──────────────────────────────────────────────────────────────
public void UpdateFacing()
{
if (_facingLocked) return;
// 读取玩家输入速度(不含平台分量),避免平台横向运动驱动朝向翻转。
float vx = _inputVelocityX;
if (Mathf.Abs(vx) < 0.1f) return;
@@ -247,6 +249,13 @@ namespace BaseGames.Player
transform.localScale = new Vector3(dir, 1f, 1f);
}
/// <summary>
/// 锁定/解锁自动朝向UpdateFacing
/// 传入 true 后 UpdateFacing 不再根据输入速度覆盖朝向,
/// 直到传入 false 解锁。适用于抓墙、蹬墙跳等需要手动控制朝向的状态。
/// </summary>
public void LockFacing(bool locked) => _facingLocked = locked;
// ── 取消窗口 ──────────────────────────────────────────────────────────
public void SetCancelWindowOpen(bool open) => _cancelWindowOpen = open;
@@ -318,7 +327,8 @@ namespace BaseGames.Player
/// </summary>
public void WallJumpAway(int wallDir)
{
_rb.velocity = new Vector2(-wallDir * _config.WallJumpAwayForceX, _config.WallJumpAwayForceY);
_inputVelocityX = -wallDir * _config.WallJumpAwayForceX;
_rb.velocity = new Vector2(_inputVelocityX, _config.WallJumpAwayForceY);
_coyoteTimer = 0f;
}
@@ -328,7 +338,8 @@ namespace BaseGames.Player
/// </summary>
public void WallJumpToward(int wallDir)
{
_rb.velocity = new Vector2(wallDir * _config.WallJumpTowardForceX, _config.WallJumpTowardForceY);
_inputVelocityX = wallDir * _config.WallJumpTowardForceX;
_rb.velocity = new Vector2(_inputVelocityX, _config.WallJumpTowardForceY);
_coyoteTimer = 0f;
}

View File

@@ -195,6 +195,18 @@ namespace BaseGames.Player
OnDamaged?.Invoke();
}
/// <summary>
/// 强制即死,无视无敌帧(危险区域、深渊等环境击杀专用)。
/// GodMode 下仍然豁免。
/// </summary>
public void Kill()
{
if (_isGodMode || !IsAlive) return;
CurrentHP = 0;
_onHPChanged?.Raise(CurrentHP);
OnDamaged?.Invoke();
}
public void FullHeal()
{
if (!IsAlive) return;

View File

@@ -38,6 +38,9 @@ namespace BaseGames.Player.States
public override void OnStateEnter()
{
// 蹬墙时解锁自动朝向(由 WallSlideState.OnStateExit 已解锁,这里保险再做一次)
Move?.LockFacing(false);
// 施加对应类型的速度
if (_isAwayJump)
{

View File

@@ -62,6 +62,11 @@ namespace BaseGames.Player.States
_lastGrabDir = _wallDir;
}
// 锁定自动朝向,防止 LateUpdate 的 UpdateFacing 覆盖手动设置的朝向
Move?.LockFacing(true);
// 抓墙时始终面朝墙壁,确保背墙跳的 FlipFacing(-_wallDir) 能正确翻转朝向
Move?.FlipFacing(_wallDir);
// 计算当前是否处于正常模式
UpdateCanJump();
@@ -76,6 +81,7 @@ namespace BaseGames.Player.States
public override void OnStateExit()
{
Move?.LockFacing(false);
Input.JumpStartedEvent -= OnJumpPressed;
}
@@ -98,6 +104,25 @@ namespace BaseGames.Player.States
return;
}
// ── 抓墙攻击:优先于方向键脱离检测,朝离墙方向翻转后进入空中攻击(无土狼时间)──
if (Buffer.ConsumeAttack())
{
Move.FlipFacing(-_wallDir);
Owner.TransitionTo(Owner.GetState<AirAttackState>());
return;
}
// ── 抓墙冲刺:优先于方向键脱离检测,朝离墙方向翻转后冲出(无土狼时间)──────────
var ds = Owner.GetState<DashState>();
if (ds != null && ds.CanDashMidAir
&& Stats != null && Stats.HasAbility(AbilityType.Dash)
&& Buffer.ConsumeDash())
{
Move.FlipFacing(-_wallDir);
Owner.TransitionTo(ds);
return;
}
// 按下方向键 → 启动墙壁土狼时间后主动脱离,自然下落
if (Input.MoveInput.y < -0.5f)
{
@@ -107,7 +132,6 @@ namespace BaseGames.Player.States
}
// 按反方向键 → 启动墙壁土狼时间后脱离
// wall coyote 存在时,离墙后短窗口内仍可触发蹬墙跳,不会误双跳
float mx = Input.MoveInput.x;
if (Mathf.Abs(mx) > 0.1f && (mx > 0f ? 1 : -1) != _wallDir)
{

View File

@@ -18,6 +18,11 @@ namespace BaseGames.World
[SerializeField] private SceneFeedback _crumbleFeedback; // 预警震动 + 碎裂粒子 + 音效
[SerializeField] private BoxCollider2D _passengerSensor; // IsTrigger检测玩家踩踏
[Header("持久化_isOneShot = true 时生效)")]
[Tooltip("平台唯一 ID。_isOneShot=true 时碎裂状态写入 WorldStateRegistry重载场景后不复原。留空则不持久化。")]
[SerializeField] private string _platformId;
[SerializeField] private WorldStateRegistry _worldState;
private BoxCollider2D _col;
private SpriteRenderer _sr;
private bool _isCrumbling;
@@ -28,6 +33,18 @@ namespace BaseGames.World
_sr = GetComponent<SpriteRenderer>();
}
private void Start()
{
// 读档恢复_isOneShot 平台已碎裂则直接禁用,无需等待触发
if (!_isOneShot || string.IsNullOrEmpty(_platformId) || _worldState == null) return;
if (!_worldState.HasFlag("crumble_" + _platformId)) return;
_isCrumbling = true;
_col.enabled = false;
_sr.enabled = false;
if (_passengerSensor != null) _passengerSensor.enabled = false;
}
private void OnTriggerEnter2D(Collider2D other)
{
if (_isCrumbling) return;
@@ -53,7 +70,12 @@ namespace BaseGames.World
_passengerSensor.enabled = false;
if (_isOneShot || _respawnDelay <= 0f)
yield break; // 永久消失
{
// 持久化一次性碎裂状态,场景重载后不复原
if (_isOneShot && !string.IsNullOrEmpty(_platformId))
_worldState?.SetFlag("crumble_" + _platformId);
yield break;
}
// 4. Respawn
yield return new WaitForSeconds(_respawnDelay);

View File

@@ -51,10 +51,15 @@ namespace BaseGames.World
// ── Physics Triggers ──────────────────────────────────────────────────
/// <summary>
/// 判断碰撞体是否为有效触发来源。子类可覆写以扩展触发主体(如幻影身体)。
/// </summary>
protected virtual bool IsValidTriggerBody(Collider2D col) => col.CompareTag("Player");
private void OnTriggerEnter2D(Collider2D other)
{
if (_triggerCondition != TriggerCondition.PlayerBody) return;
if (!other.CompareTag("Player")) return;
if (!IsValidTriggerBody(other)) return;
if (!CheckSide(other.transform.position)) return;
TryActivate();
}
@@ -62,7 +67,7 @@ namespace BaseGames.World
private void OnTriggerExit2D(Collider2D other)
{
if (_triggerCondition != TriggerCondition.PlayerBody) return;
if (!other.CompareTag("Player") || _isOneShot) return;
if (!IsValidTriggerBody(other) || _isOneShot) return;
_activated = false;
_deactivationChannel?.Raise();
}
@@ -106,13 +111,14 @@ namespace BaseGames.World
private void Start()
{
// 读档恢复:若机关已激活则静默还原
// 读档恢复:仅标记 _activated = true不广播激活事件。
// 下游组件PuzzleReceiver / 动画门等)已通过各自的 WorldStateRegistry 检查在 Start 中自行恢复,
// 重复广播会导致它们再次播放开门动画等副作用。
if (_isOneShot && !string.IsNullOrEmpty(_interactableId)
&& _worldState != null
&& _worldState.HasFlag("mechanism_" + _interactableId))
{
_activated = true;
_activationChannel?.Raise();
}
}
}

View File

@@ -4,36 +4,67 @@ using UnityEngine;
namespace BaseGames.World
{
/// <summary>
/// 危险区域。玩家进入时触发即死或持续伤害。
/// 危险区域。玩家进入时触发即死(调用 Kill(),无视无敌帧)或持续伤害。
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class HazardZone : MonoBehaviour
{
[SerializeField] private bool _isInstantKill = true;
[SerializeField] private int _damage = 9999;
[SerializeField] private bool _isInstantKill = true;
[SerializeField] private int _damage = 1;
[Tooltip("持续伤害模式下每次造成伤害的间隔(秒)。仅 _isInstantKill = false 时生效。")]
[SerializeField] private float _damageInterval = 0.5f;
private PlayerStats _cachedStats;
private float _damageTimer;
private bool _playerInside;
private void OnTriggerEnter2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
_cachedStats = other.GetComponentInParent<PlayerStats>();
if (_cachedStats == null) return;
var stats = other.GetComponentInParent<PlayerStats>();
if (stats == null) return;
_playerInside = true;
ApplyDamage();
_damageTimer = _damageInterval;
}
private void OnTriggerExit2D(Collider2D other)
{
if (!other.CompareTag("Player")) return;
_playerInside = false;
_cachedStats = null;
}
private void Update()
{
if (!_playerInside || _isInstantKill || _cachedStats == null) return;
_damageTimer -= Time.deltaTime;
if (_damageTimer <= 0f)
{
ApplyDamage();
_damageTimer = _damageInterval;
}
}
private void ApplyDamage()
{
if (_cachedStats == null) return;
if (_isInstantKill)
stats.TakeDamage(stats.MaxHP * 2); // 确保即死(超过最大血量)
_cachedStats.Kill();
else
stats.TakeDamage(_damage);
_cachedStats.TakeDamage(_damage);
}
private void OnDrawGizmos()
{
Gizmos.color = new Color(1f, 0f, 0f, 0.3f);
var col = GetComponent<Collider2D>();
if (col != null)
Gizmos.DrawCube(transform.position, col.bounds.size);
if (col == null) return;
Gizmos.color = new Color(1f, 0f, 0f, 0.3f);
Gizmos.DrawCube(transform.position, col.bounds.size);
Gizmos.color = new Color(1f, 0f, 0f, 0.8f);
if (col != null)
Gizmos.DrawWireCube(transform.position, col.bounds.size);
Gizmos.DrawWireCube(transform.position, col.bounds.size);
}
}
}

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using BaseGames.Core.Events;
using BaseGames.Input;
using UnityEngine;
@@ -22,8 +23,19 @@ namespace BaseGames.World
// 预分配检测缓冲区,避免 OverlapCircleAll 每帧 GC 分配
private readonly Collider2D[] _overlapBuffer = new Collider2D[16];
private void OnEnable() => _inputReader.InteractEvent += TryInteract;
private void OnDisable() => _inputReader.InteractEvent -= TryInteract;
// Collider → IInteractable 缓存,避免 FindNearest 每帧重复 GetComponentInParent
private readonly Dictionary<Collider2D, IInteractable> _componentCache = new();
private void OnEnable()
{
_inputReader.InteractEvent += TryInteract;
}
private void OnDisable()
{
_inputReader.InteractEvent -= TryInteract;
_componentCache.Clear(); // 清理缓存,防止跨场景持有旧引用
}
private void Update()
{
@@ -57,20 +69,29 @@ namespace BaseGames.World
private IInteractable FindNearest(Collider2D[] hits, int count)
{
IInteractable best = null;
float bestDist = float.MaxValue;
IInteractable best = null;
float bestSqrDist = float.MaxValue;
for (int i = 0; i < count; i++)
{
var col = hits[i];
var interactable = col.GetComponentInParent<IInteractable>();
if (col == null) continue;
// 查缓存,未命中时才调用 GetComponentInParent避免每帧反射开销
if (!_componentCache.TryGetValue(col, out var interactable))
{
interactable = col.GetComponentInParent<IInteractable>();
_componentCache[col] = interactable;
}
if (interactable == null || !interactable.CanInteract) continue;
float dist = Vector2.Distance(transform.position, col.transform.position);
if (dist < bestDist)
// 用 sqrMagnitude 比较距离,省去 Distance 的 sqrt 开销
float sqrDist = ((Vector2)transform.position - (Vector2)col.transform.position).sqrMagnitude;
if (sqrDist < bestSqrDist)
{
bestDist = dist;
best = interactable;
bestSqrDist = sqrDist;
best = interactable;
}
}

View File

@@ -146,6 +146,7 @@ namespace BaseGames.World
if (_moveType == MoveType.LinearAB)
{
if (_wayPoints.Length < 2) return; // 至少需要两个路径点
_movingForward = !_movingForward;
_waypointIndex = _movingForward ? 1 : 0;
}

View File

@@ -4,6 +4,7 @@ namespace BaseGames.World
{
/// <summary>
/// 幻影可交互机关。继承 DirectionalInteractable额外响应 PhantomBody 层(太虚斩形态)。
/// 通过覆写 IsValidTriggerBody 扩展触发主体_triggerCondition 条件检查由父类统一处理。
/// </summary>
public class PhantomInteractable : DirectionalInteractable
{
@@ -11,13 +12,7 @@ namespace BaseGames.World
private void Awake() => _phantomBodyLayer = LayerMask.NameToLayer("PhantomBody");
private void OnTriggerEnter2D(Collider2D other)
{
bool isPlayer = other.CompareTag("Player");
bool isPhantom = other.gameObject.layer == _phantomBodyLayer;
if (!isPlayer && !isPhantom) return;
TryActivate();
}
protected override bool IsValidTriggerBody(Collider2D col)
=> col.CompareTag("Player") || col.gameObject.layer == _phantomBodyLayer;
}
}

View File

@@ -2,6 +2,7 @@
using System;
using Animancer;
using BaseGames.Feedback;
using BaseGames.Input;
using BaseGames.World;
using UnityEngine;
@@ -38,6 +39,9 @@ namespace BaseGames.Puzzle
[Header("持久化SO 注入,非 Instance 单例)")]
[SerializeField] private WorldStateRegistry _worldState;
[Header("Hold 模式输入SwitchTriggerMode.Hold 时必填)")]
[SerializeField] private InputReaderSO _inputReader;
private bool _isActive;
public bool IsActive => _isActive;
@@ -59,16 +63,41 @@ namespace BaseGames.Puzzle
// ── IInteractable ────────────────────────────────────────────────────
public string InteractPrompt => _mode == SwitchTriggerMode.Hold ? "按住交互" : "交互";
public bool CanInteract => true;
/// <summary>
/// 压板模式不需要交互提示物理触发InteractOnce 已激活后隐藏提示。
/// </summary>
public bool CanInteract => _mode switch
{
SwitchTriggerMode.InteractOnce => !_isActive,
SwitchTriggerMode.InteractToggle => true,
SwitchTriggerMode.Hold => true,
SwitchTriggerMode.Pressure => false,
_ => false,
};
public void Interact(Transform player)
{
// Hold 模式通过 OnPlayerEnterRange 订阅输入事件处理,此处不响应
if (_mode == SwitchTriggerMode.Hold) return;
if (_mode == SwitchTriggerMode.InteractOnce && _isActive) return;
SetState(!_isActive);
}
public void OnPlayerEnterRange(Transform player) { }
public void OnPlayerExitRange() { }
public void OnPlayerEnterRange(Transform player)
{
if (_mode != SwitchTriggerMode.Hold || _inputReader == null) return;
_inputReader.InteractEvent += OnHoldStarted;
_inputReader.InteractCancelledEvent += OnHoldCancelled;
}
public void OnPlayerExitRange()
{
UnsubscribeHold();
// 玩家离开范围时停用 Hold 开关
if (_mode == SwitchTriggerMode.Hold && _isActive)
SetState(false);
}
// ── ISwitchable ──────────────────────────────────────────────────────
public void ForceState(bool active) => SetState(active);
@@ -106,5 +135,19 @@ namespace BaseGames.Puzzle
else _worldState?.ClearFlag("switch_" + _switchId);
}
}
// ── Hold 模式辅助 ──────────────────────────────────────────────────────
private void OnHoldStarted() => SetState(true);
private void OnHoldCancelled() => SetState(false);
private void UnsubscribeHold()
{
if (_inputReader == null) return;
_inputReader.InteractEvent -= OnHoldStarted;
_inputReader.InteractCancelledEvent -= OnHoldCancelled;
}
private void OnDestroy() => UnsubscribeHold();
}
}