Files
zeling_v2/Docs/Design/26_WallMechanicsSystem.md
2026-05-08 11:04:00 +08:00

17 KiB
Raw Permalink Blame History

26 · 墙壁力学系统

命名空间 BaseGames.Player
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.PlayerPlayerMovement、PlayerGroundDetector


目录

  1. 系统总览
  2. 墙壁检测架构
  3. 抓墙机制WallGrab
  4. 墙跳(两种类型)
  5. 高度记录机制wallGrabY
  6. 动量保留规则
  7. WallMechanicsConfigSO
  8. PlayerWallDetector — 完整实现
  9. AirState 集成
  10. 编辑器友好设计

1. 系统总览

墙壁力学职责:
  ├─ 墙壁检测          → 左右两侧是否接触 Wall Layer
  ├─ 抓墙WallGrab  → 空中贴墙 + 朝墙方向输入 → 自动吸附,维持不需长按
  ├─ 高度记录机制      → 首次抓墙记录 wallGrabY高于该值时强制下滑 + 禁止墙跳
  ├─ 背墙跳            → 无输入或背离输入 → 斜向远离墙壁
  ├─ 对墙跳            → 朝墙输入 → 接近垂直向上
  └─ 动量保留          → 墙跳后水平反向动量不被输入立即消除

零耦合PlayerWallDetector 是独立组件,检测结果通过属性暴露给 AirState;不直接修改 Rigidbody2D 速度,由 PlayerMovement 执行。

与 Double Jump 的关系:抓墙跳视为第一跳(消耗初始跳跃机会),不消耗双跳次数;离墙后双跳仍可用。


2. 墙壁检测架构

射线布局

每侧使用两条 Raycast(上/下),避免在墙脚处误判:

  ├── WallRay_TopLeft    ──►   │  ◄── WallRay_TopRight
  │                            │
  ├── WallRay_BotLeft    ──►   │  ◄── WallRay_BotRight
void CheckWalls()
{
    float w = _config.WallRayLength;
    LayerMask mask = _config.WallLayers;

    bool hitTopL = Physics2D.Raycast(_topLeft,  Vector2.left,  w, mask);
    bool hitBotL = Physics2D.Raycast(_botLeft,  Vector2.left,  w, mask);
    bool hitTopR = Physics2D.Raycast(_topRight, Vector2.right, w, mask);
    bool hitBotR = Physics2D.Raycast(_botRight, Vector2.right, w, mask);

    // 两条均命中才视为"贴墙"(防止墙脚/顶角误判)
    IsWallLeft  = hitTopL && hitBotL;
    IsWallRight = hitTopR && hitBotR;
    IsOnWall    = IsWallLeft || IsWallRight;
}

射线偏移量

偏移 说明
水平偏移 ±(碰撞体半宽 + 0.02f 从碰撞体外边缘发出
顶部射线 Y 偏移 +0.4f 相对碰撞体中心
底部射线 Y 偏移 -0.2f 相对碰撞体中心
射线长度 0.1f WallRayLength,不超过 Tilemap 单元格宽度

3. 抓墙机制WallGrab

触发条件

条件 说明
IsOnWall == true 贴墙检测命中
!IsGrounded 不在地面上
水平输入朝向墙体 Move.x 方向与墙体方向一致
PlayerStats.HasWallGrab == true 默认已解锁WallGrab 为基础能力)

触发后不需要持续按住IsGrabbing 标志维持直到玩家主动离开墙壁或落地。

状态分支(基于 wallGrabY见 §5

当前 Y 位置 行为 动画
transform.position.y > wallGrabY 强制下滑(WallGrabSlideSpeed禁止墙跳 WallGrabSlide
transform.position.y <= wallGrabY 静止悬挂(重力 = 0允许墙跳 WallGrab

强制下滑(高于 wallGrabY 时)

// PlayerMovement.FixedUpdate()
if (_wallDetector.IsGrabbing && !_wallDetector.CanWallJump)
{
    _rb.velocity = new Vector2(0f, -_wallConfig.WallGrabSlideSpeed);
}
参数 说明
WallGrabSlideSpeed 2.0 units/s 强制下滑速度(不可被输入覆盖)

静止悬挂at/below wallGrabY 时)

// 重力归零,水平速度清除
_rb.gravityScale = 0f;
_rb.velocity = Vector2.zero;

退出条件

条件 动作
落地 ReleaseGrab(),重置 wallGrabY
水平输入背离墙体 ReleaseGrab()
接触新的不同墙面 ReleaseGrab() 后重新 TryGrab()
执行墙跳 ReleaseGrab()(不重置 wallGrabY,墙跳后不可立即重抓同一点)

动画

  • WallGrab静止悬挂Layer[0] 全身)
  • WallGrabSlide强制下滑Layer[0] 全身)
  • AirState 检测 IsGrabbing 选择动画,!IsGrabbing 时恢复 Fall 动画

