This commit is contained in:
yangyu
2026-05-27 16:33:23 +08:00
543 changed files with 24817 additions and 56595 deletions

View File

@@ -12,14 +12,14 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: f7dd720bca19fcc49b22106fb65f7652, type: 3}
m_Name: ENM_E001_AnimConfig
m_EditorClassIdentifier:
Idle: {fileID: 0}
Walk: {fileID: 0}
Run: {fileID: 0}
Turn: {fileID: 0}
Idle: {fileID: 7400000, guid: 74d1c2f7f8e5c66409e9090885e7e007, type: 2}
Walk: {fileID: 7400000, guid: b6b9e34e957b9fa4b92e95aaa155099f, type: 2}
Run: {fileID: 7400000, guid: b6b9e34e957b9fa4b92e95aaa155099f, type: 2}
Turn: {fileID: 7400000, guid: c6d78c8270549254f8c777e0c5d4f9bf, type: 2}
Attack: {fileID: 0}
Hurt: {fileID: 0}
Hurt: {fileID: 7400000, guid: 9d5bb5bb32cdb344b80f01d998ed653f, type: 2}
Stagger: {fileID: 0}
KnockUp: {fileID: 0}
Dead: {fileID: 0}
Dead: {fileID: 7400000, guid: 7f07e13e67a5aba4b8a27234e5a84ee6, type: 2}
Alert: {fileID: 0}
Investigate: {fileID: 0}

File diff suppressed because it is too large Load Diff

View File

