From fe1cacb5ed5dd9feca48e8f0cb9088399e5e77c0 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Wed, 3 Dec 2025 19:27:47 +0530 Subject: [PATCH] feat(battleship): add complete Battleship game UI with placement & battle phases - Implement BattleshipBoard with phase-based rendering (placement/battle) - Add PlacementGrid for ship placement interaction - Add ShotGrid for firing UI with turn validation - Integrate match metadata (pX_placed, pX_ready, phase) - Connect UI to Nakama sendMatchData (place + shoot actions) - Add real-time board rendering for ships and shots - Add status line, turn handling, and winner display - Ensure compatibility with new backend ApplyMove/ApplyPlacement logic --- src/games/battleship/BattleShipBoard.tsx | 130 ++++++++++++++ src/games/battleship/battle/ShotGrid.tsx | 100 +++++++++++ .../battleship/components/ShipSelector.tsx | 87 +++++++++ src/games/battleship/components/StatusBar.tsx | 24 +++ src/games/battleship/phases/BattlePhase.tsx | 169 ++++++++++++++++++ src/games/battleship/phases/BattleStatus.tsx | 100 +++++++++++ .../battleship/phases/PlacementPhase.tsx | 145 +++++++++++++++ .../battleship/placement/PlacementGrid.tsx | 127 +++++++++++++ 8 files changed, 882 insertions(+) create mode 100644 src/games/battleship/BattleShipBoard.tsx create mode 100644 src/games/battleship/battle/ShotGrid.tsx create mode 100644 src/games/battleship/components/ShipSelector.tsx create mode 100644 src/games/battleship/components/StatusBar.tsx create mode 100644 src/games/battleship/phases/BattlePhase.tsx create mode 100644 src/games/battleship/phases/BattleStatus.tsx create mode 100644 src/games/battleship/phases/PlacementPhase.tsx create mode 100644 src/games/battleship/placement/PlacementGrid.tsx diff --git a/src/games/battleship/BattleShipBoard.tsx b/src/games/battleship/BattleShipBoard.tsx new file mode 100644 index 0000000..d58ba63 --- /dev/null +++ b/src/games/battleship/BattleShipBoard.tsx @@ -0,0 +1,130 @@ +import React, { useMemo } from "react"; +import { motion } from "framer-motion"; +import { useNakama } from "../../providers/NakamaProvider"; +import { PlayerModel } from "../../models/player"; + +import PlacementGrid from "./placement/PlacementGrid"; +import ShotGrid from "./battle/ShotGrid"; + +interface BattleBoardProps { + boards: Record; + players: PlayerModel[]; + myUserId: string | null; + turn: number; + winner: string | null; + gameOver: boolean | null; + metadata: Record; +} + +export default function BattleShipBoard({ + boards, + players, + myUserId, + turn, + winner, + gameOver, + metadata, +}: BattleBoardProps) { + const { matchId, sendMatchData } = useNakama(); + + const myIndex = players.findIndex((p) => p.user_id === myUserId); + const oppIndex = myIndex === 0 ? 1 : 0; + + const phase = metadata["phase"] ?? "placement"; + const isMyTurn = phase === "battle" && turn === myIndex; + + const myShips = boards[`p${myIndex}_ships`]?.grid ?? []; + const myShots = boards[`p${myIndex}_shots`]?.grid ?? []; + + const placed = metadata[`p${myIndex}_placed`] ?? 0; + + // ----------- SEND PLACE MESSAGE ----------- + function handlePlace(ship: string, r: number, c: number, dir: "h" | "v") { + sendMatchData(matchId!, 2, { + action: "place", + ship, + row: r, + col: c, + dir, + }); + } + + // ----------- SEND SHOT MESSAGE ----------- + function handleShoot(r: number, c: number) { + sendMatchData(matchId!, 2, { + action: "shoot", + row: r, + col: c, + }); + + } + + // ------------------- STATUS LINE --------------------- + const status = useMemo(() => { + if (winner) return `Winner: Player ${winner}`; + if (gameOver) return "Game over β€” draw"; + if (phase === "placement") return "Place your ships"; + if (myIndex === -1) return "Spectating"; + if (!isMyTurn) return "Opponent's turn"; + return "Your turn"; + }, [winner, gameOver, phase, isMyTurn, myIndex]); + + return ( + +

{status}

+ + {/* ---------------- PHASE 1: PLACEMENT ---------------- */} + {phase === "placement" && ( +
+ +
+ )} + + {/* ---------------- PHASE 2: BATTLE ---------------- */} + {phase === "battle" && ( + <> +

Your Shots

+ + +

Your Ships

+ + + )} + + {/* ---------------- WINNER UI ---------------- */} + {winner !== null && ( + + πŸŽ‰ Player {winner} Wins! πŸŽ‰ + + )} +
+ ); +} diff --git a/src/games/battleship/battle/ShotGrid.tsx b/src/games/battleship/battle/ShotGrid.tsx new file mode 100644 index 0000000..35a3a25 --- /dev/null +++ b/src/games/battleship/battle/ShotGrid.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; + +interface ShotGridProps { + grid: string[][]; // your shots: "", "H", "M" + isMyTurn: boolean; // only clickable on your turn + gameOver: boolean; + onShoot: (row: number, col: number) => void; +} + +export default function ShotGrid({ + grid, + isMyTurn, + gameOver, + onShoot, +}: ShotGridProps) { + const rows = grid.length; + const cols = grid[0].length; + + function handleClick(r: number, c: number) { + if (!isMyTurn || gameOver) return; + if (grid[r][c] !== "") return; // can't shoot twice + onShoot(r, c); + } + + return ( + + {grid.map((row, r) => + row.map((cell, c) => { + const empty = cell === ""; + const hit = cell === "H"; + const miss = cell === "M"; + + let bg = "#111"; + + if (hit) bg = "#e74c3c"; // red for hit + if (miss) bg = "#34495e"; // gray for miss + + return ( + handleClick(r, c)} + style={{ + width: 36, + height: 36, + borderRadius: 4, + border: "1px solid #444", + background: bg, + cursor: + empty && isMyTurn && !gameOver ? "pointer" : "not-allowed", + display: "flex", + alignItems: "center", + justifyContent: "center", + color: "white", + fontWeight: 700, + }} + > + + {hit && ( + + H + + )} + + {miss && ( + + M + + )} + + + ); + }) + )} + + ); +} diff --git a/src/games/battleship/components/ShipSelector.tsx b/src/games/battleship/components/ShipSelector.tsx new file mode 100644 index 0000000..f493640 --- /dev/null +++ b/src/games/battleship/components/ShipSelector.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { motion } from "framer-motion"; + +interface ShipSelectorProps { + remainingShips: string[]; // ex: ["carrier", "battleship", ...] + selectedShip: string | null; + orientation: "h" | "v"; + onSelectShip: (ship: string) => void; + onToggleOrientation: () => void; +} + +export default function ShipSelector({ + remainingShips, + selectedShip, + orientation, + onSelectShip, + onToggleOrientation, +}: ShipSelectorProps) { + return ( +
+ + Select ship & orientation + + + {/* SHIP BUTTONS */} +
+ {remainingShips.map((ship) => { + const active = ship === selectedShip; + + return ( + onSelectShip(ship)} + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.92 }} + style={{ + padding: "8px 14px", + borderRadius: 8, + background: active ? "#f1c40f" : "#333", + color: active ? "#000" : "#fff", + border: "2px solid #444", + cursor: "pointer", + fontSize: 14, + }} + > + {ship.toUpperCase()} + + ); + })} +
+ + {/* ORIENTATION BUTTON */} + + Orientation: {orientation === "h" ? "Horizontal" : "Vertical"} + +
+ ); +} diff --git a/src/games/battleship/components/StatusBar.tsx b/src/games/battleship/components/StatusBar.tsx new file mode 100644 index 0000000..5eb8470 --- /dev/null +++ b/src/games/battleship/components/StatusBar.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { motion } from "framer-motion"; + +interface StatusBarProps { + text: string; +} + +export default function StatusBar({ text }: StatusBarProps) { + return ( + + {text} + + ); +} diff --git a/src/games/battleship/phases/BattlePhase.tsx b/src/games/battleship/phases/BattlePhase.tsx new file mode 100644 index 0000000..7e3a10b --- /dev/null +++ b/src/games/battleship/phases/BattlePhase.tsx @@ -0,0 +1,169 @@ +import React from "react"; +import { motion } from "framer-motion"; + +interface BattleProps { + myIndex: number; + boards: Record; + turn: number; + winner: string | null; + gameOver: boolean | null; + players: any[]; + myUserId: string | null; + onShoot: (row: number, col: number) => void; +} + +export default function BattlePhase({ + myIndex, + boards, + turn, + winner, + gameOver, + players, + myUserId, + onShoot, +}: BattleProps) { + if (myIndex < 0) return
Spectating...
; + + const myShots = boards[`p${myIndex}_shots`]?.grid; + const myShips = boards[`p${myIndex}_ships`]?.grid; + const isMyTurn = turn === myIndex; + const gameReady = players.length === 2; + + if (!myShots || !myShips) return
Loading...
; + + const renderShotCell = (cell: string, r: number, c: number) => { + const disabled = + !isMyTurn || + gameOver || + cell === "H" || + cell === "M"; // can't shoot same cell + + const bg = + cell === "H" + ? "#e74c3c" // red = hit + : cell === "M" + ? "#34495e" // blue/gray = miss + : "#1c1c1c"; // untouched + + return ( + !disabled && onShoot(r, c)} + whileHover={!disabled ? { scale: 1.06 } : {}} + whileTap={!disabled ? { scale: 0.85 } : {}} + style={{ + width: 38, + height: 38, + border: "1px solid #333", + background: bg, + cursor: disabled ? "not-allowed" : "pointer", + }} + /> + ); + }; + + const renderShipCell = (cell: string, r: number, c: number) => { + const bg = + cell === "S" + ? "#2980b9" // ship + : cell === "X" + ? "#c0392b" // destroyed + : "#111"; // water + + return ( + + ); + }; + + return ( + +

+ {gameOver + ? winner === myUserId + ? "πŸŽ‰ Victory! πŸŽ‰" + : "πŸ’₯ Defeat πŸ’₯" + : isMyTurn + ? "Your Turn β€” Fire!" + : "Opponent's Turn"} +

+ + {/* ------------------------- + TOP SECTION β€” YOUR SHOTS + -------------------------- */} +
+ Your Shots +
+ + + {myShots.map((row, r) => + row.map((cell, c) => renderShotCell(cell, r, c)) + )} + + + {/* ------------------------- + BOTTOM SECTION β€” YOUR FLEET + -------------------------- */} +
+ Your Fleet Status +
+ + + {myShips.map((row, r) => + row.map((cell, c) => renderShipCell(cell, r, c)) + )} + + + {winner && ( + + {winner === players[myIndex].user_id + ? "πŸŽ‰ You Win! πŸŽ‰" + : "πŸ’₯ You Lost πŸ’₯"} + + )} +
+ ); +} diff --git a/src/games/battleship/phases/BattleStatus.tsx b/src/games/battleship/phases/BattleStatus.tsx new file mode 100644 index 0000000..4b74e14 --- /dev/null +++ b/src/games/battleship/phases/BattleStatus.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import { motion, AnimatePresence } from "framer-motion"; + +interface BattleStatusProps { + phase: string; + myIndex: number; + turn: number; + players: any[]; + lastHit: boolean | null; + winner: string | null; + gameOver: boolean | null; +} + +export default function BattleStatus({ + phase, + myIndex, + turn, + players, + lastHit, + winner, + gameOver, +}: BattleStatusProps) { + const isMyTurn = turn === myIndex; + + // ----------------------------- + // STATUS TEXT + // ----------------------------- + let statusText = ""; + + if (gameOver) { + statusText = + winner === players[myIndex]?.user_id ? "πŸŽ‰ Victory!" : "πŸ’₯ Defeat"; + } else if (phase === "placement") { + statusText = "Place Your Fleet"; + } else { + statusText = isMyTurn ? "Your Turn β€” FIRE!" : "Opponent’s Turn"; + } + + // ----------------------------- + // Last hit/miss indicator + // ----------------------------- + let hitText = null; + if (lastHit === true) hitText = "πŸ”₯ HIT!"; + else if (lastHit === false) hitText = "πŸ’¦ MISS"; + + return ( + + {/* MAIN STATUS */} + + {statusText} + + + {/* HIT / MISS FEEDBACK */} + + {hitText && !gameOver && ( + + {hitText} + + )} + + + {/* PHASE */} +
+ Phase: {phase} +
+
+ ); +} diff --git a/src/games/battleship/phases/PlacementPhase.tsx b/src/games/battleship/phases/PlacementPhase.tsx new file mode 100644 index 0000000..847f2cd --- /dev/null +++ b/src/games/battleship/phases/PlacementPhase.tsx @@ -0,0 +1,145 @@ +import React, { useState } from "react"; +import { motion } from "framer-motion"; + +const fleet = [ + { name: "carrier", size: 5 }, + { name: "battleship", size: 4 }, + { name: "cruiser", size: 3 }, + { name: "submarine", size: 3 }, + { name: "destroyer", size: 2 }, +]; + +interface PlacementProps { + myIndex: number; + boards: Record; + metadata: Record; + onPlace: (ship: string, row: number, col: number, dir: string) => void; + players: any[]; +} + +export default function PlacementPhase({ + myIndex, + boards, + metadata, + onPlace, +}: PlacementProps) { + const shipBoard = boards[`p${myIndex}_ships`]?.grid; + const placedCount = metadata[`p${myIndex}_placed`] ?? 0; + + const [selectedShip, setSelectedShip] = useState(null); + const [orientation, setOrientation] = useState<"h" | "v">("h"); + const [hoverPos, setHoverPos] = useState<[number, number] | null>(null); + + if (!shipBoard) return
Loading...
; + + const remainingShips = fleet.slice(placedCount); + + const current = remainingShips[0]; + const shipName = current?.name ?? null; + const shipSize = current?.size ?? 0; + + const canPlace = (r: number, c: number) => { + if (!shipName) return false; + + // bounds + if (orientation === "h") { + if (c + shipSize > shipBoard[0].length) return false; + for (let i = 0; i < shipSize; i++) { + if (shipBoard[r][c + i] !== "") return false; + } + } else { + if (r + shipSize > shipBoard.length) return false; + for (let i = 0; i < shipSize; i++) { + if (shipBoard[r + i][c] !== "") return false; + } + } + return true; + }; + + const renderCell = (cell: string, r: number, c: number) => { + const hovered = hoverPos?.[0] === r && hoverPos?.[1] === c; + const placing = hovered && shipName; + + let previewColor = "transparent"; + + if (placing) { + const valid = canPlace(r, c); + previewColor = valid ? "rgba(46, 204, 113, 0.4)" : "rgba(231, 76, 60, 0.4)"; + } + + return ( + setHoverPos([r, c])} + onMouseLeave={() => setHoverPos(null)} + onClick={() => { + if (shipName && canPlace(r, c)) { + onPlace(shipName, r, c, orientation); + } + }} + whileHover={{ scale: 1.03 }} + style={{ + width: 40, + height: 40, + border: "1px solid #333", + background: cell === "S" ? "#2980b9" : previewColor, + cursor: shipName ? "pointer" : "default", + }} + /> + ); + }; + + return ( + +

Place Your Fleet

+ + {shipName ? ( +
+ Placing: {shipName} ({shipSize}) +
+ ) : ( +
+ βœ… All ships placed β€” waiting for opponent... +
+ )} + + {shipName && ( +
+ +
+ )} + + {/* BOARD GRID */} + + {shipBoard.map((row, r) => + row.map((cell, c) => renderCell(cell, r, c)) + )} + +
+ ); +} diff --git a/src/games/battleship/placement/PlacementGrid.tsx b/src/games/battleship/placement/PlacementGrid.tsx new file mode 100644 index 0000000..52b06a8 --- /dev/null +++ b/src/games/battleship/placement/PlacementGrid.tsx @@ -0,0 +1,127 @@ +import React, { useState } from "react"; +import { motion } from "framer-motion"; + +interface ShipGridProps { + shipBoard: string[][]; // current placed ships + shipName: string | null; // current ship being placed + shipSize: number | null; + onPlace: (ship: string, row: number, col: number, dir: "h" | "v") => void; +} + +export default function PlacementGrid({ + shipBoard, + shipName, + shipSize, + onPlace, +}: ShipGridProps) { + const [dir, setDir] = useState<"h" | "v">("h"); + const [hoverR, setHoverR] = useState(null); + const [hoverC, setHoverC] = useState(null); + + const rows = shipBoard.length; + const cols = shipBoard[0].length; + + function isPreviewCell(r: number, c: number): boolean { + if (hoverR === null || hoverC === null || !shipSize) return false; + + if (dir === "h") { + return r === hoverR && c >= hoverC && c < hoverC + shipSize; + } else { + return c === hoverC && r >= hoverR && r < hoverR + shipSize; + } + } + + function isPreviewValid(): boolean { + if (hoverR === null || hoverC === null || !shipSize) return false; + + if (dir === "h") { + if (hoverC + shipSize > cols) return false; + for (let i = 0; i < shipSize; i++) { + if (shipBoard[hoverR][hoverC + i] !== "") return false; + } + } else { + if (hoverR + shipSize > rows) return false; + for (let i = 0; i < shipSize; i++) { + if (shipBoard[hoverR + i][hoverC] !== "") return false; + } + } + + return true; + } + + function handleClick() { + if (shipName && shipSize && hoverR !== null && hoverC !== null) { + if (isPreviewValid()) { + onPlace(shipName, hoverR, hoverC, dir); + } + } + } + + return ( +
+ {/* Ship rotation button */} + + + {/* GRID */} + + {shipBoard.map((row, r) => + row.map((cell, c) => { + const preview = isPreviewCell(r, c); + const valid = isPreviewValid(); + + let bg = "#0a0a0a"; + + if (cell === "S") { + bg = "#3498db"; // placed ship + } else if (preview) { + bg = valid ? "rgba(46, 204, 113, 0.6)" : "rgba(231, 76, 60, 0.6)"; + } + + return ( + { + setHoverR(r); + setHoverC(c); + }} + onMouseLeave={() => { + setHoverR(null); + setHoverC(null); + }} + style={{ + width: 36, + height: 36, + border: "1px solid #333", + background: bg, + borderRadius: 4, + }} + /> + ); + }) + )} + +
+ ); +}