4. 墙跳(两种类型)

抓墙状态下(CanWallJump == true)按跳跃键触发,行为由当前水平输入决定:

类型 触发输入 水平分量 垂直分量 说明
背墙跳 无输入 或 背离墙体 WallJumpAwayHForce(远离墙) WallJumpVerticalForce 斜向弹出,远离墙壁
对墙跳 朝向墙体输入 WallJumpTowardHForce(贴近墙,微小) WallJumpVerticalForce 接近垂直向上,略带贴墙倾向

实现

void DoWallJump()
{
    if (!_wallDetector.CanWallJump) return;  // 高于 wallGrabY 时禁跳

    int awayDir = _wallDetector.IsWallLeft ? 1 : -1;  // 远离墙壁的方向
    float inputX = _inputReader.MoveInput.x;
    bool towardWall = (awayDir == 1 && inputX < -0.3f) || (awayDir == -1 && inputX > 0.3f);

    Vector2 jumpVelocity = towardWall
        ? new Vector2(-awayDir * _wallConfig.WallJumpTowardHForce, _wallConfig.WallJumpVerticalForce)
        : new Vector2( awayDir * _wallConfig.WallJumpAwayHForce,   _wallConfig.WallJumpVerticalForce);

    _movement.SetVelocity(jumpVelocity);
    _wallDetector.ReleaseGrab();                       // 离墙
    _wallDetector.StartInputLock(_wallConfig.WallJumpInputLockDuration);
    // 双跳次数不消耗,墙跳后仍可双跳
}

参数说明

