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

505 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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;
}
}
```