From e7ebb0223b891b05c8df08dd027ffab64cc1ccfa Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Wed, 25 Jun 2025 17:04:18 -0400 Subject: [PATCH 01/20] added Context --- app/AppContext.tsx | 18 ++++++++++++++++++ app/layout.tsx | 5 ++++- components/Card.tsx | 3 ++- components/TiltCard.tsx | 42 ++++++++++++++++++++++++++++------------- server.ts | 12 +++++++++++- types/index.ts | 11 +++++++++++ 6 files changed, 75 insertions(+), 16 deletions(-) create mode 100644 app/AppContext.tsx diff --git a/app/AppContext.tsx b/app/AppContext.tsx new file mode 100644 index 0000000..bffbcd9 --- /dev/null +++ b/app/AppContext.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { createContext, useContext, useState, ReactNode } from 'react'; +import type { AppContext, Tilt } from '@/types'; + +const AppContext = createContext(undefined); + +export function AppProvider({ children }: { children: ReactNode }) { + const [tilts, setTilts] = useState([]); + + return {children}; +} + +export function useAppContext(): AppContext { + const context = useContext(AppContext); + if (!context) throw new Error('useAppContext must be used within AppProvider'); + return context; +} diff --git a/app/layout.tsx b/app/layout.tsx index 7dfc1d0..7d38c1d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import { Pirata_One, Eagle_Lake, Cinzel_Decorative } from 'next/font/google'; +import { AppProvider } from '@/app/AppContext'; import './globals.css'; const pirataOne = Pirata_One({ @@ -40,7 +41,9 @@ export default function RootLayout({ lang="en" className={`${pirataOne.variable} ${eagleLake.variable} ${cinzel.variable} antialiased`} > - {children} + + {children} + ); } diff --git a/components/Card.tsx b/components/Card.tsx index cbbf34d..52c979e 100644 --- a/components/Card.tsx +++ b/components/Card.tsx @@ -60,10 +60,11 @@ export default function Card({
{dm && ( diff --git a/components/TiltCard.tsx b/components/TiltCard.tsx index 8c16b54..8d80e08 100644 --- a/components/TiltCard.tsx +++ b/components/TiltCard.tsx @@ -1,18 +1,39 @@ -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; +import { useAppContext } from '@/app/AppContext'; export default function TiltCard({ children, + cardID, className = '', - onClick = () => {}, }: { children: React.ReactNode; + cardID: string; className?: string; - onClick: (event: React.MouseEvent) => void; }) { const cardRef = useRef(null); + const { tilts, setTilts } = useAppContext(); + + const card = cardRef.current; + + useEffect(() => { + if (!card) return; + + const tilt = tilts.find((tilt) => tilt.cardID === cardID); + if (!tilt) { + card.style.transform = `rotateX(0deg) rotateY(0deg)`; + return; + } + + const { rotateX, rotateY } = tilt; + + if (rotateX || rotateY) { + card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; + } else { + card.style.transform = `rotateX(0deg) rotateY(0deg)`; + } + }, [tilts]); const handleMouseMove = (e: React.MouseEvent) => { - const card = cardRef.current; if (!card) return; const rect = card.getBoundingClientRect(); @@ -24,22 +45,17 @@ export default function TiltCard({ const rotateX = ((y - centerY) / centerY) * -20; const rotateY = ((x - centerX) / centerX) * 20; - card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; + setTilts([...tilts.filter((tilt) => tilt.cardID !== cardID), { cardID, rotateX, rotateY }]); }; const handleMouseLeave = () => { - const card = cardRef.current; if (!card) return; - card.style.transform = `rotateX(0deg) rotateY(0deg)`; + + setTilts(tilts.filter((tilt) => tilt.cardID !== cardID)); }; return ( -
+
{children}
diff --git a/server.ts b/server.ts index 26890bc..8be15ff 100644 --- a/server.ts +++ b/server.ts @@ -4,7 +4,7 @@ import { Server as SocketIOServer, type Socket } from 'socket.io'; import GameStore from '@/lib/GameStore'; import omit from '@/tools/omit'; -import type { ClientUpdate, GameUpdate } from '@/types'; +import type { ClientUpdate, GameUpdate, Tilt } from '@/types'; const dev = process.env.NODE_ENV !== 'production'; const hostname = '0.0.0.0'; @@ -119,6 +119,16 @@ app.prepare().then(() => { } }); + socket.on('tilt', ({ gameID, tilt }: { gameID: string; tilt: Tilt }) => { + try { + const gameState = gameStore.getGame(gameID); + broadcast('tilt', gameState); + } catch (e) { + const error = e instanceof Error ? e.message : e; + console.error(Date.now(), 'Error[tilt]', error); + } + }); + socket.on('disconnect', () => { try { const game = gameStore.playerExit(socket.id); diff --git a/types/index.ts b/types/index.ts index cdfd8a8..3d9fca1 100644 --- a/types/index.ts +++ b/types/index.ts @@ -103,3 +103,14 @@ export interface Layout { name: string; text: string; } + +export interface Tilt { + cardID: string; + rotateX: number; + rotateY: number; +} + +export interface AppContext { + tilts: Tilt[]; + setTilts: (tilts: Tilt[]) => void; +} -- 2.49.1 From 1c28a603b74bbfa7bba7e5a318b95c7558df91c0 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Wed, 25 Jun 2025 18:09:29 -0400 Subject: [PATCH 02/20] moved useSocket into Context --- app/AppContext.tsx | 52 +++++++++++++++++++++++++++++++++++++++++-- app/[gameID]/page.tsx | 41 +++++++++++----------------------- types/index.ts | 5 ----- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/app/AppContext.tsx b/app/AppContext.tsx index bffbcd9..6efc698 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -1,14 +1,62 @@ 'use client'; import { createContext, useContext, useState, ReactNode } from 'react'; -import type { AppContext, Tilt } from '@/types'; +import useSocket from '@/hooks/useSocket'; +import type { GameUpdate, Tilt } from '@/types'; const AppContext = createContext(undefined); +const gameStart: GameUpdate = { + dmID: '', + spectatorID: '', + cards: [], + settings: { + positionBack: false, + positionFront: false, + prophecy: false, + notes: false, + cardStyle: 'color', + }, +}; + +export interface AppContext { + gameData: GameUpdate; + noGame: boolean; + tilts: Tilt[]; + flipCard: (cardIndex: number) => void; + handleSettings: (gameData: GameUpdate) => void; + redraw: (cardIndex: number) => void; + select: (cardIndex: number, cardID: string) => void; + setGameID: (gameID: string) => void; + setTilts: (tilts: Tilt[]) => void; +} + export function AppProvider({ children }: { children: ReactNode }) { + const [gameID, setGameID] = useState(''); + const [noGame, setNoGame] = useState(false); + const [gameData, setGameData] = useState(gameStart); + + const { flipCard, redraw, select, handleSettings } = useSocket({ + gameID, + setGameData, + setNoGame, + }); + const [tilts, setTilts] = useState([]); - return {children}; + const appInterface = { + gameData, + noGame, + tilts, + flipCard, + handleSettings, + redraw, + select, + setGameID, + setTilts, + }; + + return {children}; } export function useAppContext(): AppContext { diff --git a/app/[gameID]/page.tsx b/app/[gameID]/page.tsx index 32152b0..5f89a70 100644 --- a/app/[gameID]/page.tsx +++ b/app/[gameID]/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; -import useSocket from '@/hooks/useSocket'; +import { useAppContext } from '@/app/AppContext'; import { Eye } from 'lucide-react'; import Card from '@/components/Card'; @@ -14,43 +14,28 @@ import CardSelect from '@/components/CardSelect'; import { cardMap, layout } from '@/constants/tarokka'; -import type { Deck, GameUpdate } from '@/types'; +import type { Deck } from '@/types'; export default function GamePage() { - const { gameID: gameIDParam } = useParams(); + const { gameData, noGame, flipCard, handleSettings, redraw, select, setGameID } = useAppContext(); + const { gameID } = useParams(); - const [gameID, setGameID] = useState(''); - const [noGame, setNoGame] = useState(false); const [selectCard, setSelectCard] = useState(-1); - const [gameData, setGameData] = useState({ - dmID: '', - spectatorID: '', - cards: [], - settings: { - positionBack: false, - positionFront: false, - prophecy: false, - notes: false, - cardStyle: 'color', - }, - }); const { dmID, cards, settings } = gameData; const isDM = !!dmID; const selectDeck: Deck | null = selectCard >= 0 ? cards[selectCard].deck : null; - const socket = useSocket({ gameID, setGameData, setNoGame }); - useEffect(() => { - if (gameIDParam) { - setGameID(Array.isArray(gameIDParam) ? gameIDParam[0] : gameIDParam); + if (gameID) { + setGameID(Array.isArray(gameID) ? gameID[0] : gameID); } - }, [gameIDParam]); + }, [gameID]); - const select = (cardIndex: number, cardID: string) => { + const handleSelect = (cardIndex: number, cardID: string) => { setSelectCard(-1); - socket.select(cardIndex, cardID); + select(cardIndex, cardID); }; // map our five Tarokka cards to their proper locations in a 3x3 grid @@ -72,7 +57,7 @@ export default function GamePage() { /> )} - {isDM && } + {isDM && }
{Array.from({ length: 9 }) .map(arrangeCards) @@ -84,8 +69,8 @@ export default function GamePage() { card={card} position={layout[cardMap[index]]} settings={settings} - flipAction={() => socket.flipCard(cardMap[index])} - redrawAction={() => socket.redraw(cardMap[index])} + flipAction={() => flipCard(cardMap[index])} + redrawAction={() => redraw(cardMap[index])} selectAction={() => setSelectCard(cardMap[index])} /> )} @@ -98,7 +83,7 @@ export default function GamePage() { hand={cards} settings={settings} closeAction={() => setSelectCard(-1)} - selectAction={(cardID) => select(selectCard, cardID)} + selectAction={(cardID) => handleSelect(selectCard, cardID)} /> ) : null; diff --git a/types/index.ts b/types/index.ts index 3d9fca1..2af9ab4 100644 --- a/types/index.ts +++ b/types/index.ts @@ -109,8 +109,3 @@ export interface Tilt { rotateX: number; rotateY: number; } - -export interface AppContext { - tilts: Tilt[]; - setTilts: (tilts: Tilt[]) => void; -} -- 2.49.1 From 12ae8dd6d88fa65b57a3df8ec2f4bb1e47dfb639 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Thu, 26 Jun 2025 13:59:27 -0400 Subject: [PATCH 03/20] refactoring --- app/AppContext.tsx | 13 ++++++++++-- app/[gameID]/page.tsx | 41 +++++--------------------------------- components/Card.tsx | 30 +++++++++++----------------- components/TarokkaGrid.tsx | 30 ++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 56 deletions(-) create mode 100644 components/TarokkaGrid.tsx diff --git a/app/AppContext.tsx b/app/AppContext.tsx index 6efc698..6f2a99a 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -26,14 +26,16 @@ export interface AppContext { flipCard: (cardIndex: number) => void; handleSettings: (gameData: GameUpdate) => void; redraw: (cardIndex: number) => void; - select: (cardIndex: number, cardID: string) => void; + select: (cardID: string) => void; setGameID: (gameID: string) => void; + setSelectCardIndex: (cardIndex: number) => void; setTilts: (tilts: Tilt[]) => void; } export function AppProvider({ children }: { children: ReactNode }) { const [gameID, setGameID] = useState(''); const [noGame, setNoGame] = useState(false); + const [selectCardIndex, setSelectCardIndex] = useState(-1); const [gameData, setGameData] = useState(gameStart); const { flipCard, redraw, select, handleSettings } = useSocket({ @@ -44,6 +46,12 @@ export function AppProvider({ children }: { children: ReactNode }) { const [tilts, setTilts] = useState([]); + const handleSelect = (cardID: string) => { + setSelectCardIndex(-1); + + select(selectCardIndex, cardID); + }; + const appInterface = { gameData, noGame, @@ -51,8 +59,9 @@ export function AppProvider({ children }: { children: ReactNode }) { flipCard, handleSettings, redraw, - select, + select: handleSelect, setGameID, + setSelectCardIndex, setTilts, }; diff --git a/app/[gameID]/page.tsx b/app/[gameID]/page.tsx index 5f89a70..a9221a6 100644 --- a/app/[gameID]/page.tsx +++ b/app/[gameID]/page.tsx @@ -5,19 +5,17 @@ import { useParams } from 'next/navigation'; import { useAppContext } from '@/app/AppContext'; import { Eye } from 'lucide-react'; -import Card from '@/components/Card'; +import CardSelect from '@/components/CardSelect'; import CopyButton from '@/components/CopyButton'; import Notes from '@/components/Notes'; import NotFound from '@/components/NotFound'; import Settings from '@/components/Settings'; -import CardSelect from '@/components/CardSelect'; - -import { cardMap, layout } from '@/constants/tarokka'; +import TarokkaGrid from '@/components/TarokkaGrid'; import type { Deck } from '@/types'; export default function GamePage() { - const { gameData, noGame, flipCard, handleSettings, redraw, select, setGameID } = useAppContext(); + const { gameData, noGame, handleSettings, select, setGameID } = useAppContext(); const { gameID } = useParams(); const [selectCard, setSelectCard] = useState(-1); @@ -32,17 +30,6 @@ export default function GamePage() { } }, [gameID]); - const handleSelect = (cardIndex: number, cardID: string) => { - setSelectCard(-1); - - select(cardIndex, cardID); - }; - - // map our five Tarokka cards to their proper locations in a 3x3 grid - // common deck cards: left, top, and right - // high deck cards: bottom and center - const arrangeCards = (_cell: unknown, index: number) => cards[cardMap[index]]; - return noGame ? ( ) : cards ? ( @@ -58,32 +45,14 @@ export default function GamePage() { )} {isDM && } -
- {Array.from({ length: 9 }) - .map(arrangeCards) - .map((card, index) => ( -
- {card && ( - flipCard(cardMap[index])} - redrawAction={() => redraw(cardMap[index])} - selectAction={() => setSelectCard(cardMap[index])} - /> - )} -
- ))} -
+ flipped)} /> setSelectCard(-1)} - selectAction={(cardID) => handleSelect(selectCard, cardID)} + selectAction={select} /> ) : null; diff --git a/components/Card.tsx b/components/Card.tsx index 52c979e..663739c 100644 --- a/components/Card.tsx +++ b/components/Card.tsx @@ -1,43 +1,37 @@ 'use client'; import { useState } from 'react'; +import { useAppContext } from '@/app/AppContext'; import TiltCard from '@/components/TiltCard'; import ToolTip from '@/components/ToolTip'; import StackTheDeck from '@/components/StackTheDeck'; -import tarokkaCards from '@/constants/tarokkaCards'; import getCardInfo from '@/tools/getCardInfo'; import getURL from '@/tools/getURL'; -import { Layout, Settings, TarokkaGameCard } from '@/types'; +import tarokkaCards from '@/constants/tarokkaCards'; +import { layout } from '@/constants/tarokka'; + +import { Settings, TarokkaGameCard } from '@/types'; const cardBack = tarokkaCards.find((card) => card.back)!; type CardProps = { dm: boolean; card: TarokkaGameCard; - position: Layout; + cardIndex: number; settings: Settings; - flipAction: () => void; - redrawAction: () => void; - selectAction: () => void; }; -export default function Card({ - dm, - card, - position, - settings, - flipAction, - redrawAction, - selectAction, -}: CardProps) { +export default function Card({ dm, card, cardIndex, settings }: CardProps) { const [tooltip, setTooltip] = useState(null); + const { flipCard, redraw, setSelectCardIndex } = useAppContext(); const { aria, flipped } = card; + const position = layout[cardIndex]; const handleClick = () => { if (dm) { - flipAction(); + flipCard(cardIndex); } }; @@ -84,8 +78,8 @@ export default function Card({ /> {dm && !flipped && ( selectAction()} + onRedraw={() => redraw(cardIndex)} + onSelect={() => setSelectCardIndex(cardIndex)} onHover={setTooltip} /> )} diff --git a/components/TarokkaGrid.tsx b/components/TarokkaGrid.tsx new file mode 100644 index 0000000..089b149 --- /dev/null +++ b/components/TarokkaGrid.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useAppContext } from '@/app/AppContext'; +import Card from '@/components/Card'; +import { cardMap } from '@/constants/tarokka'; +import type {} from '@/types'; + +export default function TarokkaGrid() { + const { gameData } = useAppContext(); + + const { dmID, cards, settings } = gameData; + const isDM = !!dmID; + + // map our five Tarokka cards to their proper locations in a 3x3 grid + // common deck cards: left, top, and right + // high deck cards: bottom and center + const arrangeCards = (_cell: unknown, index: number) => cards[cardMap[index]]; + + return ( +
+ {Array.from({ length: 9 }) + .map(arrangeCards) + .map((card, index) => ( +
+ {card && } +
+ ))} +
+ ); +} -- 2.49.1 From 2c2e93649c7501cc052fabd210e38a13727fbfa1 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Thu, 26 Jun 2025 14:39:04 -0400 Subject: [PATCH 04/20] cleanup --- app/AppContext.tsx | 5 ++--- components/Card.tsx | 23 ++++++++++++----------- components/TarokkaGrid.tsx | 6 ++---- 3 files changed, 16 insertions(+), 18 deletions(-) diff --git a/app/AppContext.tsx b/app/AppContext.tsx index 6f2a99a..8adf56c 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -33,10 +33,11 @@ export interface AppContext { } export function AppProvider({ children }: { children: ReactNode }) { + const [gameData, setGameData] = useState(gameStart); const [gameID, setGameID] = useState(''); const [noGame, setNoGame] = useState(false); const [selectCardIndex, setSelectCardIndex] = useState(-1); - const [gameData, setGameData] = useState(gameStart); + const [tilts, setTilts] = useState([]); const { flipCard, redraw, select, handleSettings } = useSocket({ gameID, @@ -44,8 +45,6 @@ export function AppProvider({ children }: { children: ReactNode }) { setNoGame, }); - const [tilts, setTilts] = useState([]); - const handleSelect = (cardID: string) => { setSelectCardIndex(-1); diff --git a/components/Card.tsx b/components/Card.tsx index 663739c..57c11f8 100644 --- a/components/Card.tsx +++ b/components/Card.tsx @@ -11,32 +11,33 @@ import getURL from '@/tools/getURL'; import tarokkaCards from '@/constants/tarokkaCards'; import { layout } from '@/constants/tarokka'; -import { Settings, TarokkaGameCard } from '@/types'; +import { TarokkaGameCard } from '@/types'; const cardBack = tarokkaCards.find((card) => card.back)!; type CardProps = { - dm: boolean; card: TarokkaGameCard; cardIndex: number; - settings: Settings; }; -export default function Card({ dm, card, cardIndex, settings }: CardProps) { +export default function Card({ card, cardIndex }: CardProps) { const [tooltip, setTooltip] = useState(null); - const { flipCard, redraw, setSelectCardIndex } = useAppContext(); + const { flipCard, gameData, redraw, setSelectCardIndex } = useAppContext(); + + const { dmID, settings } = gameData; + const isDM = !!dmID; const { aria, flipped } = card; const position = layout[cardIndex]; const handleClick = () => { - if (dm) { + if (isDM) { flipCard(cardIndex); } }; const getTooltip = () => { - const text = getCardInfo(card, position, dm, settings); + const text = getCardInfo(card, position, isDM, settings); return text.length ? ( <> @@ -53,7 +54,7 @@ export default function Card({ dm, card, cardIndex, settings }: CardProps) { return (
- {dm && ( + {isDM && ( <> {aria} Card Back - {dm && !flipped && ( + {isDM && !flipped && ( redraw(cardIndex)} onSelect={() => setSelectCardIndex(cardIndex)} diff --git a/components/TarokkaGrid.tsx b/components/TarokkaGrid.tsx index 089b149..2b06deb 100644 --- a/components/TarokkaGrid.tsx +++ b/components/TarokkaGrid.tsx @@ -7,9 +7,7 @@ import type {} from '@/types'; export default function TarokkaGrid() { const { gameData } = useAppContext(); - - const { dmID, cards, settings } = gameData; - const isDM = !!dmID; + const { cards } = gameData; // map our five Tarokka cards to their proper locations in a 3x3 grid // common deck cards: left, top, and right @@ -22,7 +20,7 @@ export default function TarokkaGrid() { .map(arrangeCards) .map((card, index) => (
- {card && } + {card && }
))}
-- 2.49.1 From a0e4f54ed90055264bbbd7eda820d3689773b787 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Thu, 26 Jun 2025 15:30:03 -0400 Subject: [PATCH 05/20] more refactoring --- app/AppContext.tsx | 2 ++ app/[gameID]/page.tsx | 44 ++++++++---------------------------- components/CardSelect.tsx | 33 ++++++++++++--------------- components/Notes.tsx | 12 ++++------ components/Settings.tsx | 22 +++++++++--------- components/SpectatorLink.tsx | 19 ++++++++++++++++ 6 files changed, 61 insertions(+), 71 deletions(-) create mode 100644 components/SpectatorLink.tsx diff --git a/app/AppContext.tsx b/app/AppContext.tsx index 8adf56c..712aef7 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -23,6 +23,7 @@ export interface AppContext { gameData: GameUpdate; noGame: boolean; tilts: Tilt[]; + selectCardIndex: number; flipCard: (cardIndex: number) => void; handleSettings: (gameData: GameUpdate) => void; redraw: (cardIndex: number) => void; @@ -55,6 +56,7 @@ export function AppProvider({ children }: { children: ReactNode }) { gameData, noGame, tilts, + selectCardIndex, flipCard, handleSettings, redraw, diff --git a/app/[gameID]/page.tsx b/app/[gameID]/page.tsx index a9221a6..1219b3d 100644 --- a/app/[gameID]/page.tsx +++ b/app/[gameID]/page.tsx @@ -1,29 +1,20 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useParams } from 'next/navigation'; -import { useAppContext } from '@/app/AppContext'; -import { Eye } from 'lucide-react'; +import { useAppContext } from '@/app/AppContext'; import CardSelect from '@/components/CardSelect'; -import CopyButton from '@/components/CopyButton'; import Notes from '@/components/Notes'; import NotFound from '@/components/NotFound'; import Settings from '@/components/Settings'; +import { SpectatorLink } from '@/components/SpectatorLink'; import TarokkaGrid from '@/components/TarokkaGrid'; -import type { Deck } from '@/types'; - export default function GamePage() { - const { gameData, noGame, handleSettings, select, setGameID } = useAppContext(); + const { noGame, setGameID } = useAppContext(); const { gameID } = useParams(); - const [selectCard, setSelectCard] = useState(-1); - - const { dmID, cards, settings } = gameData; - const isDM = !!dmID; - const selectDeck: Deck | null = selectCard >= 0 ? cards[selectCard].deck : null; - useEffect(() => { if (gameID) { setGameID(Array.isArray(gameID) ? gameID[0] : gameID); @@ -32,28 +23,13 @@ export default function GamePage() { return noGame ? ( - ) : cards ? ( + ) : (
- {isDM && ( - - )} - - {isDM && } + + - flipped)} /> - setSelectCard(-1)} - selectAction={select} - /> + +
- ) : null; + ); } diff --git a/components/CardSelect.tsx b/components/CardSelect.tsx index 401aff9..fcddc6b 100644 --- a/components/CardSelect.tsx +++ b/components/CardSelect.tsx @@ -1,41 +1,36 @@ 'use client'; import { CircleX } from 'lucide-react'; +import { useAppContext } from '@/app/AppContext'; import TarokkaDeck from '@/lib/TarokkaDeck'; import getURL from '@/tools/getURL'; -import { Deck, Settings, TarokkaGameCard } from '@/types'; +import { Deck } from '@/types'; const tarokkaDeck = new TarokkaDeck(); type CardSelectProps = { - closeAction: () => void; - selectAction: (cardID: string) => void; - hand: TarokkaGameCard[]; - settings: Settings; - show: Deck | null; className?: string; }; -export default function CardSelect({ - closeAction, - selectAction, - hand, - settings, - show, - className = '', -}: CardSelectProps) { +export default function CardSelect({ className = '' }: CardSelectProps) { + const { gameData, select, selectCardIndex, setSelectCardIndex } = useAppContext(); + const { cards: hand, settings } = gameData; + const handIDs = hand.map(({ id }) => id); + const selectDeck: Deck | null = selectCardIndex >= 0 ? hand[selectCardIndex].deck : null; + + const close = () => setSelectCardIndex(-1); const handleClose = (event: React.MouseEvent) => { if (event.target === event.currentTarget) { - closeAction(); + close(); } }; - if (!show) return null; + if (!selectDeck) return null; - const cards = show === 'high' ? tarokkaDeck.getHigh() : tarokkaDeck.getLow(); + const cards = selectDeck === 'high' ? tarokkaDeck.getHigh() : tarokkaDeck.getLow(); return (
@@ -58,7 +53,7 @@ export default function CardSelect({
selectAction(card.id)} + onClick={() => select(card.id)} > flipped); const [open, setOpen] = useState(false); diff --git a/components/Settings.tsx b/components/Settings.tsx index ce5a83d..08d7a00 100644 --- a/components/Settings.tsx +++ b/components/Settings.tsx @@ -4,12 +4,13 @@ import { useState } from 'react'; import { Settings as Gear } from 'lucide-react'; import { Cinzel_Decorative } from 'next/font/google'; +import { useAppContext } from '@/app/AppContext'; import BuyMeACoffee from '@/components/BuyMeACoffee'; import CopyButton from '@/components/CopyButton'; import GitHubButton from '@/components/GitHubButton'; import Scrim from '@/components/Scrim'; import Switch from '@/components/Switch'; -import { CardStyle, GameUpdate } from '@/types'; +import { CardStyle } from '@/types'; const cinzel = Cinzel_Decorative({ variable: '--font-cinzel', @@ -17,18 +18,17 @@ const cinzel = Cinzel_Decorative({ weight: '400', }); -type SettingsProps = { - gameData: GameUpdate; - changeAction: (updatedSettings: GameUpdate) => void; -}; - const cardStyleOptions: CardStyle[] = ['standard', 'color', 'grayscale']; -export default function Settings({ gameData, changeAction }: SettingsProps) { +export default function Settings() { const [open, setOpen] = useState(false); + const { gameData, handleSettings } = useAppContext(); + + const { dmID } = gameData; + const isDM = !!dmID; const togglePermission = (key: string) => { - changeAction({ + handleSettings({ ...gameData, settings: { ...gameData.settings, @@ -38,7 +38,7 @@ export default function Settings({ gameData, changeAction }: SettingsProps) { }; const tuneRadio = (cardStyle: CardStyle) => { - changeAction({ + handleSettings({ ...gameData, settings: { ...gameData.settings, @@ -104,7 +104,7 @@ export default function Settings({ gameData, changeAction }: SettingsProps) { ); - return ( + return isDM ? (
setOpen((prev) => !prev)} @@ -129,5 +129,5 @@ export default function Settings({ gameData, changeAction }: SettingsProps) {
- ); + ) : null; } diff --git a/components/SpectatorLink.tsx b/components/SpectatorLink.tsx new file mode 100644 index 0000000..9b24b23 --- /dev/null +++ b/components/SpectatorLink.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { Eye } from 'lucide-react'; +import { useAppContext } from '@/app/AppContext'; +import CopyButton from '@/components/CopyButton'; + +export function SpectatorLink() { + const { gameData } = useAppContext(); + + return ( + + ); +} -- 2.49.1 From 2ae4c6a77b2b174221be895fb00378723449af0b Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Fri, 27 Jun 2025 18:14:06 -0400 Subject: [PATCH 06/20] teletilting --- app/AppContext.tsx | 23 ++++++++++++++++------- components/Card.tsx | 2 +- components/TiltCard.tsx | 37 +++++++++++++++++++++++++------------ hooks/useSocket.ts | 23 ++++++++++++++++------- lib/GameStore.ts | 36 +++++++++++++++++++++++++++++------- server.ts | 6 +++--- types/index.ts | 4 +++- 7 files changed, 93 insertions(+), 38 deletions(-) diff --git a/app/AppContext.tsx b/app/AppContext.tsx index 712aef7..44a62e8 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -1,6 +1,6 @@ 'use client'; -import { createContext, useContext, useState, ReactNode } from 'react'; +import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; import useSocket from '@/hooks/useSocket'; import type { GameUpdate, Tilt } from '@/types'; @@ -17,12 +17,13 @@ const gameStart: GameUpdate = { notes: false, cardStyle: 'color', }, + tilts: Array.from({ length: 5 }, () => []), }; export interface AppContext { gameData: GameUpdate; noGame: boolean; - tilts: Tilt[]; + tilt: Tilt[]; selectCardIndex: number; flipCard: (cardIndex: number) => void; handleSettings: (gameData: GameUpdate) => void; @@ -30,7 +31,7 @@ export interface AppContext { select: (cardID: string) => void; setGameID: (gameID: string) => void; setSelectCardIndex: (cardIndex: number) => void; - setTilts: (tilts: Tilt[]) => void; + setTilt: (tilt: Tilt[]) => void; } export function AppProvider({ children }: { children: ReactNode }) { @@ -38,14 +39,22 @@ export function AppProvider({ children }: { children: ReactNode }) { const [gameID, setGameID] = useState(''); const [noGame, setNoGame] = useState(false); const [selectCardIndex, setSelectCardIndex] = useState(-1); - const [tilts, setTilts] = useState([]); + const [tilt, setTilt] = useState([]); - const { flipCard, redraw, select, handleSettings } = useSocket({ + const { flipCard, redraw, select, handleSettings, emitTilt } = useSocket({ gameID, setGameData, setNoGame, }); + useEffect(() => { + const cardIndex = tilt.findIndex((tilt) => !!tilt); + + if (tilt[cardIndex]) { + emitTilt(cardIndex, tilt[cardIndex]); + } + }, [tilt]); + const handleSelect = (cardID: string) => { setSelectCardIndex(-1); @@ -55,7 +64,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const appInterface = { gameData, noGame, - tilts, + tilt, selectCardIndex, flipCard, handleSettings, @@ -63,7 +72,7 @@ export function AppProvider({ children }: { children: ReactNode }) { select: handleSelect, setGameID, setSelectCardIndex, - setTilts, + setTilt, }; return {children}; diff --git a/components/Card.tsx b/components/Card.tsx index 57c11f8..4b12f86 100644 --- a/components/Card.tsx +++ b/components/Card.tsx @@ -55,7 +55,7 @@ export default function Card({ card, cardIndex }: CardProps) {
(null); - const { tilts, setTilts } = useAppContext(); - - const card = cardRef.current; + const { gameData, setTilt } = useAppContext(); useEffect(() => { + const card = cardRef.current; if (!card) return; - const tilt = tilts.find((tilt) => tilt.cardID === cardID); + const tilt = gameData.tilts[cardIndex]; if (!tilt) { card.style.transform = `rotateX(0deg) rotateY(0deg)`; return; } - const { rotateX, rotateY } = tilt; + const tilted = tilt.filter(({ rotateX, rotateY }) => rotateX || rotateY); + + const { totalX, totalY } = tilted.reduce( + ({ totalX, totalY }, { rotateX, rotateY }) => ({ + totalX: totalX + rotateX, + totalY: totalY + rotateY, + }), + { totalX: 0, totalY: 0 }, + ); + + const rotateX = totalX / tilted.length; + const rotateY = totalY / tilted.length; if (rotateX || rotateY) { card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; } else { card.style.transform = `rotateX(0deg) rotateY(0deg)`; } - }, [tilts]); + }, [gameData]); const handleMouseMove = (e: React.MouseEvent) => { + const card = cardRef.current; if (!card) return; const rect = card.getBoundingClientRect(); @@ -45,13 +57,14 @@ export default function TiltCard({ const rotateX = ((y - centerY) / centerY) * -20; const rotateY = ((x - centerX) / centerX) * 20; - setTilts([...tilts.filter((tilt) => tilt.cardID !== cardID), { cardID, rotateX, rotateY }]); + const newTilt: Tilt[] = []; + newTilt[cardIndex] = { rotateX, rotateY }; + + setTilt(newTilt); }; const handleMouseLeave = () => { - if (!card) return; - - setTilts(tilts.filter((tilt) => tilt.cardID !== cardID)); + setTilt([]); }; return ( diff --git a/hooks/useSocket.ts b/hooks/useSocket.ts index 794c8fe..8af6b3e 100644 --- a/hooks/useSocket.ts +++ b/hooks/useSocket.ts @@ -1,7 +1,8 @@ import { useEffect } from 'react'; import { socket } from '@/socket'; +import throttle from '@/tools/throttle'; -import type { GameUpdate } from '@/types'; +import type { GameUpdate, Tilt } from '@/types'; interface UseSocketProps { gameID: string; @@ -44,6 +45,13 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP }); }; + const handleSettings = (gameData: GameUpdate) => { + socket.emit('settings', { + gameID, + gameData, + }); + }; + const redraw = (cardIndex: number) => { socket.emit('redraw', { gameID, @@ -59,17 +67,18 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP }); }; - const handleSettings = (gameData: GameUpdate) => { - socket.emit('settings', { - gameID, - gameData, + const emitTilt = throttle((cardIndex: number, tilt: Tilt) => { + socket.emit('tilt', { + cardIndex, + tilt, }); - }; + }, 33.3); return { flipCard, + handleSettings, redraw, select, - handleSettings, + emitTilt, }; } diff --git a/lib/GameStore.ts b/lib/GameStore.ts index de08e2a..c13d114 100644 --- a/lib/GameStore.ts +++ b/lib/GameStore.ts @@ -2,7 +2,7 @@ import Deck from '@/lib/TarokkaDeck'; import generateID from '@/tools/simpleID'; import parseMilliseconds from '@/tools/parseMilliseconds'; import { HOUR, DAY } from '@/constants/time'; -import { GameState, GameUpdate, Settings } from '@/types'; +import { GameState, GameUpdate, Settings, Tilt } from '@/types'; const deck = new Deck(); @@ -91,6 +91,7 @@ export default class GameStore { notes: true, cardStyle: 'color', }, + tilts: Array.from({ length: 5 }, () => []), }; this.totalCreated++; @@ -157,6 +158,21 @@ export default class GameStore { return this.gameUpdate(game); } + tilt(playerID: string, cardIndex: number, { rotateX, rotateY }: Tilt) { + const game = this.getGameByPlayerID(playerID); + const cardTilts = game.tilts[cardIndex]; + + if (!cardTilts) throw new Error(`Card tilts ${cardIndex} not found`); + + game.tilts[cardIndex] = [ + ...cardTilts.filter((tilt) => tilt.playerID !== playerID), + { playerID, rotateX, rotateY }, + ]; + game.lastUpdated = Date.now(); + + return this.gameUpdate(game); + } + updateSettings(gameID: string, settings: Settings) { const game = this.getGame(gameID); @@ -173,10 +189,18 @@ export default class GameStore { return game; } - gameUpdate(game: GameState): GameUpdate { - const { dmID, spectatorID, cards, settings } = game; + getGameByPlayerID(playerID: string): GameState { + const game = this.players.get(playerID); - return { dmID, spectatorID, cards, settings }; + if (!game) throw new Error(`Player ${playerID} not found`); + + return game; + } + + gameUpdate(game: GameState): GameUpdate { + const { dmID, spectatorID, cards, settings, tilts } = game; + + return { dmID, spectatorID, cards, settings, tilts }; } playerExit(playerID: string): GameState | null { @@ -185,9 +209,7 @@ export default class GameStore { return null; } else { - const game = this.players.get(playerID); - - if (!game) throw new Error(`Player ${playerID} not found`); + const game = this.getGameByPlayerID(playerID); this.players.delete(playerID); return this.leaveGame(game, playerID); diff --git a/server.ts b/server.ts index 8be15ff..eb8e062 100644 --- a/server.ts +++ b/server.ts @@ -119,10 +119,10 @@ app.prepare().then(() => { } }); - socket.on('tilt', ({ gameID, tilt }: { gameID: string; tilt: Tilt }) => { + socket.on('tilt', ({ cardIndex, tilt }: { cardIndex: number; tilt: Tilt }) => { try { - const gameState = gameStore.getGame(gameID); - broadcast('tilt', gameState); + const gameState = gameStore.tilt(socket.id, cardIndex, tilt); + broadcast('game-update', gameState); } catch (e) { const error = e instanceof Error ? e.message : e; console.error(Date.now(), 'Error[tilt]', error); diff --git a/types/index.ts b/types/index.ts index 2af9ab4..b98319f 100644 --- a/types/index.ts +++ b/types/index.ts @@ -82,6 +82,7 @@ export interface GameState { cards: TarokkaGameCard[]; lastUpdated: number; settings: Settings; + tilts: Tilt[][]; } export interface GameUpdate { @@ -89,6 +90,7 @@ export interface GameUpdate { spectatorID: string; cards: TarokkaGameCard[]; settings: Settings; + tilts: Tilt[][]; } export interface ClientUpdate { @@ -105,7 +107,7 @@ export interface Layout { } export interface Tilt { - cardID: string; + playerID?: string; rotateX: number; rotateY: number; } -- 2.49.1 From 1dbe6b7ec033048895619034b03bb4be1b819ecd Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Sat, 28 Jun 2025 20:16:52 -0400 Subject: [PATCH 07/20] throttle 'tilts' from the server --- app/AppContext.tsx | 20 ++++++++++---------- components/Card.tsx | 6 +++--- components/CardSelect.tsx | 4 ++-- components/Settings.tsx | 6 +++--- constants/time.ts | 2 ++ hooks/useSocket.ts | 19 ++++++++++--------- server.ts | 25 ++++++++++++++++++++++++- tools/throttle.ts | 12 ++++++++++++ 8 files changed, 66 insertions(+), 28 deletions(-) create mode 100644 tools/throttle.ts diff --git a/app/AppContext.tsx b/app/AppContext.tsx index 44a62e8..c1f30dc 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -25,10 +25,10 @@ export interface AppContext { noGame: boolean; tilt: Tilt[]; selectCardIndex: number; - flipCard: (cardIndex: number) => void; - handleSettings: (gameData: GameUpdate) => void; - redraw: (cardIndex: number) => void; - select: (cardID: string) => void; + emitFlip: (cardIndex: number) => void; + emitSettings: (gameData: GameUpdate) => void; + emitRedraw: (cardIndex: number) => void; + emitSelect: (cardID: string) => void; setGameID: (gameID: string) => void; setSelectCardIndex: (cardIndex: number) => void; setTilt: (tilt: Tilt[]) => void; @@ -41,7 +41,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const [selectCardIndex, setSelectCardIndex] = useState(-1); const [tilt, setTilt] = useState([]); - const { flipCard, redraw, select, handleSettings, emitTilt } = useSocket({ + const { emitFlip, emitRedraw, emitSelect, emitSettings, emitTilt } = useSocket({ gameID, setGameData, setNoGame, @@ -58,7 +58,7 @@ export function AppProvider({ children }: { children: ReactNode }) { const handleSelect = (cardID: string) => { setSelectCardIndex(-1); - select(selectCardIndex, cardID); + emitSelect(selectCardIndex, cardID); }; const appInterface = { @@ -66,10 +66,10 @@ export function AppProvider({ children }: { children: ReactNode }) { noGame, tilt, selectCardIndex, - flipCard, - handleSettings, - redraw, - select: handleSelect, + emitFlip, + emitSettings, + emitRedraw, + emitSelect: handleSelect, setGameID, setSelectCardIndex, setTilt, diff --git a/components/Card.tsx b/components/Card.tsx index 4b12f86..b27ba9b 100644 --- a/components/Card.tsx +++ b/components/Card.tsx @@ -22,7 +22,7 @@ type CardProps = { export default function Card({ card, cardIndex }: CardProps) { const [tooltip, setTooltip] = useState(null); - const { flipCard, gameData, redraw, setSelectCardIndex } = useAppContext(); + const { emitFlip, gameData, emitRedraw, setSelectCardIndex } = useAppContext(); const { dmID, settings } = gameData; const isDM = !!dmID; @@ -32,7 +32,7 @@ export default function Card({ card, cardIndex }: CardProps) { const handleClick = () => { if (isDM) { - flipCard(cardIndex); + emitFlip(cardIndex); } }; @@ -79,7 +79,7 @@ export default function Card({ card, cardIndex }: CardProps) { /> {isDM && !flipped && ( redraw(cardIndex)} + onRedraw={() => emitRedraw(cardIndex)} onSelect={() => setSelectCardIndex(cardIndex)} onHover={setTooltip} /> diff --git a/components/CardSelect.tsx b/components/CardSelect.tsx index fcddc6b..482ec0c 100644 --- a/components/CardSelect.tsx +++ b/components/CardSelect.tsx @@ -14,7 +14,7 @@ type CardSelectProps = { }; export default function CardSelect({ className = '' }: CardSelectProps) { - const { gameData, select, selectCardIndex, setSelectCardIndex } = useAppContext(); + const { gameData, emitSelect, selectCardIndex, setSelectCardIndex } = useAppContext(); const { cards: hand, settings } = gameData; const handIDs = hand.map(({ id }) => id); @@ -53,7 +53,7 @@ export default function CardSelect({ className = '' }: CardSelectProps) {
select(card.id)} + onClick={() => emitSelect(card.id)} > { - handleSettings({ + emitSettings({ ...gameData, settings: { ...gameData.settings, @@ -38,7 +38,7 @@ export default function Settings() { }; const tuneRadio = (cardStyle: CardStyle) => { - handleSettings({ + emitSettings({ ...gameData, settings: { ...gameData.settings, diff --git a/constants/time.ts b/constants/time.ts index 013a0b4..e422df4 100644 --- a/constants/time.ts +++ b/constants/time.ts @@ -2,3 +2,5 @@ export const SECOND = 1000; export const MINUTE = 60 * SECOND; export const HOUR = 60 * MINUTE; export const DAY = 24 * HOUR; + +export const thirtyFPS = SECOND / 30; diff --git a/hooks/useSocket.ts b/hooks/useSocket.ts index 8af6b3e..6bdb3b4 100644 --- a/hooks/useSocket.ts +++ b/hooks/useSocket.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { socket } from '@/socket'; import throttle from '@/tools/throttle'; +import { thirtyFPS } from '@/constants/time'; import type { GameUpdate, Tilt } from '@/types'; interface UseSocketProps { @@ -38,28 +39,28 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP }; }, [gameID]); - const flipCard = (cardIndex: number) => { + const emitFlip = (cardIndex: number) => { socket.emit('flip-card', { gameID, cardIndex, }); }; - const handleSettings = (gameData: GameUpdate) => { + const emitSettings = (gameData: GameUpdate) => { socket.emit('settings', { gameID, gameData, }); }; - const redraw = (cardIndex: number) => { + const emitRedraw = (cardIndex: number) => { socket.emit('redraw', { gameID, cardIndex, }); }; - const select = (cardIndex: number, cardID: string) => { + const emitSelect = (cardIndex: number, cardID: string) => { socket.emit('select', { gameID, cardIndex, @@ -72,13 +73,13 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP cardIndex, tilt, }); - }, 33.3); + }, thirtyFPS); return { - flipCard, - handleSettings, - redraw, - select, + emitFlip, + emitSettings, + emitRedraw, + emitSelect, emitTilt, }; } diff --git a/server.ts b/server.ts index eb8e062..8678e74 100644 --- a/server.ts +++ b/server.ts @@ -4,6 +4,8 @@ import { Server as SocketIOServer, type Socket } from 'socket.io'; import GameStore from '@/lib/GameStore'; import omit from '@/tools/omit'; + +import { thirtyFPS } from '@/constants/time'; import type { ClientUpdate, GameUpdate, Tilt } from '@/types'; const dev = process.env.NODE_ENV !== 'production'; @@ -15,6 +17,8 @@ const handler = app.getRequestHandler(); const gameStore = new GameStore(); +const timedReleases = {}; + app.prepare().then(() => { const httpServer = createServer(handler); @@ -25,6 +29,25 @@ app.prepare().then(() => { io.to(gameUpdate.spectatorID).emit(event, omit(gameUpdate, 'dmID')); }; + const timedRelease = (event: string, gameUpdate: GameUpdate, threshold: number) => { + const now = Date.now(); + const lastEvent = timedReleases[event]; + + if (lastEvent?.embargo >= now) { + clearTimeout(lastEvent.to); + const embargo = lastEvent.embargo - now; + + const to = setTimeout(() => { + broadcast(event, gameUpdate); + }, embargo); + + timedReleases[event] = { embargo, to }; + } else { + broadcast(event, gameUpdate); + timedReleases[event] = { embargo: now + threshold }; + } + }; + io.on('connection', (socket: Socket) => { //console.log(Date.now(), `Client connected: ${socket.id}`); @@ -122,7 +145,7 @@ app.prepare().then(() => { socket.on('tilt', ({ cardIndex, tilt }: { cardIndex: number; tilt: Tilt }) => { try { const gameState = gameStore.tilt(socket.id, cardIndex, tilt); - broadcast('game-update', gameState); + timedRelease('game-update', gameState, thirtyFPS); } catch (e) { const error = e instanceof Error ? e.message : e; console.error(Date.now(), 'Error[tilt]', error); diff --git a/tools/throttle.ts b/tools/throttle.ts new file mode 100644 index 0000000..db1f15a --- /dev/null +++ b/tools/throttle.ts @@ -0,0 +1,12 @@ +export default function throttle(func: Function, threshold: number) { + let lastCall = 0; + + return (...args: any[]) => { + const now = Date.now(); + + if (now - lastCall >= threshold) { + lastCall = now; + func(...args); + } + }; +} -- 2.49.1 From 6b3ab9a54ef8fb472904051f505b3f0d8b33f5db Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Tue, 1 Jul 2025 09:21:41 -0400 Subject: [PATCH 08/20] setting to disable tilt --- app/AppContext.tsx | 37 +++++++------ components/Settings.tsx | 115 ++++++++++++++++++++++------------------ components/TiltCard.tsx | 64 +++++++++++++--------- constants/index.ts | 33 ++++++++++++ lib/GameStore.ts | 11 ++-- types/index.ts | 11 +++- 6 files changed, 164 insertions(+), 107 deletions(-) create mode 100644 constants/index.ts diff --git a/app/AppContext.tsx b/app/AppContext.tsx index c1f30dc..3205f5c 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -1,41 +1,34 @@ 'use client'; -import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; +import { createContext, useContext, useEffect, useState } from 'react'; import useSocket from '@/hooks/useSocket'; -import type { GameUpdate, Tilt } from '@/types'; + +import { GAME_START, LOCAL_DEFAULTS } from '@/constants'; +import type { Dispatch, ReactNode, SetStateAction } from 'react'; +import type { GameUpdate, LocalSettings, Settings, Tilt } from '@/types'; const AppContext = createContext(undefined); -const gameStart: GameUpdate = { - dmID: '', - spectatorID: '', - cards: [], - settings: { - positionBack: false, - positionFront: false, - prophecy: false, - notes: false, - cardStyle: 'color', - }, - tilts: Array.from({ length: 5 }, () => []), -}; - export interface AppContext { gameData: GameUpdate; + isDM: boolean; noGame: boolean; - tilt: Tilt[]; selectCardIndex: number; + settings: Settings; + tilt: Tilt[]; emitFlip: (cardIndex: number) => void; emitSettings: (gameData: GameUpdate) => void; emitRedraw: (cardIndex: number) => void; emitSelect: (cardID: string) => void; setGameID: (gameID: string) => void; + setLocalSettings: Dispatch>; setSelectCardIndex: (cardIndex: number) => void; setTilt: (tilt: Tilt[]) => void; } export function AppProvider({ children }: { children: ReactNode }) { - const [gameData, setGameData] = useState(gameStart); + const [gameData, setGameData] = useState({ ...GAME_START }); + const [localSettings, setLocalSettings] = useState(() => ({ ...LOCAL_DEFAULTS })); const [gameID, setGameID] = useState(''); const [noGame, setNoGame] = useState(false); const [selectCardIndex, setSelectCardIndex] = useState(-1); @@ -61,16 +54,22 @@ export function AppProvider({ children }: { children: ReactNode }) { emitSelect(selectCardIndex, cardID); }; + const { dmID } = gameData; + const isDM = !!dmID; + const appInterface = { gameData, + isDM, noGame, - tilt, selectCardIndex, + settings: { ...gameData.settings, ...localSettings }, + tilt, emitFlip, emitSettings, emitRedraw, emitSelect: handleSelect, setGameID, + setLocalSettings, setSelectCardIndex, setTilt, }; diff --git a/components/Settings.tsx b/components/Settings.tsx index 83487a5..16cbb53 100644 --- a/components/Settings.tsx +++ b/components/Settings.tsx @@ -10,7 +10,9 @@ import CopyButton from '@/components/CopyButton'; import GitHubButton from '@/components/GitHubButton'; import Scrim from '@/components/Scrim'; import Switch from '@/components/Switch'; -import { CardStyle } from '@/types'; + +import { LOCAL_SETTINGS, SPECTATOR_SETTINGS } from '@/constants'; +import type { CardStyle, LocalSettings } from '@/types'; const cinzel = Cinzel_Decorative({ variable: '--font-cinzel', @@ -22,19 +24,20 @@ const cardStyleOptions: CardStyle[] = ['standard', 'color', 'grayscale']; export default function Settings() { const [open, setOpen] = useState(false); - const { gameData, emitSettings } = useAppContext(); + const { gameData, isDM, settings, emitSettings, setLocalSettings } = useAppContext(); - const { dmID } = gameData; - const isDM = !!dmID; - - const togglePermission = (key: string) => { - emitSettings({ - ...gameData, - settings: { - ...gameData.settings, - [key]: !gameData.settings[key], - }, - }); + const togglePermission = (key: keyof LocalSettings) => { + if (LOCAL_SETTINGS.includes(key)) { + setLocalSettings((prev) => ({ ...prev, [key]: !prev[key] })); + } else if (isDM) { + emitSettings({ + ...gameData, + settings: { + ...gameData.settings, + [key]: !gameData.settings[key], + }, + }); + } }; const tuneRadio = (cardStyle: CardStyle) => { @@ -47,14 +50,25 @@ export default function Settings() { }); }; + const Icon = () => ( + + ); + const Links = () => ( <> - + {isDM && ( + + )} ( <> - {Object.entries(gameData.settings) + {Object.entries(settings) .filter(([_key, value]) => typeof value === 'boolean') + .filter(([key]) => isDM || SPECTATOR_SETTINGS.includes(key)) .map(([key, value]) => ( togglePermission(key)} /> ))} ); - const CardStyle = () => ( -
-
Card style:
-
- {cardStyleOptions.map((option, index) => ( - + ))} +
+
+ ) : null; - return isDM ? ( + return (
setOpen((prev) => !prev)} @@ -122,12 +138,7 @@ export default function Settings() {
- +
- ) : null; + ); } diff --git a/components/TiltCard.tsx b/components/TiltCard.tsx index c9bce43..bdc51e6 100644 --- a/components/TiltCard.tsx +++ b/components/TiltCard.tsx @@ -12,37 +12,45 @@ export default function TiltCard({ className?: string; }) { const cardRef = useRef(null); - const { gameData, setTilt } = useAppContext(); + const { + gameData, + settings: { tilt }, + setTilt, + } = useAppContext(); useEffect(() => { const card = cardRef.current; if (!card) return; - const tilt = gameData.tilts[cardIndex]; - if (!tilt) { - card.style.transform = `rotateX(0deg) rotateY(0deg)`; - return; + if (tilt) { + const tilt = gameData.tilts[cardIndex]; + if (!tilt) { + card.style.transform = `rotateX(0deg) rotateY(0deg)`; + return; + } + + const tilted = tilt.filter(({ rotateX, rotateY }) => rotateX || rotateY); + + const { totalX, totalY } = tilted.reduce( + ({ totalX, totalY }, { rotateX, rotateY }) => ({ + totalX: totalX + rotateX, + totalY: totalY + rotateY, + }), + { totalX: 0, totalY: 0 }, + ); + + const rotateX = totalX / tilted.length; + const rotateY = totalY / tilted.length; + + if (rotateX || rotateY) { + card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; + } else { + card.style.transform = `rotateX(0deg) rotateY(0deg)`; + } + } else if (card.style.transform !== 'rotateX(0deg) rotateY(0deg)') { + card.style.transform = 'rotateX(0deg) rotateY(0deg)'; } - - const tilted = tilt.filter(({ rotateX, rotateY }) => rotateX || rotateY); - - const { totalX, totalY } = tilted.reduce( - ({ totalX, totalY }, { rotateX, rotateY }) => ({ - totalX: totalX + rotateX, - totalY: totalY + rotateY, - }), - { totalX: 0, totalY: 0 }, - ); - - const rotateX = totalX / tilted.length; - const rotateY = totalY / tilted.length; - - if (rotateX || rotateY) { - card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; - } else { - card.style.transform = `rotateX(0deg) rotateY(0deg)`; - } - }, [gameData]); + }, [tilt, gameData]); const handleMouseMove = (e: React.MouseEvent) => { const card = cardRef.current; @@ -68,7 +76,11 @@ export default function TiltCard({ }; return ( -
+
{children}
diff --git a/constants/index.ts b/constants/index.ts new file mode 100644 index 0000000..2721031 --- /dev/null +++ b/constants/index.ts @@ -0,0 +1,33 @@ +export * from '@/constants/standardCards'; +export * from '@/constants/tarokka'; +export * from '@/constants/tarokkaCards'; +export * from '@/constants/time'; + +import type { GameUpdate, LocalSettings, Settings } from '@/types'; + +export const SETTINGS: Settings = { + cardStyle: 'color', + notes: false, + positionBack: false, + positionFront: false, + prophecy: false, + tilt: true, + remoteTilt: false, +}; + +export const GAME_START: GameUpdate = { + dmID: '', + spectatorID: '', + cards: [], + settings: SETTINGS, + tilts: Array.from({ length: 5 }, () => []), +}; + +export const LOCAL_DEFAULTS: LocalSettings = { + tilt: true, + remoteTilt: true, +}; + +export const LOCAL_SETTINGS = ['tilt', 'remoteTilt']; + +export const SPECTATOR_SETTINGS = ['tilt', 'remoteTilt']; diff --git a/lib/GameStore.ts b/lib/GameStore.ts index c13d114..64d7fe6 100644 --- a/lib/GameStore.ts +++ b/lib/GameStore.ts @@ -1,7 +1,8 @@ import Deck from '@/lib/TarokkaDeck'; import generateID from '@/tools/simpleID'; import parseMilliseconds from '@/tools/parseMilliseconds'; -import { HOUR, DAY } from '@/constants/time'; + +import { HOUR, DAY, SETTINGS } from '@/constants'; import { GameState, GameUpdate, Settings, Tilt } from '@/types'; const deck = new Deck(); @@ -84,13 +85,7 @@ export default class GameStore { players: new Set(), cards: deck.getHand(), lastUpdated: Date.now(), - settings: { - positionBack: true, - positionFront: true, - prophecy: true, - notes: true, - cardStyle: 'color', - }, + settings: SETTINGS, tilts: Array.from({ length: 5 }, () => []), }; diff --git a/types/index.ts b/types/index.ts index b98319f..cfbd125 100644 --- a/types/index.ts +++ b/types/index.ts @@ -4,11 +4,18 @@ export type CardStyle = 'standard' | 'color' | 'grayscale'; export type Deck = 'high' | 'common' | 'both' | 'back' | 'all'; export interface Settings { + cardStyle: CardStyle; + notes: boolean; positionBack: boolean; positionFront: boolean; prophecy: boolean; - notes: boolean; - cardStyle: CardStyle; + tilt: boolean; + remoteTilt: boolean; +} + +export interface LocalSettings { + tilt: boolean; + remoteTilt: boolean; } export interface StandardCard { -- 2.49.1 From f61ca0d0a1e80ac6f7985f4e5e83f04a77f4e34b Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Tue, 1 Jul 2025 14:35:32 -0400 Subject: [PATCH 09/20] keep tilts synced --- app/AppContext.tsx | 4 ++++ components/TiltCard.tsx | 12 +++++------- lib/GameStore.ts | 31 +++++++++++++++++++------------ server.ts | 3 +-- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/app/AppContext.tsx b/app/AppContext.tsx index 3205f5c..7261041 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -45,6 +45,10 @@ export function AppProvider({ children }: { children: ReactNode }) { if (tilt[cardIndex]) { emitTilt(cardIndex, tilt[cardIndex]); + } else { + // cardIndex does not matter + // all tilts for this user will be cleared + emitTilt(0, { rotateX: 0, rotateY: 0 }); } }, [tilt]); diff --git a/components/TiltCard.tsx b/components/TiltCard.tsx index bdc51e6..413eb5b 100644 --- a/components/TiltCard.tsx +++ b/components/TiltCard.tsx @@ -23,15 +23,13 @@ export default function TiltCard({ if (!card) return; if (tilt) { - const tilt = gameData.tilts[cardIndex]; - if (!tilt) { + const tilts = gameData.tilts[cardIndex]; + if (!tilts.length) { card.style.transform = `rotateX(0deg) rotateY(0deg)`; return; } - const tilted = tilt.filter(({ rotateX, rotateY }) => rotateX || rotateY); - - const { totalX, totalY } = tilted.reduce( + const { totalX, totalY } = tilts.reduce( ({ totalX, totalY }, { rotateX, rotateY }) => ({ totalX: totalX + rotateX, totalY: totalY + rotateY, @@ -39,8 +37,8 @@ export default function TiltCard({ { totalX: 0, totalY: 0 }, ); - const rotateX = totalX / tilted.length; - const rotateY = totalY / tilted.length; + const rotateX = totalX / tilts.length; + const rotateY = totalY / tilts.length; if (rotateX || rotateY) { card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; diff --git a/lib/GameStore.ts b/lib/GameStore.ts index 64d7fe6..5ff278d 100644 --- a/lib/GameStore.ts +++ b/lib/GameStore.ts @@ -107,11 +107,15 @@ export default class GameStore { return this.gameUpdate(game); } - leaveGame(game: GameState, playerID: string): GameState { + leaveGame(playerID: string): GameUpdate { + const game = this.getGameByPlayerID(playerID); + + this.players.delete(playerID); game.players.delete(playerID); + this._clearTilts(game, playerID); game.lastUpdated = Date.now(); - return game; + return this.gameUpdate(game); } flipCard(gameID: string, cardIndex: number): GameUpdate { @@ -159,15 +163,21 @@ export default class GameStore { if (!cardTilts) throw new Error(`Card tilts ${cardIndex} not found`); - game.tilts[cardIndex] = [ - ...cardTilts.filter((tilt) => tilt.playerID !== playerID), - { playerID, rotateX, rotateY }, - ]; - game.lastUpdated = Date.now(); + this._clearTilts(game, playerID); + + if (rotateX && rotateY) { + game.tilts[cardIndex] = [...game.tilts[cardIndex], { playerID, rotateX, rotateY }]; + game.lastUpdated = Date.now(); + } return this.gameUpdate(game); } + _clearTilts(game: GameState, playerID: string) { + game.tilts = game.tilts.map((card) => card.filter((tilt) => tilt.playerID !== playerID)); + game.lastUpdated = Date.now(); + } + updateSettings(gameID: string, settings: Settings) { const game = this.getGame(gameID); @@ -198,16 +208,13 @@ export default class GameStore { return { dmID, spectatorID, cards, settings, tilts }; } - playerExit(playerID: string): GameState | null { + playerExit(playerID: string): GameUpdate | null { if (this.startUps.has(playerID)) { this.startUps.delete(playerID); return null; } else { - const game = this.getGameByPlayerID(playerID); - - this.players.delete(playerID); - return this.leaveGame(game, playerID); + return this.leaveGame(playerID); } } diff --git a/server.ts b/server.ts index 8678e74..cc887ef 100644 --- a/server.ts +++ b/server.ts @@ -21,7 +21,6 @@ const timedReleases = {}; app.prepare().then(() => { const httpServer = createServer(handler); - const io = new SocketIOServer(httpServer); const broadcast = (event: string, gameUpdate: GameUpdate) => { @@ -32,9 +31,9 @@ app.prepare().then(() => { const timedRelease = (event: string, gameUpdate: GameUpdate, threshold: number) => { const now = Date.now(); const lastEvent = timedReleases[event]; + clearTimeout(lastEvent?.to); if (lastEvent?.embargo >= now) { - clearTimeout(lastEvent.to); const embargo = lastEvent.embargo - now; const to = setTimeout(() => { -- 2.49.1 From 4ec4ac0242cb47717241779905a05ed1ae08e957 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Tue, 1 Jul 2025 16:43:40 -0400 Subject: [PATCH 10/20] setting to disable remote tilt --- app/AppContext.tsx | 18 ++++++++------- components/TiltCard.tsx | 51 ++++++++++++++++++++++++++--------------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/app/AppContext.tsx b/app/AppContext.tsx index 7261041..664186e 100644 --- a/app/AppContext.tsx +++ b/app/AppContext.tsx @@ -41,16 +41,18 @@ export function AppProvider({ children }: { children: ReactNode }) { }); useEffect(() => { - const cardIndex = tilt.findIndex((tilt) => !!tilt); + if (localSettings.remoteTilt) { + const cardIndex = tilt.findIndex((tilt) => !!tilt); - if (tilt[cardIndex]) { - emitTilt(cardIndex, tilt[cardIndex]); - } else { - // cardIndex does not matter - // all tilts for this user will be cleared - emitTilt(0, { rotateX: 0, rotateY: 0 }); + if (tilt[cardIndex]) { + emitTilt(cardIndex, tilt[cardIndex]); + } else { + // cardIndex does not matter + // all tilts for this user will be cleared + emitTilt(0, { rotateX: 0, rotateY: 0 }); + } } - }, [tilt]); + }, [tilt, localSettings]); const handleSelect = (cardID: string) => { setSelectCardIndex(-1); diff --git a/components/TiltCard.tsx b/components/TiltCard.tsx index 413eb5b..87902f2 100644 --- a/components/TiltCard.tsx +++ b/components/TiltCard.tsx @@ -14,8 +14,9 @@ export default function TiltCard({ const cardRef = useRef(null); const { gameData, - settings: { tilt }, + settings: { tilt, remoteTilt }, setTilt, + tilt: localTilts, } = useAppContext(); useEffect(() => { @@ -23,32 +24,44 @@ export default function TiltCard({ if (!card) return; if (tilt) { - const tilts = gameData.tilts[cardIndex]; - if (!tilts.length) { - card.style.transform = `rotateX(0deg) rotateY(0deg)`; - return; - } + if (remoteTilt) { + const tilts = gameData.tilts[cardIndex]; + if (!tilts.length) { + card.style.transform = `rotateX(0deg) rotateY(0deg)`; + return; + } - const { totalX, totalY } = tilts.reduce( - ({ totalX, totalY }, { rotateX, rotateY }) => ({ - totalX: totalX + rotateX, - totalY: totalY + rotateY, - }), - { totalX: 0, totalY: 0 }, - ); + const { totalX, totalY } = tilts.reduce( + ({ totalX, totalY }, { rotateX, rotateY }) => ({ + totalX: totalX + rotateX, + totalY: totalY + rotateY, + }), + { totalX: 0, totalY: 0 }, + ); - const rotateX = totalX / tilts.length; - const rotateY = totalY / tilts.length; + const rotateX = totalX / tilts.length; + const rotateY = totalY / tilts.length; - if (rotateX || rotateY) { - card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; + if (rotateX || rotateY) { + card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; + } else { + card.style.transform = `rotateX(0deg) rotateY(0deg)`; + } } else { - card.style.transform = `rotateX(0deg) rotateY(0deg)`; + console.log(localTilts); + const rotateX = localTilts[cardIndex]?.rotateX || 0; + const rotateY = localTilts[cardIndex]?.rotateY || 0; + + if (rotateX || rotateY) { + card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; + } else { + card.style.transform = `rotateX(0deg) rotateY(0deg)`; + } } } else if (card.style.transform !== 'rotateX(0deg) rotateY(0deg)') { card.style.transform = 'rotateX(0deg) rotateY(0deg)'; } - }, [tilt, gameData]); + }, [tilt, localTilts, gameData]); const handleMouseMove = (e: React.MouseEvent) => { const card = cardRef.current; -- 2.49.1 From ddb1575dc875c6c5d7fdd8eb2afc6fc6e7fb1199 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Wed, 2 Jul 2025 07:58:53 -0400 Subject: [PATCH 11/20] use local tilts, don't wait for server relay --- components/TiltCard.tsx | 47 +++++++++++++++++------------------------ hooks/useSocket.ts | 2 ++ 2 files changed, 21 insertions(+), 28 deletions(-) diff --git a/components/TiltCard.tsx b/components/TiltCard.tsx index 87902f2..63a294e 100644 --- a/components/TiltCard.tsx +++ b/components/TiltCard.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef } from 'react'; import { useAppContext } from '@/app/AppContext'; import type { Tilt } from '@/types'; +const ZERO_ROTATION = 'rotateX(0deg) rotateY(0deg)'; + export default function TiltCard({ children, cardIndex, @@ -24,42 +26,31 @@ export default function TiltCard({ if (!card) return; if (tilt) { - if (remoteTilt) { - const tilts = gameData.tilts[cardIndex]; - if (!tilts.length) { - card.style.transform = `rotateX(0deg) rotateY(0deg)`; - return; - } + const rotateX = localTilts[cardIndex]?.rotateX || 0; + const rotateY = localTilts[cardIndex]?.rotateY || 0; - const { totalX, totalY } = tilts.reduce( - ({ totalX, totalY }, { rotateX, rotateY }) => ({ + const tilts = remoteTilt + ? [...gameData.tilts[cardIndex], { rotateX, rotateY }] + : [{ rotateX, rotateY }]; + + const { totalX, totalY, count } = tilts + .filter(({ rotateX, rotateY }) => !!rotateX && !!rotateY) + .reduce( + ({ totalX, totalY, count }, { rotateX, rotateY }) => ({ totalX: totalX + rotateX, totalY: totalY + rotateY, + count: ++count, }), - { totalX: 0, totalY: 0 }, + { totalX: 0, totalY: 0, count: 0 }, ); - const rotateX = totalX / tilts.length; - const rotateY = totalY / tilts.length; - - if (rotateX || rotateY) { - card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; - } else { - card.style.transform = `rotateX(0deg) rotateY(0deg)`; - } + if (count && (totalX || totalY)) { + card.style.transform = `rotateX(${totalX / count}deg) rotateY(${totalY / count}deg)`; } else { - console.log(localTilts); - const rotateX = localTilts[cardIndex]?.rotateX || 0; - const rotateY = localTilts[cardIndex]?.rotateY || 0; - - if (rotateX || rotateY) { - card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`; - } else { - card.style.transform = `rotateX(0deg) rotateY(0deg)`; - } + card.style.transform = ZERO_ROTATION; } - } else if (card.style.transform !== 'rotateX(0deg) rotateY(0deg)') { - card.style.transform = 'rotateX(0deg) rotateY(0deg)'; + } else if (card.style.transform !== ZERO_ROTATION) { + card.style.transform = ZERO_ROTATION; } }, [tilt, localTilts, gameData]); diff --git a/hooks/useSocket.ts b/hooks/useSocket.ts index 6bdb3b4..4963bf6 100644 --- a/hooks/useSocket.ts +++ b/hooks/useSocket.ts @@ -21,6 +21,8 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP }); socket.on('game-update', (data: GameUpdate) => { + // remove user's own tilts in favor of local values + data.tilts = data.tilts.map((card) => card.filter((tilt) => tilt.playerID !== socket.id)); setGameData(data); }); -- 2.49.1 From c6dfed9bed407168f6919a4b6ee3a8186dcd3fc4 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Wed, 2 Jul 2025 08:19:35 -0400 Subject: [PATCH 12/20] untilt --- components/TiltCard.tsx | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/components/TiltCard.tsx b/components/TiltCard.tsx index 63a294e..fd190ca 100644 --- a/components/TiltCard.tsx +++ b/components/TiltCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useAppContext } from '@/app/AppContext'; import type { Tilt } from '@/types'; @@ -14,6 +14,7 @@ export default function TiltCard({ className?: string; }) { const cardRef = useRef(null); + const [untilt, setUntilt] = useState(false); const { gameData, settings: { tilt, remoteTilt }, @@ -45,15 +46,23 @@ export default function TiltCard({ ); if (count && (totalX || totalY)) { + setUntilt(false); card.style.transform = `rotateX(${totalX / count}deg) rotateY(${totalY / count}deg)`; } else { - card.style.transform = ZERO_ROTATION; + setUntilt(true); } } else if (card.style.transform !== ZERO_ROTATION) { - card.style.transform = ZERO_ROTATION; + setUntilt(true); } }, [tilt, localTilts, gameData]); + useEffect(() => { + const card = cardRef.current; + if (!card || !untilt) return; + + card.style.transform = ZERO_ROTATION; + }, [untilt]); + const handleMouseMove = (e: React.MouseEvent) => { const card = cardRef.current; if (!card) return; @@ -83,7 +92,11 @@ export default function TiltCard({ onMouseMove={tilt ? handleMouseMove : undefined} onMouseLeave={handleMouseLeave} > -
+
setUntilt(false)} + className={`h-full w-full transition-transform ${untilt ? 'duration-500' : 'duration-0'}`} + > {children}
-- 2.49.1 From d531a9bd6acf0539a4f7f1acd0ee05d6f36e2b55 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Wed, 2 Jul 2025 12:01:31 -0400 Subject: [PATCH 13/20] cards that shine --- components/TiltCard.tsx | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/components/TiltCard.tsx b/components/TiltCard.tsx index fd190ca..34e0c9a 100644 --- a/components/TiltCard.tsx +++ b/components/TiltCard.tsx @@ -4,6 +4,24 @@ import type { Tilt } from '@/types'; const ZERO_ROTATION = 'rotateX(0deg) rotateY(0deg)'; +const tiltSheen = (sheen: HTMLDivElement, tiltX: number, tiltY: number) => { + const rect = sheen.getBoundingClientRect(); + const centerX = rect.width / 2; + const centerY = rect.height / 2; + const sheenX = centerX + (tiltY / -20) * centerX; + const sheenY = centerY + (tiltX / 20) * centerY; + + sheen.style.opacity = '1'; + sheen.style.backgroundImage = ` + radial-gradient( + circle at + ${sheenX}px ${sheenY}px, + #ffffff44, + #0000000f + ) + `; +}; + export default function TiltCard({ children, cardIndex, @@ -14,6 +32,7 @@ export default function TiltCard({ className?: string; }) { const cardRef = useRef(null); + const sheenRef = useRef(null); const [untilt, setUntilt] = useState(false); const { gameData, @@ -24,7 +43,8 @@ export default function TiltCard({ useEffect(() => { const card = cardRef.current; - if (!card) return; + const sheen = sheenRef.current; + if (!card || !sheen) return; if (tilt) { const rotateX = localTilts[cardIndex]?.rotateX || 0; @@ -47,7 +67,12 @@ export default function TiltCard({ if (count && (totalX || totalY)) { setUntilt(false); - card.style.transform = `rotateX(${totalX / count}deg) rotateY(${totalY / count}deg)`; + + const x = totalX / count; + const y = totalY / count; + + card.style.transform = `rotateX(${x}deg) rotateY(${y}deg)`; + tiltSheen(sheen, x, y); } else { setUntilt(true); } @@ -58,9 +83,11 @@ export default function TiltCard({ useEffect(() => { const card = cardRef.current; - if (!card || !untilt) return; + const sheen = sheenRef.current; + if (!card || !sheen || !untilt) return; card.style.transform = ZERO_ROTATION; + sheen.style.opacity = '0'; }, [untilt]); const handleMouseMove = (e: React.MouseEvent) => { @@ -88,7 +115,7 @@ export default function TiltCard({ return (
@@ -98,6 +125,10 @@ export default function TiltCard({ className={`h-full w-full transition-transform ${untilt ? 'duration-500' : 'duration-0'}`} > {children} +
); -- 2.49.1 From 522fdf106eea97c3667ec34600d419254fcdb64d Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Wed, 2 Jul 2025 12:07:14 -0400 Subject: [PATCH 14/20] throttle mouse events --- components/TiltCard.tsx | 7 +++++-- hooks/useSocket.ts | 7 ++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/components/TiltCard.tsx b/components/TiltCard.tsx index 34e0c9a..f12fb86 100644 --- a/components/TiltCard.tsx +++ b/components/TiltCard.tsx @@ -1,5 +1,8 @@ import { useEffect, useRef, useState } from 'react'; import { useAppContext } from '@/app/AppContext'; +import throttle from '@/tools/throttle'; + +import { thirtyFPS } from '@/constants/time'; import type { Tilt } from '@/types'; const ZERO_ROTATION = 'rotateX(0deg) rotateY(0deg)'; @@ -90,7 +93,7 @@ export default function TiltCard({ sheen.style.opacity = '0'; }, [untilt]); - const handleMouseMove = (e: React.MouseEvent) => { + const handleMouseMove = throttle((e: React.MouseEvent) => { const card = cardRef.current; if (!card) return; @@ -107,7 +110,7 @@ export default function TiltCard({ newTilt[cardIndex] = { rotateX, rotateY }; setTilt(newTilt); - }; + }, thirtyFPS); const handleMouseLeave = () => { setTilt([]); diff --git a/hooks/useSocket.ts b/hooks/useSocket.ts index 4963bf6..37b1f37 100644 --- a/hooks/useSocket.ts +++ b/hooks/useSocket.ts @@ -1,8 +1,5 @@ import { useEffect } from 'react'; import { socket } from '@/socket'; -import throttle from '@/tools/throttle'; - -import { thirtyFPS } from '@/constants/time'; import type { GameUpdate, Tilt } from '@/types'; interface UseSocketProps { @@ -70,12 +67,12 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP }); }; - const emitTilt = throttle((cardIndex: number, tilt: Tilt) => { + const emitTilt = (cardIndex: number, tilt: Tilt) => { socket.emit('tilt', { cardIndex, tilt, }); - }, thirtyFPS); + }; return { emitFlip, -- 2.49.1 From 2f861dbd389ddb140aea44ae058d6cc6e67aa923 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Wed, 2 Jul 2025 14:47:36 -0400 Subject: [PATCH 15/20] reconnect clients --- hooks/useSocket.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/hooks/useSocket.ts b/hooks/useSocket.ts index 37b1f37..0513e65 100644 --- a/hooks/useSocket.ts +++ b/hooks/useSocket.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { socket } from '@/socket'; import type { GameUpdate, Tilt } from '@/types'; @@ -9,11 +9,15 @@ interface UseSocketProps { } export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketProps) { + const [connect, setConnect] = useState(1); + const [disconnected, setDisconnected] = useState(true); + useEffect(() => { if (gameID) { socket.emit('join', gameID); socket.on('init', (data: GameUpdate) => { + setDisconnected(false); setGameData(data); }); @@ -31,14 +35,20 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP socket.on('flip-error', (error) => { console.error('Error:', error); }); + + socket.on('disconnect', () => { + setDisconnected(true); + }); } return () => { socket.removeAllListeners(); }; - }, [gameID]); + }, [gameID, connect]); const emitFlip = (cardIndex: number) => { + if (disconnected) setConnect(connect + 1); + socket.emit('flip-card', { gameID, cardIndex, @@ -46,6 +56,8 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP }; const emitSettings = (gameData: GameUpdate) => { + if (disconnected) setConnect(connect + 1); + socket.emit('settings', { gameID, gameData, @@ -53,6 +65,8 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP }; const emitRedraw = (cardIndex: number) => { + if (disconnected) setConnect(connect + 1); + socket.emit('redraw', { gameID, cardIndex, @@ -60,6 +74,8 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP }; const emitSelect = (cardIndex: number, cardID: string) => { + if (disconnected) setConnect(connect + 1); + socket.emit('select', { gameID, cardIndex, @@ -68,6 +84,8 @@ export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketP }; const emitTilt = (cardIndex: number, tilt: Tilt) => { + if (disconnected) setConnect(connect + 1); + socket.emit('tilt', { cardIndex, tilt, -- 2.49.1 From 743177486ae62a94b56cd5a16aa1d6e88fbf9996 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Wed, 2 Jul 2025 15:31:50 -0400 Subject: [PATCH 16/20] settings menu sizing is a bit better --- components/Settings.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/components/Settings.tsx b/components/Settings.tsx index 16cbb53..965a3a4 100644 --- a/components/Settings.tsx +++ b/components/Settings.tsx @@ -127,7 +127,16 @@ export default function Settings() { className={`transition-all duration-250 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`} >
-- 2.49.1 From 0ed38ee09852941eb7dd65fa68aafa85002d31e3 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Wed, 2 Jul 2025 15:48:19 -0400 Subject: [PATCH 17/20] more Settings layout tweaks --- components/Settings.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/components/Settings.tsx b/components/Settings.tsx index 965a3a4..2cb699b 100644 --- a/components/Settings.tsx +++ b/components/Settings.tsx @@ -132,16 +132,15 @@ export default function Settings() { flex flex-col items-center justify-evenly bg-slate-800 text-yellow-400 rounded-lg border border-yellow-400 - py-3 px-4 + h-full py-3 px-4 transition-all duration-250 - h-full - ${open ? `opacity-100 w-[350px] ${isDM ? 'max-h-[400px]' : 'max-h-[175px]'}` : 'opacity-0 w-0 max-h-0'} + ${open ? `opacity-100 ${isDM ? 'w-[350px] max-h-[375px]' : 'w-[300px] max-h-[150px]'}` : 'opacity-0 w-0 max-h-0'} `} > - + -- 2.49.1 From ed6fef5ef16ebd0204e2f4f8f6d07898ad6fb158 Mon Sep 17 00:00:00 2001 From: Gavin McDonald Date: Thu, 3 Jul 2025 09:11:30 -0400 Subject: [PATCH 18/20] reanimate Switches --- app/[gameID]/page.tsx | 2 +- components/Settings.tsx | 152 -------------------------- components/Settings/CardStyle.tsx | 55 ++++++++++ components/Settings/ExternalLinks.tsx | 16 +++ components/Settings/GameLinks.tsx | 27 +++++ components/Settings/Permissions.tsx | 34 ++++++ components/Settings/index.tsx | 56 ++++++++++ components/Switch.tsx | 36 ++++-- 8 files changed, 215 insertions(+), 163 deletions(-) delete mode 100644 components/Settings.tsx create mode 100644 components/Settings/CardStyle.tsx create mode 100644 components/Settings/ExternalLinks.tsx create mode 100644 components/Settings/GameLinks.tsx create mode 100644 components/Settings/Permissions.tsx create mode 100644 components/Settings/index.tsx diff --git a/app/[gameID]/page.tsx b/app/[gameID]/page.tsx index 1219b3d..d873fda 100644 --- a/app/[gameID]/page.tsx +++ b/app/[gameID]/page.tsx @@ -7,7 +7,7 @@ import { useAppContext } from '@/app/AppContext'; import CardSelect from '@/components/CardSelect'; import Notes from '@/components/Notes'; import NotFound from '@/components/NotFound'; -import Settings from '@/components/Settings'; +import Settings from '@/components/Settings/index'; import { SpectatorLink } from '@/components/SpectatorLink'; import TarokkaGrid from '@/components/TarokkaGrid'; diff --git a/components/Settings.tsx b/components/Settings.tsx deleted file mode 100644 index 2cb699b..0000000 --- a/components/Settings.tsx +++ /dev/null @@ -1,152 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { Settings as Gear } from 'lucide-react'; -import { Cinzel_Decorative } from 'next/font/google'; - -import { useAppContext } from '@/app/AppContext'; -import BuyMeACoffee from '@/components/BuyMeACoffee'; -import CopyButton from '@/components/CopyButton'; -import GitHubButton from '@/components/GitHubButton'; -import Scrim from '@/components/Scrim'; -import Switch from '@/components/Switch'; - -import { LOCAL_SETTINGS, SPECTATOR_SETTINGS } from '@/constants'; -import type { CardStyle, LocalSettings } from '@/types'; - -const cinzel = Cinzel_Decorative({ - variable: '--font-cinzel', - subsets: ['latin'], - weight: '400', -}); - -const cardStyleOptions: CardStyle[] = ['standard', 'color', 'grayscale']; - -export default function Settings() { - const [open, setOpen] = useState(false); - const { gameData, isDM, settings, emitSettings, setLocalSettings } = useAppContext(); - - const togglePermission = (key: keyof LocalSettings) => { - if (LOCAL_SETTINGS.includes(key)) { - setLocalSettings((prev) => ({ ...prev, [key]: !prev[key] })); - } else if (isDM) { - emitSettings({ - ...gameData, - settings: { - ...gameData.settings, - [key]: !gameData.settings[key], - }, - }); - } - }; - - const tuneRadio = (cardStyle: CardStyle) => { - emitSettings({ - ...gameData, - settings: { - ...gameData.settings, - cardStyle, - }, - }); - }; - - const Icon = () => ( - - ); - - const Links = () => ( - <> - {isDM && ( - - )} - - - ); - - const Permissions = () => ( - <> - {Object.entries(settings) - .filter(([_key, value]) => typeof value === 'boolean') - .filter(([key]) => isDM || SPECTATOR_SETTINGS.includes(key)) - .map(([key, value]) => ( - togglePermission(key)} /> - ))} - - ); - - const CardStyle = () => - isDM ? ( -
-
Card style:
-
- {cardStyleOptions.map((option, index) => ( - - ))} -
-
- ) : null; - - return ( -
- setOpen((prev) => !prev)} - className={`transition-all duration-250 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`} - > -
- - - - - - - -
-
- -
- ); -} diff --git a/components/Settings/CardStyle.tsx b/components/Settings/CardStyle.tsx new file mode 100644 index 0000000..88d0fe9 --- /dev/null +++ b/components/Settings/CardStyle.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useAppContext } from '@/app/AppContext'; +import type { CardStyle } from '@/types'; + +const cardStyleOptions: CardStyle[] = ['standard', 'color', 'grayscale']; + +export default function CardStyle() { + const { gameData, isDM, settings, emitSettings } = useAppContext(); + + const tuneRadio = (cardStyle: CardStyle) => { + emitSettings({ + ...gameData, + settings: { + ...gameData.settings, + cardStyle, + }, + }); + }; + + return isDM ? ( +
+
Card style:
+
+ {cardStyleOptions.map((option, index) => ( + + ))} +
+
+ ) : null; +} diff --git a/components/Settings/ExternalLinks.tsx b/components/Settings/ExternalLinks.tsx new file mode 100644 index 0000000..d5aecf1 --- /dev/null +++ b/components/Settings/ExternalLinks.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { useAppContext } from '@/app/AppContext'; +import BuyMeACoffee from '@/components/BuyMeACoffee'; +import GitHubButton from '@/components/GitHubButton'; + +export default function CardStyle() { + const { isDM } = useAppContext(); + + return ( + + + + + ); +} diff --git a/components/Settings/GameLinks.tsx b/components/Settings/GameLinks.tsx new file mode 100644 index 0000000..a2d9d9d --- /dev/null +++ b/components/Settings/GameLinks.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useAppContext } from '@/app/AppContext'; +import CopyButton from '@/components/CopyButton'; + +export default function Links() { + const { gameData, isDM } = useAppContext(); + + return ( + <> + {isDM && ( + + )} + + + ); +} diff --git a/components/Settings/Permissions.tsx b/components/Settings/Permissions.tsx new file mode 100644 index 0000000..a95a1a5 --- /dev/null +++ b/components/Settings/Permissions.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useAppContext } from '@/app/AppContext'; +import Switch from '@/components/Switch'; +import { LOCAL_SETTINGS, SPECTATOR_SETTINGS } from '@/constants'; + +export default function Permissions() { + const { gameData, isDM, settings, emitSettings, setLocalSettings } = useAppContext(); + + const togglePermission = (key: string) => { + if (LOCAL_SETTINGS.includes(key)) { + setLocalSettings((prev) => ({ ...prev, [key]: !prev[key] })); + } else if (isDM) { + emitSettings({ + ...gameData, + settings: { + ...gameData.settings, + [key]: !gameData.settings[key], + }, + }); + } + }; + + return ( + <> + {Object.entries(settings) + .filter(([_key, value]) => typeof value === 'boolean') + .filter(([key]) => isDM || SPECTATOR_SETTINGS.includes(key)) + .map(([key, value]) => ( + togglePermission(key)} /> + ))} + + ); +} diff --git a/components/Settings/index.tsx b/components/Settings/index.tsx new file mode 100644 index 0000000..6cc8903 --- /dev/null +++ b/components/Settings/index.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { useState } from 'react'; +import { Settings as Gear } from 'lucide-react'; +import { Cinzel_Decorative } from 'next/font/google'; + +import { useAppContext } from '@/app/AppContext'; +import Scrim from '@/components/Scrim'; + +import CardStyle from './CardStyle'; +import ExternalLinks from './ExternalLinks'; +import GameLinks from './GameLinks'; +import Permissions from './Permissions'; + +const cinzel = Cinzel_Decorative({ + variable: '--font-cinzel', + subsets: ['latin'], + weight: '400', +}); + +export default function Settings() { + const [open, setOpen] = useState(false); + const { isDM } = useAppContext(); + + return ( +
+ setOpen((prev) => !prev)} + className={`transition-all duration-250 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`} + > +
+ + + + +
+
+ +
+ ); +} diff --git a/components/Switch.tsx b/components/Switch.tsx index 0931631..f43d140 100644 --- a/components/Switch.tsx +++ b/components/Switch.tsx @@ -1,25 +1,41 @@ +import type { ChangeEventHandler } from 'react'; + export interface SwitchProps { label: string; value: boolean; - toggleAction: (event: React.ChangeEvent) => void; + toggleAction: ChangeEventHandler; } +const nonInitialCaps = /(?!^)([A-Z])/g; + export default function Switch({ label, value, toggleAction }: SwitchProps) { return (