Slingshot physical separation — fix stuck regression

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 <noreply@anthropic.com>
This commit is contained in:
aevgarik
2026-05-24 16:11:16 +03:00
parent 847bc24fb8
commit 8e279d20df
2 changed files with 23 additions and 4 deletions

View File

@@ -21,7 +21,7 @@ export interface SlingshotConfig {
export const DEFAULT_SLINGSHOT_CONFIG: SlingshotConfig = { export const DEFAULT_SLINGSHOT_CONFIG: SlingshotConfig = {
minSpeedPxSec: 400, minSpeedPxSec: 400,
maxSpeedPxSec: 600, maxSpeedPxSec: 600,
cooldownMs: 200, cooldownMs: 250, // 200 → 250: extra safety после physical separation fix
}; };
/** /**

View File

@@ -22,6 +22,7 @@ import {
} from '../game/BallStuckWatchdog'; } from '../game/BallStuckWatchdog';
import { BallEnergyController } from '../game/BallEnergyController'; import { BallEnergyController } from '../game/BallEnergyController';
import { import {
computeRailNormal,
computeSlingshotImpulse, computeSlingshotImpulse,
RailCooldownTracker, RailCooldownTracker,
matterVelocityToPxSec, matterVelocityToPxSec,
@@ -741,7 +742,10 @@ export class MatchScene extends Phaser.Scene {
/** /**
* Side slingshot — активный rebound от боковой rail. * Side slingshot — активный rebound от боковой rail.
* НЕ начисляет очки, НЕ меняет owner, НЕ влияет на leaderboard. * НЕ начисляет очки, НЕ меняет owner, НЕ влияет на leaderboard.
* Только impulse + cooldown + visual feedback + telemetry. *
* Important: physical separation после impulse — раньше просто
* setVelocity оставлял ball в contact manifold rail body, что вызывало
* regression stuck/повторный fire после cooldown.
*/ */
private handleSideSlingshot( private handleSideSlingshot(
side: GuardWallSpec['side'], side: GuardWallSpec['side'],
@@ -755,11 +759,26 @@ export class MatchScene extends Phaser.Scene {
y: ballBody.velocity.y, y: ballBody.velocity.y,
}); });
const impulse = computeSlingshotImpulse(side, speedBeforePxSec); 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 // Visual: flash rail + spark particles
this.table.flashRail(side); this.table.flashRail(side);
this.spawnSlingshotParticles(side, ballBody.position); this.spawnSlingshotParticles(side, { x: newX, y: newY });
// Telemetry // Telemetry
this.trackEvent('side_slingshot_hit', { this.trackEvent('side_slingshot_hit', {