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