From 7d5dec9c0e5f7e13e6350ca4939b75b5c13c06e8 Mon Sep 17 00:00:00 2001 From: aevgarik Date: Sun, 24 May 2026 15:40:15 +0300 Subject: [PATCH] Button positioning + goal X tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on 7761cd6 — 3 findings. ## #1 UX: buttons обрезались краем canvas / BOOST пересекал goal line Old: все 3 кнопки на y=H-40=1240. С radius 52 bottom edge 1292 > 1280 canvas. BOOST overlap'ил goal line (1260) визуально. Fix: - L/R: r=48 (диам 96, выше mobile threshold), y=H-55=1225 Bottom 1273 (within canvas), top 1177 (ниже rails) Overlap goal line только в non-gap X (line drawn только в 306..414, L/R в 22..118 и 602..698) - BOOST: r=36 (диам 72, на mobile threshold), y=H-85=1195 Полностью выше goal line — не пересекает её визуально Asymmetric Y — trade-off за visual cleanliness ## #3 Residual: goal X-check был center-only Old: ballX > goalLeftX && ballX < goalRightX — центр должен быть строго в gap. На крайних X edge мяча визуально в gap, но центр чуть вне → ball отскакивает. Fix: добавлен xTolerance = ballRadius/2. - ballX > goalLeftX - xTolerance (lenient слева) - ballX < goalRightX + xTolerance (lenient справа) Симметрично с Y edge-check. Если значительная часть мяча в gap, гол засчитывается. Не too lenient: tolerance только half-radius (11px из 22), не full radius. ## #2 Residual (acknowledged, not fixed) ball_unstuck всё ещё triggers 1 раз в mobile match. Watchdog работает как safety net (это ОК per architecture), но «полностью самодостаточная» физика без сторонних косвенных edge cases requires более глубокий tuning (possibly per-personality setup-bumper аранжировки). Откладывается до external playtest данных по hot-spots stuck events. ## Tests 154/154 unchanged — все изменения в untested rendering layer + Table геометрии (table-tests были бы integration-level, не покрываются текущим test suite scope). typecheck/lint/build ✅. Co-Authored-By: Claude Opus 4.7 --- src/game/Table.ts | 17 ++++++++++------- src/scenes/MatchScene.ts | 22 +++++++++++++--------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/game/Table.ts b/src/game/Table.ts index bd9d50e..f2d6aa9 100644 --- a/src/game/Table.ts +++ b/src/game/Table.ts @@ -226,25 +226,28 @@ export class Table { } /** - * Проверка: гол игрока (низ). Триггерит когда EDGE мяча (не центр) пересёк - * линию ворот — чтобы не получалось «попал в ворота, но отскочил обратно». - * ballRadius default 22 (BALL_RADIUS). + * Проверка: гол игрока (низ). Edge-based по обеим осям: + * - Y: edge мяча пересёк линию (ballY + radius > goalY) + * - X: центр мяча в gap ± half-radius (lenient — даёт пройти мячу когда + * значительная часть в gap, симметрично с Y). */ isInPlayerGoal(ballX: number, ballY: number, ballRadius = 22): boolean { const g = this.geometry; + const xTolerance = ballRadius / 2; return ( ballY + ballRadius > g.playerGoalY && - ballX > g.goalLeftX && - ballX < g.goalRightX + ballX > g.goalLeftX - xTolerance && + ballX < g.goalRightX + xTolerance ); } isInAIGoal(ballX: number, ballY: number, ballRadius = 22): boolean { const g = this.geometry; + const xTolerance = ballRadius / 2; return ( ballY - ballRadius < g.aiGoalY && - ballX > g.goalLeftX && - ballX < g.goalRightX + ballX > g.goalLeftX - xTolerance && + ballX < g.goalRightX + xTolerance ); } } diff --git a/src/scenes/MatchScene.ts b/src/scenes/MatchScene.ts index 10935d1..c352d77 100644 --- a/src/scenes/MatchScene.ts +++ b/src/scenes/MatchScene.ts @@ -931,16 +931,20 @@ export class MatchScene extends Phaser.Scene { // (mobile-friendly), в нижних углах вне playfield/guard rails. // Те же handlers что и keyboard A/D/S. В aiming-фазе L/R = rotate aim. - // Bottom Y — у самого края canvas, ниже flipper rest и guard rails (которые - // теперь продолжают линию флипперов под углом 30°) - const buttonY = GAME_HEIGHT - 40; - const sideButtonR = 52; // диаметр 104 — выше mobile threshold 72-88 - const centerButtonR = 44; // BOOST чуть меньше, центр + // Layout: + // L/R r=48 на y=H-55=1225 → top 1177 (ниже rails), bottom 1273 (выше canvas edge); + // overlap x не пересекает goal line (которая drawn только в gap 306..414). + // BOOST r=36 на y=H-85=1195 → выше goal line (1260), не overlap'ит её визуально. + // Asymmetric Y — trade-off за чистый visual. + const sideButtonR = 48; // диаметр 96 — выше mobile threshold 72-88 + const centerButtonR = 36; // BOOST меньше, поднят выше чтобы не overlap goal line + const sideButtonY = GAME_HEIGHT - 55; + const centerButtonY = GAME_HEIGHT - 85; // Player L — левый нижний угол this.createCornerButton({ x: 70, - y: buttonY, + y: sideButtonY, radius: sideButtonR, color: PALETTE.player, label: 'L', @@ -957,7 +961,7 @@ export class MatchScene extends Phaser.Scene { // Player R — правый нижний угол this.createCornerButton({ x: GAME_WIDTH - 70, - y: buttonY, + y: sideButtonY, radius: sideButtonR, color: PALETTE.player, label: 'R', @@ -971,10 +975,10 @@ export class MatchScene extends Phaser.Scene { }, }); - // BOOST — центр, отдельной кнопкой + // BOOST — центр, выше L/R чтобы не пересекать drawn goal line this.createCornerButton({ x: GAME_WIDTH / 2, - y: buttonY, + y: centerButtonY, radius: centerButtonR, color: PALETTE.accent, label: 'B',