This commit is contained in:
Gavin McDonald
2025-04-12 15:17:02 -04:00
parent 1734eec436
commit 6508d40b2d
19 changed files with 1415 additions and 1344 deletions

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
dist
build
coverage

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"useTabs": true,
"trailingComma": "all",
"printWidth": 100,
}

View File

@@ -1,68 +1,72 @@
"use client"; 'use client';
import { useEffect, useState } from "react"; import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { socket } from "@/socket"; import { socket } from '@/socket';
import Card from '@/components/Card'; import Card from '@/components/Card';
import type { GameUpdate, ClientUpdate, StandardGameCard, TarokkaGameCard } from '@/types'; import type { GameUpdate, ClientUpdate, StandardGameCard, TarokkaGameCard } from '@/types';
export default function GamePage() { export default function GamePage() {
const { gameID: gameIDParam } = useParams(); const { gameID: gameIDParam } = useParams();
const [gameID, setGameID] = useState(''); const [gameID, setGameID] = useState('');
const [cards, setCards] = useState<StandardGameCard[] | TarokkaGameCard[]>([]); const [cards, setCards] = useState<StandardGameCard[] | TarokkaGameCard[]>([]);
useEffect(() => { useEffect(() => {
if (gameIDParam) { if (gameIDParam) {
setGameID(Array.isArray(gameIDParam) ? gameIDParam[0] : gameIDParam); setGameID(Array.isArray(gameIDParam) ? gameIDParam[0] : gameIDParam);
} }
}, [gameIDParam]) }, [gameIDParam]);
useEffect(() => { useEffect(() => {
if (gameID) { if (gameID) {
socket.emit('join', gameID); socket.emit('join', gameID);
socket.on('init', (data: GameUpdate) => { socket.on('init', (data: GameUpdate) => {
console.log('init', data); console.log('init', data);
setCards(data.cards); setCards(data.cards);
}); });
socket.on('card-flipped', (data: GameUpdate) => { socket.on('card-flipped', (data: GameUpdate) => {
console.log('>>>', data); console.log('>>>', data);
setCards(data.cards); setCards(data.cards);
}); });
} }
return gameID ? () => { return gameID
socket.off('init'); ? () => {
socket.off('card-flipped'); socket.off('init');
} : undefined; socket.off('card-flipped');
}, [gameID]); }
: undefined;
}, [gameID]);
const flipCard = (cardIndex: number) => { const flipCard = (cardIndex: number) => {
const flip: ClientUpdate = { const flip: ClientUpdate = {
gameID, gameID,
cardIndex, cardIndex,
}; };
socket.emit('flip-card', flip); socket.emit('flip-card', flip);
}; };
return cards.length ? ( return cards.length ? (
<main className="min-h-screen flex flex-col items-center justify-center gap-4 bg-[url('/img/table3.png')] bg-cover bg-center"> <main className="min-h-screen flex flex-col items-center justify-center gap-4 bg-[url('/img/table3.png')] bg-cover bg-center">
<div className="grid grid-cols-3 grid-rows-3 gap-8 w-fit mx-auto"> <div className="grid grid-cols-3 grid-rows-3 gap-8 w-fit mx-auto">
{Array.from({ length: 9 }).map((_, i) => { {Array.from({ length: 9 }).map((_, i) => {
const cardIndex = [1, 3, 4, 5, 7].indexOf(i); const cardIndex = [1, 3, 4, 5, 7].indexOf(i);
return ( return (
<div key={i} className="aspect-[2/3]}"> <div key={i} className="aspect-[2/3]}">
{cardIndex !== -1 && <Card card={cards[cardIndex]} flipAction={() => flipCard(cardIndex)} />} {cardIndex !== -1 && (
</div> <Card card={cards[cardIndex]} flipAction={() => flipCard(cardIndex)} />
) )}
})} </div>
</div> );
</main> })}
) : null; </div>
</main>
) : null;
} }

View File

@@ -1,3 +0,0 @@
export default function B() {
return <div>b</div>;
}

View File

@@ -1,26 +1,26 @@
@import "tailwindcss"; @import 'tailwindcss';
:root { :root {
--background: #ffffff; --background: #ffffff;
--foreground: #171717; --foreground: #171717;
} }
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: #0a0a0a; --background: #0a0a0a;
--foreground: #ededed; --foreground: #ededed;
} }
} }
body { body {
background: var(--background); background: var(--background);
color: var(--foreground); color: var(--foreground);
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
} }

