diff --git a/src/game/BallStuckWatchdog.ts b/src/game/BallStuckWatchdog.ts index b250aec..fdcfa90 100644 --- a/src/game/BallStuckWatchdog.ts +++ b/src/game/BallStuckWatchdog.ts @@ -1,20 +1,21 @@ -// v4: Position-based detection + sliding-window escalation. +// v4: Position-based detection + CUMULATIVE counter (no auto-reset). // // История фикса: // v1 velocity-based → micro-jitter ломал stuck-timer // v2 + physical displacement → не достаточно если overlap не найден -// v3 position-based → решил detection, но `healthyReset` сбрасывал -// attempts каждый раз когда мяч на секунду улетал после nudge. -// Реальный stuck-цикл: stuck → nudge → ball отскакивает → reset -// attempts → re-stuck → nudge #1 опять. Бесконечно. +// v3 position-based с healthyReset → reset attempts каждый раз когда +// мяч на секунду улетал после nudge → бесконечный loop attempt=1 +// v4 cumulative counter БЕЗ auto-reset (только manual reset() или после +// hard_respawn) → 4-й stuck гарантированно эскалирует, независимо +// от количества temporary recoveries между ними. // -// v4 ключевая идея: НЕ сбрасывать attempts по «healthy движению». -// Вместо этого — sliding window of nudge fire timestamps. Если в окне -// escalationWindowMs накопилось > maxNudgeAttempts → respawn. -// Window сам себя дренирует: после 10s без новых fire'ов counter → 0. +// nudgeAttempts persistent в рамках одной match-phase. Reset через: +// - reset() — вызывается из MatchScene при goal/match-start/cradle release +// - hard_respawn (внутренний reset перед return) // -// Это значит: «застрял → разлепили → опять застрял в течение 10 сек» -// корректно эскалирует. +// Это решает scenario: stuck → nudge → ball отскакивает на 2s → re-stuck → +// должен эскалировать. v3 в этом случае loop'ил attempt=1 бесконечно; +// v4 escalates на 4-й кумулятивный fire. export type StuckAction = 'none' | 'nudge' | 'hard_respawn'; diff --git a/src/game/Bumper.ts b/src/game/Bumper.ts index 671d6a6..3a1b5c1 100644 --- a/src/game/Bumper.ts +++ b/src/game/Bumper.ts @@ -128,16 +128,28 @@ export class Bumper { }); } - // Гарантируем минимальную скорость после отскока — иначе мяч может - // абсорбироваться bumper'ом и зависнуть (особенно curve без speedBoost). + // Anti-trap фикс: гарантировать что после отскока мяч движется НАРУЖУ + // от bumper'а с минимальной скоростью. Иначе Matter может затолкать мяч + // обратно в contact manifold (root-cause stuck-loop'а на этом bumper'е). const v2 = ballBody.velocity; const speed2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y); - const MIN_POST_HIT_SPEED = 2.0; // Matter units (~200 px/sec) - if (speed2 < MIN_POST_HIT_SPEED && (speed2 > 0.001 || true)) { - const angle = speed2 > 0.001 ? Math.atan2(v2.y, v2.x) : Math.random() * Math.PI * 2; + const MIN_POST_HIT_SPEED = 2.5; // Matter units (~250 px/sec) + + // Вектор «наружу» от центра bumper'а к центру мяча + const dx = ballBody.position.x - this.body.position.x; + const dy = ballBody.position.y - this.body.position.y; + const dlen = Math.sqrt(dx * dx + dy * dy) || 0.001; + const normX = dx / dlen; + const normY = dy / dlen; + + // Радиальная компонента velocity (положительная = наружу, отрицательная = внутрь) + const radial = v2.x * normX + v2.y * normY; + + if (speed2 < MIN_POST_HIT_SPEED || radial < 0.5) { + // Force outward с гарантированной скоростью this.scene.matter.body.setVelocity(ballBody, { - x: Math.cos(angle) * MIN_POST_HIT_SPEED, - y: Math.sin(angle) * MIN_POST_HIT_SPEED, + x: normX * MIN_POST_HIT_SPEED, + y: normY * MIN_POST_HIT_SPEED, }); } diff --git a/src/scenes/MatchScene.ts b/src/scenes/MatchScene.ts index 4c6ba40..538becf 100644 --- a/src/scenes/MatchScene.ts +++ b/src/scenes/MatchScene.ts @@ -223,8 +223,10 @@ export class MatchScene extends Phaser.Scene { this.ball.setVelocity(v.x * ratio, v.y * ratio); } - // Anti-stuck watchdog v3 — position-based detection (устойчиво к micro-jitter). - // Skip когда booster cradle активен (там мяч намеренно sticky). + // Anti-stuck watchdog v4 — position-based + cumulative counter (см. BallStuckWatchdog.ts). + // Counter сбрасывается ТОЛЬКО при goal respawn, match start или cradle release — + // temporary recovery после nudge не очищает счётчик, поэтому re-stuck гарантированно + // эскалирует. Skip когда booster.aiming (мяч намеренно sticky). if (this.booster.getState() !== 'aiming') { const action = this.stuckWatchdog.tick(this.ball.position, this.time.now); if (action === 'nudge') {