- 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
146 lines
3.9 KiB
TypeScript
146 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|