Doc drift fix + physics root-cause: force outward velocity post-bumper
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user