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, + }; +}