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 = {
|
||||
minSpeedPxSec: 400,
|
||||
maxSpeedPxSec: 600,
|
||||
cooldownMs: 200,
|
||||
cooldownMs: 250, // 200 → 250: extra safety после physical separation fix
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user