@@ -420,43 +420,195 @@ namespace BaseGames.Editor
/// 构建触发多边形路径(本地坐标,相对于新 CameraArea
/// 取 TriggerRegion 各点的世界坐标减去 areaWorldPos
/// 若点数不足 3 则兜底使用可视矩形。
///
/// 处理流程:
/// 1. 优先尝试直角多边形重建(坐标分组配对算法)→ 精确还原横竖边构成的触发区域
/// 2. 降级:最近邻贪心 + 2-opt 消除自相交 → 适用于含斜边的多边形
/// 3. Shoelace 叉积校正绕向 → 确保 CCWPolygonCollider2D 要求)
/// </summary>
private static Vector2[] BuildTriggerPath(ZoneEntry entry, Vector3 areaWorldPos, Rect fallback)
{
if (entry.TriggerWorldPts.Count >= 3)
{
// 将世界坐标转换为 areaGO 本地坐标
var localPts = new Vector2[entry.TriggerWorldPts.Count];
for (int i = 0; i < localPts.Length; i++)
localPts[i] = entry.TriggerWorldPts[i] - (Vector2)areaWorldPos;
// 按照质心角度排序,确保顶点顺序能够围成合法多边形
return SortPointsByAngle(localPts);
// 优先:直角多边形精确重建
var rectilinear = TryReconstructRectilinear(localPts);
if (rectilinear != null)
return EnsureCounterClockwise(rectilinear);
// 降级:最近邻 + 2-opt适用于含斜边或点分布不规则的情况
Debug.LogWarning($"[迁移工具] {entry.ZoneObj.name} 无法识别为直角多边形,已使用 2-opt 降级处理,请手动检查形状。");
var ordered = NearestNeighborOrder(localPts);
TwoOptFix(ordered);
return EnsureCounterClockwise(ordered);
}
return RectToPolygon(fallback);
}
/// <summary>
/// 将一组点按照围绕质心的角度(逆时针)排序,使其能够围成合法的简单多边形
/// 适用于凸多边形及质心在多边形内部的凹多边形。
/// 直角多边形Rectilinear Polygon顶点排序
///
/// 算法原理:
/// 简单直角多边形中,每列(同 x的顶点按 y 升序两两配对为纵向边,
/// 每行(同 y的顶点按 x 升序两两配对为横向边Scanline 奇偶规则)。
/// 每个顶点恰好有 1 条纵向边 + 1 条横向边,即恰好 2 个邻居,可遍历为完整环路。
///
/// 返回 null 表示点集不满足直角多边形条件(奇数分组、邻居数 ≠ 2 等),调用方应降级处理。
/// </summary>
private static Vector2[] SortPointsByAngle(Vector2[] points)
private static Vector2[] TryReconstructRectilinear(Vector2[] points)
{
// 计算质心
Vector2 centroid = Vector2.zero;
foreach (var p in points)
centroid += p;
centroid /= points.Length;
// 同轴容差:小于此值的坐标差视为同行/同列(处理浮点误差)
const float kTol = 0.1f;
int n = points.Length;
// 按照相对质心的极角升序排列(逆时针)
var sorted = new System.Collections.Generic.List<Vector2>(points);
sorted.Sort((a, b) =>
var adj = new List<int>[n];
for (int i = 0; i < n; i++) adj[i] = new List<int>();
// 按 x 分组 → 纵向边;按 y 分组 → 横向边
if (!AddAxisEdges(points, adj, groupByX: true, tol: kTol)) return null;
if (!AddAxisEdges(points, adj, groupByX: false, tol: kTol)) return null;
// 每个顶点必须恰好有 2 个邻居1 横 + 1 纵)才构成合法简单多边形
for (int i = 0; i < n; i++)
if (adj[i].Count != 2) return null;
// 从顶点 0 出发遍历环路
var result = new Vector2[n];
var visited = new bool[n];
result[0] = points[0];
visited[0] = true;
int prev = -1, curr = 0;
for (int step = 1; step < n; step++)
{
float angleA = Mathf.Atan2(a.y - centroid.y, a.x - centroid.x);
float angleB = Mathf.Atan2(b.y - centroid.y, b.x - centroid.x);
return angleA.CompareTo(angleB);
});
return sorted.ToArray();
int next = -1;
foreach (int nb in adj[curr])
if (nb != prev && !visited[nb]) { next = nb; break; }
if (next == -1) return null; // 环路断裂
result[step] = points[next];
visited[next] = true;
prev = curr;
curr = next;
}
return result;
}
/// <summary>
/// 按指定轴x 或 y将顶点分组每组内按垂直轴坐标排序后两两配对添加边。
/// 若任意组的顶点数为奇数(无法完整配对)则返回 false。
/// </summary>
private static bool AddAxisEdges(Vector2[] pts, List<int>[] adj, bool groupByX, float tol)
{
var groups = new Dictionary<int, List<int>>();
for (int i = 0; i < pts.Length; i++)
{
// 将坐标映射到整数 key容差范围内的值归入同组
int key = Mathf.RoundToInt((groupByX ? pts[i].x : pts[i].y) / tol);
if (!groups.TryGetValue(key, out var list))
groups[key] = list = new List<int>();
list.Add(i);
}
foreach (var group in groups.Values)
{
if (group.Count % 2 != 0) return false; // 奇数个顶点无法配对
// 按垂直轴升序排列后两两配对为边
group.Sort((a, b) =>
(groupByX ? pts[a].y : pts[a].x).CompareTo(groupByX ? pts[b].y : pts[b].x));
for (int k = 0; k + 1 < group.Count; k += 2)
{
adj[group[k]].Add(group[k + 1]);
adj[group[k + 1]].Add(group[k]);
}
}
return true;
}
/// <summary>
/// 最近邻贪心排序(降级路径):从最左侧点出发,每步选取欧氏距离最近的未访问点。
/// </summary>
private static Vector2[] NearestNeighborOrder(Vector2[] points)
{
int n = points.Length;
var visited = new bool[n];
var result = new Vector2[n];
int startIdx = 0;
for (int i = 1; i < n; i++)
if (points[i].x < points[startIdx].x) startIdx = i;
result[0] = points[startIdx];
visited[startIdx] = true;
for (int step = 1; step < n; step++)
{
float minSqDist = float.MaxValue;
int nearest = -1;
for (int i = 0; i < n; i++)
{
if (visited[i]) continue;
float sq = (points[i] - result[step - 1]).sqrMagnitude;
if (sq < minSqDist) { minSqDist = sq; nearest = i; }
}
result[step] = points[nearest];
visited[nearest] = true;
}
return result;
}
/// <summary>
/// 2-opt 优化(降级路径):检测并翻转所有自相交边对,直到无交叉为止。
/// </summary>
private static void TwoOptFix(Vector2[] pts)
{
int n = pts.Length;
bool improved = true;
while (improved)
{
improved = false;
for (int i = 0; i < n - 1; i++)
for (int j = i + 2; j < n; j++)
{
if (i == 0 && j == n - 1) continue;
if (SegmentsIntersect(pts[i], pts[(i + 1) % n], pts[j], pts[(j + 1) % n]))
{
System.Array.Reverse(pts, i + 1, j - i);
improved = true;
}
}
}
}
/// <summary>用叉积判断线段 AB 与 CD 是否严格相交(不含端点接触)。</summary>
private static bool SegmentsIntersect(Vector2 a, Vector2 b, Vector2 c, Vector2 d)
{
float d1 = Cross2D(d - c, a - c), d2 = Cross2D(d - c, b - c);
float d3 = Cross2D(b - a, c - a), d4 = Cross2D(b - a, d - a);
return ((d1 > 0f && d2 < 0f) || (d1 < 0f && d2 > 0f)) &&
((d3 > 0f && d4 < 0f) || (d3 < 0f && d4 > 0f));
}
private static float Cross2D(Vector2 u, Vector2 v) => u.x * v.y - u.y * v.x;
/// <summary>
/// 使用 Shoelace 公式(叉积累加)验证绕向:
/// 2A = Σ (xᵢ · yᵢ₊₁ xᵢ₊₁ · yᵢ)
/// Unity Y 轴朝上A > 0 = CCWA &lt; 0 = CW。
/// 若为顺时针则翻转(不改变形状,只调整绕向)。
/// </summary>
private static Vector2[] EnsureCounterClockwise(Vector2[] points)
{
float signedArea = 0f;
int n = points.Length;
for (int i = 0; i < n; i++)
{
Vector2 curr = points[i];
Vector2 next = points[(i + 1) % n];
signedArea += curr.x * next.y - next.x * curr.y;
}
if (signedArea < 0f)
System.Array.Reverse(points);
return points;
}
/// <summary>

