938 lines
37 KiB
JavaScript
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 */ }
|
|
}
|
|
}
|