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
This commit is contained in:
145
src/games/battleship/phases/PlacementPhase.tsx
Normal file
145
src/games/battleship/phases/PlacementPhase.tsx
Normal file
@@ -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<string, { grid: string[][] }>;
|
||||
metadata: Record<string, any>;
|
||||
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<string | null>(null);
|
||||
const [orientation, setOrientation] = useState<"h" | "v">("h");
|
||||
const [hoverPos, setHoverPos] = useState<[number, number] | null>(null);
|
||||
|
||||
if (!shipBoard) return <div>Loading...</div>;
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
key={`${r}-${c}`}
|
||||
onMouseEnter={() => 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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<h2 style={{ marginBottom: 10 }}>Place Your Fleet</h2>
|
||||
|
||||
{shipName ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<strong>Placing:</strong> {shipName} ({shipSize})
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: 12, color: "#2ecc71" }}>
|
||||
✅ All ships placed — waiting for opponent...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shipName && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
onClick={() => setOrientation(orientation === "h" ? "v" : "h")}
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
background: "#111",
|
||||
border: "1px solid #666",
|
||||
borderRadius: 6,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
Orientation: {orientation === "h" ? "Horizontal" : "Vertical"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BOARD GRID */}
|
||||
<motion.div
|
||||
layout
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${shipBoard[0].length}, 40px)`,
|
||||
gap: 4,
|
||||
justifyContent: "center",
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
{shipBoard.map((row, r) =>
|
||||
row.map((cell, c) => renderCell(cell, r, c))
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user