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
|
// v1 velocity-based → micro-jitter ломал stuck-timer
|
||||||
// v2 + physical displacement → не достаточно если overlap не найден
|
// v2 + physical displacement → не достаточно если overlap не найден
|
||||||
// v3 position-based → решил detection, но `healthyReset` сбрасывал
|
// v3 position-based с healthyReset → reset attempts каждый раз когда
|
||||||
// attempts каждый раз когда мяч на секунду улетал после nudge.
|
// мяч на секунду улетал после nudge → бесконечный loop attempt=1
|
||||||
// Реальный stuck-цикл: stuck → nudge → ball отскакивает → reset
|
// v4 cumulative counter БЕЗ auto-reset (только manual reset() или после
|
||||||
// attempts → re-stuck → nudge #1 опять. Бесконечно.
|
// hard_respawn) → 4-й stuck гарантированно эскалирует, независимо
|
||||||
|
// от количества temporary recoveries между ними.
|
||||||
//
|
//
|
||||||
// v4 ключевая идея: НЕ сбрасывать attempts по «healthy движению».
|
// nudgeAttempts persistent в рамках одной match-phase. Reset через:
|
||||||
// Вместо этого — sliding window of nudge fire timestamps. Если в окне
|
// - reset() — вызывается из MatchScene при goal/match-start/cradle release
|
||||||
// escalationWindowMs накопилось > maxNudgeAttempts → respawn.
|
// - hard_respawn (внутренний reset перед return)
|
||||||
// Window сам себя дренирует: после 10s без новых fire'ов counter → 0.
|
|
||||||
//
|
//
|
||||||
// Это значит: «застрял → разлепили → опять застрял в течение 10 сек»
|
// Это решает scenario: stuck → nudge → ball отскакивает на 2s → re-stuck →
|
||||||
// корректно эскалирует.
|
// должен эскалировать. v3 в этом случае loop'ил attempt=1 бесконечно;
|
||||||
|
// v4 escalates на 4-й кумулятивный fire.
|
||||||
|
|
||||||
export type StuckAction = 'none' | 'nudge' | 'hard_respawn';
|
export type StuckAction = 'none' | 'nudge' | 'hard_respawn';
|
||||||
|
|
||||||
|
|||||||
@@ -128,16 +128,28 @@ export class Bumper {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Гарантируем минимальную скорость после отскока — иначе мяч может
|
// Anti-trap фикс: гарантировать что после отскока мяч движется НАРУЖУ
|
||||||
// абсорбироваться bumper'ом и зависнуть (особенно curve без speedBoost).
|
// от bumper'а с минимальной скоростью. Иначе Matter может затолкать мяч
|
||||||
|
// обратно в contact manifold (root-cause stuck-loop'а на этом bumper'е).
|
||||||
const v2 = ballBody.velocity;
|
const v2 = ballBody.velocity;
|
||||||
const speed2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
|
const speed2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
|
||||||
const MIN_POST_HIT_SPEED = 2.0; // Matter units (~200 px/sec)
|
const MIN_POST_HIT_SPEED = 2.5; // Matter units (~250 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;
|
// Вектор «наружу» от центра 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, {
|
this.scene.matter.body.setVelocity(ballBody, {
|
||||||
x: Math.cos(angle) * MIN_POST_HIT_SPEED,
|
x: normX * MIN_POST_HIT_SPEED,
|
||||||
y: Math.sin(angle) * 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);
|
this.ball.setVelocity(v.x * ratio, v.y * ratio);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anti-stuck watchdog v3 — position-based detection (устойчиво к micro-jitter).
|
// Anti-stuck watchdog v4 — position-based + cumulative counter (см. BallStuckWatchdog.ts).
|
||||||
// Skip когда booster cradle активен (там мяч намеренно sticky).
|
// Counter сбрасывается ТОЛЬКО при goal respawn, match start или cradle release —
|
||||||
|
// temporary recovery после nudge не очищает счётчик, поэтому re-stuck гарантированно
|
||||||
|
// эскалирует. Skip когда booster.aiming (мяч намеренно sticky).
|
||||||
if (this.booster.getState() !== 'aiming') {
|
if (this.booster.getState() !== 'aiming') {
|
||||||
const action = this.stuckWatchdog.tick(this.ball.position, this.time.now);
|
const action = this.stuckWatchdog.tick(this.ball.position, this.time.now);
|
||||||
if (action === 'nudge') {
|
if (action === 'nudge') {
|
||||||
|
|||||||
Reference in New Issue
Block a user