fertig-classic-games/public/src/games/battleship/BattleshipGame.js

938 lines
37 KiB
JavaScript

import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
SIZE, SHIPS, createInitialState, makeShip, canPlace, placeShipsRandom,
applySalvo, salvoCount, aliveCount,
} from './BattleshipLogic.js';
import { chooseSalvo, nextThinkDelay, placeFleet } from './BattleshipAI.js';
// ── Layout ───────────────────────────────────────────────────────────────────
const CELL = 60;
const GRID = CELL * SIZE; // 600
const BY = 220; // grids' top Y
const EX = 290; // Enemy Waters origin X
const RX = EX + GRID + 140; // Your Fleet origin X (1030)
const DEPTH = {
bg: -2, ocean: 0, wave: 1, sonar: 2, grid: 3, label: 4,
ship: 6, reveal: 7, marker: 9, reticle: 11, armed: 12,
missile: 30, fx: 35, ui: 50, banner: 60, overlay: 70,
};
// ── Palette (naval HUD / teal sonar) ───────────────────────────────────────────
const C = {
oceanTop: 0x0c2c3a,
ocean: 0x0a2632,
oceanDk: 0x061a24,
grid: 0x2f6f82,
gridDim: 0x1b4b59,
frame: COLORS.accent,
frameDk: 0x3a2e12,
sonar: 0x39ffd0,
reticle: 0x4fe8d8,
armed: 0xffd24a,
hit: 0xff5a3c,
hitGlow: 0xffb648,
miss: 0xdfeef5,
shipBody: 0x53606d,
shipLt: 0x7c8a98,
shipDk: 0x29333c,
shipDeck: 0x3b4753,
valid: 0x39d98a,
invalid: 0xe0564b,
sunkBody: 0x6e2b24,
sunkDk: 0x3a1512,
};
const SHIP_PALETTES = {
fleet: { body: C.shipBody, light: C.shipLt, dark: C.shipDk, deck: C.shipDeck },
valid: { body: C.valid, light: 0x9bf5c4, dark: 0x1f7a4d, deck: 0x2aa869 },
invalid: { body: C.invalid, light: 0xf2a59d, dark: 0x7d241d, deck: 0xb83c33 },
sunk: { body: C.sunkBody, light: 0x9c4138, dark: C.sunkDk, deck: 0x551f19 },
};
export default class BattleshipGame extends Phaser.Scene {
constructor() { super('BattleshipGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.animating = false;
this.shipPieces = []; // one draggable container per ship (placement)
this.armed = []; // [{r,c}] cells armed this turn
this.activeShip = null; // piece currently being dragged
this.lastPointer = { x: 0, y: 0 };
this.playerSalvoN = 0;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.input.mouse?.disableContextMenu();
this.buildTextures();
this.buildBackground();
this.buildGrid(EX, BY, 'ENEMY WATERS');
this.buildGrid(RX, BY, 'YOUR FLEET');
this.buildSonar();
this.buildLayers();
this.buildPortraits();
this.buildEnemyZone();
this.buildCommonUI();
this.input.keyboard.on('keydown-R', () => this.rotateActiveShip());
this.gs = createInitialState();
this.beginPlacement();
}
// ── Texture + ambient construction ───────────────────────────────────────────
buildTextures() {
// Soft round particle used (tinted) for splashes, embers, smoke, bubbles.
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1); g.fillCircle(8, 8, 8);
g.fillStyle(0xffffff, 0.5); g.fillCircle(8, 8, 5);
g.generateTexture('bsDot', 16, 16);
g.destroy();
// Sonar sweep: a fading wedge that we rotate over Enemy Waters.
const R = GRID / 2;
const s = this.make.graphics({ x: 0, y: 0, add: false });
const steps = 70;
const spread = Phaser.Math.DegToRad(130);
for (let i = 0; i < steps; i++) {
const a0 = -spread + (spread / steps) * i;
const a1 = a0 + spread / steps + 0.012;
s.fillStyle(C.sonar, (i / steps) * 0.45);
s.slice(R, R, R, a0, a1, false);
s.fillPath();
}
s.lineStyle(3, 0x9bffe9, 0.85);
s.lineBetween(R, R, R + R, R);
s.generateTexture('bsSonar', GRID, GRID);
s.destroy();
}
buildBackground() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
}
// Deep-sea gradient backdrop.
const g = this.add.graphics().setDepth(DEPTH.bg);
for (let i = 0; i < GAME_HEIGHT; i += 4) {
const t = i / GAME_HEIGHT;
const col = Phaser.Display.Color.Interpolate.ColorWithColor(
Phaser.Display.Color.ValueToColor(0x05131b),
Phaser.Display.Color.ValueToColor(0x0a2230), 100, Math.floor(t * 100));
g.fillStyle(Phaser.Display.Color.GetColor(col.r, col.g, col.b), 1);
g.fillRect(0, i, GAME_WIDTH, 4);
}
}
buildGrid(ox, oy, title) {
const g = this.add.graphics().setDepth(DEPTH.ocean);
// Gold frame.
const F = 16;
g.fillStyle(C.frameDk, 1);
g.fillRoundedRect(ox - F, oy - F, GRID + F * 2, GRID + F * 2, 14);
g.lineStyle(3, C.frame, 1);
g.strokeRoundedRect(ox - F + 4, oy - F + 4, GRID + F * 2 - 8, GRID + F * 2 - 8, 10);
// Ocean fill.
g.fillStyle(C.ocean, 1);
g.fillRect(ox, oy, GRID, GRID);
g.fillStyle(C.oceanDk, 0.45);
for (let r = 0; r < SIZE; r++)
for (let c = (r % 2); c < SIZE; c += 2)
g.fillRect(ox + c * CELL, oy + r * CELL, CELL, CELL); // subtle checker
// Grid lines.
g.lineStyle(1, C.gridDim, 0.9);
for (let i = 0; i <= SIZE; i++) {
g.lineBetween(ox + i * CELL, oy, ox + i * CELL, oy + GRID);
g.lineBetween(ox, oy + i * CELL, ox + GRID, oy + i * CELL);
}
// Coordinate labels.
for (let c = 0; c < SIZE; c++)
this.add.text(ox + c * CELL + CELL / 2, oy - 19, String.fromCharCode(65 + c), {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5, 1).setDepth(DEPTH.label);
for (let r = 0; r < SIZE; r++)
this.add.text(ox - 25, oy + r * CELL + CELL / 2, String(r + 1), {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(1, 0.5).setDepth(DEPTH.label);
// Title.
this.add.text(ox + GRID / 2, oy - 60, title, {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.label);
// Gentle drifting wave lines for ambiance.
const waves = this.add.graphics().setDepth(DEPTH.wave);
waves.lineStyle(2, 0x3a7d92, 0.10);
for (let y = oy + 30; y < oy + GRID; y += 70)
waves.lineBetween(ox + 6, y, ox + GRID - 6, y);
this.tweens.add({ targets: waves, y: 14, alpha: 0.6, duration: 3800, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
}
buildSonar() {
const img = this.add.image(EX + GRID / 2, BY + GRID / 2, 'bsSonar')
.setDepth(DEPTH.sonar).setAlpha(0.5).setBlendMode(Phaser.BlendModes.ADD);
const mask = this.make.graphics({ x: 0, y: 0, add: false });
mask.fillStyle(0xffffff); mask.fillRect(EX, BY, GRID, GRID);
img.setMask(mask.createGeometryMask());
this.tweens.add({ targets: img, angle: 360, duration: 4200, repeat: -1, ease: 'Linear' });
}
buildLayers() {
this.enemyReveal = this.add.graphics().setDepth(DEPTH.reveal); // sunk enemy hulls
this.enemyMarkers = this.add.graphics().setDepth(DEPTH.marker);
this.yourMarkers = this.add.graphics().setDepth(DEPTH.marker);
this.reticle = this.add.graphics().setDepth(DEPTH.reticle);
this.armedLayer = this.add.graphics().setDepth(DEPTH.armed);
}
buildPortraits() {
const opp = this.opponents[0];
const r = 70;
// Opponent — top-left margin.
this.oppX = EX / 2; this.oppY = 330;
this.opponentPortrait = createOpponentPortrait(this, opp, this.oppX, this.oppY, r, DEPTH.ui);
this.add.text(this.oppX, this.oppY + r + 12, opp?.name ?? 'CPU', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
wordWrap: { width: 240 }, align: 'center',
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
// Player — bottom-right margin.
this.plrX = RX + GRID + (GAME_WIDTH - (RX + GRID)) / 2;
this.plrY = 740;
createPlayerPortrait(this, this.plrX, this.plrY, r, DEPTH.ui, 'Battleship');
this.add.text(this.plrX, this.plrY - r - 12, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.textHex,
wordWrap: { width: 240 }, align: 'center',
}).setOrigin(0.5, 1).setDepth(DEPTH.ui);
// Fleet-status panels (5 pips each).
this.enemyFleetPips = this.buildFleetPanel(this.oppX, this.oppY + r + 56, 'Enemy fleet');
this.yourFleetPips = this.buildFleetPanel(this.plrX, this.plrY + r + 16, 'Your fleet');
}
buildFleetPanel(cx, top, label) {
this.add.text(cx, top, label, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex,
}).setOrigin(0.5, 0).setDepth(DEPTH.ui);
const pips = [];
const w = 26, gap = 8;
SHIPS.forEach((spec, i) => {
const totalH = SHIPS.length * (w + gap);
const y = top + 46 + i * (w + gap);
const x = cx - (spec.len * 9) - 65;
const g = this.add.graphics().setDepth(DEPTH.ui);
this.drawPip(g, x, y, spec, 0);
const t = this.add.text(cx + 9, y, spec.name, {
fontFamily: '"Julius Sans One"', fontSize: '13px', color: COLORS.mutedHex,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui);
pips.push({ g, t, spec, x, y });
});
return pips;
}
drawPip(g, x, y, spec, hits) {
g.clear();
const seg = 16, gap = 2;
for (let i = 0; i < spec.len; i++) {
const damaged = i < hits;
g.fillStyle(damaged ? C.hit : C.shipBody, 1);
g.fillRoundedRect(x + i * (seg + gap), y - 7, seg, 14, 3);
}
}
refreshFleetPanels() {
const update = (pips, fleet) => {
for (const pip of pips) {
const ship = fleet.find((s) => s.name === pip.spec.name);
const hits = ship ? ship.hits.filter(Boolean).length : 0;
this.drawPip(pip.g, pip.x, pip.y, pip.spec, hits);
if (ship?.sunk) { pip.g.setAlpha(0.55); pip.t.setColor(COLORS.dangerHex); pip.t.setText(`${pip.spec.name}`); }
}
};
update(this.enemyFleetPips, this.gs.fleets.player2);
update(this.yourFleetPips, this.gs.fleets.player1);
}
buildEnemyZone() {
this.enemyZone = this.add.zone(EX, BY, GRID, GRID).setOrigin(0)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.reticle);
this.enemyZone.on('pointermove', (p) => this.onEnemyHover(p));
this.enemyZone.on('pointerout', () => this.reticle.clear());
this.enemyZone.on('pointerdown', (p) => this.onEnemyClick(p));
}
buildCommonUI() {
new Button(this, 130, 46, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 170, height: 44, fontSize: 20 }).setDepth(DEPTH.ui);
this.statusText = this.add.text(GAME_WIDTH / 2, BY + GRID + 70, '', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(DEPTH.ui);
}
// ── Placement phase ────────────────────────────────────────────────────────
beginPlacement() {
this.setStatus('Position your fleet — drag ships onto the grid. Press R or right-click to rotate.');
this.buildDock();
this.buildPlacementUI();
}
buildDock() {
const dockY = BY + GRID + 130;
let x = RX + 6;
const scale = 0.46;
this.shipPieces = SHIPS.map((spec) => {
const container = this.add.container(0, 0).setDepth(DEPTH.ship);
const body = this.add.graphics();
container.add(body);
const piece = {
spec, body, container, horizontal: true, placed: false,
r: 0, c: 0, dockX: 0, dockY: 0, dockScale: scale, arrow: null,
};
this.drawPiece(piece, 'fleet');
this.setPieceHit(piece);
container.setScale(scale);
const w = spec.len * CELL * scale;
piece.dockX = x; piece.dockY = dockY;
container.setPosition(x, dockY);
x += w + 16;
container.on('dragstart', () => this.onShipDragStart(piece));
container.on('drag', (p) => this.onShipDrag(piece, p));
container.on('dragend', () => this.onShipDragEnd(piece));
container.on('pointerdown', (p) => { if (p.rightButtonDown()) this.rotatePiece(piece); });
return piece;
});
this.buildDockArrows();
}
buildDockArrows() {
for (const piece of this.shipPieces) {
const shipW = piece.spec.len * CELL * piece.dockScale;
const cx = piece.dockX + shipW / 2;
const startY = piece.dockY - 34;
const hw = 14, hh = 12;
const g = this.add.graphics().setDepth(DEPTH.ui + 1);
g.fillStyle(0xffd24a, 1);
g.fillTriangle(cx - hw, startY - hh, cx + hw, startY - hh, cx, startY + hh);
g.lineStyle(2, 0xffedaa, 0.7);
g.strokeTriangle(cx - hw, startY - hh, cx + hw, startY - hh, cx, startY + hh);
this.tweens.add({
targets: g, y: 8, alpha: 0.72, duration: 580,
ease: 'Sine.easeInOut', yoyo: true, repeat: -1,
});
piece.arrow = g;
}
}
_destroyArrow(piece) {
if (piece.arrow) { piece.arrow.destroy(); piece.arrow = null; }
}
buildPlacementUI() {
const bx = EX / 2;
const bottom = GAME_HEIGHT - 40;
const readyY = bottom - 56;
const clearY = readyY - 70;
const randomY = clearY - 70;
this.placementUI = [
new Button(this, bx, randomY, 'Randomize', () => this.randomizePlacement(),
{ width: 200, height: 50, fontSize: 22 }).setDepth(DEPTH.ui),
new Button(this, bx, clearY, 'Clear', () => this.clearPlacement(),
{ variant: 'ghost', width: 200, height: 50, fontSize: 22 }).setDepth(DEPTH.ui),
];
this.readyBtn = new Button(this, bx, readyY, 'Ready', () => this.startBattle(),
{ width: 200, height: 56, fontSize: 26 }).setDepth(DEPTH.ui);
this.placementUI.push(this.readyBtn);
this.updateReadyButton();
}
setPieceHit(piece) {
const w = piece.horizontal ? piece.spec.len * CELL : CELL;
const h = piece.horizontal ? CELL : piece.spec.len * CELL;
piece.container.setSize(w, h);
// Our hull graphics are top-left anchored (local 0,0 → w,h), but Phaser's
// input normalizes the hit test by a Container's displayOrigin, which is
// hardcoded to (width/2, height/2). Offset the hit area by the same amount
// so the clickable zone lines up with the visible ship instead of sitting
// up-and-left of it.
piece.container.setInteractive(
new Phaser.Geom.Rectangle(w / 2, h / 2, w, h), Phaser.Geom.Rectangle.Contains);
this.input.setDraggable(piece.container);
}
drawPiece(piece, paletteName) {
const g = piece.body;
const { spec, horizontal } = piece;
const pal = SHIP_PALETTES[paletteName];
const w = horizontal ? spec.len * CELL : CELL;
const h = horizontal ? CELL : spec.len * CELL;
const p = 5;
g.clear();
g.fillStyle(0x000000, 0.3); g.fillRoundedRect(p + 2, p + 4, w - 2 * p, h - 2 * p, 12);
g.fillStyle(pal.body, 1); g.fillRoundedRect(p, p, w - 2 * p, h - 2 * p, 12);
g.fillStyle(pal.light, 0.45);
g.fillRoundedRect(p + 3, p + 3, horizontal ? w - 2 * p - 6 : (w - 2 * p) * 0.5,
horizontal ? (h - 2 * p) * 0.45 : h - 2 * p - 6, 8);
g.lineStyle(2, pal.dark, 1); g.strokeRoundedRect(p, p, w - 2 * p, h - 2 * p, 12);
g.lineStyle(1, pal.dark, 0.55);
for (let i = 1; i < spec.len; i++) {
if (horizontal) { const lx = i * CELL; g.lineBetween(lx, p + 4, lx, h - p - 4); }
else { const ly = i * CELL; g.lineBetween(p + 4, ly, w - p - 4, ly); }
}
g.fillStyle(pal.deck, 1);
for (let i = 0; i < spec.len; i++) {
const cx = horizontal ? i * CELL + CELL / 2 : w / 2;
const cy = horizontal ? h / 2 : i * CELL + CELL / 2;
g.fillCircle(cx, cy, CELL * 0.13);
}
}
placedFleetExcluding(exclude) {
return this.shipPieces
.filter((pc) => pc.placed && pc !== exclude)
.map((pc) => makeShip(pc.spec, pc.r, pc.c, pc.horizontal));
}
syncFleetFromPieces() {
this.gs.fleets.player1 = this.shipPieces
.filter((pc) => pc.placed)
.map((pc) => makeShip(pc.spec, pc.r, pc.c, pc.horizontal));
}
onShipDragStart(piece) {
this.activeShip = piece;
if (piece.placed) { piece.placed = false; this.syncFleetFromPieces(); this.updateReadyButton(); }
piece.container.setScale(1).setDepth(DEPTH.missile);
this._destroyArrow(piece);
}
onShipDrag(piece, pointer) {
this.lastPointer = { x: pointer.x, y: pointer.y };
const { spec, horizontal } = piece;
const inside = pointer.x >= RX && pointer.x < RX + GRID && pointer.y >= BY && pointer.y < BY + GRID;
if (inside) {
let bowC = Math.floor((pointer.x - RX) / CELL);
let bowR = Math.floor((pointer.y - BY) / CELL);
bowC = Phaser.Math.Clamp(bowC, 0, horizontal ? SIZE - spec.len : SIZE - 1);
bowR = Phaser.Math.Clamp(bowR, 0, horizontal ? SIZE - 1 : SIZE - spec.len);
const valid = canPlace(this.placedFleetExcluding(piece), spec.len, bowR, bowC, horizontal);
piece.container.setPosition(RX + bowC * CELL, BY + bowR * CELL);
piece.dragCell = { bowR, bowC, valid };
this.drawPiece(piece, valid ? 'valid' : 'invalid');
} else {
piece.container.setPosition(pointer.x - CELL / 2, pointer.y - CELL / 2);
piece.dragCell = null;
this.drawPiece(piece, 'fleet');
}
}
onShipDragEnd(piece) {
if (piece.dragCell?.valid) {
this.placePiece(piece, piece.dragCell.bowR, piece.dragCell.bowC);
} else {
this.returnToDock(piece);
}
piece.dragCell = null;
this.activeShip = null;
}
placePiece(piece, bowR, bowC) {
piece.placed = true; piece.r = bowR; piece.c = bowC;
piece.container.setScale(1).setDepth(DEPTH.ship)
.setPosition(RX + bowC * CELL, BY + bowR * CELL);
this.drawPiece(piece, 'fleet');
this.setPieceHit(piece);
this.syncFleetFromPieces();
this.updateReadyButton();
playSound(this, SFX.PIECE_CLICK);
}
returnToDock(piece) {
piece.placed = false;
this.drawPiece(piece, 'fleet');
this.setPieceHit(piece);
this.tweens.add({
targets: piece.container, x: piece.dockX, y: piece.dockY,
scaleX: piece.dockScale, scaleY: piece.dockScale, duration: 220, ease: 'Quad.easeOut',
});
piece.container.setDepth(DEPTH.ship);
this.syncFleetFromPieces();
this.updateReadyButton();
}
rotatePiece(piece) {
if (this.gs.phase !== 'placement') return;
piece.horizontal = !piece.horizontal;
this.drawPiece(piece, 'fleet');
this.setPieceHit(piece);
if (piece.placed) {
// Keep it placed only if still legal; otherwise pop back to dock.
if (canPlace(this.placedFleetExcluding(piece), piece.spec.len, piece.r, piece.c, piece.horizontal)) {
this.syncFleetFromPieces();
} else {
this.returnToDock(piece);
}
}
}
rotateActiveShip() {
const piece = this.activeShip;
if (!piece) return;
piece.horizontal = !piece.horizontal;
this.drawPiece(piece, piece.dragCell?.valid === false ? 'invalid' : 'fleet');
// Don't call setPieceHit here — calling setInteractive mid-drag corrupts
// Phaser's drag state; placePiece/returnToDock will update it after drop.
this.onShipDrag(piece, this.lastPointer);
}
randomizePlacement() {
for (const piece of this.shipPieces) this._destroyArrow(piece);
const layout = placeShipsRandom();
for (const piece of this.shipPieces) {
const ship = layout.find((s) => s.name === piece.spec.name);
piece.horizontal = ship.horizontal;
piece.r = ship.cells[0].r; piece.c = ship.cells[0].c;
piece.placed = true;
this.drawPiece(piece, 'fleet');
this.setPieceHit(piece);
piece.container.setScale(1).setDepth(DEPTH.ship)
.setPosition(RX + piece.c * CELL, BY + piece.r * CELL);
}
this.syncFleetFromPieces();
this.updateReadyButton();
playSound(this, SFX.PIECE_CLICK);
}
clearPlacement() {
for (const piece of this.shipPieces) {
piece.placed = false; piece.horizontal = true;
this.drawPiece(piece, 'fleet');
this.setPieceHit(piece);
this.tweens.add({
targets: piece.container, x: piece.dockX, y: piece.dockY,
scaleX: piece.dockScale, scaleY: piece.dockScale, duration: 220, ease: 'Quad.easeOut',
});
piece.container.setDepth(DEPTH.ship);
}
this.syncFleetFromPieces();
this.updateReadyButton();
}
updateReadyButton() {
if (!this.readyBtn) return;
this.readyBtn.setEnabled(this.gs.fleets.player1.length === SHIPS.length);
}
// ── Battle phase ─────────────────────────────────────────────────────────────
startBattle() {
if (this.gs.fleets.player1.length !== SHIPS.length) return;
for (const piece of this.shipPieces) this._destroyArrow(piece);
// Lock the player's fleet (no more dragging) and place the AI's.
for (const piece of this.shipPieces) { piece.container.disableInteractive(); this.input.setDraggable(piece.container, false); }
this.placementUI.forEach((b) => b.destroy());
this.placementUI = [];
this.gs.fleets.player2 = placeFleet(this.opponents[0]?.skill ?? 3);
this.gs.phase = 'battle';
this.gs.turn = 'player1';
this.buildBattleUI();
this.refreshFleetPanels();
this.showBanner('Battle stations!', COLORS.accentHex);
this.time.delayedCall(900, () => this.beginPlayerTurn());
}
buildBattleUI() {
const cx = GAME_WIDTH / 2;
this.salvoText = this.add.text(cx, BY + GRID + 110, '', {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
this.fireBtn = new Button(this, cx, BY + GRID + 170, 'FIRE', () => this.onFire(),
{ width: 240, height: 60, fontSize: 30, bg: 0x6b1f14, bgHover: C.hit })
.setDepth(DEPTH.ui);
this.fireBtn.setEnabled(false);
}
beginPlayerTurn() {
if (this.gs.phase !== 'battle') return;
this.gs.turn = 'player1';
this.armed = [];
const untried = this.countUntried('player1');
this.playerSalvoN = Math.min(salvoCount(this.gs, 'player1'), untried);
this.drawArmed();
this.updateSalvoUI();
this.setStatus(`Your salvo — arm ${this.playerSalvoN} target${this.playerSalvoN === 1 ? '' : 's'} on Enemy Waters, then FIRE.`);
}
countUntried(player) {
let n = 0;
const grid = this.gs.shots[player];
for (let r = 0; r < SIZE; r++) for (let c = 0; c < SIZE; c++) if (grid[r][c] === null) n++;
return n;
}
updateSalvoUI() {
if (this.salvoText) this.salvoText.setText(`SALVO ${this.armed.length} / ${this.playerSalvoN}`);
this.fireBtn?.setEnabled(this.armed.length === this.playerSalvoN && this.playerSalvoN > 0);
}
enemyCellFromPointer(p) {
const c = Math.floor((p.x - EX) / CELL);
const r = Math.floor((p.y - BY) / CELL);
if (r < 0 || r >= SIZE || c < 0 || c >= SIZE) return null;
return { r, c };
}
onEnemyHover(p) {
this.reticle.clear();
if (!this.isPlayerActionable()) return;
const cell = this.enemyCellFromPointer(p);
if (!cell || this.gs.shots.player1[cell.r][cell.c] !== null) return;
if (this.armed.some((a) => a.r === cell.r && a.c === cell.c)) return;
this.drawReticle(this.reticle, cell.r, cell.c, C.reticle, 0.9);
}
onEnemyClick(p) {
if (!this.isPlayerActionable()) return;
const cell = this.enemyCellFromPointer(p);
if (!cell || this.gs.shots.player1[cell.r][cell.c] !== null) return;
const idx = this.armed.findIndex((a) => a.r === cell.r && a.c === cell.c);
if (idx >= 0) { this.armed.splice(idx, 1); }
else if (this.armed.length < this.playerSalvoN) { this.armed.push(cell); }
else return;
playSound(this, SFX.PIECE_CLICK);
this.reticle.clear();
this.drawArmed();
this.updateSalvoUI();
}
isPlayerActionable() {
return this.gs.phase === 'battle' && this.gs.turn === 'player1' && !this.animating;
}
drawArmed() {
this.armedLayer.clear();
for (const a of this.armed) this.drawReticle(this.armedLayer, a.r, a.c, C.armed, 1, true);
}
drawReticle(g, r, c, color, alpha, locked = false) {
const x = EX + c * CELL + CELL / 2;
const y = BY + r * CELL + CELL / 2;
const R = CELL * 0.42;
g.lineStyle(2, color, alpha);
g.strokeCircle(x, y, R);
g.lineStyle(locked ? 3 : 2, color, alpha);
g.lineBetween(x - R - 4, y, x - R + 10, y);
g.lineBetween(x + R - 10, y, x + R + 4, y);
g.lineBetween(x, y - R - 4, x, y - R + 10);
g.lineBetween(x, y + R - 10, x, y + R + 4);
if (locked) { g.fillStyle(color, 0.9); g.fillCircle(x, y, 4); }
}
onFire() {
if (!this.isPlayerActionable() || this.armed.length !== this.playerSalvoN || this.playerSalvoN === 0) return;
this.fireBtn.setEnabled(false);
this.reticle.clear(); this.armedLayer.clear();
this.setStatus('Incoming fire!');
this.fireVolley('player1', this.armed.slice(), true);
}
// ── AI turn ───────────────────────────────────────────────────────────────
beginAITurn() {
if (this.gs.phase !== 'battle') return;
this.gs.turn = 'player2';
const name = this.opponents[0]?.name ?? 'Opponent';
this.setStatus(`${name} is taking aim…`);
this.showBanner(`${name}'s salvo`, COLORS.dangerHex);
this.time.delayedCall(nextThinkDelay(this.opponents[0]?.skill ?? 3), () => {
const n = Math.min(salvoCount(this.gs, 'player2'), this.countUntried('player2'));
const cells = chooseSalvo(this.gs, 'player2', this.opponents[0]?.skill ?? 3, n);
this.fireVolley('player2', cells, false);
});
}
// ── Volley animation + resolution ───────────────────────────────────────────
fireVolley(byPlayer, cells, firedByPlayer) {
this.animating = true;
const { state, results } = applySalvo(this.gs, byPlayer, cells);
this.gs = state;
const targetOrigin = firedByPlayer ? { x: EX, y: BY } : { x: RX, y: BY };
const source = firedByPlayer ? { x: this.plrX, y: this.plrY } : { x: this.oppX, y: this.oppY };
let landed = 0;
const total = results.length;
if (total === 0) { this.finishVolley(firedByPlayer); return; }
results.forEach((res, i) => {
this.time.delayedCall(i * 250, () => {
this.launchMissile(source, targetOrigin, res, () => {
this.resolveImpact(targetOrigin, res, firedByPlayer);
landed++;
if (landed === total) this.time.delayedCall(650, () => this.finishVolley(firedByPlayer));
});
});
});
}
launchMissile(source, origin, res, onImpact) {
const tx = origin.x + res.c * CELL + CELL / 2;
const ty = origin.y + res.r * CELL + CELL / 2;
const p0 = source;
const p2 = { x: tx, y: ty };
const p1 = { x: (p0.x + p2.x) / 2, y: Math.min(p0.y, p2.y) - 280 };
playSound(this, SFX.BATTLESHIP_LAUNCH);
const missile = this.add.graphics().setDepth(DEPTH.missile);
missile.fillStyle(0xfff2c8, 1); missile.fillCircle(0, 0, 5);
missile.fillStyle(C.hitGlow, 0.6); missile.fillCircle(0, 0, 9);
const obj = { t: 0 };
let prev = { ...p0 };
this.tweens.add({
targets: obj, t: 1, duration: 1000, ease: 'Sine.easeIn',
onUpdate: () => {
const t = obj.t, it = 1 - t;
const x = it * it * p0.x + 2 * it * t * p1.x + t * t * p2.x;
const y = it * it * p0.y + 2 * it * t * p1.y + t * t * p2.y;
missile.setPosition(x, y);
// Smoke trail.
const s = this.add.image(prev.x, prev.y, 'bsDot').setDepth(DEPTH.missile - 1)
.setTint(0x9aa6ad).setAlpha(0.5).setScale(0.5);
this.tweens.add({ targets: s, alpha: 0, scale: 1.4, duration: 400, onComplete: () => s.destroy() });
prev = { x, y };
},
onComplete: () => { missile.destroy(); onImpact(); },
});
}
resolveImpact(origin, res, firedByPlayer) {
const x = origin.x + res.c * CELL + CELL / 2;
const y = origin.y + res.r * CELL + CELL / 2;
if (res.result === 'miss') {
playSound(this, SFX.BATTLESHIP_MISS);
this.splash(x, y);
} else {
playSound(this, SFX.BATTLESHIP_HIT);
this.explosion(x, y);
this.cameras.main.shake(res.result === 'sunk' ? 320 : 180, res.result === 'sunk' ? 0.009 : 0.005);
if (res.result === 'sunk') this.onShipSunk(res.ship, firedByPlayer);
if (this.opponentPortrait) this.playOppEmotion(firedByPlayer ? 'upset' : 'happy');
}
this.renderMarkers();
this.refreshFleetPanels();
}
splash(x, y) {
const ripple = this.add.graphics().setDepth(DEPTH.fx);
const prox = { p: 0 };
this.tweens.add({ targets: prox, p: 1, duration: 600, onUpdate: () => {
ripple.clear(); ripple.lineStyle(3, C.miss, 1 - prox.p);
ripple.strokeCircle(x, y, 6 + prox.p * 34);
}, onComplete: () => ripple.destroy() });
const e = this.add.particles(x, y, 'bsDot', {
speed: { min: 60, max: 200 }, lifespan: 520, scale: { start: 0.7, end: 0 },
alpha: { start: 0.9, end: 0 }, quantity: 12, angle: { min: 200, max: 340 },
tint: [C.miss, 0x8fd0e0, 0xbfe6f2],
}).setDepth(DEPTH.fx);
this.time.delayedCall(60, () => e.stop());
this.time.delayedCall(700, () => e.destroy());
}
explosion(x, y) {
const flash = this.add.circle(x, y, 30, C.hitGlow, 0.9).setDepth(DEPTH.fx);
this.tweens.add({ targets: flash, scale: 2.2, alpha: 0, duration: 360, onComplete: () => flash.destroy() });
const e = this.add.particles(x, y, 'bsDot', {
speed: { min: 120, max: 420 }, lifespan: 700, scale: { start: 1.1, end: 0 },
alpha: { start: 1, end: 0 }, quantity: 22, angle: { min: 0, max: 360 },
tint: [C.hit, C.hitGlow, 0xffe27a],
}).setDepth(DEPTH.fx);
this.time.delayedCall(80, () => e.stop());
this.time.delayedCall(900, () => e.destroy());
}
onShipSunk(ship, firedByPlayer) {
const who = firedByPlayer ? 'Enemy' : 'Your';
this.showBanner(`${who} ${ship.name} sunk!`, firedByPlayer ? COLORS.accentHex : COLORS.dangerHex);
if (firedByPlayer) {
this.renderEnemyReveal(); // draw the revealed hull, then flourish below
const bowR = ship.cells[0].r, bowC = ship.cells[0].c;
const cx = EX + bowC * CELL + (ship.horizontal ? ship.len : 1) * CELL / 2;
const cy = BY + bowR * CELL + (ship.horizontal ? 1 : ship.len) * CELL / 2;
this.bubbles(cx, cy);
} else {
// Flash + sink the player's own ship piece.
const piece = this.shipPieces.find((pc) => pc.spec.name === ship.name);
if (piece) {
this.drawPiece(piece, 'sunk');
this.tweens.add({ targets: piece.container, alpha: 0.6, y: piece.container.y + 6, duration: 600, ease: 'Sine.easeIn' });
const cx = piece.container.x + (ship.horizontal ? ship.len : 1) * CELL / 2;
const cy = piece.container.y + (ship.horizontal ? 1 : ship.len) * CELL / 2;
this.bubbles(cx, cy);
}
}
}
bubbles(x, y) {
const e = this.add.particles(x, y, 'bsDot', {
speed: { min: 20, max: 70 }, lifespan: 1100, scale: { start: 0.5, end: 0 },
alpha: { start: 0.7, end: 0 }, quantity: 3, frequency: 60,
angle: { min: 250, max: 290 }, tint: [0xbfe6f2, 0x8fd0e0],
}).setDepth(DEPTH.fx);
this.time.delayedCall(900, () => e.stop());
this.time.delayedCall(2100, () => e.destroy());
}
finishVolley(firedByPlayer) {
this.renderMarkers();
this.renderEnemyReveal();
this.refreshFleetPanels();
this.animating = false;
if (this.gs.phase === 'game_over') { this.time.delayedCall(500, () => this.onGameOver()); return; }
if (firedByPlayer) this.beginAITurn();
else this.beginPlayerTurn();
}
// ── Marker / reveal rendering ───────────────────────────────────────────────
renderMarkers() {
this.drawMarkerGrid(this.enemyMarkers, this.gs.shots.player1, EX);
this.drawMarkerGrid(this.yourMarkers, this.gs.shots.player2, RX);
}
drawMarkerGrid(g, grid, ox) {
g.clear();
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const mark = grid[r][c];
if (!mark) continue;
const x = ox + c * CELL + CELL / 2;
const y = BY + r * CELL + CELL / 2;
if (mark === 'miss') {
g.fillStyle(0x0a1a22, 0.5); g.fillCircle(x, y, 9);
g.fillStyle(C.miss, 0.95); g.fillCircle(x, y, 7);
g.lineStyle(1, 0x88a6b2, 0.8); g.strokeCircle(x, y, 7);
} else {
g.fillStyle(C.hit, 0.25); g.fillCircle(x, y, 16);
g.fillStyle(C.hit, 1); g.fillCircle(x, y, 10);
g.fillStyle(C.hitGlow, 1); g.fillCircle(x, y, 5);
}
}
}
}
renderEnemyReveal() {
this.enemyReveal.clear();
for (const ship of this.gs.fleets.player2) {
if (!ship.sunk) continue;
const bowR = ship.cells[0].r, bowC = ship.cells[0].c;
this.drawSunkHull(this.enemyReveal, EX + bowC * CELL, BY + bowR * CELL, ship.len, ship.horizontal);
}
}
drawSunkHull(g, x, y, len, horizontal) {
const pal = SHIP_PALETTES.sunk;
const w = horizontal ? len * CELL : CELL;
const h = horizontal ? CELL : len * CELL;
const p = 7;
g.fillStyle(pal.dark, 0.9); g.fillRoundedRect(x + p, y + p, w - 2 * p, h - 2 * p, 10);
g.fillStyle(pal.body, 0.85); g.fillRoundedRect(x + p + 2, y + p + 2, w - 2 * p - 4, h - 2 * p - 4, 8);
g.lineStyle(2, pal.light, 0.7); g.strokeRoundedRect(x + p, y + p, w - 2 * p, h - 2 * p, 10);
}
// ── Banners / status / portrait ──────────────────────────────────────────────
setStatus(text) { this.statusText?.setText(text); }
playOppEmotion(emotion) { try { this.opponentPortrait?.playEmotion(emotion); } catch (_) {} }
showBanner(text, colorHex) {
const cx = GAME_WIDTH / 2;
const banner = this.add.text(cx, BY - 90, text, {
fontFamily: 'Righteous', fontSize: '40px', color: colorHex ?? COLORS.textHex,
backgroundColor: '#06141cdd', padding: { x: 30, y: 12 },
}).setOrigin(0.5).setDepth(DEPTH.banner);
this.tweens.add({
targets: banner, y: BY - 30, duration: 320, ease: 'Back.easeOut',
onComplete: () => this.time.delayedCall(1100, () =>
this.tweens.add({ targets: banner, y: BY - 90, alpha: 0, duration: 240, onComplete: () => banner.destroy() })),
});
}
// ── Game over ────────────────────────────────────────────────────────────────
onGameOver() {
const human = this.gs.winner === 'player1';
const name = this.opponents[0]?.name ?? 'Opponent';
this.playOppEmotion(human ? 'upset' : 'happy');
this.recordResult(human ? 'win' : 'loss');
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
if (human) {
const e = this.add.particles(cx, cy - 60, 'bsDot', {
speed: { min: 180, max: 520 }, lifespan: 1600, scale: { start: 1.4, end: 0 },
alpha: { start: 1, end: 0 }, quantity: 6, frequency: 30, angle: { min: 0, max: 360 },
tint: [C.armed, 0xffffff, C.sonar, C.hitGlow],
}).setDepth(DEPTH.overlay - 1);
this.time.delayedCall(2200, () => e.destroy());
}
this.time.delayedCall(400, () => {
const yourLeft = aliveCount(this.gs.fleets.player1);
const enemyLeft = aliveCount(this.gs.fleets.player2);
const msg = human
? `🎉 Victory!\nFleet remaining — You ${yourLeft} · ${name} ${enemyLeft}`
: `${name} wins!\nFleet remaining — You ${yourLeft} · ${name} ${enemyLeft}`;
const overlay = this.add.rectangle(cx, cy, 820, 320, 0x06141c, 0.92)
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.overlay);
const txt = this.add.text(cx, cy - 50, msg, {
fontFamily: '"Julius Sans One"', fontSize: '32px',
color: human ? '#ffd24a' : COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(DEPTH.overlay + 1);
new Button(this, cx - 100, cy + 90, 'Play Again', () => this.scene.restart(this._restartData()),
{ width: 180, fontSize: 24 }).setDepth(DEPTH.overlay + 1);
new Button(this, cx + 100, cy + 90, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 180, fontSize: 24 }).setDepth(DEPTH.overlay + 1);
});
}
_restartData() {
return { game: this.gameDef, opponents: this.opponents, playfield: this.playfield };
}
async recordResult(result) {
try {
const score = result === 'win'
? 100 + 20 * aliveCount(this.gs.fleets.player1)
: 20 * (SHIPS.length - aliveCount(this.gs.fleets.player2));
await api.post('/history/single-player', { slug: 'battleship', score, opponentScores: [], result });
} catch { /* best effort */ }
}
}