View File

@@ -1,34 +1,30 @@
import type { Metadata } from "next"; import type { Metadata } from 'next';
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from 'next/font/google';
import "./globals.css"; import './globals.css';
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: '--font-geist-sans',
subsets: ["latin"], subsets: ['latin'],
}); });
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
variable: "--font-geist-mono", variable: '--font-geist-mono',
subsets: ["latin"], subsets: ['latin'],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: 'Create Next App',
description: "Generated by create next app", description: 'Generated by create next app',
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="en">
<body <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
className={`${geistSans.variable} ${geistMono.variable} antialiased`} </html>
> );
{children}
</body>
</html>
);
} }

View File

@@ -3,22 +3,21 @@ import { useRouter } from 'next/navigation';
import generateID from '@/tools/simpleID'; import generateID from '@/tools/simpleID';
export default function Home() { export default function Home() {
const router = useRouter(); const router = useRouter();
const handleCreateGame = () => { const handleCreateGame = () => {
const id = generateID(); const id = generateID();
router.push(`/${id}`); router.push(`/${id}`);
}; };
return ( return (
<main className="min-h-screen flex items-center justify-center bg-[url('/img/table3.png')] bg-cover bg-center"> <main className="min-h-screen flex items-center justify-center bg-[url('/img/table3.png')] bg-cover bg-center">
<button <button
onClick={handleCreateGame} onClick={handleCreateGame}
className="bg-blue-600 text-white text-lg px-6 py-3 rounded-xl shadow hover:bg-blue-700 transition" className="bg-blue-600 text-white text-lg px-6 py-3 rounded-xl shadow hover:bg-blue-700 transition"
> >
Create New Game Create New Game
</button> </button>
</main> </main>
); );
} }

View File

