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:
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
Reference in New Issue
Block a user