17 KiB
24 · 地面检测系统
命名空间
BaseGames.Player
所属文档集 ← 返回索引 · 总览
依赖BaseGames.Core.Events·BaseGames.Player
目录
- 系统总览
- 多射线检测架构
- 斜坡处理
- 单向平台穿越
- 平台边缘检测
- GroundSurfaceSO — 地面物理材质
- 地面类型枚举与脚步系统集成
- Coyote Time 实现
- PlayerGroundDetector — 完整实现
- 编辑器友好设计
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
// 检测参数(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;
}
PlayerGroundDetector 在 hit.collider 上调用 GetComponent<GroundSurfaceTag>() 获取当前地面材质(结果缓存,避免每帧 GetComponent)。
7. 地面类型枚举与脚步系统集成
// PlayerGroundDetector 对外暴露
public GroundSurfaceSO CurrentSurface { get; private set; }
脚步系统(FootstepSystem,见 20_AnimationEventSystem)在 Animation Event FootstepLeft / FootstepRight 触发时:
- 读取
PlayerGroundDetector.CurrentSurface - 通过
CurrentSurface.FootstepAddressLabel加载对应音效 - 通过
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;
}
}