diff --git a/index.html b/index.html
index 9cda10d..c683e95 100644
--- a/index.html
+++ b/index.html
@@ -20,12 +20,11 @@
user-select: none;
-webkit-user-select: none;
}
+ /* НЕ используем flex centering — Phaser Scale.CENTER_BOTH центрирует canvas
+ через margin'ы; flex поверх этого создаёт double-centering и сдвиг вправо. */
#game-container {
width: 100vw;
height: 100vh;
- display: flex;
- align-items: center;
- justify-content: center;
}
#loading {
color: #ff006e;
diff --git a/src/game/Ball.ts b/src/game/Ball.ts
index 6f7cbdb..1582a20 100644
--- a/src/game/Ball.ts
+++ b/src/game/Ball.ts
@@ -45,9 +45,11 @@ export class Ball {
});
this.body = matterBody as unknown as MatterJS.BodyType;
- // Visual sprite (Phaser arc rendering)
+ // Visual sprite (Phaser arc rendering). Depth 30 — выше fixed/setup bumpers,
+ // particle effects (50) ниже не уйдут под мяч.
this.sprite = scene.add.circle(x, y, BALL_RADIUS, PALETTE.neutral);
this.sprite.setStrokeStyle(2, PALETTE.white, 0.8);
+ this.sprite.setDepth(30);
}
/** Обновить позицию sprite по физическому body. Вызывать в update(). */
@@ -93,9 +95,10 @@ export class Ball {
this.scene.matter.body.setVelocity(this.body, { x: vx, y: vy });
}
- /** Установить позицию (для респауна после гола). */
+ /** Установить позицию (для респауна после гола / stuck nudge). Sprite — sync immediate. */
setPosition(x: number, y: number): void {
this.scene.matter.body.setPosition(this.body, { x, y });
+ this.sprite.setPosition(x, y);
}
/** Доступ к Matter.js body для коллизий и cleanup. */
diff --git a/src/game/Bumper.ts b/src/game/Bumper.ts
index 3a1b5c1..cf956c4 100644
--- a/src/game/Bumper.ts
+++ b/src/game/Bumper.ts
@@ -1,6 +1,7 @@
import * as Phaser from 'phaser';
import { PALETTE, BUMPER_POINTS } from '../config/defaults';
import type { BumperType, BumperScoreSource } from '../types';
+import { computeOutwardForce } from './bumperPhysics';
// Bumper — статичный круглый bumper.
// v3.10 Defensive setup scoring: source определяет credit-логику в MatchTracker.
@@ -129,28 +130,16 @@ export class Bumper {
}
// Anti-trap фикс: гарантировать что после отскока мяч движется НАРУЖУ
- // от bumper'а с минимальной скоростью. Иначе Matter может затолкать мяч
- // обратно в contact manifold (root-cause stuck-loop'а на этом bumper'е).
- const v2 = ballBody.velocity;
- const speed2 = Math.sqrt(v2.x * v2.x + v2.y * v2.y);
- const MIN_POST_HIT_SPEED = 2.5; // Matter units (~250 px/sec)
-
- // Вектор «наружу» от центра bumper'а к центру мяча
- const dx = ballBody.position.x - this.body.position.x;
- const dy = ballBody.position.y - this.body.position.y;
- const dlen = Math.sqrt(dx * dx + dy * dy) || 0.001;
- const normX = dx / dlen;
- const normY = dy / dlen;
-
- // Радиальная компонента velocity (положительная = наружу, отрицательная = внутрь)
- const radial = v2.x * normX + v2.y * normY;
-
- if (speed2 < MIN_POST_HIT_SPEED || radial < 0.5) {
- // Force outward с гарантированной скоростью
- this.scene.matter.body.setVelocity(ballBody, {
- x: normX * MIN_POST_HIT_SPEED,
- y: normY * MIN_POST_HIT_SPEED,
- });
+ // от bumper'а с минимальной скоростью (см. bumperPhysics.ts).
+ const outward = computeOutwardForce({
+ 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)
+ radialThreshold: 0.5,
+ });
+ if (outward) {
+ this.scene.matter.body.setVelocity(ballBody, outward);
}
// Bounce animation
diff --git a/src/game/bumperPhysics.test.ts b/src/game/bumperPhysics.test.ts
new file mode 100644
index 0000000..e0895ad
--- /dev/null
+++ b/src/game/bumperPhysics.test.ts
@@ -0,0 +1,134 @@
+import { describe, it, expect } from 'vitest';
+import { computeOutwardForce } from './bumperPhysics';
+
+const BUMPER = { x: 100, y: 100 };
+const MIN_SPEED = 2.5;
+const RADIAL_THRESHOLD = 0.5;
+
+function inputs(overrides: {
+ ballPos: { x: number; y: number };
+ ballVel: { x: number; y: number };
+}) {
+ return {
+ ...overrides,
+ bumperPos: BUMPER,
+ minSpeed: MIN_SPEED,
+ radialThreshold: RADIAL_THRESHOLD,
+ };
+}
+
+describe('computeOutwardForce — happy paths (no force needed)', () => {
+ it('fast + outward → null (мяч уже летит наружу с нужной скоростью)', () => {
+ // Ball справа от bumper'а, velocity вправо (наружу)
+ const r = computeOutwardForce(
+ inputs({ ballPos: { x: 150, y: 100 }, ballVel: { x: 5, y: 0 } }),
+ );
+ expect(r).toBeNull();
+ });
+
+ it('fast outward + диагональ → null', () => {
+ const r = computeOutwardForce(
+ inputs({ ballPos: { x: 130, y: 80 }, ballVel: { x: 4, y: -4 } }),
+ );
+ expect(r).toBeNull();
+ });
+});
+
+describe('computeOutwardForce — force apply cases', () => {
+ it('slow + outward (radial>=threshold) → ВСЁ РАВНО force (speed < min)', () => {
+ // Ball справа, velocity 1.0 наружу (radial=1 > 0.5, но speed=1 < 2.5)
+ const r = computeOutwardForce(
+ inputs({ ballPos: { x: 150, y: 100 }, ballVel: { x: 1, y: 0 } }),
+ );
+ expect(r).not.toBeNull();
+ if (!r) return;
+ // Outward (вправо) с MIN_SPEED
+ expect(r.x).toBeCloseTo(2.5);
+ expect(r.y).toBeCloseTo(0);
+ });
+
+ it('fast + inward (radial<0) → force outward', () => {
+ // Ball справа, velocity сильная влево (внутрь bumper'а)
+ 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); // вправо (наружу)
+ expect(r.y).toBeCloseTo(0);
+ });
+
+ it('zero velocity → force outward', () => {
+ const r = computeOutwardForce(
+ inputs({ ballPos: { x: 150, y: 100 }, ballVel: { x: 0, y: 0 } }),
+ );
+ expect(r).not.toBeNull();
+ if (!r) return;
+ expect(r.x).toBeCloseTo(2.5);
+ expect(r.y).toBeCloseTo(0);
+ });
+
+ it('fast + tangential (radial≈0) → force apply (radial {
+ // Ball справа, velocity вверх (perpendicular к outward) — 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.y).toBeCloseTo(0);
+ });
+});
+
+describe('computeOutwardForce — direction correctness', () => {
+ it('ball over bumper (y {
+ const r = computeOutwardForce(
+ inputs({ ballPos: { x: 100, y: 70 }, ballVel: { x: 0, y: 0 } }),
+ );
+ expect(r).not.toBeNull();
+ if (!r) return;
+ expect(r.x).toBeCloseTo(0);
+ expect(r.y).toBeCloseTo(-2.5);
+ });
+
+ it('ball справа-внизу от bumper → outward по диагонали (+x, +y)', () => {
+ // Ball в (130, 130), bumper в (100, 100) — direction = (30, 30) / |...| = (0.707, 0.707)
+ const r = computeOutwardForce(
+ inputs({ ballPos: { x: 130, y: 130 }, ballVel: { x: 0, y: 0 } }),
+ );
+ expect(r).not.toBeNull();
+ if (!r) return;
+ const expectedMag = Math.sqrt(r.x * r.x + r.y * r.y);
+ expect(expectedMag).toBeCloseTo(2.5);
+ // 45° angle outward
+ expect(r.x).toBeCloseTo(2.5 * Math.cos(Math.PI / 4));
+ expect(r.y).toBeCloseTo(2.5 * Math.sin(Math.PI / 4));
+ });
+});
+
+describe('computeOutwardForce — degenerate cases', () => {
+ it('ball точно в центре bumper → deterministic fallback (вверх)', () => {
+ const r = computeOutwardForce(
+ inputs({ ballPos: { x: 100, y: 100 }, ballVel: { x: 0, y: 0 } }),
+ );
+ expect(r).not.toBeNull();
+ if (!r) return;
+ expect(r.x).toBe(0);
+ expect(r.y).toBe(-2.5);
+ });
+});
+
+describe('computeOutwardForce — REAL stuck regression', () => {
+ it('codex bug case: low speed + radial nearly zero → force outward', () => {
+ // Симуляция Matter contact manifold: мяч у поверхности bumper'а,
+ // velocity почти ноль с небольшой тангенциальной компонентой
+ const r = computeOutwardForce(
+ inputs({ ballPos: { x: 142, y: 100 }, ballVel: { x: 0.1, y: 0.3 } }),
+ );
+ expect(r).not.toBeNull();
+ if (!r) return;
+ // Должен быть outward (вправо) с MIN_SPEED
+ expect(r.x).toBeCloseTo(2.5);
+ expect(r.y).toBeCloseTo(0);
+ });
+});
diff --git a/src/game/bumperPhysics.ts b/src/game/bumperPhysics.ts
new file mode 100644
index 0000000..7977e9d
--- /dev/null
+++ b/src/game/bumperPhysics.ts
@@ -0,0 +1,55 @@
+// Pure helpers для Bumper physics — unit-тестируемые.
+// Используется в Bumper.handleBallHit чтобы гарантировать outward velocity
+// после impulse (anti-trap фикс v4).
+
+export interface OutwardForceInputs {
+ ballPos: { x: number; y: number };
+ ballVel: { x: number; y: number };
+ bumperPos: { x: number; y: number };
+ /** Минимальная скорость наружу (Matter units). */
+ minSpeed: number;
+ /** Если radial < этого порога → force apply (мяч движется внутрь или почти не движется). */
+ radialThreshold: number;
+}
+
+/**
+ * Возвращает новую velocity если outward-force нужно применить, иначе null.
+ *
+ * Логика: если speed < minSpeed ИЛИ radial component (dot(v, outward_unit_vec)) < radialThreshold,
+ * мяч движется недостаточно или внутрь bumper'а → force outward с minSpeed.
+ */
+export function computeOutwardForce(
+ inputs: OutwardForceInputs,
+): { x: number; y: number } | null {
+ const { ballPos, ballVel, bumperPos, minSpeed, radialThreshold } = inputs;
+
+ const speed = Math.sqrt(ballVel.x * ballVel.x + ballVel.y * ballVel.y);
+
+ // Outward unit vector от центра bumper'а к центру мяча.
+ // Degenerate case: мяч точно в центре bumper'а — используем random direction.
+ const dx = ballPos.x - bumperPos.x;
+ const dy = ballPos.y - bumperPos.y;
+ const dlen = Math.sqrt(dx * dx + dy * dy);
+ let normX: number;
+ let normY: number;
+ if (dlen < 0.001) {
+ // Fallback: deterministic outward (вверх) при degenerate centerpoint.
+ normX = 0;
+ normY = -1;
+ } else {
+ normX = dx / dlen;
+ normY = dy / dlen;
+ }
+
+ // Radial = проекция velocity на outward vector (положительный = наружу)
+ const radial = ballVel.x * normX + ballVel.y * normY;
+
+ if (speed >= minSpeed && radial >= radialThreshold) {
+ return null; // OK, мяч движется наружу с достаточной скоростью
+ }
+
+ return {
+ x: normX * minSpeed,
+ y: normY * minSpeed,
+ };
+}