Button positioning + goal X tolerance

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 <noreply@anthropic.com>
This commit is contained in:
aevgarik
2026-05-24 15:40:15 +03:00
parent 7761cd6047
commit 7d5dec9c0e
2 changed files with 23 additions and 16 deletions

View File

@@ -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
);
}
}

View File

@@ -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',