@@ -1,24 +1,19 @@
'use client'; 'use client';
import { StandardGameCard, TarokkaGameCard } from "@/types"; import { StandardGameCard, TarokkaGameCard } from '@/types';
type CardProps = { type CardProps = {
card: StandardGameCard | TarokkaGameCard; card: StandardGameCard | TarokkaGameCard;
flipAction: () => void; flipAction: () => void;
}; };
export default function Card({ card: { aria, url }, flipAction }: CardProps) { export default function Card({ card: { aria, url }, flipAction }: CardProps) {
return ( return (
<div <div
className="h-[21vh] w-[15vh] flex items-center justify-center cursor-pointer transform transition-transform duration-200 hover:scale-150 relative z-0 hover:z-10" className="h-[21vh] w-[15vh] flex items-center justify-center cursor-pointer transform transition-transform duration-200 hover:scale-150 relative z-0 hover:z-10"
onClick={flipAction} onClick={flipAction}
> >
<img <img className="rounded-xl" src={url} alt={aria} />
className="rounded-xl" </div>
src={url} );
alt={aria}
/>
</div>
);
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,75 +1,80 @@
import Deck from './TarokkaDeck' import Deck from './TarokkaDeck';
import { GameState, GameUpdate, TarokkaGameCard } from '../types' import { GameState, GameUpdate, TarokkaGameCard } from '../types';
const deck = new Deck(); const deck = new Deck();
export default class GameStore { export default class GameStore {
private games: Map<string, GameState>; private games: Map<string, GameState>;
constructor() { constructor() {
this.games = new Map(); this.games = new Map();
} }
createGame(id: string): GameState { createGame(id: string): GameState {
if (this.games.has(id)) throw new Error(`Game ${id} already exists`); if (this.games.has(id)) throw new Error(`Game ${id} already exists`);
const newGame: GameState = { const newGame: GameState = {
id, id,
players: new Set(), players: new Set(),
cards: deck.select(5).map(card => ({ ...card, flipped: false })), cards: deck.select(5).map((card) => ({ ...card, flipped: false })),
lastUpdated: Date.now(), lastUpdated: Date.now(),
}; };
this.games.set(id, newGame); this.games.set(id, newGame);
return newGame; return newGame;
} }
joinGame(gameID: string, playerID: string): GameUpdate { joinGame(gameID: string, playerID: string): GameUpdate {
const game = this.games.get(gameID) || this.createGame(gameID); const game = this.games.get(gameID) || this.createGame(gameID);
game.players.add(playerID); game.players.add(playerID);
game.lastUpdated = Date.now(); game.lastUpdated = Date.now();
return this.gameUpdate(game); return this.gameUpdate(game);
} }
leaveGame(gameID: string, playerID: string): GameState { leaveGame(gameID: string, playerID: string): GameState {
const game = this.getGame(gameID); const game = this.getGame(gameID);
game.players.delete(playerID); game.players.delete(playerID);
game.lastUpdated = Date.now(); game.lastUpdated = Date.now();
return game; return game;
} }
flipCard(gameID: string, cardIndex: number): GameUpdate { flipCard(gameID: string, cardIndex: number): GameUpdate {
const game = this.getGame(gameID); const game = this.getGame(gameID);
const card = game.cards[cardIndex]; const card = game.cards[cardIndex];
if (!card) throw new Error(`Card ${cardIndex} not found`); if (!card) throw new Error(`Card ${cardIndex} not found`);
card.flipped = !card.flipped; card.flipped = !card.flipped;
game.lastUpdated = Date.now(); game.lastUpdated = Date.now();
return this.gameUpdate(game); return this.gameUpdate(game);
} }
getGame(gameID: string): GameState { getGame(gameID: string): GameState {
const game = this.games.get(gameID); const game = this.games.get(gameID);
if (!game) throw new Error(`Game ${gameID} not found`); if (!game) throw new Error(`Game ${gameID} not found`);
return game; return game;
} }
gameUpdate(game: GameState): GameUpdate { gameUpdate(game: GameState): GameUpdate {
const { id, cards } = game; const { id, cards } = game;
return { id, cards: (cards as TarokkaGameCard[]).map((card: TarokkaGameCard) => card.flipped ? card : { ...deck.getBack(), flipped: false }) }; return {
} id,
cards: (cards as TarokkaGameCard[]).map((card: TarokkaGameCard) =>
card.flipped ? card : { ...deck.getBack(), flipped: false },
),
};
}
deleteGame(gameID: string): void { deleteGame(gameID: string): void {
this.games.delete(gameID); this.games.delete(gameID);
} }
} }

View File

@@ -3,45 +3,45 @@ import cards from '../constants/standardCards';
import type { StandardCard } from '../types'; import type { StandardCard } from '../types';
export interface Options { export interface Options {
back: number; back: number;
jokers: boolean; jokers: boolean;
} }
export interface OptionProps { export interface OptionProps {
back?: number; back?: number;
jokers?: boolean; jokers?: boolean;
} }
const DEFAULT_OPTIONS = { const DEFAULT_OPTIONS = {
jokers: false, jokers: false,
back: 1, back: 1,
}; };
export default class Cards { export default class Cards {
private options: Options; private options: Options;
private deck: StandardCard[] = []; private deck: StandardCard[] = [];
private backs: StandardCard[] = []; private backs: StandardCard[] = [];
private jokers: StandardCard[] = []; private jokers: StandardCard[] = [];
constructor(options: OptionProps = {}) { constructor(options: OptionProps = {}) {
this.options = { ...DEFAULT_OPTIONS, ...options }; this.options = { ...DEFAULT_OPTIONS, ...options };
this.deck = cards.filter(card => !card.back && (this.options.jokers || !card.joker)); this.deck = cards.filter((card) => !card.back && (this.options.jokers || !card.joker));
this.backs = cards.filter(card => card.back); this.backs = cards.filter((card) => card.back);
this.jokers = cards.filter(card => card.joker); this.jokers = cards.filter((card) => card.joker);
} }
select(count: number): StandardCard[] { select(count: number): StandardCard[] {
return getRandomItems(this.deck, count); return getRandomItems(this.deck, count);
} }
getBack(style: number): StandardCard { getBack(style: number): StandardCard {
style = style || this.options.back; style = style || this.options.back;
return this.backs.find(card => card.id.startsWith(String(style))) || this.backs[0]; return this.backs.find((card) => card.id.startsWith(String(style))) || this.backs[0];
} }
getJokers(): StandardCard[] { getJokers(): StandardCard[] {
return this.jokers; return this.jokers;
} }
} }

View File

@@ -3,19 +3,19 @@ import cards from '../constants/tarokkaCards';
import type { TarokkaCard } from '../types'; import type { TarokkaCard } from '../types';
export default class TarokkaDeck { export default class TarokkaDeck {
private deck: TarokkaCard[] = []; private deck: TarokkaCard[] = [];
private backs: TarokkaCard[] = []; private backs: TarokkaCard[] = [];
constructor() { constructor() {
this.deck = cards.filter(card => !card.back); this.deck = cards.filter((card) => !card.back);
this.backs = cards.filter(card => card.back); this.backs = cards.filter((card) => card.back);
} }
select(count: number): TarokkaCard[] { select(count: number): TarokkaCard[] {
return getRandomItems(this.deck, count); return getRandomItems(this.deck, count);
} }
getBack(): TarokkaCard { getBack(): TarokkaCard {
return this.backs[0]; return this.backs[0];
} }
} }

50
package-lock.json generated
View File

@@ -1,5 +1,5 @@
{ {
"name": "custom-server-app", "name": "tarokka",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -14,10 +14,11 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^22.14.0", "@types/node": "^22.14.1",
"@types/react": "^19.1.0", "@types/react": "^19.1.1",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.1.2",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"prettier": "^3.5.3",
"tailwindcss": "^4", "tailwindcss": "^4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.8.3" "typescript": "^5.8.3"
@@ -822,18 +823,20 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.14.0", "version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", "integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.1.0", "version": "19.1.1",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz",
"integrity": "sha512-UaicktuQI+9UKyA4njtDOGBD/67t8YEBt2xdfqu8+gP9hqPUPsiXlNPcpS2gVdjmis5GKPG3fCxbQLVgxsQZ8w==", "integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -843,6 +846,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==", "integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
"dev": true, "dev": true,
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.0.0" "@types/react": "^19.0.0"
} }
@@ -1123,6 +1127,7 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
}, },
@@ -1738,6 +1743,7 @@
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
"integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"chokidar": "^3.5.2", "chokidar": "^3.5.2",
"debug": "^4", "debug": "^4",
@@ -1831,6 +1837,22 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/prettier": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
"integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pstree.remy": { "node_modules/pstree.remy": {
"version": "1.1.8", "version": "1.1.8",
"resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
@@ -1841,6 +1863,7 @@
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -1849,6 +1872,7 @@
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "scheduler": "^0.26.0"
}, },
@@ -1871,7 +1895,8 @@
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.26.0", "version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==" "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.1", "version": "7.7.1",
@@ -1957,6 +1982,7 @@
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"semver": "^7.5.3" "semver": "^7.5.3"
}, },
@@ -2219,6 +2245,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "dev": true,
"license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2236,7 +2263,8 @@
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
}, },
"node_modules/v8-compile-cache-lib": { "node_modules/v8-compile-cache-lib": {
"version": "3.0.1", "version": "3.0.1",

View File

@@ -8,19 +8,20 @@
"dependencies": { "dependencies": {
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"next": "latest", "next": "latest",
"react": "^18.2.0", "react": "^19.1.0",
"react-dom": "^18.2.0", "react-dom": "^19.1.0",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
"socket.io-client": "^4.8.1" "socket.io-client": "^4.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^18.11.5", "@types/node": "^22.14.1",
"@types/react": "^18.0.23", "@types/react": "^19.1.1",
"@types/react-dom": "^18.0.7", "@types/react-dom": "^19.1.2",
"nodemon": "^2.0.20", "nodemon": "^3.1.9",
"prettier": "^3.5.3",
"tailwindcss": "^4", "tailwindcss": "^4",
"ts-node": "^10.9.1", "ts-node": "^10.9.2",
"typescript": "^4.8.4" "typescript": "^5.8.3"
} }
} }

