From 8e279d20df71ffcf84b92a375d478969edc457ad Mon Sep 17 00:00:00 2001 From: aevgarik Date: Sun, 24 May 2026 16:11:16 +0300 Subject: [PATCH] =?UTF-8?q?Slingshot=20physical=20separation=20=E2=80=94?= =?UTF-8?q?=20fix=20stuck=20regression?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on 847bc24: - ball_hard_respawn вернулся после slingshot rails (mobile match: x1 respawn, x3 unstuck до match_end) - desktop match: x3 unstuck (без respawn, но проблема видна) ## Root cause handleSideSlingshot ставил velocity (computeSlingshotImpulse) НО НЕ выносил ball из contact manifold rail body. Matter resolves contact каждый tick, и: 1. Ball остаётся в contact с rail (overlap) 2. Cooldown 200ms истёк → re-trigger slingshot 3. Velocity снова normal direction → ball в том же месте → loop 4. Watchdog видит «position не меняется» → fire nudge/respawn ## Fix ### MatchScene.handleSideSlingshot Физическое смещение ПЕРЕД impulse: ```ts const safeDistance = 36; // ballR (22) + railThick/2 (6) + margin (8) const newX = ballBody.position.x + normal.x * safeDistance; const newY = ballBody.position.y + normal.y * safeDistance; this.ball.setPosition(clampedX, clampedY); // через wrapper — sprite sync this.ball.setVelocity(impulse.x, impulse.y); ``` normal = computeRailNormal(side) — уже inward unit vector. Ball.setPosition immediate-sync'ит sprite (см. prior fix). Position clamp к табличным границам предотвращает teleport в стенку. ### sideSlingshot.ts — cooldown 200 → 250ms Extra safety margin после physical separation. Если Matter всё же re-detects collision на следующий frame, дольше cooldown даёт ball точно выйти из rail зоны. ## Также - Used this.ball.setPosition / setVelocity (wrapper) instead of raw matter.body.* — sprite sync guaranteed, consistent с prior architecture - Sparks теперь spawn'аются на NEW (post-displacement) ball position ## Tests 175/175 unchanged. Physical-separation логика не покрывается unit-тестами напрямую (требует Matter mock); существующие tests на impulse math и cooldown работают как раньше. Codex finding #3 (npm audit moderate) — known, отложено до Phase 5 Vite 8 upgrade. typecheck/lint/build ✅. Co-Authored-By: Claude Opus 4.7 --- src/game/sideSlingshot.ts | 2 +- src/scenes/MatchScene.ts | 25 ++++++++++++++++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/game/sideSlingshot.ts b/src/game/sideSlingshot.ts index 272d9f7..dcaa07a 100644 --- a/src/game/sideSlingshot.ts +++ b/src/game/sideSlingshot.ts @@ -21,7 +21,7 @@ export interface SlingshotConfig { export const DEFAULT_SLINGSHOT_CONFIG: SlingshotConfig = { minSpeedPxSec: 400, maxSpeedPxSec: 600, - cooldownMs: 200, + cooldownMs: 250, // 200 → 250: extra safety после physical separation fix }; /** diff --git a/src/scenes/MatchScene.ts b/src/scenes/MatchScene.ts index 6aa795d..ed499d6 100644 --- a/src/scenes/MatchScene.ts +++ b/src/scenes/MatchScene.ts @@ -22,6 +22,7 @@ import { } from '../game/BallStuckWatchdog'; import { BallEnergyController } from '../game/BallEnergyController'; import { + computeRailNormal, computeSlingshotImpulse, RailCooldownTracker, matterVelocityToPxSec, @@ -741,7 +742,10 @@ export class MatchScene extends Phaser.Scene { /** * Side slingshot — активный rebound от боковой rail. * НЕ начисляет очки, НЕ меняет owner, НЕ влияет на leaderboard. - * Только impulse + cooldown + visual feedback + telemetry. + * + * Important: physical separation после impulse — раньше просто + * setVelocity оставлял ball в contact manifold rail body, что вызывало + * regression stuck/повторный fire после cooldown. */ private handleSideSlingshot( side: GuardWallSpec['side'], @@ -755,11 +759,26 @@ export class MatchScene extends Phaser.Scene { y: ballBody.velocity.y, }); const impulse = computeSlingshotImpulse(side, speedBeforePxSec); - this.matter.body.setVelocity(ballBody, impulse); + + // PHYSICAL SEPARATION: сдвигаем ball в направлении inward normal так, + // чтобы он вышел из contact manifold rail body. Иначе следующий frame + // Matter сразу re-triggers collision → stuck loop. + // Distance = ballRadius (22) + railThickness/2 (6) + margin (8) = 36 + const normal = computeRailNormal(side); + const safeDistance = 36; + const newX = ballBody.position.x + normal.x * safeDistance; + const newY = ballBody.position.y + normal.y * safeDistance; + // Clamp к границам стола чтобы не телепортировать в стенку + const clampedX = Math.max(30, Math.min(GAME_WIDTH - 30, newX)); + const clampedY = Math.max(30, Math.min(GAME_HEIGHT - 30, newY)); + this.ball.setPosition(clampedX, clampedY); + + // Impulse + this.ball.setVelocity(impulse.x, impulse.y); // Visual: flash rail + spark particles this.table.flashRail(side); - this.spawnSlingshotParticles(side, ballBody.position); + this.spawnSlingshotParticles(side, { x: newX, y: newY }); // Telemetry this.trackEvent('side_slingshot_hit', {