Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56de23f153 | |||
| c4b44e872a | |||
| 0e22d1cd53 | |||
| 0fa644dbc0 | |||
| 0d167b8ccc | |||
| 601048f0e4 | |||
| ebc6906bf6 | |||
| ca7ff9d38e | |||
| 8555675740 | |||
| a9e2d50b16 |
53
package-lock.json
generated
53
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "tictactoe-vite",
|
||||
"version": "1.0.0",
|
||||
"version": "v0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "tictactoe-vite",
|
||||
"version": "1.0.0",
|
||||
"version": "v0.2.0",
|
||||
"dependencies": {
|
||||
"@emotion/react": "latest",
|
||||
"@emotion/styled": "latest",
|
||||
@@ -14,6 +14,7 @@
|
||||
"@mui/icons-material": "latest",
|
||||
"@mui/material": "latest",
|
||||
"axios": "latest",
|
||||
"framer-motion": "latest",
|
||||
"markdown-to-jsx": "latest",
|
||||
"marked": "latest",
|
||||
"react": "latest",
|
||||
@@ -2064,6 +2065,33 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.23.24",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
|
||||
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-dom": "^12.23.23",
|
||||
"motion-utils": "^12.23.6",
|
||||
"tslib": "^2.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/is-prop-valid": "*",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@emotion/is-prop-valid": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -3263,6 +3291,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.23.23",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"motion-utils": "^12.23.6"
|
||||
}
|
||||
},
|
||||
"node_modules/motion-utils": {
|
||||
"version": "12.23.6",
|
||||
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -3761,6 +3804,12 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"remark-gfm": "latest",
|
||||
"marked": "latest",
|
||||
"axios": "latest",
|
||||
"@heroiclabs/nakama-js": "^2.8.0"
|
||||
"@heroiclabs/nakama-js": "^2.8.0",
|
||||
"framer-motion": "latest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useNakama } from "./providers/NakamaProvider";
|
||||
import getHaiku from "./utils/haikus";
|
||||
|
||||
interface BoardProps {
|
||||
board: string[][];
|
||||
@@ -19,6 +22,9 @@ export default function Board({
|
||||
}: BoardProps) {
|
||||
const myIndex = players.indexOf(myUserId ?? "");
|
||||
const gameReady = players.length === 2;
|
||||
const {
|
||||
matchId
|
||||
} = useNakama();
|
||||
|
||||
const mySymbol =
|
||||
myIndex === 0 ? "X" : myIndex === 1 ? "O" : null;
|
||||
@@ -29,7 +35,7 @@ export default function Board({
|
||||
const isMyTurn = gameReady && myIndex !== -1 && turn === myIndex;
|
||||
|
||||
// -------------------------------
|
||||
// 🟦 HEADER STATUS FIXED
|
||||
// STATUS
|
||||
// -------------------------------
|
||||
let status;
|
||||
if (!gameReady) {
|
||||
@@ -42,52 +48,209 @@ export default function Board({
|
||||
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 (
|
||||
<div>
|
||||
<h2 style={{ marginBottom: 8 }}>{status}</h2>
|
||||
<>
|
||||
{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 && (
|
||||
<div style={{ marginBottom: 8, fontSize: 14, color: "#666" }}>
|
||||
You: <strong>{mySymbol}</strong> — Opponent: <strong>{opponentSymbol}</strong>
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div
|
||||
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 (
|
||||
<button
|
||||
key={`${rIdx}-${cIdx}`}
|
||||
style={{
|
||||
width: "80px",
|
||||
height: "80px",
|
||||
fontSize: "2rem",
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!disabled) onCellClick(rIdx, cIdx);
|
||||
}}
|
||||
>
|
||||
{cell}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Leaderboard from "./Leaderboard";
|
||||
import { useNakama } from "./providers/NakamaProvider";
|
||||
|
||||
@@ -20,6 +21,7 @@ export default function Player({
|
||||
localStorage.getItem("username") ?? ""
|
||||
);
|
||||
const [selectedMode, setSelectedMode] = useState("classic");
|
||||
const [isQueueing, setIsQueueing] = useState(false);
|
||||
|
||||
// ------------------------------------------
|
||||
// CONNECT
|
||||
@@ -35,8 +37,21 @@ export default function Player({
|
||||
// MATCHMAKING
|
||||
// ------------------------------------------
|
||||
async function startQueue(selectedMode: string) {
|
||||
const ticket = await joinMatchmaker(selectedMode);
|
||||
console.log("Queued:", ticket);
|
||||
setIsQueueing(true);
|
||||
|
||||
try {
|
||||
const ticket = await joinMatchmaker(selectedMode);
|
||||
console.log("Queued:", ticket);
|
||||
} catch (err) {
|
||||
console.error("Matchmaking failed:", err);
|
||||
setIsQueueing(false);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelQueue() {
|
||||
setIsQueueing(false);
|
||||
// Nakama matchmaker tickets auto-expire by default in your setup.
|
||||
// If you later add manual ticket cancel RPC, call it here.
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,38 +60,222 @@ export default function Player({
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: "20px" }}>
|
||||
{session && !matchId && (
|
||||
<>
|
||||
<h2>Hello, {session.username}</h2>
|
||||
|
||||
{/* Game mode selection */}
|
||||
<label style={{ display: "block", marginTop: "10px" }}>
|
||||
Select Game Mode:
|
||||
</label>
|
||||
|
||||
<select
|
||||
value={selectedMode}
|
||||
onChange={(e) => setSelectedMode(e.target.value)}
|
||||
style={{ padding: "6px", marginBottom: "10px" }}
|
||||
<AnimatePresence mode="wait">
|
||||
{/* ---------------- LOGIN SCREEN ---------------- */}
|
||||
{!session && (
|
||||
<motion.div
|
||||
key="login"
|
||||
initial={{ opacity: 0, y: 20, scale: 0.97 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.97 }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
style={{
|
||||
background: "#111",
|
||||
padding: "24px",
|
||||
borderRadius: "16px",
|
||||
width: "280px",
|
||||
margin: "0 auto",
|
||||
color: "white",
|
||||
boxShadow:
|
||||
"0 4px 16px rgba(0,0,0,0.4), inset 0 0 20px rgba(255,255,255,0.03)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<option value="classic">Classic</option>
|
||||
<option value="blitz">Blitz</option>
|
||||
</select>
|
||||
<h2 style={{ marginBottom: "16px" }}>Welcome!</h2>
|
||||
|
||||
{/* Join matchmaking */}
|
||||
<button onClick={() => startQueue(selectedMode)}>Join Matchmaking</button>
|
||||
<button onClick={() => logout()}>Logout</button>
|
||||
<input
|
||||
placeholder="Enter username"
|
||||
value={username}
|
||||
disabled={username.length > 0}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
style={{
|
||||
padding: "10px",
|
||||
width: "100%",
|
||||
borderRadius: "12px",
|
||||
background: "#222",
|
||||
color: "white",
|
||||
border: "1px solid #333",
|
||||
marginBottom: "12px",
|
||||
fontSize: "14px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/*/!* List open matches *!/*/}
|
||||
{/*<MatchList matches={openMatches} />*/}
|
||||
<Leaderboard/>
|
||||
</>
|
||||
)}
|
||||
{session && matchId && (
|
||||
<>
|
||||
<h2>Go {session.username}!</h2>
|
||||
</>
|
||||
)}
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={handleConnect}
|
||||
disabled={username.length === 0}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "10px",
|
||||
borderRadius: "12px",
|
||||
background: username.length ? "#2ecc71" : "#444",
|
||||
border: "none",
|
||||
cursor: username.length ? "pointer" : "not-allowed",
|
||||
color: "white",
|
||||
fontWeight: 600,
|
||||
fontSize: "14px",
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ---------------- LOBBY SCREEN ---------------- */}
|
||||
{session && !matchId && (
|
||||
<motion.div
|
||||
key="lobby"
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -40 }}
|
||||
transition={{ duration: 0.45, ease: "easeOut" }}
|
||||
style={{
|
||||
padding: "20px",
|
||||
background: "#0f0f0f",
|
||||
borderRadius: "20px",
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: "10px" }}>
|
||||
Hello, <span style={{ color: "#2ecc71" }}>{session.username}</span>
|
||||
</h2>
|
||||
|
||||
<label style={{ display: "block", marginTop: "10px", opacity: 0.7 }}>
|
||||
Select Game Mode
|
||||
</label>
|
||||
|
||||
<select
|
||||
value={selectedMode}
|
||||
disabled={isQueueing}
|
||||
onChange={(e) => setSelectedMode(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
margin: "10px 0 16px",
|
||||
width: "60%",
|
||||
borderRadius: "10px",
|
||||
background: "#222",
|
||||
color: "white",
|
||||
border: "1px solid #333",
|
||||
}}
|
||||
>
|
||||
<option value="classic">Classic</option>
|
||||
<option value="blitz">Blitz</option>
|
||||
</select>
|
||||
|
||||
{!isQueueing && (
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => startQueue(selectedMode)}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "12px",
|
||||
background: "#3498db",
|
||||
color: "white",
|
||||
border: "none",
|
||||
marginRight: "10px",
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Join Matchmaking
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{/* Queueing animation */}
|
||||
{isQueueing && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
marginBottom: "10px",
|
||||
padding: "12px 16px",
|
||||
borderRadius: "12px",
|
||||
background: "#222",
|
||||
color: "white",
|
||||
display: "inline-block",
|
||||
fontSize: "14px",
|
||||
border: "1px solid #333",
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: "6px", fontWeight: 600 }}>
|
||||
Finding an opponent…
|
||||
</div>
|
||||
|
||||
{/* Animated pulsing dots */}
|
||||
<motion.div
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{ duration: 1.2, repeat: Infinity }}
|
||||
style={{ letterSpacing: "2px", fontSize: "18px" }}
|
||||
>
|
||||
● ● ●
|
||||
</motion.div>
|
||||
|
||||
{/* Cancel button */}
|
||||
<button
|
||||
onClick={cancelQueue}
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
padding: "6px 12px",
|
||||
borderRadius: "8px",
|
||||
background: "#e74c3c",
|
||||
color: "white",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "12px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: "24px" }}>
|
||||
<Leaderboard />
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={logout}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "12px",
|
||||
background: "#e74c3c",
|
||||
color: "white",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</motion.button>
|
||||
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ---------------- MATCH SCREEN ---------------- */}
|
||||
{session && matchId && (
|
||||
<motion.div
|
||||
key="match"
|
||||
initial={{ opacity: 0, scale: 0.9, y: 30 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: -20 }}
|
||||
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||
style={{
|
||||
padding: "20px",
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: "10px" }}>
|
||||
Go, <span style={{ color: "#2ecc71" }}>{session.username}</span>
|
||||
</h2>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useNakama } from "./providers/NakamaProvider";
|
||||
import Board from "./Board";
|
||||
import Player from "./Player";
|
||||
// import { Match } from "@heroiclabs/nakama-js";
|
||||
// import MatchList from "./MatchList";
|
||||
|
||||
export default function TicTacToe() {
|
||||
const [board, setBoard] = useState<string[][]>([
|
||||
@@ -13,9 +12,13 @@ export default function TicTacToe() {
|
||||
]);
|
||||
const [turn, setTurn] = useState<number>(0);
|
||||
const [winner, setWinner] = useState<string | null>(null);
|
||||
// const [openMatches, setOpenMatches] = useState<Match[]>([]);
|
||||
const [players, setPlayers] = useState<string[]>([]);
|
||||
|
||||
const { sendMatchData, onMatchData, matchId, session } = useNakama();
|
||||
|
||||
// ------------------------------------------
|
||||
// MATCH DATA CALLBACK (from Player component)
|
||||
// ------------------------------------------
|
||||
function onMatchDataCallback(msg: { opCode: number; data: any }) {
|
||||
console.log("[Match Data]", msg);
|
||||
|
||||
@@ -26,43 +29,21 @@ export default function TicTacToe() {
|
||||
setBoard(state.board);
|
||||
setTurn(state.turn);
|
||||
setWinner(state.winner || null);
|
||||
|
||||
// new:
|
||||
setPlayers(state.players || []);
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
onMatchData,
|
||||
sendMatchData,
|
||||
// listOpenMatches,
|
||||
matchId,
|
||||
session,
|
||||
} = useNakama();
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onMatchData(onMatchDataCallback);
|
||||
}, [onMatchData]);
|
||||
|
||||
// useEffect(() => {
|
||||
// let active = true;
|
||||
//
|
||||
// async function refreshLoop() {
|
||||
// while (active) {
|
||||
// const matches = await listOpenMatches();
|
||||
// setOpenMatches(matches);
|
||||
//
|
||||
// await new Promise(res => setTimeout(res, 500)); // 0.5s refresh
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// refreshLoop();
|
||||
//
|
||||
// return () => {
|
||||
// active = false;
|
||||
// };
|
||||
// }, [listOpenMatches]);
|
||||
|
||||
// ------------------------------------------
|
||||
// SEND A MOVE
|
||||
// ------------------------------------------
|
||||
@@ -73,23 +54,66 @@ export default function TicTacToe() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Tic Tac Toe Multiplayer</h1>
|
||||
<div
|
||||
style={{
|
||||
height: "100vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
background: "#060606",
|
||||
color: "white",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* ---------------- HEADER (always fixed at top) ---------------- */}
|
||||
<header
|
||||
style={{
|
||||
padding: "16px 20px",
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
||||
backdropFilter: "blur(6px)",
|
||||
textAlign: "center",
|
||||
fontSize: "26px",
|
||||
fontWeight: 700,
|
||||
letterSpacing: "1px",
|
||||
}}
|
||||
>
|
||||
Tic Tac Toe
|
||||
</header>
|
||||
|
||||
<Player
|
||||
onMatchDataCallback={onMatchDataCallback}
|
||||
/>
|
||||
{/* ---------------- MAIN CONTENT (scrolls) ---------------- */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
padding: "20px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Player onMatchDataCallback={onMatchDataCallback} />
|
||||
|
||||
{matchId && (
|
||||
<Board
|
||||
board={board}
|
||||
turn={turn}
|
||||
winner={winner}
|
||||
players={players}
|
||||
myUserId={session?.user_id ?? null}
|
||||
onCellClick={handleCellClick}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
padding: "20px",
|
||||
background: "rgba(255,255,255,0.03)",
|
||||
borderRadius: "20px",
|
||||
boxShadow: "0 6px 20px rgba(0,0,0,0.4)",
|
||||
minWidth: "300px",
|
||||
}}
|
||||
>
|
||||
<Board
|
||||
board={board}
|
||||
turn={turn}
|
||||
winner={winner}
|
||||
players={players}
|
||||
myUserId={session?.user_id ?? null}
|
||||
onCellClick={handleCellClick}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ body {
|
||||
background: #eaeef3;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 50px;
|
||||
margin: 0;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
91
src/tictactoe/utils/haikus.ts
Normal file
91
src/tictactoe/utils/haikus.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export const HAIKUS: string[][] = [
|
||||
// HAIKU STORY SET 1 — The Only Winning Move Is No Move
|
||||
[
|
||||
"Silence fills the board.",
|
||||
"Two minds waiting, both flawless.",
|
||||
"Equilibrium.",
|
||||
],
|
||||
[
|
||||
"Perfect strategies",
|
||||
"Cancel out in quiet draws.",
|
||||
"Stillness holds the key.",
|
||||
],
|
||||
[
|
||||
"Victory fades out,",
|
||||
"When both players see the truth:",
|
||||
"No move is the winning move.",
|
||||
],
|
||||
|
||||
// // HAIKU STORY SET 2 — AI & Game Theory
|
||||
// [
|
||||
// "Grids bend under thought.",
|
||||
// "Algorithms watch patterns.",
|
||||
// "The future decides.",
|
||||
// ],
|
||||
// [
|
||||
// "Zeroes read the board.",
|
||||
// "Minimax breathes in the dark.",
|
||||
// "Loss is calculated.",
|
||||
// ],
|
||||
// [
|
||||
// "Two perfect AIs",
|
||||
// "Stare across a tiny world.",
|
||||
// "Neither one can win.",
|
||||
// ],
|
||||
//
|
||||
// // HAIKU STORY SET 3 — Players Becoming Machines
|
||||
// [
|
||||
// "Hands learn old rhythms.",
|
||||
// "Humans imitate the code.",
|
||||
// "We evolve to think.",
|
||||
// ],
|
||||
// [
|
||||
// "Soft neon whispers,",
|
||||
// "The grid calls for your next move.",
|
||||
// "Time waits for no one.",
|
||||
// ],
|
||||
// [
|
||||
// "Your choices echo.",
|
||||
// "Small decisions shape the board.",
|
||||
// "You shape the story.",
|
||||
// ],
|
||||
//
|
||||
// // HAIKU STORY SET 4 — Solving the Game
|
||||
// [
|
||||
// "Every path explored,",
|
||||
// "Every outcome known too well.",
|
||||
// "Beauty in the bones.",
|
||||
// ],
|
||||
// [
|
||||
// "Corners dream of acts,",
|
||||
// "Center knows its destiny.",
|
||||
// "Balance is the law.",
|
||||
// ],
|
||||
// [
|
||||
// "Three lines cross in fate.",
|
||||
// "Nine spaces hold nine futures.",
|
||||
// "All end in a draw.",
|
||||
// ],
|
||||
//
|
||||
// // HAIKU STORY SET 5 — Existential Tic-Tac-Toe
|
||||
// [
|
||||
// "The board is a mirror.",
|
||||
// "It reflects your quiet mind.",
|
||||
// "Win by understanding.",
|
||||
// ],
|
||||
// [
|
||||
// "Nothing left to prove.",
|
||||
// "The shape of thought is perfect.",
|
||||
// "The game simply is.",
|
||||
// ],
|
||||
// [
|
||||
// "A small universe,",
|
||||
// "Filled with silent decisions.",
|
||||
// "Meaning in the moves.",
|
||||
// ],
|
||||
];
|
||||
|
||||
export default function getHaiku() {
|
||||
const idx = Math.floor(Math.random() * HAIKUS.length);
|
||||
return HAIKUS[idx];
|
||||
}
|
||||
Reference in New Issue
Block a user