17 KiB
17 KiB
26 · 墙壁力学系统
命名空间
BaseGames.Player
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Player(PlayerMovement、PlayerGroundDetector)
目录
- 系统总览
- 墙壁检测架构
- 抓墙机制(WallGrab)
- 墙跳(两种类型)
- 高度记录机制(wallGrabY)
- 动量保留规则
- WallMechanicsConfigSO
- PlayerWallDetector — 完整实现
- AirState 集成
- 编辑器友好设计
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() 立即清零,墙跳失去意义。
解决方案:输入锁定
墙跳后 WallJumpInputLockDuration(0.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 集成
PlayerAirState 在 OnStateFixedUpdate 中整合地面检测与抓墙检测:
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;
}
}