参数 说明
WallJumpAwayHForce 10.0 背墙跳水平分量
WallJumpTowardHForce 3.0 对墙跳水平分量(微小,贴近墙)
WallJumpVerticalForce 15.0 两种墙跳共用垂直分量
WallJumpInputLockDuration 0.15s 墙跳后锁定水平输入(见 §6

触发条件

条件 说明
IsGrabbing && CanWallJump 抓墙中且处于 wallGrabY 或以下
跳跃键按下 JumpStartedEvent 触发

5. 高度记录机制wallGrabY

设计意图

防止玩家通过无限抓墙上爬:首次抓墙时记录角色 Y 坐标(wallGrabY),高于该值的区域仅允许下滑,不允许墙跳;到达或低于该值才能静止悬挂并墙跳。

记录与重置规则

事件 操作
首次 TryGrab()(新墙面或重新接触) 记录 wallGrabY = transform.position.y
落地(IsGrounded == true ResetWallGrabY()(重置为 float.MaxValue
接触新的不同墙面 ResetWallGrabY() 后重新记录
在同一墙面多次抓墙 不更新 wallGrabY(保持首次记录值)

CanWallJump 计算

public bool CanWallJump => IsGrabbing && transform.position.y <= _wallGrabY + 0.05f;
//                                          ^ 允许 5cm 浮动(防抖)

完整状态示意

首次接触墙面 Y=5.0
  ├─ 玩家 Y > 5.0(高于记录值)  → 强制下滑,禁止墙跳 [WallGrabSlide]
  └─ 玩家 Y ≤ 5.0(记录值或以下)→ 静止悬挂,允许墙跳 [WallGrab]
                                        └─ 墙跳后 wallGrabY 保持(跳到更高处后再抓同墙仍受限)
                                        └─ 落地后 wallGrabY 重置(下一次抓墙重新记录)

6. 动量保留规则

问题背景

若墙跳后玩家立刻向墙壁方向推摇杆,水平速度会被 Move() 立即清零,墙跳失去意义。

解决方案:输入锁定

墙跳后 WallJumpInputLockDuration0.15s)内,水平输入不覆盖速度,允许动量完全展开:

// PlayerWallDetector
bool _inputLocked;
float _inputLockEndTime;

public void StartInputLock(float duration)
{
    _inputLocked = true;
    _inputLockEndTime = Time.time + duration;
}

// PlayerMovement.Move() 内部
public void Move(float inputX)
{
    if (_wallDetector.IsInputLocked) return; // 锁定期间忽略水平输入
    // ... 正常移动
}

// PlayerWallDetector.Update()
if (_inputLocked && Time.time >= _inputLockEndTime)
    _inputLocked = false;

public bool IsInputLocked => _inputLocked;

朝向翻转规则

情况 翻转行为
墙滑中 保持朝向墙壁(FacingDirection 朝向墙体方向)
墙跳瞬间 立刻翻转为远离墙壁方向
输入锁定期 朝向不跟随输入,以墙跳方向为准

7. WallMechanicsConfigSO

[CreateAssetMenu(menuName = "Player/WallMechanicsConfig")]
public class WallMechanicsConfigSO : ScriptableObject
{
    [Header("墙壁检测")]
    public float     WallRayLength             = 0.1f;
    public float     WallRayTopOffsetY         = 0.4f;
    public float     WallRayBotOffsetY         = -0.2f;
    public LayerMask WallLayers;               // Wall | Ground

    [Header("抓墙")]
    [Range(0f, 6f)]
    public float     WallGrabSlideSpeed        = 2.0f;   // 高于 wallGrabY 时强制下滑速度

    [Header("墙跳")]
    public float     WallJumpAwayHForce        = 10.0f;  // 背墙跳水平分量
    public float     WallJumpTowardHForce      = 3.0f;   // 对墙跳水平分量
    public float     WallJumpVerticalForce     = 15.0f;  // 两种墙跳共用垂直分量
    [Range(0f, 0.5f)]
    public float     WallJumpInputLockDuration = 0.15f;
}

8. PlayerWallDetector — 完整实现

namespace BaseGames.Player
{
    [DefaultExecutionOrder(-49)] // 在 PlayerGroundDetector(-50) 之后PlayerController(0) 之前
    public class PlayerWallDetector : MonoBehaviour
    {
        // ── 配置 ─────────────────────────────────────────────────
        [SerializeField] WallMechanicsConfigSO _config;

        // ── 对外属性 ─────────────────────────────────────────────
        public bool IsWallLeft    { get; private set; }
        public bool IsWallRight   { get; private set; }
        public bool IsOnWall      => IsWallLeft || IsWallRight;
        public bool IsGrabbing    { get; private set; }  // 当前是否处于抓墙状态
        public bool CanWallJump   => IsGrabbing && transform.position.y <= _wallGrabY + 0.05f;
        public bool IsInputLocked => _inputLocked && Time.time < _inputLockEndTime;

        // ── 内部 ─────────────────────────────────────────────────
        Rigidbody2D   _rb;
        PlayerStats   _stats;
        float         _wallGrabY = float.MaxValue;  // 首次抓墙时记录的 Y 坐标
        bool          _inputLocked;
        float         _inputLockEndTime;

        void Awake()
        {
            _rb    = GetComponentInParent<Rigidbody2D>();
            _stats = GetComponentInParent<PlayerStats>();
        }

        void FixedUpdate()
        {
            CheckWalls();
        }

        void CheckWalls()
        {
            float hw  = _config.WallRayLength;
            LayerMask m = _config.WallLayers;
            Vector2 pos = transform.position;

            Vector2 tl = pos + new Vector2(-0.25f, _config.WallRayTopOffsetY);
            Vector2 bl = pos + new Vector2(-0.25f, _config.WallRayBotOffsetY);
            Vector2 tr = pos + new Vector2(+0.25f, _config.WallRayTopOffsetY);
            Vector2 br = pos + new Vector2(+0.25f, _config.WallRayBotOffsetY);

            IsWallLeft  = Physics2D.Raycast(tl, Vector2.left,  hw, m) &&
                          Physics2D.Raycast(bl, Vector2.left,  hw, m);
            IsWallRight = Physics2D.Raycast(tr, Vector2.right, hw, m) &&
                          Physics2D.Raycast(br, Vector2.right, hw, m);

            // 如果平台检测消失,自动退出抓墙
            if (IsGrabbing && !IsOnWall)
                ReleaseGrab();
        }

        // 尝试进入抓墙状态
        public bool TryGrab()
        {
            if (!IsOnWall || !_stats.HasWallGrab) return false;
            if (!IsGrabbing)
            {
                // 首次抓墙或接触新墙面:记录 Y
                if (_wallGrabY == float.MaxValue)
                    _wallGrabY = transform.position.y;
                IsGrabbing = true;
            }
            return true;
        }

        // 退出抓墙状态
        public void ReleaseGrab(bool resetY = false)
        {
            IsGrabbing = false;
            if (resetY) ResetWallGrabY();
        }

        // 落地时调用,重置高度记录
        public void ResetWallGrabY() => _wallGrabY = float.MaxValue;

        public void StartInputLock(float duration)
        {
            _inputLocked      = true;
            _inputLockEndTime = Time.time + duration;
        }
    }
}

9. AirState 集成

PlayerAirStateOnStateFixedUpdate 中整合地面检测与抓墙检测:

void OnStateFixedUpdate()
{
    bool grounded  = _controller.GroundDetector.IsGrounded;
    bool grabbing  = _controller.WallDetector.IsGrabbing;
    bool canWallJump = _controller.WallDetector.CanWallJump;

    // 落地时重置 wallGrabY
    if (grounded)
    {
        _controller.WallDetector.ResetWallGrabY();
        float inputX = _inputReader.MoveInput.x;
        _controller.TryTransitionState(
            Mathf.Abs(inputX) > 0.1f ? _controller.RunState : _controller.IdleState);
        return;
    }

    // 尝试抓墙(空中 + 贴墙 + 朝向墙体输入)
    float moveX = _inputReader.MoveInput.x;
    bool towardWall = (_controller.WallDetector.IsWallLeft  && moveX < -0.3f) ||
                      (_controller.WallDetector.IsWallRight && moveX >  0.3f);
    if (towardWall)
        _controller.WallDetector.TryGrab();

    // 抓墙状态中处理速度(由 PlayerMovement 执行)
    if (grabbing)
    {
        if (!canWallJump)
            _movement.SetVelocity(new Vector2(0f, -_wallConfig.WallGrabSlideSpeed));
        else
            _movement.SetVelocity(Vector2.zero);  // 静止悬挂
        return;
    }

    // 普通水平移动(输入锁定期间跳过)
    if (!_controller.WallDetector.IsInputLocked)
        _movement.Move(moveX * _movementConfig.AirMoveSpeed);
}

// 跳跃事件回调
void OnJumpStarted()
{
    if (_controller.WallDetector.CanWallJump)
        DoWallJump();
    else if (_controller.GroundDetector.CanCoyoteJump)
    {
        _controller.GroundDetector.ConsumeCoyoteJump();
        DoJump();
    }
    else if (!_hasDoubleJumped && _stats.HasDoubleJump)
        DoDoubleJump();
}

10. 编辑器友好设计

Gizmos 可视化

#if UNITY_EDITOR
void OnDrawGizmosSelected()
{
    if (_config == null) return;
    Vector2 pos = transform.position;

    // 左侧射线(红 = 未命中,绿 = 命中)
    Gizmos.color = IsWallLeft ? Color.green : Color.red;
    Vector2 tl = pos + new Vector2(-0.25f, _config.WallRayTopOffsetY);
    Vector2 bl = pos + new Vector2(-0.25f, _config.WallRayBotOffsetY);
    Gizmos.DrawRay(tl, Vector2.left * _config.WallRayLength);
    Gizmos.DrawRay(bl, Vector2.left * _config.WallRayLength);

    // 右侧射线
    Gizmos.color = IsWallRight ? Color.green : Color.red;
    Vector2 tr = pos + new Vector2(+0.25f, _config.WallRayTopOffsetY);
    Vector2 br = pos + new Vector2(+0.25f, _config.WallRayBotOffsetY);
    Gizmos.DrawRay(tr, Vector2.right * _config.WallRayLength);
    Gizmos.DrawRay(br, Vector2.right * _config.WallRayLength);
}
#endif

自定义 Inspector运行时状态

[CustomEditor(typeof(PlayerWallDetector))]
public class PlayerWallDetectorEditor : Editor
{
    public override VisualElement CreateInspectorGUI()
    {
        var root = new VisualElement();
        InspectorElement.FillDefaultInspector(root, serializedObject, this);

        var status = new Foldout { text = "运行时状态(只读)" };
        var label  = new Label();
        status.Add(label);
        root.Add(status);

        root.schedule.Execute(() =>
        {
            if (target is PlayerWallDetector det)
                label.text = $"WallL:{det.IsWallLeft}  WallR:{det.IsWallRight}  " +
                             $"Grabbing:{det.IsGrabbing}  CanWallJump:{det.CanWallJump}  " +
                             $"InputLocked:{det.IsInputLocked}";
        }).Every(100);
        return root;
    }
}