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:
130
src/games/battleship/BattleShipBoard.tsx
Normal file
130
src/games/battleship/BattleShipBoard.tsx
Normal file
@@ -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<string, { grid: string[][] }>;
|
||||||
|
players: PlayerModel[];
|
||||||
|
myUserId: string | null;
|
||||||
|
turn: number;
|
||||||
|
winner: string | null;
|
||||||
|
gameOver: boolean | null;
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginBottom: 8 }}>{status}</h2>
|
||||||
|
|
||||||
|
{/* ---------------- PHASE 1: PLACEMENT ---------------- */}
|
||||||
|
{phase === "placement" && (
|
||||||
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
|
<PlacementGrid
|
||||||
|
grid={myShips}
|
||||||
|
placed={placed}
|
||||||
|
onPlace={handlePlace}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------------- PHASE 2: BATTLE ---------------- */}
|
||||||
|
{phase === "battle" && (
|
||||||
|
<>
|
||||||
|
<h3>Your Shots</h3>
|
||||||
|
<ShotGrid
|
||||||
|
grid={myShots}
|
||||||
|
isMyTurn={isMyTurn}
|
||||||
|
gameOver={!!gameOver}
|
||||||
|
onShoot={handleShoot}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h3 style={{ marginTop: "18px" }}>Your Ships</h3>
|
||||||
|
<PlacementGrid
|
||||||
|
grid={myShips}
|
||||||
|
placed={999} // show ships but disable placement
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------------- WINNER UI ---------------- */}
|
||||||
|
{winner !== null && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: [1, 1.05, 1] }}
|
||||||
|
transition={{ repeat: Infinity, duration: 1.4 }}
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: "20px",
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: "#f1c40f",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎉 Player {winner} Wins! 🎉
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/games/battleship/battle/ShotGrid.tsx
Normal file
100
src/games/battleship/battle/ShotGrid.tsx
Normal file
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${cols}, 36px)`,
|
||||||
|
gap: "4px",
|
||||||
|
justifyContent: "center",
|
||||||
|
marginTop: "12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<motion.button
|
||||||
|
key={`${r}-${c}`}
|
||||||
|
whileHover={empty && isMyTurn && !gameOver ? { scale: 1.1 } : {}}
|
||||||
|
whileTap={empty && isMyTurn && !gameOver ? { scale: 0.9 } : {}}
|
||||||
|
onClick={() => 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{hit && (
|
||||||
|
<motion.span
|
||||||
|
key="hit"
|
||||||
|
initial={{ scale: 0.2, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.2, opacity: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 10 }}
|
||||||
|
>
|
||||||
|
H
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{miss && (
|
||||||
|
<motion.span
|
||||||
|
key="miss"
|
||||||
|
initial={{ scale: 0.2, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.2, opacity: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 10 }}
|
||||||
|
>
|
||||||
|
M
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
src/games/battleship/components/ShipSelector.tsx
Normal file
87
src/games/battleship/components/ShipSelector.tsx
Normal file
@@ -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 (
|
||||||
|
<div style={{ marginTop: 12, textAlign: "center" }}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.9 }}
|
||||||
|
style={{
|
||||||
|
marginBottom: 10,
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#ddd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Select ship & orientation
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* SHIP BUTTONS */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "10px",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
marginBottom: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{remainingShips.map((ship) => {
|
||||||
|
const active = ship === selectedShip;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={ship}
|
||||||
|
onClick={() => 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()}
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ORIENTATION BUTTON */}
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.92 }}
|
||||||
|
onClick={onToggleOrientation}
|
||||||
|
style={{
|
||||||
|
padding: "8px 14px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "#222",
|
||||||
|
color: "white",
|
||||||
|
border: "2px solid #444",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Orientation: <strong>{orientation === "h" ? "Horizontal" : "Vertical"}</strong>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/games/battleship/components/StatusBar.tsx
Normal file
24
src/games/battleship/components/StatusBar.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
interface StatusBarProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBar({ text }: StatusBarProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={text}
|
||||||
|
initial={{ opacity: 0, y: -6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
fontSize: "18px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
src/games/battleship/phases/BattlePhase.tsx
Normal file
169
src/games/battleship/phases/BattlePhase.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
interface BattleProps {
|
||||||
|
myIndex: number;
|
||||||
|
boards: Record<string, { grid: string[][] }>;
|
||||||
|
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 <div>Spectating...</div>;
|
||||||
|
|
||||||
|
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 <div>Loading...</div>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<motion.div
|
||||||
|
key={`shot-${r}-${c}`}
|
||||||
|
onClick={() => !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 (
|
||||||
|
<motion.div
|
||||||
|
key={`ship-${r}-${c}`}
|
||||||
|
style={{
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
border: "1px solid #222",
|
||||||
|
background: bg,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginBottom: 10 }}>
|
||||||
|
{gameOver
|
||||||
|
? winner === myUserId
|
||||||
|
? "🎉 Victory! 🎉"
|
||||||
|
: "💥 Defeat 💥"
|
||||||
|
: isMyTurn
|
||||||
|
? "Your Turn — Fire!"
|
||||||
|
: "Opponent's Turn"}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* -------------------------
|
||||||
|
TOP SECTION — YOUR SHOTS
|
||||||
|
-------------------------- */}
|
||||||
|
<div style={{ marginBottom: 12, opacity: 0.85 }}>
|
||||||
|
<strong>Your Shots</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${myShots[0].length}, 38px)`,
|
||||||
|
gap: 4,
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{myShots.map((row, r) =>
|
||||||
|
row.map((cell, c) => renderShotCell(cell, r, c))
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* -------------------------
|
||||||
|
BOTTOM SECTION — YOUR FLEET
|
||||||
|
-------------------------- */}
|
||||||
|
<div style={{ marginTop: 28, marginBottom: 12, opacity: 0.85 }}>
|
||||||
|
<strong>Your Fleet Status</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${myShips[0].length}, 38px)`,
|
||||||
|
gap: 4,
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{myShips.map((row, r) =>
|
||||||
|
row.map((cell, c) => renderShipCell(cell, r, c))
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{winner && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
scale: [1, 1.06, 1],
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
repeat: Infinity,
|
||||||
|
duration: 1.4,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
color: "#f1c40f",
|
||||||
|
fontSize: "20px",
|
||||||
|
marginTop: "18px",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{winner === players[myIndex].user_id
|
||||||
|
? "🎉 You Win! 🎉"
|
||||||
|
: "💥 You Lost 💥"}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/games/battleship/phases/BattleStatus.tsx
Normal file
100
src/games/battleship/phases/BattleStatus.tsx
Normal file
@@ -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 (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: 14,
|
||||||
|
color: "#eee",
|
||||||
|
fontFamily: "sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* MAIN STATUS */}
|
||||||
|
<motion.div
|
||||||
|
key={statusText}
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
style={{
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
marginBottom: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusText}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* HIT / MISS FEEDBACK */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{hitText && !gameOver && (
|
||||||
|
<motion.div
|
||||||
|
key={hitText}
|
||||||
|
initial={{ opacity: 0, scale: 0.7 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.7 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
style={{
|
||||||
|
color: lastHit ? "#e74c3c" : "#3498db",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 600,
|
||||||
|
marginTop: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hitText}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* PHASE */}
|
||||||
|
<div style={{ marginTop: 6, opacity: 0.55, fontSize: 14 }}>
|
||||||
|
Phase: <strong>{phase}</strong>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
src/games/battleship/placement/PlacementGrid.tsx
Normal file
127
src/games/battleship/placement/PlacementGrid.tsx
Normal file
@@ -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<number | null>(null);
|
||||||
|
const [hoverC, setHoverC] = useState<number | null>(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 (
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
{/* Ship rotation button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setDir(dir === "h" ? "v" : "h")}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
marginBottom: "10px",
|
||||||
|
background: "#333",
|
||||||
|
color: "white",
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rotate Ship ({dir.toUpperCase()})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* GRID */}
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${cols}, 36px)`,
|
||||||
|
gap: "4px",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
{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 (
|
||||||
|
<motion.div
|
||||||
|
key={`${r}-${c}`}
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
setHoverR(r);
|
||||||
|
setHoverC(c);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHoverR(null);
|
||||||
|
setHoverC(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
border: "1px solid #333",
|
||||||
|
background: bg,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user