View File

@@ -389,7 +389,7 @@ namespace BaseGames.Editor
var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E001 草蛭");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_CaoZhi");
Undo.RegisterCreatedObjectUndo(go, "Place E001");
go.transform.position = GetDropPosition();
@@ -470,6 +470,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E001 草蛭", go, report);
}
@@ -479,7 +480,7 @@ namespace BaseGames.Editor
var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E002 簧蛭");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_HuangZhi");
Undo.RegisterCreatedObjectUndo(go, "Place E002");
go.transform.position = GetDropPosition();
@@ -548,6 +549,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E002 簧蛭", go, report);
}
@@ -557,7 +559,7 @@ namespace BaseGames.Editor
var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E003 幼蛭");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_YouZhi");
Undo.RegisterCreatedObjectUndo(go, "Place E003");
go.transform.position = GetDropPosition();
@@ -631,6 +633,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E003 幼蛭", go, report);
}
@@ -640,7 +643,7 @@ namespace BaseGames.Editor
var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E004 蛭母");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_ZhiMu");
Undo.RegisterCreatedObjectUndo(go, "Place E004");
go.transform.position = GetDropPosition();
@@ -757,6 +760,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E004 蛭母", go, report);
}
@@ -766,7 +770,7 @@ namespace BaseGames.Editor
var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E005 肥蛭");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_FeiZhi");
Undo.RegisterCreatedObjectUndo(go, "Place E005");
go.transform.position = GetDropPosition();
@@ -849,6 +853,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E005 肥蛭", go, report);
}
@@ -858,7 +863,7 @@ namespace BaseGames.Editor
var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place E006 讙");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_Huan");
Undo.RegisterCreatedObjectUndo(go, "Place E006");
go.transform.position = GetDropPosition();
@@ -951,6 +956,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("E006 讙", go, report);
}
@@ -960,7 +966,7 @@ namespace BaseGames.Editor
var report = new List<string>();
int undoGroup = Undo.GetCurrentGroup();
Undo.SetCurrentGroupName("Place Boss 嘲风");
EnemyBase.SuppressValidationWarnings = true;
GameObject go = new GameObject("ENM_ChaoFeng");
Undo.RegisterCreatedObjectUndo(go, "Place ChaoFeng");
go.transform.position = GetDropPosition();
@@ -1067,6 +1073,7 @@ namespace BaseGames.Editor
Undo.CollapseUndoOperations(undoGroup);
Selection.activeGameObject = go;
EnemyBase.SuppressValidationWarnings = false;
MarkDirtyAndLog("Boss 嘲风 (ChaoFeng)", go, report);
}

View File

