Touch controls v3.12: corner circular buttons L/R/BOOST

Заменяет огромные rectangle touch zones на корнерные circular кнопки.

## Дизайн (per user spec)

- Player L — левый нижний угол, диаметр 104 (radius 52, выше mobile
  threshold 72-88)
- Player R — правый нижний угол, симметрично
- BOOST — центр-низ, чуть меньше (radius 44, диаметр 88)
- Все три в y=GAME_HEIGHT-70 — ниже playfield и guard rails

## Visual style

- Двухслойный neon: glow halo (radius+4, alpha 0.12 + outline 0.4)
  + основной disk (alpha 0.18 + outline 0.85)
- Цвет = palette владельца (player магента / BOOST accent yellow)
- Label по центру (L/R/B, 32px monospace bold)
- Под label — стрелка-хинт ◀/▶ для L/R (14px, alpha 0.6) указывающая
  направление поворота aim в booster cradle
- Press feedback: scale 1.0 → 0.88 yoyo 60ms

## Поведение

- Default: L → activate playerFlipperLeft, R → activate playerFlipperRight,
  B → handleBoosterTap
- В booster.aiming: L → rotateAim(-1), R → rotateAim(+1)
- Keyboard A/D/S работает параллельно (не тронут)
- Mouse click на desktop работает так же как touch

## Refactor

Extract'нул создание кнопки в createCornerButton helper. Глобальное
DRY: 3 кнопки через единый код.

152/152 tests, typecheck/lint/build .

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
aevgarik
2026-05-24 15:03:05 +03:00
parent 78eea8bf62
commit aec39f9782

View File

@@ -925,64 +925,111 @@ export class MatchScene extends Phaser.Scene {
}
private buildTouchZones(): void {
// 3 touch zones внизу экрана для mobile + desktop click.
// Visual: полупрозрачные overlay'и; реагируют на pointerdown.
// Booster — placeholder в Phase 2, реальный effect в Phase 3.
const zoneH = 220;
const zoneY = GAME_HEIGHT - zoneH / 2;
const boosterW = 140;
const sideW = (GAME_WIDTH - boosterW) / 2;
// Corner buttons (v3.12) — L/R + BOOST. Disk-форма, ≥100px диаметр
// (mobile-friendly), в нижних углах вне playfield/guard rails.
// Те же handlers что и keyboard A/D/S. В aiming-фазе L/R = rotate aim.
// Left zone — в aiming-фазе rotate aim вместо flipper (mirror keyboard logic)
const leftZone = this.add.rectangle(sideW / 2, zoneY, sideW - 8, zoneH, PALETTE.player, 0.06);
leftZone.setStrokeStyle(1, PALETTE.player, 0.25);
leftZone.setInteractive();
leftZone.on('pointerdown', () => {
if (this.booster.getState() === 'aiming') {
this.booster.rotateAim(-1);
} else {
this.playerFlipperLeft.activate();
}
this.tweens.add({ targets: leftZone, fillAlpha: 0.22, duration: 60, yoyo: true });
});
this.add
.text(sideW / 2, zoneY, '◀ L', {
fontFamily: 'monospace', fontSize: '20px', color: '#ff006e', fontStyle: 'bold',
})
.setOrigin(0.5).setAlpha(0.7);
// Bottom Y — около нижнего края, ниже guard rail и playfield
const buttonY = GAME_HEIGHT - 70;
const sideButtonR = 52; // диаметр 104 — выше mobile threshold 72-88
const centerButtonR = 44; // BOOST чуть меньше, центр
// Right zone
const rightX = GAME_WIDTH - sideW / 2;
const rightZone = this.add.rectangle(rightX, zoneY, sideW - 8, zoneH, PALETTE.player, 0.06);
rightZone.setStrokeStyle(1, PALETTE.player, 0.25);
rightZone.setInteractive();
rightZone.on('pointerdown', () => {
if (this.booster.getState() === 'aiming') {
this.booster.rotateAim(1);
} else {
this.playerFlipperRight.activate();
}
this.tweens.add({ targets: rightZone, fillAlpha: 0.22, duration: 60, yoyo: true });
// Player L — левый нижний угол
this.createCornerButton({
x: 70,
y: buttonY,
radius: sideButtonR,
color: PALETTE.player,
label: 'L',
arrow: '◀',
onTap: () => {
if (this.booster.getState() === 'aiming') {
this.booster.rotateAim(-1);
} else {
this.playerFlipperLeft.activate();
}
},
});
this.add
.text(rightX, zoneY, 'R ▶', {
fontFamily: 'monospace', fontSize: '20px', color: '#ff006e', fontStyle: 'bold',
})
.setOrigin(0.5).setAlpha(0.7);
// Booster zone (центр, узкая) — Phase 2 placeholder
const boosterZone = this.add.rectangle(GAME_WIDTH / 2, zoneY, boosterW, zoneH * 0.5, PALETTE.accent, 0.05);
boosterZone.setStrokeStyle(1, PALETTE.accent, 0.3);
boosterZone.setInteractive();
boosterZone.on('pointerdown', () => {
this.tweens.add({ targets: boosterZone, fillAlpha: 0.25, duration: 80, yoyo: true });
this.handleBoosterTap();
// Player R — правый нижний угол
this.createCornerButton({
x: GAME_WIDTH - 70,
y: buttonY,
radius: sideButtonR,
color: PALETTE.player,
label: 'R',
arrow: '▶',
onTap: () => {
if (this.booster.getState() === 'aiming') {
this.booster.rotateAim(1);
} else {
this.playerFlipperRight.activate();
}
},
});
// BOOST — центр, отдельной кнопкой
this.createCornerButton({
x: GAME_WIDTH / 2,
y: buttonY,
radius: centerButtonR,
color: PALETTE.accent,
label: 'B',
onTap: () => this.handleBoosterTap(),
});
}
private createCornerButton(args: {
x: number;
y: number;
radius: number;
color: number;
label: string;
arrow?: string; // optional rotation hint ◀/▶
onTap: () => void;
}): void {
const { x, y, radius, color, label, arrow, onTap } = args;
const colorHex = `#${color.toString(16).padStart(6, '0')}`;
// Outer glow (двухслойный neon outline)
const glow = this.add.circle(x, y, radius + 4, color, 0.12);
glow.setStrokeStyle(2, color, 0.4);
// Основной disk
const disk = this.add.circle(x, y, radius, color, 0.18);
disk.setStrokeStyle(3, color, 0.85);
disk.setInteractive({ useHandCursor: true });
disk.on('pointerdown', () => {
onTap();
this.tweens.add({
targets: [glow, disk],
scale: { from: 1.0, to: 0.88 },
duration: 60,
yoyo: true,
});
});
// Label (L / R / B)
this.add
.text(GAME_WIDTH / 2, zoneY, 'BOOST', {
fontFamily: 'monospace', fontSize: '18px', color: '#ffbe0b', fontStyle: 'bold',
.text(x, y - (arrow ? 6 : 0), label, {
fontFamily: 'monospace',
fontSize: '32px',
color: colorHex,
fontStyle: 'bold',
})
.setOrigin(0.5).setAlpha(0.7);
.setOrigin(0.5);
// Arrow hint под label (только для L/R)
if (arrow) {
this.add
.text(x, y + 18, arrow, {
fontFamily: 'monospace',
fontSize: '14px',
color: colorHex,
})
.setOrigin(0.5)
.setAlpha(0.6);
}
}
private buildHUD(): void {