chore: initial commit
This commit is contained in:
508
Docs/Design/24_GroundDetectionSystem.md
Normal file
508
Docs/Design/24_GroundDetectionSystem.md
Normal file
@@ -0,0 +1,508 @@
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user