@@ -710,8 +710,12 @@ namespace BaseGames.Enemies
}
#if UNITY_EDITOR
/// <summary>Set to true during batch editor placement to suppress mid-wiring OnValidate warnings.</summary>
public static bool SuppressValidationWarnings { get; set; }
protected virtual void OnValidate()
{
if (SuppressValidationWarnings) return;
if (_statsSO == null)
Debug.LogWarning($"[EnemyBase] {gameObject.name} 缺少 EnemyStatsSO 配置(运行时会 NullRef。", this);
if (_stats == null)

View File

@@ -49,9 +49,14 @@ namespace BaseGames.Player
private bool _wasGrounded;
// 跳跃/二段跳期间禁用斜坡吸附,防止把起跳判定成斜坡而立即下压
private bool _slopeSnapDisabled;
private readonly Collider2D[] _groundBuffer = new Collider2D[4];
private int _groundHitCount;
private readonly Collider2D[] _groundBuffer = new Collider2D[4];
private int _groundHitCount;
private readonly ContactPoint2D[] _slopeContactBuffer = new ContactPoint2D[8];
private readonly ContactPoint2D[] _wallContactBuffer = new ContactPoint2D[8];
// 跳跃上升阶段贴墙时保护 vy物理摩擦会在碰墙瞬间降低垂直速度
// 通过 OnCollisionEnter/Stay2D 将 vy 恢复到碰撞前的值。
private float _savedVy;
private bool _preserveVyOnWallContact;
#if UNITY_EDITOR
// ── 运行时调试Inspector 中可见)───────────────────────────────
@@ -111,6 +116,10 @@ namespace BaseGames.Player
_pendingHorizontalZero = false;
}
// 保存本帧物理步开始前的垂直速度,用于 OnCollisionEnter/Stay2D 中恢复被墙壁
// 摩擦力降低的 vy状态机 -100 比本脚本 -200 晚执行,不会影响此处的读取时机)。
_savedVy = _rb.velocity.y;
CheckGrounded();
CheckWalls();
@@ -283,6 +292,47 @@ namespace BaseGames.Player
/// <summary>消耗墙壁土狼时间,防止同一帧被多次触发。</summary>
public void ConsumeWallCoyote() => _wallCoyoteTimer = 0f;
// ── 跳跃上升贴墙 vy 保护 ──────────────────────────────────────────────
/// <summary>
/// JumpState.OnStateEnter/Exit 调用,开启/关闭跳跃上升阶段的 vy 保护。
/// 开启后OnCollisionEnter/Stay2D 检测到水平墙壁接触且角色有朝墙速度时,
/// 将 vy 恢复到本帧物理步前的值,消除物理摩擦对跳跃最高点的影响。
/// </summary>
public void SetPreserveVyOnWallContact(bool preserve)
=> _preserveVyOnWallContact = preserve;
private void OnCollisionEnter2D(Collision2D collision)
=> TryRestoreVyFromWallFriction(collision);
private void OnCollisionStay2D(Collision2D collision)
=> TryRestoreVyFromWallFriction(collision);
/// <summary>
/// 检测水平墙壁碰撞时是否因摩擦力降低了 vy若是则恢复到碰撞前的值。
/// 只在角色确实以朝向墙壁的水平速度_inputVelocityX发生碰撞时才恢复
/// 防止 ZeroHVel 正常工作vx=0无摩擦的帧中错误地抵消重力。
/// </summary>
private void TryRestoreVyFromWallFriction(Collision2D collision)
{
if (!_preserveVyOnWallContact || _savedVy <= 0f) return;
for (int i = 0; i < collision.contactCount; i++)
{
float nx = collision.GetContact(i).normal.x;
if (Mathf.Abs(nx) > 0.5f)
{
// 法线朝右nx > 0.5= 左侧墙角色朝左运动时产生摩擦vx < 0
// 法线朝左nx < -0.5= 右侧墙角色朝右运动时产生摩擦vx > 0
bool hadVelocityIntoWall = (nx > 0.5f && _inputVelocityX < -0.1f)
|| (nx < -0.5f && _inputVelocityX > 0.1f);
if (hadVelocityIntoWall)
{
_rb.velocity = new Vector2(_rb.velocity.x, _savedVy);
return;
}
}
}
}
// ── IPassengerReceiver ────────────────────────────────────────────────
/// <summary>
/// MovingPlatform.FixedUpdate(-300) 推送本帧平台期望位移。
@@ -445,6 +495,20 @@ namespace BaseGames.Player
_isWallLeft = Physics2D.Raycast(pos, Vector2.left, len, _groundLayer);
_isWallRight = Physics2D.Raycast(pos, Vector2.right, len, _groundLayer);
// 物理接触点兜底:补充射线未覆盖图层或长度不足时的漏检。
// GetContacts 返回上一物理步的接触点,由本脚本(-200读取时先于状态机-100
// 可在状态机决定是否施加水平速度之前获得"当帧最新"的墙壁接触信息。
if (!_isWallLeft || !_isWallRight)
{
int cnt = _rb.GetContacts(_wallContactBuffer);
for (int i = 0; i < cnt; i++)
{
float nx = _wallContactBuffer[i].normal.x;
if (nx > 0.5f) _isWallLeft = true; // 法线朝右 = 左侧有墙
if (nx < -0.5f) _isWallRight = true; // 法线朝左 = 右侧有墙
}
}
}
private void OnDrawGizmos()

View File

