feat: confetti, winner popup, bracket view

This commit is contained in:
2026-03-30 16:53:13 -04:00
Unverified
parent 77724ba260
commit e4fa58f327
13 changed files with 640 additions and 217 deletions

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Confetti from "react-confetti";
import Board from "@/components/Board";
import {
BoardState,
@@ -16,6 +17,8 @@ type GamePhase = "idle" | "connected" | "ready" | "playing" | "game-over";
type GameResult = "win" | "loss" | "draw" | "terminated";
const WIN_CONFETTI_DURATION_MS = 10000;
export default function PlayPage() {
const router = useRouter();
const {
@@ -24,7 +27,6 @@ export default function PlayPage() {
status,
send,
subscribe,
disconnect,
reconnectAttempts,
shouldRedirectToConnect,
clearRedirectFlag,
@@ -39,26 +41,16 @@ export default function PlayPage() {
row: number;
} | null>(null);
const [gameResult, setGameResult] = useState<GameResult | null>(null);
const [moveCount, setMoveCount] = useState(0);
const [statusMessages, setStatusMessages] = useState<string[]>([]);
const [tournamentMode, setTournamentMode] = useState(false);
const [showWinConfetti, setShowWinConfetti] = useState(false);
const [viewport, setViewport] = useState({ width: 0, height: 0 });
const myColorRef = useRef<1 | 2 | null>(null);
const isMyTurnRef = useRef(false);
const addStatus = useCallback(
(msg: string) =>
setStatusMessages((prev) => [
`[${new Date().toLocaleTimeString()}] ${msg}`,
...prev.slice(0, 29),
]),
[],
);
const resetGame = useCallback(() => {
setBoard(createEmptyBoard());
setLastMove(null);
setMoveCount(0);
setMyColor(null);
myColorRef.current = null;
setIsMyTurn(false);
@@ -66,6 +58,33 @@ export default function PlayPage() {
setGameResult(null);
}, []);
useEffect(() => {
const updateViewport = () => {
setViewport({
width: window.innerWidth,
height: window.innerHeight,
});
};
updateViewport();
window.addEventListener("resize", updateViewport);
return () => window.removeEventListener("resize", updateViewport);
}, []);
useEffect(() => {
if (gameResult !== "win") {
return;
}
setShowWinConfetti(true);
const timeoutId = setTimeout(
() => setShowWinConfetti(false),
WIN_CONFETTI_DURATION_MS,
);
return () => clearTimeout(timeoutId);
}, [gameResult]);
useEffect(() => {
if (status === "disconnected" && shouldRedirectToConnect) {
clearRedirectFlag();
@@ -99,16 +118,13 @@ export default function PlayPage() {
case "CONNECT_ACK":
case "RECONNECT_ACK":
setGamePhase((prev) => (prev === "idle" ? "connected" : prev));
addStatus("Connected to server");
break;
case "ERROR":
addStatus(`${msg.message}`);
break;
case "READY_ACK":
setGamePhase("ready");
addStatus("⏳ Waiting for an opponent...");
break;
case "GAME_START": {
@@ -120,11 +136,6 @@ export default function PlayPage() {
const firstTurn = msg.goesFirst;
setIsMyTurn(firstTurn);
isMyTurnRef.current = firstTurn;
addStatus(
msg.goesFirst
? "🔴 You are Red - you go first"
: "🟡 You are Yellow - wait for opponent's move",
);
break;
}
@@ -139,10 +150,8 @@ export default function PlayPage() {
setLastMove({ column: msg.column, row });
return next;
});
setMoveCount((n) => n + 1);
setIsMyTurn(true);
isMyTurnRef.current = true;
addStatus(`Opponent played column ${msg.column}`);
break;
}
@@ -151,7 +160,6 @@ export default function PlayPage() {
setGamePhase("game-over");
setIsMyTurn(false);
isMyTurnRef.current = false;
addStatus("🏆 You won!");
break;
case "GAME_LOSS":
@@ -159,7 +167,6 @@ export default function PlayPage() {
setGamePhase("game-over");
setIsMyTurn(false);
isMyTurnRef.current = false;
addStatus("💔 You lost");
break;
case "GAME_DRAW":
@@ -167,7 +174,6 @@ export default function PlayPage() {
setGamePhase("game-over");
setIsMyTurn(false);
isMyTurnRef.current = false;
addStatus("🤝 Draw");
break;
case "GAME_TERMINATED":
@@ -175,12 +181,10 @@ export default function PlayPage() {
setGamePhase("game-over");
setIsMyTurn(false);
isMyTurnRef.current = false;
addStatus("⛔ Match terminated");
break;
case "TOURNAMENT_START":
setTournamentMode(true);
addStatus(`🏆 Tournament started: ${msg.tournamentType}`);
break;
case "TOURNAMENT_END":
@@ -188,14 +192,12 @@ export default function PlayPage() {
resetGame();
send(cmd.ready());
setGamePhase("ready");
addStatus("⏳ Ready for next round...");
break;
case "TOURNAMENT_CANCEL":
setTournamentMode(false);
setGamePhase("connected");
resetGame();
addStatus("Tournament cancelled");
break;
default:
@@ -204,7 +206,7 @@ export default function PlayPage() {
});
return unsubscribe;
}, [addStatus, resetGame, send, subscribe]);
}, [resetGame, send, subscribe]);
const handleColumnClick = useCallback(
(col: number) => {
@@ -221,18 +223,15 @@ export default function PlayPage() {
setIsMyTurn(false);
isMyTurnRef.current = false;
setMoveCount((n) => n + 1);
send(cmd.play(col));
addStatus(`You played column ${col}`);
},
[addStatus, gamePhase, send],
[gamePhase, send],
);
const sendReady = useCallback(() => {
send(cmd.ready());
setGamePhase("ready");
addStatus("⏳ Waiting for an opponent...");
}, [addStatus, send]);
}, [send]);
const myColorLabel =
myColor === 1 ? "🔴 Red" : myColor === 2 ? "🟡 Yellow" : null;
@@ -243,6 +242,17 @@ export default function PlayPage() {
return (
<div className="flex flex-col gap-6">
{showWinConfetti && (
<Confetti
width={viewport.width}
height={viewport.height}
recycle={false}
numberOfPieces={300}
gravity={0.28}
className="pointer-events-none fixed! inset-0! z-40!"
/>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">🎮 Play Connect4</h1>
@@ -262,26 +272,6 @@ export default function PlayPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="flex flex-col gap-4">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 flex flex-col gap-3">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">
Session
</h2>
<div className="text-xs text-gray-400">
Status: <span className="text-gray-200">{status}</span>
</div>
<div className="text-xs text-gray-400">
User: <span className="text-gray-200">{username}</span>
</div>
<button
onClick={disconnect}
className="w-full py-2 bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors"
>
Disconnect
</button>
</div>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 flex flex-col gap-3">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">
Match
@@ -313,9 +303,6 @@ export default function PlayPage() {
myColor && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-800">
<div
className={`w-4 h-4 rounded-full ${myColor === 1 ? "bg-red-500" : "bg-yellow-400"}`}
/>
<span className="text-white font-medium text-sm">
You are {myColorLabel}
</span>
@@ -334,10 +321,6 @@ export default function PlayPage() {
: "⏳ Waiting for opponent..."}
</div>
)}
<div className="text-xs text-gray-500 text-center">
{moveCount} move{moveCount !== 1 ? "s" : ""} played
</div>
</div>
)}
@@ -350,23 +333,6 @@ export default function PlayPage() {
</button>
)}
</div>
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-2">
Status Log
</h2>
<div className="flex flex-col gap-0.5 max-h-52 overflow-y-auto">
{statusMessages.length === 0 ? (
<p className="text-gray-600 text-xs">No events yet</p>
) : (
statusMessages.map((m, i) => (
<p key={i} className="text-xs text-gray-400 font-mono">
{m}
</p>
))
)}
</div>
</div>
</div>
<div className="lg:col-span-2 bg-gray-900 border border-gray-700 rounded-xl p-6 flex flex-col items-center justify-center gap-4 min-h-96">
@@ -418,7 +384,6 @@ export default function PlayPage() {
: "⛔ Match Terminated"}
</div>
)}
<Board
board={board}
lastMove={lastMove}
@@ -455,7 +420,7 @@ function PhaseIndicator({
}) {
if (phase === "playing" && isMyTurn) {
return (
<span className="px-3 py-1.5 rounded-full text-sm font-medium bg-green-900/60 text-green-300 animate-pulse">
<span className="px-3 py-1.5 rounded-full text-sm font-medium bg-green-900/60 text-green-300">
Your Turn!
</span>
);