505 lines
17 KiB
Markdown
505 lines
17 KiB
Markdown
# 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<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 集成
|
||
|
||
`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;
|
||
}
|
||
}
|
||
```
|