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

17 KiB
Raw Permalink Blame History

24 · 地面检测系统

命名空间 BaseGames.Player
所属文档集 ← 返回索引 · 总览
依赖 BaseGames.Core.Events · BaseGames.Player


目录

  1. 系统总览
  2. 多射线检测架构
  3. 斜坡处理
  4. 单向平台穿越
  5. 平台边缘检测
  6. GroundSurfaceSO — 地面物理材质
  7. 地面类型枚举与脚步系统集成
  8. Coyote Time 实现
  9. PlayerGroundDetector — 完整实现
  10. 编辑器友好设计

1. 系统总览

地面检测系统负责:

GroundDetectionSystem 职责:
  ├─ IsGrounded             → 玩家是否站在地面上(含斜坡/单向平台)
  ├─ GroundNormal           → 当前地面法线(用于斜坡移动方向矫正)
  ├─ GroundSurfaceType      → 当前地面材质Wood / Stone / Metal / Grass / Water
  ├─ IsNearEdge             → 玩家是否站在平台边缘(用于下坠预警动画)
  ├─ CanDropThrough         → 是否可向下穿越当前平台
  └─ CoyoteTime             → 离地后的短暂缓冲跳跃窗口

零耦合原则PlayerGroundDetectorPlayerController 的子组件,结果通过属性暴露;脚步系统通过 GroundSurfaceType 属性读取,不订阅内部事件。


2. 多射线检测架构

射线布局(脚部 BoxCast 方案)

使用 BoxCast 而非单点 Raycast覆盖玩家脚部整个宽度避免在平台边缘时误判

    ┌──────────────┐
    │   Player     │
    │              │
    └──┬──┬──┬──┬──┘   ← 脚部 BoxCast 宽度 = Collider 宽度 × 0.9
       ↓  ↓  ↓  ↓
   ─────────────────   ← Ground Layer
// 检测参数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
}

每帧检测流程

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 时,将水平移动方向投影到地面切线上,防止在斜坡上"漂浮"或减速:

// 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 下坡时施加的额外向下力,防止飞出斜坡

斜坡角度计算

public float SlopeAngle => Vector2.Angle(GroundNormal, Vector2.up);
public bool  IsOnSlope   => SlopeAngle > 1f && SlopeAngle < _config.MaxSlopeAngle;

4. 单向平台穿越

穿越逻辑

触发穿越:
  玩家按住 ↓ + 跳跃键
        │
        ▼
  临时禁用 OneWayPlatform 碰撞
  (Physics2D.IgnoreLayerCollision)
        │
        ▼
  等待 0.25s(足以穿越平台厚度)
        │
        ▼
  恢复碰撞检测
// 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检测足部以下是否有地面

// 检测脚底左右是否悬空
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、粒子特效引用

[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 引用:

public class GroundSurfaceTag : MonoBehaviour
{
    [SerializeField] public GroundSurfaceSO Surface;
}

PlayerGroundDetectorhit.collider 上调用 GetComponent<GroundSurfaceTag>() 获取当前地面材质(结果缓存,避免每帧 GetComponent


7. 地面类型枚举与脚步系统集成

// PlayerGroundDetector 对外暴露
public GroundSurfaceSO CurrentSurface { get; private set; }

脚步系统(FootstepSystem,见 20_AnimationEventSystem)在 Animation Event FootstepLeft / FootstepRight 触发时:

  1. 读取 PlayerGroundDetector.CurrentSurface
  2. 通过 CurrentSurface.FootstepAddressLabel 加载对应音效
  3. 通过 CurrentSurface.FootstepDustFX 实例化粒子(可选)

8. Coyote Time 实现

Coyote Time(土狼时间):玩家离地后仍有短暂窗口可以跳跃,提升平台跳跃手感。

// 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 — 完整实现

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

[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 可视化

#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

[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;
    }
}