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,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;
}
}
```