View File

@@ -6,51 +6,50 @@ import GameStore from './lib/GameStore';
import type { ClientUpdate } from './types'; import type { ClientUpdate } from './types';
const dev = process.env.NODE_ENV !== 'production'; const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost'; const hostname = '0.0.0.0';
const port = 3000; const port = 3000;
// when using middleware `hostname` and `port` must be provided
const app = next({ dev, hostname, port }); const app = next({ dev, hostname, port });
const handler = app.getRequestHandler(); const handler = app.getRequestHandler();
const gameStore = new GameStore(); const gameStore = new GameStore();
app.prepare().then(() => { app.prepare().then(() => {
const httpServer = createServer(handler); const httpServer = createServer(handler);
const io = new SocketIOServer(httpServer); const io = new SocketIOServer(httpServer);
io.on('connection', (socket: Socket) => { io.on('connection', (socket: Socket) => {
console.log(`Client connected: ${socket.id}`); console.log(`Client connected: ${socket.id}`);
socket.on('join', (gameID) => { socket.on('join', (gameID) => {
socket.join(gameID); socket.join(gameID);
const gameUpdate = gameStore.joinGame(gameID, socket.id); const gameUpdate = gameStore.joinGame(gameID, socket.id);
console.log(`Socket ${socket.id} joined game ${gameID}`) console.log(`Socket ${socket.id} joined game ${gameID}`);
socket.emit('init', gameUpdate); socket.emit('init', gameUpdate);
}) });
socket.on('flip-card', ({ gameID, cardIndex }: ClientUpdate) => { socket.on('flip-card', ({ gameID, cardIndex }: ClientUpdate) => {
console.log('Card flipped:', { gameID, cardIndex }); console.log('Card flipped:', { gameID, cardIndex });
const gameUpdate = gameStore.flipCard(gameID, cardIndex); const gameUpdate = gameStore.flipCard(gameID, cardIndex);
io.to(gameID).emit('card-flipped', gameUpdate); io.to(gameID).emit('card-flipped', gameUpdate);
}); });
socket.on('disconnect', () => { socket.on('disconnect', () => {
console.log(`Client disconnected: ${socket.id}`); console.log(`Client disconnected: ${socket.id}`);
}); });
}); });
httpServer httpServer
.once('error', (err) => { .once('error', (err) => {
console.error('Server error:', err); console.error('Server error:', err);
process.exit(1); process.exit(1);
}) })
.listen(port, () => { .listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`); console.log(`> Ready on http://${hostname}:${port}`);
}); });
}); });