@@ -41,15 +41,32 @@ namespace BaseGames.Player.States
_isDoubleJump = false; // 消耗标记
Input.JumpCancelledEvent += OnJumpCancelled;
// 开启上升阶段贴墙 vy 保护:防止物理摩擦降低跳跃最高点
Move.SetPreserveVyOnWallContact(true);
}
public override void OnStateUpdate()
{
// 上升结束时转为下落
// 上升结束时转为下落
// 例外:按住朝墙方向且射线已检测到墙时,物理摩擦可能将 vy 瞬间压到 ≤ 0
// 此时不触发 FallState让后续抓墙检测在 vy 稳定后接管状态转换,
// 防止因一帧摩擦导致跳跃高度降低并错误进入 FallState。
if (Move.Rb.velocity.y <= 0f)
{
_owner.TransitionTo(_owner.GetState<FallState>());
return;
bool pressingTowardDetectedWall = false;
if (Mathf.Abs(Input.MoveInput.x) > 0.01f)
{
int inputDir = Input.MoveInput.x > 0 ? 1 : -1;
bool cwRay = inputDir > 0 ? Move.IsWallRight : Move.IsWallLeft;
var wd0 = Owner.WallDetector;
pressingTowardDetectedWall = cwRay
|| (wd0 != null && wd0.IsTouchingWall && wd0.WallDirection == inputDir);
}
if (!pressingTowardDetectedWall)
{
_owner.TransitionTo(_owner.GetState<FallState>());
return;
}
}
// ── 下冲刺(下 + 冲刺 → 向下冲刺,优先于普通冲刺)──────────────────────
@@ -98,8 +115,10 @@ namespace BaseGames.Player.States
}
// ── 抓墙:贴墙 + 朝向墙壁按键,或蹬墙跳后的自动抓墙──────────────
// 仅在上升结束后vy ≤ 0才进入抓墙状态上升阶段阻止转换以保留顶点重力缩减
// 避免贴墙按方向键导致跳跃最大高度降低。
var wd = Owner.WallDetector;
if (wd != null && wd.IsTouchingWall && !Move.IsGrounded)
if (wd != null && wd.IsTouchingWall && !Move.IsGrounded && !Move.IsRising)
{
int wallDir = wd.WallDirection;
bool pressingTowardWall = Mathf.Abs(Input.MoveInput.x) > 0.01f
@@ -130,11 +149,19 @@ namespace BaseGames.Player.States
{
int inputDir = Input.MoveInput.x > 0 ? 1 : -1;
var wd = Owner.WallDetector;
// PlayerWallDetectororder 0在状态机order -100之后执行
// 其 IsTouchingWall / HasPartialContact 对状态机而言是上一帧的结果。
// PlayerMovement.CheckWalls()order -200在状态机之前执行
// IsWallLeft / IsWallRight 是当帧最新结果,可提前一帧停止施力,
// 防止角色以 RunSpeed 压入墙面后物理引擎的摩擦力降低上升速度。
bool currentFrameWall = inputDir > 0 ? Move.IsWallRight : Move.IsWallLeft;
if (wd != null && wd.IsTouchingWall && wd.WallDirection == inputDir)
{
// 按下朝墙方向键 + 在墙上(且未著地)→ 进入抓墙状态
// 仅在上升结束后vy ≤ 0才进入抓墙状态上升阶段只停止水平施力
// 保留顶点重力缩减逻辑,防止贴墙按键导致跳跃最大高度降低。
var wss = Owner.GetState<WallSlideState>();
if (wss != null && !Move.IsGrounded)
if (wss != null && !Move.IsGrounded && !Move.IsRising)
{
wss.PrepareEnter(inputDir);
Owner.TransitionTo(wss);
@@ -142,10 +169,10 @@ namespace BaseGames.Player.States
}
Move.ZeroHorizontalVelocity();
}
else if (wd != null && wd.HasPartialContact(inputDir))
else if (currentFrameWall || (wd != null && wd.HasPartialContact(inputDir)))
{
// 仅部分射线命中(如检测点高于矮墙顶部),停止施加朝墙方向的水平速度
// 防止角色边角被卡在墙顶而无法继续下落
// 当帧单射线已检测到墙PlayerMovement -200 先于状态机 -100 执行)
// 或上一帧部分射线命中——停止施力,防止以 RunSpeed 压入墙面产生摩擦力
Move.ZeroHorizontalVelocity();
}
else
@@ -160,6 +187,7 @@ namespace BaseGames.Player.States
// 顶点悬停可能已降低重力,离开本状态时必须恢复;
// 否则进入 FallState/DashState 等后续状态时重力仍低于默认值。
Move.SetGravityScale(Cfg.DefaultGravityScale);
Move.SetPreserveVyOnWallContact(false);
Input.JumpCancelledEvent -= OnJumpCancelled;
}