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

509 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.
# 24 · 地面检测系统
> **命名空间** `BaseGames.Player`
> **所属文档集** [← 返回索引](./README.md) · [总览](./00_Overview.md)
> **依赖** `BaseGames.Core.Events` · `BaseGames.Player`
---
## 目录
1. [系统总览](#1-系统总览)
2. [多射线检测架构](#2-多射线检测架构)
3. [斜坡处理](#3-斜坡处理)
4. [单向平台穿越](#4-单向平台穿越)
5. [平台边缘检测](#5-平台边缘检测)
6. [GroundSurfaceSO — 地面物理材质](#6-groundsurfaceso--地面物理材质)
7. [地面类型枚举与脚步系统集成](#7-地面类型枚举与脚步系统集成)
8. [Coyote Time 实现](#8-coyote-time-实现)
9. [PlayerGroundDetector — 完整实现](#9-playergrounddetector--完整实现)
10. [编辑器友好设计](#10-编辑器友好设计)
---
## 1. 系统总览
地面检测系统负责:
```
GroundDetectionSystem 职责:
├─ IsGrounded → 玩家是否站在地面上(含斜坡/单向平台)
├─ GroundNormal → 当前地面法线(用于斜坡移动方向矫正)
├─ GroundSurfaceType → 当前地面材质Wood / Stone / Metal / Grass / Water
├─ IsNearEdge → 玩家是否站在平台边缘(用于下坠预警动画)
├─ CanDropThrough → 是否可向下穿越当前平台
└─ CoyoteTime → 离地后的短暂缓冲跳跃窗口
```
**零耦合原则**`PlayerGroundDetector``PlayerController` 的子组件,结果通过属性暴露;脚步系统通过 `GroundSurfaceType` 属性读取,不订阅内部事件。
---
## 2. 多射线检测架构
### 射线布局(脚部 BoxCast 方案)
使用 **BoxCast** 而非单点 Raycast覆盖玩家脚部整个宽度避免在平台边缘时误判
```
┌──────────────┐
│ Player │
│ │
└──┬──┬──┬──┬──┘ ← 脚部 BoxCast 宽度 = Collider 宽度 × 0.9
↓ ↓ ↓ ↓
───────────────── ← Ground Layer
```
```csharp
// 检测参数GroundDetectionConfigSO 中配置)
public struct GroundCheckConfig
{
public float BoxWidth; // 脚部宽度Collider 宽度 × 0.9,约 0.45f
public float BoxHeight; // 射线长度0.05f,仅检测贴地情况)
public float OffsetY; // 从脚底向下偏移(-0.02f,贴边检测)
public LayerMask GroundLayers; // Ground | OneWayPlatform
}
```
### 每帧检测流程
```csharp
void FixedUpdate()
{
// 1. BoxCast 检测
Vector2 origin = (Vector2)transform.position + Vector2.up * _config.OffsetY;
RaycastHit2D hit = Physics2D.BoxCast(
origin,
new Vector2(_config.BoxWidth, 0.02f),
0f,
Vector2.down,
_config.BoxHeight,
_config.GroundLayers
);
// 2. 单向平台过滤:仅在 Rigidbody2D.velocity.y <= 0 时接受
if (hit && hit.collider.gameObject.layer == LayerMask.NameToLayer("OneWayPlatform"))
{
hit = (_rb.velocity.y <= 0.1f) ? hit : default;
}
// 3. 更新状态
bool wasGrounded = IsGrounded;
IsGrounded = hit.collider != null;
GroundNormal = IsGrounded ? hit.normal : Vector2.up;
GroundCollider = IsGrounded ? hit.collider : null;
// 4. 落地事件(仅首次着地触发)
if (!wasGrounded && IsGrounded)
OnLanded?.Invoke(Mathf.Abs(_rb.velocity.y));
// 5. 离地时记录时间(用于 Coyote Time
if (wasGrounded && !IsGrounded)
_lastGroundedTime = Time.time;
}
```
---
## 3. 斜坡处理
### 斜坡移动矫正
`GroundNormal` 偏离 `Vector2.up` 时,将水平移动方向投影到地面切线上,防止在斜坡上"漂浮"或减速:
```csharp
// PlayerMovement.Move() 内部
public Vector2 GetMoveDirectionOnSlope(float inputX)
{
if (!_groundDetector.IsGrounded) return new Vector2(inputX, 0f);
// 地面切线(法线顺时针旋转 90°
Vector2 tangent = new Vector2(_groundDetector.GroundNormal.y,
-_groundDetector.GroundNormal.x);
return tangent * inputX;
}
```
### 斜坡参数
| 参数 | 值 | 说明 |
|------|-----|------|
| `MaxSlopeAngle` | 50° | 超过此角度视为墙壁,无法行走 |
| `SlopeSpeedMultiplier_Up` | 0.85 | 上坡速度乘数 |
| `SlopeSpeedMultiplier_Down` | 1.0 | 下坡不减速 |
| `SlopeStickForce` | -5f | 下坡时施加的额外向下力,防止飞出斜坡 |
### 斜坡角度计算
```csharp
public float SlopeAngle => Vector2.Angle(GroundNormal, Vector2.up);
public bool IsOnSlope => SlopeAngle > 1f && SlopeAngle < _config.MaxSlopeAngle;
```
---
## 4. 单向平台穿越
### 穿越逻辑
```
触发穿越:
玩家按住 ↓ + 跳跃键
临时禁用 OneWayPlatform 碰撞
(Physics2D.IgnoreLayerCollision)
等待 0.25s(足以穿越平台厚度)
恢复碰撞检测
```
```csharp
// PlayerMovement 中的穿越实现
public IEnumerator DropThroughPlatform()
{
int playerLayer = gameObject.layer;
int platformLayer = LayerMask.NameToLayer("OneWayPlatform");
Physics2D.IgnoreLayerCollision(playerLayer, platformLayer, true);
yield return new WaitForSeconds(0.25f);
Physics2D.IgnoreLayerCollision(playerLayer, platformLayer, false);
}
```
### 下跳冲量
穿越时施加 `-2f` 的向下冲量,确保玩家能迅速离开平台(尤其是较厚的 Tilemap 单向平台)。
---
## 5. 平台边缘检测
### 用途
- 触发**边缘摇摇欲坠**动画Blend Shape 或附加动画层)
- 阻止在悬崖边缘继续行走(可选,策划配置)
- 为 AI 敌人的寻路提供跌落预判NavLink
### 实现
在脚部左右两侧各发射一条短 Raycast检测足部以下是否有地面
```csharp
// 检测脚底左右是否悬空
bool edgeLeft = !Physics2D.Raycast(
(Vector2)transform.position + new Vector2(-_halfWidth, 0f),
Vector2.down, _config.EdgeCheckDepth, _config.GroundLayers);
bool edgeRight = !Physics2D.Raycast(
(Vector2)transform.position + new Vector2(+_halfWidth, 0f),
Vector2.down, _config.EdgeCheckDepth, _config.GroundLayers);
IsNearEdge = (edgeLeft && !edgeRight) || (!edgeLeft && edgeRight);
IsOverVoid = edgeLeft && edgeRight; // 双侧悬空(极窄平台)
```
### 边缘检测配置
| 参数 | 值 | 说明 |
|------|-----|------|
| `EdgeCheckDepth` | 0.6f | 检测深度(略大于玩家身高的一半)|
| `EdgeCheckHorizontalOffset` | 0.22f | 距中心的水平距离(约等于碰撞体半宽)|
---
## 6. GroundSurfaceSO — 地面物理材质
每种地面类型对应一个 `GroundSurfaceSO` 资产,配置摩擦力、脚步声 Label、粒子特效引用
```csharp
[CreateAssetMenu(menuName = "World/GroundSurface")]
public class GroundSurfaceSO : ScriptableObject
{
[Header("物理属性")]
public float FrictionCoefficient; // 地面摩擦系数(影响加速/减速时长)
public float BounceFactor; // 弹性0 = 不弹,默认 0
[Header("音效")]
public string FootstepAddressLabel; // Addressable Label如 "Footstep_Stone"
public float FootstepVolumeScale; // 音量缩放
[Header("特效")]
public AssetReferenceGameObject LandingDustFX; // 落地粒子Addressable 引用)
public AssetReferenceGameObject FootstepDustFX; // 脚步粒子(可为 null
}
```
**资产存放路径**`Assets/ScriptableObjects/World/Surfaces/`
### 内置地面类型
| 资产名 | 摩擦系数 | 脚步声 Label | 说明 |
|--------|---------|------------|------|
| `Surface_Stone.asset` | 0.8 | `Footstep_Stone` | 石头、砖块 |
| `Surface_Wood.asset` | 0.7 | `Footstep_Wood` | 木质平台、地板 |
| `Surface_Metal.asset` | 0.5 | `Footstep_Metal` | 金属格栅、机关 |
| `Surface_Grass.asset` | 0.9 | `Footstep_Grass` | 草地、土路 |
| `Surface_Water_Shallow.asset` | 1.0 | `Footstep_Water` | 浅水(不触发游泳)|
| `Surface_Ice.asset` | 0.1 | `Footstep_Stone` | 冰面(极低摩擦)|
### Tilemap 关联
`TilemapCollider2D` 对应的 GameObject 上挂载 `GroundSurfaceTag` 组件,持有 `GroundSurfaceSO` 引用:
```csharp
public class GroundSurfaceTag : MonoBehaviour
{
[SerializeField] public GroundSurfaceSO Surface;
}
```
`PlayerGroundDetector``hit.collider` 上调用 `GetComponent<GroundSurfaceTag>()` 获取当前地面材质(结果缓存,避免每帧 GetComponent
---
## 7. 地面类型枚举与脚步系统集成
```csharp
// PlayerGroundDetector 对外暴露
public GroundSurfaceSO CurrentSurface { get; private set; }
```
脚步系统(`FootstepSystem`,见 [20_AnimationEventSystem](./20_AnimationEventSystem.md))在 Animation Event `FootstepLeft` / `FootstepRight` 触发时:
1. 读取 `PlayerGroundDetector.CurrentSurface`
2. 通过 `CurrentSurface.FootstepAddressLabel` 加载对应音效
3. 通过 `CurrentSurface.FootstepDustFX` 实例化粒子(可选)
---
## 8. Coyote Time 实现
**Coyote Time**(土狼时间):玩家离地后仍有短暂窗口可以跳跃,提升平台跳跃手感。
```csharp
// PlayerGroundDetector 暴露接口
public bool CanCoyoteJump =>
!IsGrounded &&
Time.time - _lastGroundedTime <= _config.CoyoteTimeDuration &&
!_usedCoyoteJump;
public void ConsumeCoyoteJump() => _usedCoyoteJump = true;
// 落地时重置
private void OnLandedInternal()
{
_usedCoyoteJump = false;
_lastGroundedTime = -999f; // 防止落地后立刻再次 Coyote
}
```
### Coyote Time 参数
| 参数 | 值 | 位置 |
|------|-----|------|
| `CoyoteTimeDuration` | 0.12s | `GroundDetectionConfigSO` |
| 重置条件 | 落地时 | 自动重置 |
| 使用次数 | 1 次(每次离地)| `_usedCoyoteJump` 标志位 |
---
## 9. PlayerGroundDetector — 完整实现
```csharp
namespace BaseGames.Player
{
[DefaultExecutionOrder(-50)] // 在 PlayerController 之前执行
public class PlayerGroundDetector : MonoBehaviour
{
// ── 配置 ──────────────────────────────────────────────────
[SerializeField] GroundDetectionConfigSO _config;
// ── 对外属性 ──────────────────────────────────────────────
public bool IsGrounded { get; private set; }
public Vector2 GroundNormal { get; private set; } = Vector2.up;
public Collider2D GroundCollider { get; private set; }
public GroundSurfaceSO CurrentSurface { get; private set; }
public bool IsNearEdge { get; private set; }
public bool IsOverVoid { get; private set; }
public float SlopeAngle => Vector2.Angle(GroundNormal, Vector2.up);
public bool IsOnSlope => SlopeAngle > 1f && SlopeAngle < _config.MaxSlopeAngle;
public bool CanCoyoteJump =>
!IsGrounded &&
Time.time - _lastGroundedTime <= _config.CoyoteTimeDuration &&
!_usedCoyoteJump;
// ── 内部状态 ──────────────────────────────────────────────
Rigidbody2D _rb;
float _lastGroundedTime = -999f;
bool _usedCoyoteJump;
Collider2D _cachedSurfaceCollider; // 缓存:避免每帧 GetComponent
// ── 事件 ──────────────────────────────────────────────────
public event System.Action<float> OnLanded; // 参数:落地速度(绝对值)
void Awake() => _rb = GetComponentInParent<Rigidbody2D>();
void FixedUpdate()
{
CheckGround();
CheckEdge();
}
void CheckGround()
{
Vector2 origin = (Vector2)transform.position + Vector2.up * _config.OffsetY;
RaycastHit2D hit = Physics2D.BoxCast(
origin,
new Vector2(_config.BoxWidth, 0.02f),
0f, Vector2.down, _config.BoxHeight,
_config.GroundLayers);
// 单向平台:仅在下落/静止时接受
if (hit && hit.collider.gameObject.layer == LayerMask.NameToLayer("OneWayPlatform"))
if (_rb.velocity.y > 0.1f) hit = default;
bool wasGrounded = IsGrounded;
IsGrounded = hit.collider != null;
GroundNormal = IsGrounded ? hit.normal : Vector2.up;
GroundCollider = IsGrounded ? hit.collider : null;
// 更新地面材质(有缓存,不每帧 GetComponent
if (IsGrounded && GroundCollider != _cachedSurfaceCollider)
{
_cachedSurfaceCollider = GroundCollider;
CurrentSurface = GroundCollider.GetComponent<GroundSurfaceTag>()?.Surface;
}
else if (!IsGrounded) { _cachedSurfaceCollider = null; CurrentSurface = null; }
// 事件派发
if (!wasGrounded && IsGrounded)
{
_usedCoyoteJump = false;
OnLanded?.Invoke(Mathf.Abs(_rb.velocity.y));
}
if (wasGrounded && !IsGrounded)
_lastGroundedTime = Time.time;
}
void CheckEdge()
{
if (!IsGrounded) { IsNearEdge = false; IsOverVoid = false; return; }
float hw = _config.EdgeCheckHorizontalOffset;
bool edgeL = !Physics2D.Raycast(
(Vector2)transform.position + new Vector2(-hw, 0f),
Vector2.down, _config.EdgeCheckDepth, _config.GroundLayers);
bool edgeR = !Physics2D.Raycast(
(Vector2)transform.position + new Vector2(+hw, 0f),
Vector2.down, _config.EdgeCheckDepth, _config.GroundLayers);
IsNearEdge = (edgeL && !edgeR) || (!edgeL && edgeR);
IsOverVoid = edgeL && edgeR;
}
public void ConsumeCoyoteJump() => _usedCoyoteJump = true;
}
}
```
### GroundDetectionConfigSO
```csharp
[CreateAssetMenu(menuName = "Player/GroundDetectionConfig")]
public class GroundDetectionConfigSO : ScriptableObject
{
[Header("BoxCast 参数")]
public float BoxWidth = 0.45f;
public float BoxHeight = 0.08f;
public float OffsetY = -0.02f;
public LayerMask GroundLayers;
[Header("斜坡")]
[Range(0f, 80f)]
public float MaxSlopeAngle = 50f;
public float SlopeSpeedMultiplier_Up = 0.85f;
public float SlopeStickForce = -5f;
[Header("边缘检测")]
public float EdgeCheckDepth = 0.6f;
public float EdgeCheckHorizontalOffset = 0.22f;
[Header("Coyote Time")]
[Range(0f, 0.3f)]
public float CoyoteTimeDuration = 0.12f;
}
```
---
## 10. 编辑器友好设计
### Gizmos 可视化
```csharp
#if UNITY_EDITOR
void OnDrawGizmosSelected()
{
if (_config == null) return;
Vector2 origin = (Vector2)transform.position + Vector2.up * _config.OffsetY;
// BoxCast 范围
Gizmos.color = IsGrounded ? Color.green : Color.red;
Gizmos.DrawWireCube(origin + Vector2.down * _config.BoxHeight * 0.5f,
new Vector3(_config.BoxWidth, 0.02f, 0f));
// 边缘检测射线
Gizmos.color = Color.yellow;
float hw = _config.EdgeCheckHorizontalOffset;
Gizmos.DrawRay((Vector2)transform.position + new Vector2(-hw, 0f), Vector2.down * _config.EdgeCheckDepth);
Gizmos.DrawRay((Vector2)transform.position + new Vector2(+hw, 0f), Vector2.down * _config.EdgeCheckDepth);
// 地面法线
if (IsGrounded)
{
Gizmos.color = Color.cyan;
Gizmos.DrawRay((Vector2)transform.position, GroundNormal * 0.5f);
}
}
#endif
```
### 自定义 Inspector
```csharp
[CustomEditor(typeof(PlayerGroundDetector))]
public class PlayerGroundDetectorEditor : Editor
{
public override VisualElement CreateInspectorGUI()
{
var root = new VisualElement();
InspectorElement.FillDefaultInspector(root, serializedObject, this);
// 运行时状态(只读)
var statusGroup = new Foldout { text = "运行时状态(只读)" };
var groundLabel = new Label();
var normalLabel = new Label();
var surfaceLabel = new Label();
var edgeLabel = new Label();
statusGroup.Add(groundLabel);
statusGroup.Add(normalLabel);
statusGroup.Add(surfaceLabel);
statusGroup.Add(edgeLabel);
root.Add(statusGroup);
root.schedule.Execute(() =>
{
if (target is PlayerGroundDetector det)
{
groundLabel.text = $"IsGrounded: {det.IsGrounded} SlopeAngle: {det.SlopeAngle:F1}°";
normalLabel.text = $"GroundNormal: {det.GroundNormal}";
surfaceLabel.text = $"Surface: {(det.CurrentSurface != null ? det.CurrentSurface.name : "")}";
edgeLabel.text = $"NearEdge: {det.IsNearEdge} OverVoid: {det.IsOverVoid} CoyoteOk: {det.CanCoyoteJump}";
}
}).Every(100);
return root;
}
}
```