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 { private buildTouchZones(): void {
// 3 touch zones внизу экрана для mobile + desktop click. // Corner buttons (v3.12) — L/R + BOOST. Disk-форма, ≥100px диаметр
// Visual: полупрозрачные overlay'и; реагируют на pointerdown. // (mobile-friendly), в нижних углах вне playfield/guard rails.
// Booster — placeholder в Phase 2, реальный effect в Phase 3. // Те же handlers что и keyboard A/D/S. В aiming-фазе L/R = rotate aim.
const zoneH = 220;
const zoneY = GAME_HEIGHT - zoneH / 2;
const boosterW = 140;
const sideW = (GAME_WIDTH - boosterW) / 2;
// Left zone — в aiming-фазе rotate aim вместо flipper (mirror keyboard logic) // Bottom Y — около нижнего края, ниже guard rail и playfield
const leftZone = this.add.rectangle(sideW / 2, zoneY, sideW - 8, zoneH, PALETTE.player, 0.06); const buttonY = GAME_HEIGHT - 70;
leftZone.setStrokeStyle(1, PALETTE.player, 0.25); const sideButtonR = 52; // диаметр 104 — выше mobile threshold 72-88
leftZone.setInteractive(); const centerButtonR = 44; // BOOST чуть меньше, центр
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);
// Right zone // Player L — левый нижний угол
const rightX = GAME_WIDTH - sideW / 2; this.createCornerButton({
const rightZone = this.add.rectangle(rightX, zoneY, sideW - 8, zoneH, PALETTE.player, 0.06); x: 70,
rightZone.setStrokeStyle(1, PALETTE.player, 0.25); y: buttonY,
rightZone.setInteractive(); radius: sideButtonR,
rightZone.on('pointerdown', () => { color: PALETTE.player,
if (this.booster.getState() === 'aiming') { label: 'L',
this.booster.rotateAim(1); arrow: '◀',
} else { onTap: () => {
this.playerFlipperRight.activate(); if (this.booster.getState() === 'aiming') {
} this.booster.rotateAim(-1);
this.tweens.add({ targets: rightZone, fillAlpha: 0.22, duration: 60, yoyo: true }); } 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 // Player R — правый нижний угол
const boosterZone = this.add.rectangle(GAME_WIDTH / 2, zoneY, boosterW, zoneH * 0.5, PALETTE.accent, 0.05); this.createCornerButton({
boosterZone.setStrokeStyle(1, PALETTE.accent, 0.3); x: GAME_WIDTH - 70,
boosterZone.setInteractive(); y: buttonY,
boosterZone.on('pointerdown', () => { radius: sideButtonR,
this.tweens.add({ targets: boosterZone, fillAlpha: 0.25, duration: 80, yoyo: true }); color: PALETTE.player,
this.handleBoosterTap(); 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 this.add
.text(GAME_WIDTH / 2, zoneY, 'BOOST', { .text(x, y - (arrow ? 6 : 0), label, {
fontFamily: 'monospace', fontSize: '18px', color: '#ffbe0b', fontStyle: 'bold', 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 { private buildHUD(): void {