# 26 · 墙壁力学系统 > **命名空间** `BaseGames.Player` > **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md) > **依赖** `BaseGames.Player`(PlayerMovement、PlayerGroundDetector) --- ## 目录 1. [系统总览](#1-系统总览) 2. [墙壁检测架构](#2-墙壁检测架构) 3. [抓墙机制(WallGrab)](#3-抓墙机制wallgrab) 4. [墙跳(两种类型)](#4-墙跳两种类型) 5. [高度记录机制(wallGrabY)](#5-高度记录机制wallgraby) 6. [动量保留规则](#6-动量保留规则) 7. [WallMechanicsConfigSO](#7-wallmechanicsconfigso) 8. [PlayerWallDetector — 完整实现](#8-playerwalldetector--完整实现) 9. [AirState 集成](#9-airstate-集成) 10. [编辑器友好设计](#10-编辑器友好设计) --- ## 1. 系统总览 ``` 墙壁力学职责: ├─ 墙壁检测 → 左右两侧是否接触 Wall Layer ├─ 抓墙(WallGrab) → 空中贴墙 + 朝墙方向输入 → 自动吸附,维持不需长按 ├─ 高度记录机制 → 首次抓墙记录 wallGrabY,高于该值时强制下滑 + 禁止墙跳 ├─ 背墙跳 → 无输入或背离输入 → 斜向远离墙壁 ├─ 对墙跳 → 朝墙输入 → 接近垂直向上 └─ 动量保留 → 墙跳后水平反向动量不被输入立即消除 ``` **零耦合**:`PlayerWallDetector` 是独立组件,检测结果通过属性暴露给 `AirState`;不直接修改 Rigidbody2D 速度,由 `PlayerMovement` 执行。 **与 Double Jump 的关系**:抓墙跳视为**第一跳**(消耗初始跳跃机会),**不消耗双跳次数**;离墙后双跳仍可用。 --- ## 2. 墙壁检测架构 ### 射线布局 每侧使用**两条 Raycast**(上/下),避免在墙脚处误判: ``` ├── WallRay_TopLeft ──► │ ◄── WallRay_TopRight │ │ ├── WallRay_BotLeft ──► │ ◄── WallRay_BotRight ``` ```csharp 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 时) ```csharp // PlayerMovement.FixedUpdate() if (_wallDetector.IsGrabbing && !_wallDetector.CanWallJump) { _rb.velocity = new Vector2(0f, -_wallConfig.WallGrabSlideSpeed); } ``` | 参数 | 值 | 说明 | |------|----|------| | `WallGrabSlideSpeed` | 2.0 units/s | 强制下滑速度(不可被输入覆盖)| ### 静止悬挂(at/below wallGrabY 时) ```csharp // 重力归零,水平速度清除 _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` | 接近垂直向上,略带贴墙倾向 | ### 实现 ```csharp 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 计算 ```csharp 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()` 立即清零,墙跳失去意义。 ### 解决方案:输入锁定 墙跳后 `WallJumpInputLockDuration`(0.15s)内,水平输入**不覆盖速度**,允许动量完全展开: ```csharp // 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 ```csharp [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 — 完整实现 ```csharp 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(); _stats = GetComponentInParent(); } 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 集成 `PlayerAirState` 在 `OnStateFixedUpdate` 中整合地面检测与抓墙检测: ```csharp 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 可视化 ```csharp #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(运行时状态) ```csharp [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; } } ```