176 lines
5.5 KiB
JavaScript
176 lines
5.5 KiB
JavaScript
// Battleship — pure game logic (no Phaser, no rendering).
|
||
//
|
||
// Two players, each with a fleet of 5 ships on a 10×10 grid. Hidden
|
||
// information: a player only learns enemy ship positions by firing at them.
|
||
// Salvo mode: on your turn you fire one shot per ship still afloat.
|
||
|
||
export const SIZE = 10;
|
||
|
||
// Standard fleet, largest → smallest.
|
||
export const SHIPS = [
|
||
{ name: 'Carrier', len: 5 },
|
||
{ name: 'Battleship', len: 4 },
|
||
{ name: 'Cruiser', len: 3 },
|
||
{ name: 'Submarine', len: 3 },
|
||
{ name: 'Destroyer', len: 2 },
|
||
];
|
||
|
||
export function other(player) {
|
||
return player === 'player1' ? 'player2' : 'player1';
|
||
}
|
||
|
||
function emptyGrid() {
|
||
return Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
|
||
}
|
||
|
||
// A ship occupies `len` contiguous cells from (r,c), horizontal or vertical.
|
||
function shipCells(len, r, c, horizontal) {
|
||
const cells = [];
|
||
for (let i = 0; i < len; i++) {
|
||
cells.push(horizontal ? { r, c: c + i } : { r: r + i, c });
|
||
}
|
||
return cells;
|
||
}
|
||
|
||
// True if a ship of `len` placed at (r,c) fits in bounds and overlaps nothing
|
||
// already in `fleet`. Ships are allowed to touch (standard rules).
|
||
export function canPlace(fleet, len, r, c, horizontal) {
|
||
const cells = shipCells(len, r, c, horizontal);
|
||
for (const cell of cells) {
|
||
if (cell.r < 0 || cell.r >= SIZE || cell.c < 0 || cell.c >= SIZE) return false;
|
||
}
|
||
const occupied = new Set();
|
||
for (const ship of fleet) for (const cell of ship.cells) occupied.add(`${cell.r},${cell.c}`);
|
||
for (const cell of cells) if (occupied.has(`${cell.r},${cell.c}`)) return false;
|
||
return true;
|
||
}
|
||
|
||
// Build a ship object ready to drop into a fleet.
|
||
export function makeShip(spec, r, c, horizontal) {
|
||
const cells = shipCells(spec.len, r, c, horizontal);
|
||
return {
|
||
name: spec.name,
|
||
len: spec.len,
|
||
horizontal,
|
||
cells,
|
||
hits: Array(spec.len).fill(false),
|
||
sunk: false,
|
||
};
|
||
}
|
||
|
||
// A valid random 5-ship layout. Used by the Randomize button and the AI.
|
||
export function placeShipsRandom() {
|
||
const fleet = [];
|
||
for (const spec of SHIPS) {
|
||
// Try random placements until one is legal (always terminates on 10×10).
|
||
for (let guard = 0; guard < 1000; guard++) {
|
||
const horizontal = Math.random() < 0.5;
|
||
const r = Math.floor(Math.random() * (horizontal ? SIZE : SIZE - spec.len + 1));
|
||
const c = Math.floor(Math.random() * (horizontal ? SIZE - spec.len + 1 : SIZE));
|
||
if (canPlace(fleet, spec.len, r, c, horizontal)) {
|
||
fleet.push(makeShip(spec, r, c, horizontal));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
return fleet;
|
||
}
|
||
|
||
export function createInitialState() {
|
||
return {
|
||
phase: 'placement', // 'placement' | 'battle' | 'game_over'
|
||
turn: 'player1',
|
||
fleets: { player1: [], player2: [] },
|
||
shots: { player1: emptyGrid(), player2: emptyGrid() }, // firer's view of enemy
|
||
winner: null,
|
||
};
|
||
}
|
||
|
||
export function cloneState(state) {
|
||
const cloneFleet = (fleet) => fleet.map((s) => ({
|
||
name: s.name, len: s.len, horizontal: s.horizontal,
|
||
cells: s.cells.map((cell) => ({ r: cell.r, c: cell.c })),
|
||
hits: s.hits.slice(),
|
||
sunk: s.sunk,
|
||
}));
|
||
const cloneGrid = (g) => g.map((row) => row.slice());
|
||
return {
|
||
phase: state.phase,
|
||
turn: state.turn,
|
||
fleets: { player1: cloneFleet(state.fleets.player1), player2: cloneFleet(state.fleets.player2) },
|
||
shots: { player1: cloneGrid(state.shots.player1), player2: cloneGrid(state.shots.player2) },
|
||
winner: state.winner,
|
||
};
|
||
}
|
||
|
||
// Has `player` already fired at (r,c)?
|
||
export function shotAt(state, player, r, c) {
|
||
return state.shots[player][r][c] !== null;
|
||
}
|
||
|
||
// Find the ship in `fleet` occupying (r,c), or null.
|
||
function shipAt(fleet, r, c) {
|
||
for (const ship of fleet) {
|
||
for (let i = 0; i < ship.cells.length; i++) {
|
||
if (ship.cells[i].r === r && ship.cells[i].c === c) return { ship, idx: i };
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function aliveCount(fleet) {
|
||
return fleet.reduce((n, s) => n + (s.sunk ? 0 : 1), 0);
|
||
}
|
||
|
||
// Number of shots `player` gets this turn = their own surviving ships.
|
||
export function salvoCount(state, player) {
|
||
return aliveCount(state.fleets[player]);
|
||
}
|
||
|
||
// Apply a single shot. Mutates `state` (callers pass a clone). Returns the
|
||
// outcome plus the ship that was hit/sunk (for reveal animations).
|
||
export function fireShot(state, byPlayer, r, c) {
|
||
const target = other(byPlayer);
|
||
const hit = shipAt(state.fleets[target], r, c);
|
||
if (!hit) {
|
||
state.shots[byPlayer][r][c] = 'miss';
|
||
return { result: 'miss', ship: null };
|
||
}
|
||
state.shots[byPlayer][r][c] = 'hit';
|
||
hit.ship.hits[hit.idx] = true;
|
||
if (hit.ship.hits.every(Boolean)) {
|
||
hit.ship.sunk = true;
|
||
return { result: 'sunk', ship: hit.ship };
|
||
}
|
||
return { result: 'hit', ship: hit.ship };
|
||
}
|
||
|
||
// Fire a whole salvo of cells, resolve game over / turn handoff.
|
||
// Returns { state, results: [{ r, c, result, ship }] }.
|
||
export function applySalvo(state, byPlayer, cells) {
|
||
const next = cloneState(state);
|
||
const results = [];
|
||
for (const { r, c } of cells) {
|
||
if (shotAt(next, byPlayer, r, c)) continue; // guard against duplicates
|
||
const outcome = fireShot(next, byPlayer, r, c);
|
||
results.push({ r, c, ...outcome });
|
||
}
|
||
if (isGameOver(next)) {
|
||
next.phase = 'game_over';
|
||
next.winner = getWinner(next);
|
||
} else {
|
||
next.turn = other(byPlayer);
|
||
}
|
||
return { state: next, results };
|
||
}
|
||
|
||
export function isGameOver(state) {
|
||
return aliveCount(state.fleets.player1) === 0 || aliveCount(state.fleets.player2) === 0;
|
||
}
|
||
|
||
export function getWinner(state) {
|
||
if (aliveCount(state.fleets.player2) === 0) return 'player1';
|
||
if (aliveCount(state.fleets.player1) === 0) return 'player2';
|
||
return null;
|
||
}
|