Files
tic-tac-toe-ui/src/tictactoe/Board.tsx
Vishesh 'ironeagle' Bangotra 7671e9b2cc feat: add draw-state support using game_over flag and update UI handling
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.
2025-12-01 18:16:46 +05:30

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>
)}
</>
)
}