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 */ } } }