/// import { action, computed, NAME, pattern, Stream, UI, Writable, } from "commontools"; import { areAllShipsSunk, buildShipPositions, COLS, createInitialState, findShipAt, type GameState, GRID_INDICES, isShipSunk, ROWS, SHIP_NAMES, } from "./schemas.tsx"; // Re-export types for test compatibility export type { Coordinate, GameState, PlayerBoard, Ship, ShipType, SquareState, } from "./schemas.tsx"; // ============================================================================= // Types // ============================================================================= interface BoardCell { row: number; col: number; bgColor: string; content: string; gridRow: string; gridCol: string; } // ============================================================================= // Pattern // ============================================================================= type Input = Record; interface Output { game: Writable; fireShot: Stream<{ row: number; col: number }>; passDevice: Stream; playerReady: Stream; resetGame: Stream; } export default pattern((_input) => { const game = Writable.of(createInitialState()); // --------------------------------------------------------------------------- // Actions // --------------------------------------------------------------------------- const fireShot = action<{ row: number; col: number }>(({ row, col }) => { const state = game.get(); // Can't fire if game is over, in transition, or awaiting pass if (state.phase === "finished") return; if (state.viewingAs === null) return; if (state.awaitingPass) return; // Can only fire on your turn if (state.currentTurn !== state.viewingAs) return; // Target the opponent's board const targetPlayer = state.viewingAs === 1 ? 2 : 1; const targetBoard = targetPlayer === 1 ? state.player1 : state.player2; const shots = targetBoard.shots; // Can't fire at same spot twice if (shots[row][col] !== "empty") return; // Check if hit const hitShip = findShipAt(targetBoard.ships, { row, col }); const newShots = shots.map((r, ri) => r.map((c, ci) => ri === row && ci === col ? (hitShip ? "hit" : "miss") : c ) ); const newTargetBoard = { ...targetBoard, shots: newShots }; // Build message let message = ""; const coordStr = `${COLS[col]}${row + 1}`; if (hitShip) { if (isShipSunk(hitShip, newShots)) { message = `${coordStr}: Hit! You sunk the ${SHIP_NAMES[hitShip.type]}!`; } else { message = `${coordStr}: Hit!`; } } else { message = `${coordStr}: Miss.`; } // Check for win const allSunk = areAllShipsSunk(targetBoard.ships, newShots); if (allSunk) { game.set({ ...state, player1: targetPlayer === 1 ? newTargetBoard : state.player1, player2: targetPlayer === 2 ? newTargetBoard : state.player2, phase: "finished", winner: state.currentTurn, lastMessage: `${message} Player ${state.currentTurn} wins!`, viewingAs: state.viewingAs, awaitingPass: false, }); } else { const nextTurn = state.currentTurn === 1 ? 2 : 1; game.set({ ...state, player1: targetPlayer === 1 ? newTargetBoard : state.player1, player2: targetPlayer === 2 ? newTargetBoard : state.player2, currentTurn: nextTurn as 1 | 2, lastMessage: message, viewingAs: state.viewingAs, awaitingPass: true, }); } }); const passDevice = action(() => { const state = game.get(); if (state.phase === "finished") return; if (!state.awaitingPass) return; game.set({ ...state, viewingAs: null, awaitingPass: false, }); }); const playerReady = action(() => { const state = game.get(); if (state.phase === "finished") return; if (state.viewingAs !== null) return; game.set({ ...state, viewingAs: state.currentTurn, awaitingPass: false, lastMessage: `Player ${state.currentTurn}'s turn - fire at the enemy board!`, }); }); const resetGame = action(() => { game.set(createInitialState()); }); // --------------------------------------------------------------------------- // Computed Values // --------------------------------------------------------------------------- // Single computed for all display values (accessed via properties) const gameStatus = computed(() => { const state = game.get(); return { lastMessage: state.lastMessage, currentTurn: state.currentTurn, viewingAs: state.viewingAs, winner: state.winner, awaitingPass: state.awaitingPass, }; }); // Screen visibility conditions const isTransition = computed(() => game.get().viewingAs === null); const isFinished = computed(() => game.get().phase === "finished"); // Board cell computation const myBoardCells = computed((): BoardCell[] => { const state = game.get(); const viewer = state.viewingAs; if (viewer === null) return []; const myBoard = viewer === 1 ? state.player1 : state.player2; const shots = myBoard.shots; const shipPositions = buildShipPositions(myBoard.ships); return GRID_INDICES.map(({ row, col }) => { const shotState = shots[row]?.[col] ?? "empty"; const hasShip = !!shipPositions[`${row},${col}`]; const bgColor = shotState === "hit" ? "#dc2626" : shotState === "miss" ? "#374151" : hasShip ? "#22c55e" : "#1e3a5f"; const content = shotState === "hit" ? "X" : shotState === "miss" ? "O" : ""; return { row, col, bgColor, content, gridRow: `${row + 2}`, gridCol: `${col + 2}`, }; }); }); const enemyBoardCells = computed((): BoardCell[] => { const state = game.get(); const viewer = state.viewingAs; if (viewer === null) return []; const enemyBoard = viewer === 1 ? state.player2 : state.player1; const shots = enemyBoard.shots; return GRID_INDICES.map(({ row, col }) => { const shotState = shots[row]?.[col] ?? "empty"; const bgColor = shotState === "hit" ? "#dc2626" : shotState === "miss" ? "#374151" : "#1e3a5f"; const content = shotState === "hit" ? "X" : shotState === "miss" ? "O" : ""; return { row, col, bgColor, content, gridRow: `${row + 2}`, gridCol: `${col + 2}`, }; }); }); // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const gridContainerStyle = { display: "grid", gridTemplateColumns: "30px repeat(10, 32px)", gap: "2px", backgroundColor: "#000", padding: "2px", }; const headerCellStyle = { backgroundColor: "#1a1a2e", display: "flex", alignItems: "center", justifyContent: "center", color: "#888", fontSize: "12px", fontWeight: "bold", height: "30px", }; const baseCellStyle = { display: "flex", alignItems: "center", justifyContent: "center", color: "#fff", fontSize: "16px", fontWeight: "bold", height: "32px", width: "32px", }; // --------------------------------------------------------------------------- // CSS-based screen visibility (avoid ifElse DOM destruction) // --------------------------------------------------------------------------- // Compute display styles for each screen - CSS changes instead of DOM swap const transitionDisplay = computed(() => isTransition ? "flex" : "none"); const victoryDisplay = computed(() => isFinished ? "flex" : "none"); const gameDisplay = computed(() => { const state = game.get(); const showGame = state.phase !== "finished" && state.viewingAs !== null; return showGame ? "block" : "none"; }); // --------------------------------------------------------------------------- // UI Components (now always rendered, visibility via CSS) // --------------------------------------------------------------------------- const transitionScreen = ( Pass device to Player {gameStatus.currentTurn} Make sure the other player isn't looking! I'm Player {gameStatus.currentTurn} - Ready! ); const victoryScreen = ( Player {gameStatus.winner} Wins! All enemy ships have been sunk! Play Again ); const gameScreen = ( {/* Status bar */} {gameStatus.lastMessage} {/* Pass button (shown after firing) */} gameStatus.awaitingPass ? "block" : "none"), textAlign: "center", padding: "16px", marginBottom: "20px", backgroundColor: "#1e40af", borderRadius: "8px", }} > Pass to Player {gameStatus.currentTurn} {/* Game boards */} {/* My Board (left) */} Your Fleet {COLS.map((c, i) => {c})} {ROWS.map((rowIdx) => ( {rowIdx + 1} ))} {myBoardCells.map((cell) => ( {cell.content} ))} {/* Enemy Board (right) */} Enemy Waters {COLS.map((c, i) => {c})} {ROWS.map((rowIdx) => ( {rowIdx + 1} ))} {enemyBoardCells.map((cell) => ( fireShot.send({ row: cell.row, col: cell.col })} > {cell.content} ))} {/* Legend */} Your Ship Unknown X Hit O Miss ); // --------------------------------------------------------------------------- // Main UI // --------------------------------------------------------------------------- return { [NAME]: "Battleship", [UI]: ( BATTLESHIP {/* Screen container - relative positioning for absolute children */} {victoryScreen} {transitionScreen} {gameScreen} {/* Reset button (always visible) */} Reset Game ), game, fireShot, passDevice, playerReady, resetGame, }; });
Make sure the other player isn't looking!
All enemy ships have been sunk!