CRITICAL fix: Matter velocity units — launch ball moved 60x too fast

## Корневая причина статичного поля (codex #1)

launchBall и Booster.shoot имели мёртвый код:
   speed * 0.01 * 100  ==  speed  (no-op)
который должен был быть преобразованием px/sec → Matter units (/60),
но эффективно НЕ конвертировал.

setVelocity(400, 0) в Matter = 400 units/timestep = ~24000 px/sec.
Это вызывало:
- Ball tunnel'ил через стенки/bumpers (Matter без CCD не handle'ит
  velocity >> body_size/timestep)
- Watchdog видел chaotic position и fire'ил постоянно (3-7 ball_unstuck
  в одном матче по словам codex)
- В browser screenshot ball мог оказаться где угодно или вне viewport,
  визуально создавая впечатление "статичного поля"

Goals регистрировались потому что ball все равно проходил через
goal zone при tunneling, telemetry работала, но визуальный feedback
был сломан.

## Fix

### MatchScene.launchBall
```
const speedMatter = BALL_SPEED_START / 60; // 400/60 = 6.67 Matter units
this.ball.setVelocity(cos(angle) * speedMatter, sin(angle) * speedMatter);
```
Результат: ~6.67 Matter units = ~400 px/sec (intended pinball speed).

### Booster.shoot
Аналогично: shootSpeed/60 = ~18.3 Matter units = ~1100 px/sec.

## Дополнительные fixes

### bumperPhysics.computeOutwardForce — preserve speed
Раньше force outward всегда сбрасывал velocity на minSpeed=2.5
(replace, not min). Fast ball heading into bumper резко тормозил.

Теперь: targetSpeed = Math.max(speed, minSpeed). Direction меняется
наружу, но магнитуда preserved (минимум minSpeed как floor).

minSpeed bumped 2.5 → 5 (~300 px/sec @60fps) — для надёжного escape
из contact manifold при низкоскоростных hits.

### Tests

- Обновил «fast + inward» test — теперь expect preserved speed (5)
  вместо клипа до 2.5
- Обновил «tangential» аналогично
- Добавил «REAL fix» test: very fast inward (speed=10) → preserved
  to 10, не клипнуто к minSpeed

118/118 tests, typecheck/lint/build .

## Ожидаемый эффект

Ball теперь должен:
1. Двигаться с осмысленной скоростью (~400 px/sec на launch,
   ~600-1000 px/sec после флипперов)
2. НЕ tunnel'ить через объекты (velocity << body_size/timestep)
3. Visible в каждом frame screenshot
4. Watchdog почти не должен срабатывать (только на genuine edge cases)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
aevgarik
2026-05-24 13:37:32 +03:00
parent cff8656f0b
commit 4548ae7544
5 changed files with 39 additions and 17 deletions

View File

