From 6bb1c81c838cc2b3f0ea642b02bf694dc785138d Mon Sep 17 00:00:00 2001 From: aevgarik Date: Sun, 24 May 2026 13:13:17 +0300 Subject: [PATCH] Doc drift fix + physics root-cause: force outward velocity post-bumper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on ff7a787: - #1 Medium: stuck больше не блокирует навсегда (watchdog escalates), но физика всё ещё regularly загоняет мяч в тот же AI bumper до hard_respawn (91.7s wait в worst case). Не Critical, но root cause не устранён. - #2 Low: комментарии в watchdog и MatchScene drift'нули после v3→v4 refactor. ## Fix #1 — root-cause физики Bumper.handleBallHit раньше гарантировал только MIN_POST_HIT_SPEED, но НЕ направление. Если post-impulse velocity направлена обратно в bumper (radial component < 0), Matter resolved bы contact pushing ball back, и мяч застревал в loop. Теперь после impulse: - Вычисляем "outward" вектор = от центра bumper'а к центру мяча - Радиальная компонента velocity = dot(v, outward) - Если speed < MIN_POST_HIT_SPEED (2.5) ИЛИ radial < 0.5 (мяч движется внутрь или почти не движется): setVelocity({outward * MIN_POST_HIT_SPEED}) Гарантирует что после КАЖДОГО bumper hit мяч движется наружу с минимум 250 px/sec. Должно резко снизить частоту stuck-events и watchdog escalation'ов в реальном gameplay. ## Fix #2 — комментарии под актуальную v4 модель - src/game/BallStuckWatchdog.ts header: «sliding-window escalation» → «cumulative counter (no auto-reset)» + объяснение почему v3 bug требовал именно cumulative подход - src/scenes/MatchScene.ts:226: «v3 — position-based» → «v4 — position-based + cumulative counter» ## Trade-off (документировано в коде) Curve bumper's ±35° angle rotation применяется ДО outward-force. Если итоговая скорость низкая ИЛИ направлена внутрь — outward форсируется (curve effect частично перебивается). Slingshot/turbo speedBoost ×1.2/×1.5 обычно достаточен чтобы пройти MIN threshold, их эффект сохраняется. 107/107 tests, typecheck/lint/build ✅. Co-Authored-By: Claude Opus 4.7 --- src/game/BallStuckWatchdog.ts | 23 ++++++++++++----------- src/game/Bumper.ts | 26 +++++++++++++++++++------- src/scenes/MatchScene.ts | 6 ++++-- 3 files changed, 35 insertions(+), 20 deletions(-) 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') {