chore: initial commit

This commit is contained in:
2026-05-08 11:04:00 +08:00
commit f55d2a57c3
6278 changed files with 866081 additions and 0 deletions

View File

@@ -0,0 +1,504 @@
# 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;
}
}
```