@@ -157,9 +157,12 @@ export class Booster {
*/
shoot(ctx: BoosterContext): { x: number; y: number } | null {
if (this.state !== 'aiming') return null;
const vx = Math.cos(this.aimAngleRad) * this.config.shootSpeed * 0.01;
const vy = Math.sin(this.aimAngleRad) * this.config.shootSpeed * 0.01;
const velocity = { x: vx * 100, y: vy * 100 };
// Matter velocity = position delta per timestep; конвертируем px/sec → Matter units (/60).
const speedMatter = this.config.shootSpeed / 60;
const velocity = {
x: Math.cos(this.aimAngleRad) * speedMatter,
y: Math.sin(this.aimAngleRad) * speedMatter,
};
this.state = 'cooldown';
this.cradlePos = null;
this.cooldownUntil = ctx.now + 200;

View File

@@ -135,7 +135,7 @@ export class Bumper {
ballPos: { x: ballBody.position.x, y: ballBody.position.y },
ballVel: { x: ballBody.velocity.x, y: ballBody.velocity.y },
bumperPos: { x: this.body.position.x, y: this.body.position.y },
minSpeed: 2.5, // Matter units (~250 px/sec)
minSpeed: 5, // Matter units (~300 px/sec при 60fps)
radialThreshold: 0.5,
});
if (outward) {

View File

@@ -47,17 +47,30 @@ describe('computeOutwardForce — force apply cases', () => {
expect(r.y).toBeCloseTo(0);
});
it('fast + inward (radial<0) → force outward', () => {
// Ball справа, velocity сильная влево (внутрь bumper'а)
it('fast + inward (radial<0) → force outward, PRESERVE SPEED', () => {
// Ball справа, velocity сильная влево (внутрь bumper'а), speed=5
const r = computeOutwardForce(
inputs({ ballPos: { x: 150, y: 100 }, ballVel: { x: -5, y: 0 } }),
);
expect(r).not.toBeNull();
if (!r) return;
expect(r.x).toBeCloseTo(2.5); // вправо (наружу)
// Direction outward (вправо), magnitude = max(5, minSpeed=2.5) = 5
expect(r.x).toBeCloseTo(5); // вправо, СОХРАНИЛИ скорость
expect(r.y).toBeCloseTo(0);
});
it('REAL fix: very fast inward → preserve high speed, не замедляем', () => {
// Ball справа, velocity сильная внутрь, speed=10
const r = computeOutwardForce(
inputs({ ballPos: { x: 150, y: 100 }, ballVel: { x: -10, y: 0 } }),
);
expect(r).not.toBeNull();
if (!r) return;
const mag = Math.sqrt(r.x * r.x + r.y * r.y);
expect(mag).toBeCloseTo(10); // sped preserved, not clipped to 2.5
expect(r.x).toBeCloseTo(10);
});
it('zero velocity → force outward', () => {
const r = computeOutwardForce(
inputs({ ballPos: { x: 150, y: 100 }, ballVel: { x: 0, y: 0 } }),
@@ -68,14 +81,14 @@ describe('computeOutwardForce — force apply cases', () => {
expect(r.y).toBeCloseTo(0);
});
it('fast + tangential (radial≈0) → force apply (radial<threshold=0.5)', () => {
// Ball справа, velocity вверх (perpendicular к outward) — radial=0
it('fast + tangential (radial≈0) → force outward с preserved speed', () => {
// Ball справа, velocity вверх (perpendicular к outward) — speed=5, radial=0
const r = computeOutwardForce(
inputs({ ballPos: { x: 150, y: 100 }, ballVel: { x: 0, y: -5 } }),
);
expect(r).not.toBeNull();
if (!r) return;
expect(r.x).toBeCloseTo(2.5);
expect(r.x).toBeCloseTo(5); // preserved speed
expect(r.y).toBeCloseTo(0);
});
});

View File

@@ -48,8 +48,11 @@ export function computeOutwardForce(
return null; // OK, мяч движется наружу с достаточной скоростью
}
// Preserve incoming speed (только направление меняем); minSpeed = floor.
// Это критично: fast inward → outward at SAME speed, не замедляем мяч.
const targetSpeed = Math.max(speed, minSpeed);
return {
x: normX * minSpeed,
y: normY * minSpeed,
x: normX * targetSpeed,
y: normY * targetSpeed,
};
}

View File

@@ -311,13 +311,16 @@ export class MatchScene extends Phaser.Scene {
}
private launchBall(): void {
// Random vector ±30° от перпендикуляра к боковой стенке (т.е. влево)
// Random vector ±30° от перпендикуляра к боковой стенке (т.е. влево).
// Matter velocity = position delta per timestep (~16.67ms @ 60fps),
// поэтому конвертируем px/sec → Matter units (/60).
// Старый код имел * 0.01 * 100 = no-op → setVelocity(400) = 24000 px/sec,
// ball tunnel'ил через объекты и watchdog fire'ил постоянно.
const angleVariation = (Math.random() * 60 - 30) * (Math.PI / 180);
const baseAngle = Math.PI; // влево
const baseAngle = Math.PI;
const angle = baseAngle + angleVariation;
const vx = Math.cos(angle) * BALL_SPEED_START * 0.01; // Matter — мaленькие velocity units
const vy = Math.sin(angle) * BALL_SPEED_START * 0.01;
this.ball.setVelocity(vx * 100, vy * 100);
const speedMatter = BALL_SPEED_START / 60; // ~6.67 Matter units = ~400 px/sec
this.ball.setVelocity(Math.cos(angle) * speedMatter, Math.sin(angle) * speedMatter);
}
private placeSetupBumpers(setup: SetupConfig, side: 'player' | 'ai'): void {