├── static ├── favicon.ico └── paper-boat.png ├── .vscode ├── extensions.json └── settings.json ├── .gitignore ├── twind.config.ts ├── dev.ts ├── game ├── config.ts ├── types.ts ├── utils.ts ├── actions.ts ├── generator_test.ts ├── utils_test.ts ├── generator.ts └── state.ts ├── components ├── Button.tsx ├── Header.tsx ├── Footer.tsx └── Icons.tsx ├── main.ts ├── fresh.gen.ts ├── routes └── index.tsx ├── deno.json ├── LICENSE ├── README.md └── islands ├── Battlefield.tsx └── FirstMate.tsx /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karelklima/battleship/main/static/favicon.ico -------------------------------------------------------------------------------- /static/paper-boat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karelklima/battleship/main/static/paper-boat.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "denoland.vscode-deno", 4 | "sastan.twind-intellisense" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "editor.defaultFormatter": "denoland.vscode-deno" 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dotenv environment variable files 2 | .env 3 | .env.development.local 4 | .env.test.local 5 | .env.production.local 6 | .env.local 7 | -------------------------------------------------------------------------------- /twind.config.ts: -------------------------------------------------------------------------------- 1 | import { Options } from "$fresh/plugins/twind.ts"; 2 | 3 | export default { 4 | selfURL: import.meta.url, 5 | } as Options; 6 | -------------------------------------------------------------------------------- /dev.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S deno run -A --watch=static/,routes/ 2 | 3 | import dev from "$fresh/dev.ts"; 4 | 5 | await dev(import.meta.url, "./main.ts"); 6 | -------------------------------------------------------------------------------- /game/config.ts: -------------------------------------------------------------------------------- 1 | /** Size of the battlefield */ 2 | export const GRID_SIZE = 10; 3 | 4 | /** Available ships on the battlefield */ 5 | export const SHIPS = [5, 4, 4]; 6 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "preact"; 2 | import { IS_BROWSER } from "$fresh/runtime.ts"; 3 | 4 | export function Button(props: JSX.HTMLAttributes) { 5 | return ( 6 | ; 124 | } 125 | -------------------------------------------------------------------------------- /game/generator.ts: -------------------------------------------------------------------------------- 1 | import { GRID_SIZE, SHIPS } from "./config.ts"; 2 | import { type SectorIndex, type Ship } from "./types.ts"; 3 | import { 4 | bitwiseAnd, 5 | bitwiseOr, 6 | coordinates, 7 | index, 8 | randomBoolean, 9 | randomInt, 10 | range, 11 | } from "./utils.ts"; 12 | 13 | /** 14 | * Generates random ship placement on the grid and returns the list 15 | * of board indexes that the ship occupies. 16 | * 17 | * @param shipSize Size of the ship to be generated 18 | * @return Ship defined by indexes on board that it occupies 19 | */ 20 | export const generateRandomShipPlacement = (shipSize: number): Ship => { 21 | // decide whether the ship is oriented horizontally or vertically 22 | const horizontal = randomBoolean(); 23 | // pick a random starting coordinate limited by the ship size and orientation 24 | // so that the ship cannot be generated out of bounds of the grid 25 | const row = randomInt(horizontal ? GRID_SIZE : GRID_SIZE - shipSize + 1); 26 | const col = randomInt(horizontal ? GRID_SIZE - shipSize + 1 : GRID_SIZE); 27 | 28 | const sectorIndexes: SectorIndex[] = []; 29 | for (let i = 0; i < shipSize; i++) { 30 | if (horizontal) { 31 | sectorIndexes.push(index([row, col + i])); 32 | } else { 33 | sectorIndexes.push(index([row + i, col])); 34 | } 35 | } 36 | 37 | return sectorIndexes; 38 | }; 39 | 40 | /** 41 | * Generates a block map of indexes that a ship occupies, including her immediate surroundings 42 | * defined as a distance in number of squares from the ship 43 | * 44 | * @param ship Ship defined by indexes on board that it occupies 45 | * @param distance Blocked area around the ship 46 | * @returns blocked indexes on board that cannot be occupied by other ships 47 | */ 48 | export const generateOccupancyMap = ( 49 | ship: Ship, 50 | distance: number, 51 | ): boolean[] => { 52 | const [firstRow, firstColumn] = coordinates(ship[0]); 53 | const [lastRow, lastColumn] = coordinates( 54 | ship[ship.length - 1], 55 | ); 56 | 57 | // calculate area limits using ship area and its surroundings of size `distance` 58 | const minRow = firstRow - distance; 59 | const maxRow = lastRow + distance; 60 | const minCol = firstColumn - distance; 61 | const maxCol = lastColumn + distance; 62 | 63 | const blockGrid = range(GRID_SIZE * GRID_SIZE).map((i) => { 64 | const [row, col] = coordinates(i); 65 | if (row >= minRow && row <= maxRow && col >= minCol && col <= maxCol) { 66 | return true; 67 | } 68 | return false; 69 | }); 70 | 71 | return blockGrid; 72 | }; 73 | 74 | /** 75 | * Randomly generates ships on the battlefield based on the preconfigured 76 | * board size and number and size of ships. 77 | * 78 | * The algorithm may take too long in the worst case scenario if there is 79 | * too many ships, therefore if a random place for a ship is not found in 80 | * a reasonable time, the ship will not be present in the game. Chances of 81 | * that happening are statistically close to impossible. 82 | * 83 | * @returns Ships and their position on board represented by an array of 84 | * sector indexes that the ship occupies 85 | */ 86 | export const generateShipsPositions = (): Ship[] => { 87 | const ships: Ship[] = []; 88 | 89 | // Map of sectors occupied by already generated ships 90 | let cumulativeOccupancyMap = range(GRID_SIZE * GRID_SIZE).map((_) => false); 91 | 92 | for (const shipSize of SHIPS) { 93 | let attempts = 0; 94 | while (attempts++ < 100) { 95 | const candidateShip = generateRandomShipPlacement(shipSize); 96 | const immediateOccupancyMap = generateOccupancyMap(candidateShip, 0); 97 | const collisions = bitwiseAnd( 98 | cumulativeOccupancyMap, 99 | immediateOccupancyMap, 100 | ); 101 | 102 | if (collisions.filter((it) => it === true).length > 0) { 103 | // collision detected, a new random ship needs to be generated 104 | continue; 105 | } 106 | 107 | // update sector occupancy map 108 | const occupancyMap = generateOccupancyMap(candidateShip, 1); 109 | cumulativeOccupancyMap = bitwiseOr(cumulativeOccupancyMap, occupancyMap); 110 | ships.push(candidateShip); 111 | break; 112 | } 113 | } 114 | 115 | return ships; 116 | }; 117 | -------------------------------------------------------------------------------- /game/state.ts: -------------------------------------------------------------------------------- 1 | import { computed, signal } from "@preact/signals"; 2 | import { zip } from "$std/collections/zip.ts"; 3 | 4 | import { generateOccupancyMap, generateShipsPositions } from "./generator.ts"; 5 | import { GRID_SIZE } from "./config.ts"; 6 | import { range } from "./utils.ts"; 7 | import { SectorStatus, type Ship } from "./types.ts"; 8 | 9 | /** 10 | * This file contains the application state. There are three core state variables: 11 | * - ships - contains a list of ships and their positions on the battlefield 12 | * - shots - contains a sector map of shots that the player made, i.e. which sectors were targeted 13 | * - message - contains a message to display to the player 14 | * 15 | * All of the rest of the application state is reactively computed from these three 16 | * core state variables. 17 | */ 18 | 19 | /** Generates random configuration of ships on the battlefield */ 20 | export const createInitialShipPositions = () => generateShipsPositions(); 21 | 22 | /** Generates empty initial sector map of shots (i.e. no shots taken yet) */ 23 | export const createInitialShots = () => 24 | range(GRID_SIZE * GRID_SIZE).map((_) => false); 25 | 26 | /** Generates initial message for the player */ 27 | export const createInitialMessage = () => ({ 28 | title: "Attack enemy ships!", 29 | subtitle: "Pick a spot and give the order.", 30 | }); 31 | 32 | /** Contains a list of ships and their positions on the battlefield */ 33 | export const ships = signal(createInitialShipPositions()); 34 | 35 | /** Contains a sector map of shots the the played made */ 36 | export const shots = signal(createInitialShots()); 37 | 38 | /** Contains a message to display to the player */ 39 | export const message = signal(createInitialMessage()); 40 | 41 | /** Partitions all ships between destroyed group and still fighting group */ 42 | export const shipsStatus = computed(() => { 43 | const destroyed: Ship[] = []; 44 | const fighting: Ship[] = []; 45 | for (const ship of ships.value) { 46 | let intact = false; 47 | for (const sectorIndex of ship) { 48 | if (shots.value[sectorIndex] === false) { 49 | // there is at least one part of ship that has not been targeted 50 | intact = true; 51 | break; 52 | } 53 | } 54 | if (!intact) { 55 | destroyed.push(ship); 56 | } else { 57 | fighting.push(ship); 58 | } 59 | } 60 | return { 61 | destroyed, 62 | fighting, 63 | }; 64 | }); 65 | 66 | /** Computes whether the game is over based on the number of ships that are still intact */ 67 | export const gameOver = computed(() => { 68 | const { fighting } = shipsStatus.value; 69 | return fighting.length < 1; 70 | }); 71 | 72 | /** 73 | * Computes a map of sectors that must be empty based on the previous shots and hits 74 | * so that the user cannot target these sectors. The purpose of this is that when a ship 75 | * is destroyed, the immediate sectors should be disabled for targeting, because they 76 | * cannot contain a ship or its part. If the game is over, all the squares are marked as 77 | * empty / disabled to target by the player, as there are no more ships to destroy. 78 | */ 79 | const emptySquares = computed(() => { 80 | if (gameOver.value) { 81 | return range(GRID_SIZE * GRID_SIZE).map((_) => true); 82 | } 83 | const { destroyed } = shipsStatus.value; 84 | const blockMaps = destroyed.map((ship) => generateOccupancyMap(ship, 1)); 85 | const emptyArr = range(GRID_SIZE * GRID_SIZE).map((_) => false); 86 | return zip(emptyArr, ...blockMaps).map((flags) => flags.includes(true)); 87 | }); 88 | 89 | /** Computes a map of sectors that contain a ship part that has been shot by the player */ 90 | const shipHits = computed(() => { 91 | const hits = range(GRID_SIZE * GRID_SIZE).map((_) => false); 92 | for (const ship of ships.value) { 93 | for (const boardIndex of ship) { 94 | if (shots.value[boardIndex] === true) { 95 | hits[boardIndex] = true; 96 | } 97 | } 98 | } 99 | return hits; 100 | }); 101 | 102 | /** Computes the number of successful shots on targets */ 103 | export const hitCount = computed(() => { 104 | return shipHits.value.filter((it) => it).length; 105 | }); 106 | 107 | /** Computes the number of missed shots */ 108 | export const missCount = computed(() => { 109 | const shotsCount = shots.value.filter((it) => it).length; 110 | return shotsCount - hitCount.value; 111 | }); 112 | 113 | /** 114 | * Computes the whole battlefield - the result is a list of battlefield 115 | * sectors represented by their status. For example, for a battlefield of 116 | * size 10x10, there will be 100 sector statuses computed by this function. 117 | * The statuses of the sectors may be: 118 | * - SANK - the sector contains a part of a ship that has been fully destroyed 119 | * - HIT - the sector contains a part of a ship that has been shot, but not fully destroyed 120 | * - MISS - the sector does not contain a ship, and the player targeted it 121 | * - EMPTY - the sector cannot be targeted, but does not contain a ship (e.g. a sector next to a destroyed ship) 122 | */ 123 | export const battlefield = computed(() => { 124 | return zip(shots.value, emptySquares.value, shipHits.value).map( 125 | ([isFired, isEmpty, isHit]) => { 126 | if (isEmpty && isHit) { 127 | return SectorStatus.SANK; 128 | } 129 | if (isHit) { 130 | return SectorStatus.HIT; 131 | } 132 | if (isFired) { 133 | return SectorStatus.MISS; 134 | } 135 | if (isEmpty) { 136 | return SectorStatus.EMPTY; 137 | } 138 | return SectorStatus.UNKNOWN; 139 | }, 140 | ); 141 | }); 142 | --------------------------------------------------------------------------------