# 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()` 获取当前地面材质(结果缓存,避免每帧 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 OnLanded; // 参数:落地速度(绝对值) void Awake() => _rb = GetComponentInParent(); 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()?.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; } } ```