View File

@@ -1,11 +1,11 @@
export default function getRandomItems<T>(items: T[], count: number): T[] { export default function getRandomItems<T>(items: T[], count: number): T[] {
const shuffled = [...items]; const shuffled = [...items];
// Fisher-Yates shuffle // Fisher-Yates shuffle
for (let i = shuffled.length - 1; i > 0; i--) { for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
} }
return count > shuffled.length ? shuffled : shuffled.slice(0, count); return count > shuffled.length ? shuffled : shuffled.slice(0, count);
} }

View File

@@ -1,10 +1,9 @@
import getRandomItems from '@/tools/getRandomItems'; import getRandomItems from '@/tools/getRandomItems';
const alphabet = const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
'0123456789abcdefghijklmnopqrstuvwxyz';
const generateID = (length: number = 6) => { const generateID = (length: number = 6) => {
return getRandomItems(alphabet.split(''), length).join(''); return getRandomItems(alphabet.split(''), length).join('');
}; };
export default generateID; export default generateID;

View File

@@ -1,45 +1,45 @@
export interface StandardCard { export interface StandardCard {
id: string; id: string;
aria: string; aria: string;
back: boolean; back: boolean;
face: boolean; face: boolean;
joker: boolean; joker: boolean;
suit: 'Clubs' | 'Diamonds' | 'Hearts' | 'Spades' | null; suit: 'Clubs' | 'Diamonds' | 'Hearts' | 'Spades' | null;
url: string; url: string;
} }
export interface StandardGameCard extends StandardCard { export interface StandardGameCard extends StandardCard {
flipped: boolean; flipped: boolean;
} }
export interface TarokkaCard { export interface TarokkaCard {
id: string; id: string;
name: string; name: string;
card: string; card: string;
description: string; description: string;
aria: string; aria: string;
back: boolean; back: boolean;
suit: 'Coins' | 'Glyphs' | 'High Deck' | 'Stars' | 'Swords' | null; suit: 'Coins' | 'Glyphs' | 'High Deck' | 'Stars' | 'Swords' | null;
url: string; url: string;
} }
export interface TarokkaGameCard extends TarokkaCard { export interface TarokkaGameCard extends TarokkaCard {
flipped: boolean; flipped: boolean;
} }
export interface GameState { export interface GameState {
id: string; id: string;
players: Set<string>; players: Set<string>;
cards: StandardGameCard[] | TarokkaGameCard[]; cards: StandardGameCard[] | TarokkaGameCard[];
lastUpdated: number; lastUpdated: number;
} }
export interface GameUpdate { export interface GameUpdate {
id: string; id: string;
cards: StandardGameCard[] | TarokkaGameCard[]; cards: StandardGameCard[] | TarokkaGameCard[];
} }
export interface ClientUpdate { export interface ClientUpdate {
gameID: string; gameID: string;
cardIndex: number; cardIndex: number;
} }