Updated match data callback to interpret { game_over: true, winner: -1 } as a draw.
Added winner = "draw" UI state for display and disabling board interactions.
Updated status text in Board component to show “Draw!” when applicable.
Adjusted winner highlighting logic to avoid highlighting any symbol during draw.
Ensured ongoing games always set winner = null for consistent behavior.
272 lines
8.0 KiB
TypeScript
272 lines
8.0 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { useNakama } from "./providers/NakamaProvider";
|
|
import getHaiku from "./utils/haikus";
|
|
|
|
export interface PlayerModel {
|
|
user_id: string;
|
|
username: string;
|
|
index: number;
|
|
metadata: Record<string, string>; // e.g. { symbol: "X" }
|
|
}
|
|
|
|
interface BoardProps {
|
|
board: string[][];
|
|
turn: number;
|
|
winner: string | null;
|
|
gameOver: boolean | null;
|
|
players: PlayerModel[];
|
|
myUserId: string | null;
|
|
onCellClick: (row: number, col: number) => void;
|
|
}
|
|
|
|
export default function Board({
|
|
board,
|
|
turn,
|
|
winner,
|
|
gameOver,
|
|
players,
|
|
myUserId,
|
|
onCellClick,
|
|
}: BoardProps) {
|
|
const myIndex = players.findIndex(p => p.user_id === myUserId);
|
|
const gameReady = players.length === 2;
|
|
const {
|
|
matchId
|
|
} = useNakama();
|
|
|
|
const mySymbol =
|
|
myIndex !== null && players[myIndex]
|
|
? players[myIndex].metadata?.symbol ?? null
|
|
: null;
|
|
|
|
const opponentSymbol =
|
|
myIndex !== null && players.length === 2
|
|
? players[1 - myIndex].metadata?.symbol ?? null
|
|
: null;
|
|
|
|
const isMyTurn = gameReady && myIndex !== -1 && turn === myIndex;
|
|
|
|
// -------------------------------
|
|
// STATUS
|
|
// -------------------------------
|
|
let status;
|
|
if (!gameReady) {
|
|
status = "Waiting for opponent...";
|
|
} else if (winner) {
|
|
status = `Winner: ${winner}`;
|
|
} else if (gameOver) {
|
|
status = `Draw!!!`;
|
|
} else if (myIndex === -1) {
|
|
status = "Spectating";
|
|
} else {
|
|
status = isMyTurn ? "Your turn" : "Opponent's turn";
|
|
}
|
|
|
|
const [haiku, setHaiku] = useState(getHaiku());
|
|
const [haikuIndex, setHaikuIndex] = useState(0);
|
|
const nextLineIn = 3600;
|
|
const allLinesStay = 2400;
|
|
const allLinesFade = 1200;
|
|
|
|
useEffect(() => {
|
|
const totalTime = haiku.length * nextLineIn + allLinesStay + allLinesFade;
|
|
|
|
const timer = setTimeout(() => {
|
|
const next = getHaiku();
|
|
setHaiku(next);
|
|
setHaikuIndex((i) => i + 1);
|
|
}, totalTime);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [haikuIndex]);
|
|
|
|
return (
|
|
<>
|
|
{matchId && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.35 }}
|
|
>
|
|
<motion.h2
|
|
key={status}
|
|
initial={{ opacity: 0, y: -6 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25 }}
|
|
style={{ marginBottom: 8 }}
|
|
>
|
|
{status}
|
|
</motion.h2>
|
|
|
|
{gameReady && mySymbol && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 0.75 }}
|
|
style={{ marginBottom: 8, fontSize: 14 }}
|
|
>
|
|
You: <strong>{mySymbol}</strong> — Opponent:{" "}
|
|
<strong>{opponentSymbol}</strong>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* -------------------------
|
|
BOARD
|
|
-------------------------- */}
|
|
<motion.div
|
|
layout
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: "repeat(3, 80px)",
|
|
gap: "10px",
|
|
marginTop: "6px",
|
|
}}
|
|
>
|
|
{board.map((row, rIdx) =>
|
|
row.map((cell, cIdx) => {
|
|
const disabled =
|
|
!!cell ||
|
|
!!winner ||
|
|
!gameReady ||
|
|
myIndex === -1 ||
|
|
!isMyTurn;
|
|
|
|
return (
|
|
<motion.button
|
|
key={`${rIdx}-${cIdx}-${cell}`} // rerender when cell changes
|
|
layout
|
|
whileHover={
|
|
!disabled
|
|
? {
|
|
scale: 1.1,
|
|
boxShadow: "0px 0px 10px rgba(255,255,255,0.4)",
|
|
}
|
|
: {}
|
|
}
|
|
whileTap={!disabled ? { scale: 0.85 } : {}}
|
|
onClick={() => !disabled && onCellClick(rIdx, cIdx)}
|
|
style={{
|
|
width: "80px",
|
|
height: "80px",
|
|
fontSize: "2rem",
|
|
borderRadius: "10px",
|
|
border: "2px solid #333",
|
|
background: "#111",
|
|
color: "white",
|
|
cursor: disabled ? "not-allowed" : "pointer",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<AnimatePresence>
|
|
{cell && (
|
|
<motion.span
|
|
key="symbol"
|
|
initial={{ scale: 0.3, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.3, opacity: 0 }}
|
|
transition={{ type: "spring", stiffness: 200, damping: 12 }}
|
|
style={{
|
|
color:
|
|
winner === cell
|
|
? "#f1c40f" // highlight winning symbol
|
|
: "white",
|
|
textShadow:
|
|
winner === cell
|
|
? "0 0 12px rgba(241,196,15,0.8)"
|
|
: "none",
|
|
}}
|
|
>
|
|
{cell}
|
|
</motion.span>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.button>
|
|
);
|
|
})
|
|
)}
|
|
</motion.div>
|
|
|
|
{!winner && (
|
|
<div
|
|
style={{
|
|
minHeight: "90px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
marginTop: "14px",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={haikuIndex}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{
|
|
duration: 2.4,
|
|
ease: "easeInOut",
|
|
}}
|
|
style={{
|
|
textAlign: "center",
|
|
lineHeight: "1.35",
|
|
}}
|
|
>
|
|
{haiku.map((line, i) => (
|
|
<motion.div
|
|
key={i}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
transition={{
|
|
delay: i * (nextLineIn / 1000),
|
|
duration: nextLineIn / 1000,
|
|
ease: "easeOut",
|
|
}}
|
|
style={{
|
|
fontSize: "18px",
|
|
color: "#f1c40f",
|
|
fontWeight: 700,
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{line}
|
|
</motion.div>
|
|
))}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
)}
|
|
|
|
{/* Winner pulse animation */}
|
|
{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: "14px",
|
|
fontWeight: 700,
|
|
textAlign: "center",
|
|
}}
|
|
>
|
|
🎉 {winner} Wins! 🎉
|
|
</motion.div>
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|