Compare commits

..

36 Commits

Author SHA1 Message Date
Gavin McDonald
269abd237e middleware -> proxy 2025-12-06 17:40:39 -05:00
Gavin McDonald
52636017a5 up to date, audited 391 packages in 15s
144 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
2025-12-06 17:37:07 -05:00
Gavin McDonald
9ca34540e8 fix tilt settings 2025-07-08 16:04:30 -04:00
Gavin McDonald
6e312d5d2e touch tilt 2025-07-08 15:49:02 -04:00
Gavin McDonald
11245bf4d8 fix mobile spacing and scrolling 2025-07-08 08:24:40 -04:00
Gavin McDonald
62c3b7b557 clean up fonts 2025-07-08 07:52:43 -04:00
Gavin McDonald
01a05d2aa1 update screenshot 2025-07-04 14:38:53 -04:00
Gavin McDonald
c6c1c63dcd version bump 2025-07-04 14:31:12 -04:00
Gavin McDonald
87de2f57b2 fix typo 2025-07-04 14:30:42 -04:00
Gavin McDonald
b4b0c853f1 simplify tools imports 2025-07-04 14:18:38 -04:00
Gavin McDonald
c6e316a1f8 simplify tilt calculations 2025-07-04 13:59:57 -04:00
Gavin McDonald
d3eb6f1b46 a bit less computation 2025-07-03 21:05:04 -04:00
Gavin McDonald
8cbf281ef8 version bump 2025-07-03 17:03:54 -04:00
Gavin McDonald
fc0466ae89 flippable sheen 2025-07-03 16:38:53 -04:00
Gavin McDonald
22f949aaf8 gussy up Notes 2025-07-03 16:15:08 -04:00
Gavin McDonald
f0aee17ea0 fix default settings values 2025-07-03 15:54:34 -04:00
fa352238bb teletilt (#3)
- Context
- sync _tilts_ between participants
- shiny cards
- reconnect clients
- updates Settings
- re-animate Switches

Co-authored-by: Gavin McDonald <gavinmcdoh@gmail.com>
Reviewed-on: #3
2025-07-03 14:40:35 -04:00
59aa904c5a useSocket (#2)
Co-authored-by: Gavin McDonald <gavinmcdoh@gmail.com>
Reviewed-on: #2
2025-06-16 20:11:26 -04:00
Gavin McDonald
06a87381d5 Tilt them cards 2025-06-16 14:05:34 -04:00
5444e25249 stack-the-deck (#1)
Allow for redrawing or explicitly selecting a card for replacement.

Co-authored-by: Gavin McDonald <gavinmcdoh@gmail.com>
Reviewed-on: #1
2025-06-13 07:38:51 -04:00
Gavin McDonald
c4f4b09f18 a more-accurate command name 2025-05-17 18:11:47 -04:00
Gavin McDonald
25493671c5 gussy up the landing page 2025-05-17 18:10:53 -04:00
Gavin McDonald
7e8fe9eb79 attemp to fix social sharing links 2025-05-09 15:35:59 -04:00
Gavin McDonald
8412ec49a2 update social share images 2025-05-09 15:00:43 -04:00
Gavin McDonald
d4100cc44f drop shadow on start button 2025-05-09 14:47:29 -04:00
Gavin McDonald
4448bc9c57 new deck style 2025-05-09 14:32:38 -04:00
Gavin McDonald
a15b80c23e favicons and such 2025-05-09 09:30:54 -04:00
Gavin McDonald
2108324cf4 a bit less logging 2025-05-08 18:17:55 -04:00
Gavin McDonald
8bf8b4c5cb glowing cards 2025-05-03 16:55:29 -04:00
Gavin McDonald
493891a8e2 Settings tweaks 2025-05-03 16:44:05 -04:00
Gavin McDonald
af70401916 BuyMeACoffee glow 2025-05-03 16:30:40 -04:00
Gavin McDonald
82ccb0f6fb embiggen the spectator link 2025-05-03 16:28:02 -04:00
Gavin McDonald
6a1f1174a3 GitHub and Buy Me a Coffee links, various tweaks 2025-05-02 20:08:04 -04:00
Gavin McDonald
35afa28e44 a more-obvious spectator link 2025-05-01 18:31:37 -04:00
Gavin McDonald
bc7339439c less logging 2025-05-01 16:36:46 -04:00
Gavin McDonald
af26a64e8b simplify things a bit 2025-05-01 16:28:54 -04:00
78 changed files with 2703 additions and 1179 deletions

View File

@@ -2,16 +2,18 @@
**Tarokka** is a real-time Tarokka card reading app for _Dungeons & Dragons: Curse of Strahd_. It simulates Madam Evas fortune-telling, revealing a heros fate and Strahds secrets, and is built to deliver an authentic, immersive experience for DMs and players alike. **Tarokka** is a real-time Tarokka card reading app for _Dungeons & Dragons: Curse of Strahd_. It simulates Madam Evas fortune-telling, revealing a heros fate and Strahds secrets, and is built to deliver an authentic, immersive experience for DMs and players alike.
![screenshot](public/screenshot.png) To be honest, Id say this is overkill for what is a relatively small aspect of _Curse of Strahd_ but I had fun making it. Its a [Next.js](https://nextjs.org/) app running a custom server in order to employ [Socket.IO](https://socket.io/). When a new session is created the DM (dungeon master) is presented a game with the cards shuffled and laid out on the table. There is a link available for sharing the session to any spectators. The DM has access to settings that allow for limiting what info spectators have access to (card purpose, prophecy, notes) and the style of cards used.
![screenshot](public/img/screenshot.png)
You can see it live at: You can see it live at:
👉 [https://tarokka.mcmorgans.us](https://tarokka.mcmorgans.us) 👉 [https://tarokka.app](https://tarokka.app)
--- ---
## ✨ Features ## ✨ Features
- 🔮 **Faithful to the Tarokka Deck**: Supports all cards and positions used by Madam Eva's reading. - 🔮 **Faithful to the Tarokka Deck**: Supports all cards and positions used by Madam Evas reading.
- 💬 Dynamic prophecy rendering based on card and position - 💬 Dynamic prophecy rendering based on card and position
- 🎨 Multiple card styles - 🎨 Multiple card styles
- 🧙 Separate DM and Spectator Views - 🧙 Separate DM and Spectator Views

92
app/AppContext.tsx Normal file
View File

@@ -0,0 +1,92 @@
'use client';
import { createContext, useContext, useEffect, useState } from 'react';
import useSocket from '@/hooks/useSocket';
import { reduceTilts } from '@/tools';
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<AppContext | undefined>(undefined);
export interface AppContext {
gameData: GameUpdate;
isDM: boolean;
noGame: boolean;
selectCardIndex: number;
settings: Settings;
tilts: Tilt[];
emitFlip: (cardIndex: number) => void;
emitSettings: (gameData: GameUpdate) => void;
emitRedraw: (cardIndex: number) => void;
emitSelect: (cardID: string) => void;
setGameID: (gameID: string) => void;
setLocalSettings: Dispatch<SetStateAction<LocalSettings>>;
setSelectCardIndex: (cardIndex: number) => void;
setLocalTilt: (tilt: Tilt[]) => void;
}
export function AppProvider({ children }: { children: ReactNode }) {
const [gameData, setGameData] = useState<GameUpdate>({ ...GAME_START });
const [localSettings, setLocalSettings] = useState<LocalSettings>(() => ({ ...LOCAL_DEFAULTS }));
const [gameID, setGameID] = useState('');
const [noGame, setNoGame] = useState(false);
const [selectCardIndex, setSelectCardIndex] = useState(-1);
const [localTilt, setLocalTilt] = useState<Tilt[]>([]);
const { emitFlip, emitRedraw, emitSelect, emitSettings, emitTilt } = useSocket({
gameID,
setGameData,
setNoGame,
});
useEffect(() => {
if (localSettings.remoteTilt) {
const cardIndex = localTilt.findIndex((tilt) => !!tilt);
if (localTilt[cardIndex]) {
emitTilt(cardIndex, localTilt[cardIndex]);
} else {
// cardIndex does not matter
// all tilts for this user will be cleared
emitTilt(0, { percentX: -1, percentY: -1, rotateX: 0, rotateY: 0 });
}
}
}, [localTilt, localSettings]);
const handleSelect = (cardID: string) => {
setSelectCardIndex(-1);
emitSelect(selectCardIndex, cardID);
};
const { dmID } = gameData;
const isDM = !!dmID;
const settings = { ...gameData.settings, ...localSettings };
const appInterface = {
gameData,
isDM,
noGame,
selectCardIndex,
settings,
tilts: reduceTilts(gameData, localTilt, settings),
emitFlip,
emitSettings,
emitRedraw,
emitSelect: handleSelect,
setGameID,
setLocalSettings,
setSelectCardIndex,
setLocalTilt,
};
return <AppContext.Provider value={appInterface}>{children}</AppContext.Provider>;
}
export function useAppContext(): AppContext {
const context = useContext(AppContext);
if (!context) throw new Error('useAppContext must be used within AppProvider');
return context;
}

View File

@@ -1,112 +1,35 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { socket } from '@/socket';
import Settings from '@/components/Settings'; import { useAppContext } from '@/app/AppContext';
import Card from '@/components/Card'; import CardSelect from '@/components/CardSelect';
import Notes from '@/components/Notes'; import Notes from '@/components/Notes';
import NotFound from '@/components/NotFound'; import NotFound from '@/components/NotFound';
import { cardMap, layout } from '@/constants/tarokka'; import Settings from '@/components/Settings/index';
import { SpectatorLink } from '@/components/SpectatorLink';
import type { GameUpdate, ClientUpdate } from '@/types'; import TarokkaGrid from '@/components/TarokkaGrid';
export default function GamePage() { export default function GamePage() {
const { gameID: gameIDParam } = useParams(); const { noGame, setGameID } = useAppContext();
const { gameID } = useParams();
const [gameID, setGameID] = useState('');
const [noGame, setNoGame] = useState(false);
const [gameData, setGameData] = useState<GameUpdate>({
dmID: '',
spectatorID: '',
cards: [],
settings: {
positionBack: false,
positionFront: false,
prophecy: false,
notes: false,
cardStyle: 'color',
},
});
const { dmID, cards, settings } = gameData;
const isDM = !!dmID;
useEffect(() => {
if (gameIDParam) {
setGameID(Array.isArray(gameIDParam) ? gameIDParam[0] : gameIDParam);
}
}, [gameIDParam]);
useEffect(() => { useEffect(() => {
if (gameID) { if (gameID) {
socket.emit('join', gameID); setGameID(Array.isArray(gameID) ? gameID[0] : gameID);
socket.on('init', (data: GameUpdate) => {
setGameData(data);
});
socket.on('game-update', (data: GameUpdate) => {
setGameData(data);
});
socket.on('join-error', (error) => {
console.error('Error:', error);
setNoGame(true);
});
socket.on('flip-error', (error) => {
console.error('Error:', error);
});
} }
return () => {
socket.removeAllListeners();
};
}, [gameID]); }, [gameID]);
const flipCard = (cardIndex: number) => {
const flip: ClientUpdate = {
gameID,
cardIndex,
};
socket.emit('flip-card', flip);
};
const handleSettings = (gameData: GameUpdate) => {
socket.emit('settings', { gameID, gameData });
};
// 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 ? ( return noGame ? (
<NotFound /> <NotFound />
) : cards ? ( ) : (
<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="h-dvh flex flex-col items-center justify-center gap-4 bg-[url('/img/table3.png')] bg-cover bg-center">
{isDM && <Settings gameData={gameData} changeAction={handleSettings} />} <SpectatorLink />
<div className="grid grid-cols-3 grid-rows-3 gap-8 w-fit mx-auto"> <Settings />
{Array.from({ length: 9 }) <TarokkaGrid />
.map(arrangeCards) <Notes />
.map((card, index) => ( <CardSelect />
<div key={index} className="aspect-[2/3]}">
{card && (
<Card
dm={isDM}
card={card}
position={layout[cardMap[index]]}
settings={settings}
flipAction={() => flipCard(cardMap[index])}
/>
)}
</div>
))}
</div>
<Notes gameData={gameData} show={cards.every(({ flipped }) => flipped)} />
</main> </main>
) : null; );
} }

BIN
app/apple-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -40,3 +40,21 @@ body {
.rotate-y-180 { .rotate-y-180 {
transform: rotateY(180deg); transform: rotateY(180deg);
} }
.see-through {
mask-image: radial-gradient(circle at center, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 1) 75%);
mask-size: cover;
mask-repeat: no-repeat;
-webkit-mask-image: radial-gradient(circle at center, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 1) 75%);
-webkit-mask-size: cover;
-webkit-mask-repeat: no-repeat;
}
.scrollbar-hide {
scrollbar-width: none;
-ms-overflow-style: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}

BIN
app/icon0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

3
app/icon1.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -1,28 +1,22 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Pirata_One, Eagle_Lake, Cinzel_Decorative } from 'next/font/google'; import { Eagle_Lake } from 'next/font/google';
import { AppProvider } from '@/app/AppContext';
import './globals.css'; import './globals.css';
const pirataOne = Pirata_One({
variable: '--font-pirata',
subsets: ['latin'],
weight: '400',
});
const eagleLake = Eagle_Lake({ const eagleLake = Eagle_Lake({
variable: '--font-eagle-lake', variable: '--font-eagle-lake',
subsets: ['latin'], subsets: ['latin'],
weight: '400', weight: '400',
}); });
const cinzel = Cinzel_Decorative({
variable: '--font-cinzel',
subsets: ['latin'],
weight: '400',
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Tarokka', title: 'Tarokka',
description: 'Fortune telling for D&Ds Curse of Strahd', description: 'Fortune telling for D&Ds Curse of Strahd',
metadataBase: new URL('https://tarokka.app'),
appleWebApp: {
title: 'Tarokka',
statusBarStyle: 'black-translucent',
},
}; };
export default function RootLayout({ export default function RootLayout({
@@ -31,11 +25,10 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html <html lang="en" className={`${eagleLake.variable} antialiased overscroll-none`}>
lang="en" <body className={`${eagleLake.className} antialiased h-dvh`}>
className={`${pirataOne.variable} ${eagleLake.variable} ${cinzel.variable} antialiased`} <AppProvider>{children}</AppProvider>
> </body>
<body className={`${eagleLake.className} antialiased`}>{children}</body>
</html> </html>
); );
} }

22
app/manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "Tarokka",
"short_name": "Tarokka",
"icons": [
{
"src": "/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"theme_color": "#000000",
"background_color": "#000000",
"display": "standalone"
}

BIN
app/opengraph-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 747 KiB

View File

@@ -16,13 +16,19 @@ export default function Home() {
}; };
return ( return (
<main className="min-h-screen flex items-center justify-center bg-[url('/img/table3.png')] bg-cover bg-center"> <main className="flex justify-center items-center h-dvh text-yellow-400 bg-[url('/img/table3.png')] bg-cover bg-center">
<button <div className="flex flex-col items-center gap-8 text-center">
onClick={handleCreateGame} <h1 className="text-5xl font-bold text-center text-primary">Tarokka</h1>
className="bg-slate-800 hover:bg-slate-700 text-yellow-400 hover:text-yellow-300 text-lg px-6 py-3 rounded-lg shadow transition-all duration-250 cursor-pointer" <p className="text-l text-center w-[350px] m-auto">
> Online Tarokka readings for <em>Dungeons & Dragons: Curse of Strahd</em>.
Create New Game </p>
</button> <button
onClick={handleCreateGame}
className="bg-slate-800 hover:bg-slate-700 border border-yellow-500/25 hover:drop-shadow-[0_0_3px_rgba(255,215,0,0.5)] hover:text-yellow-300 text-lg px-6 py-3 rounded-lg shadow transition-all duration-250 cursor-pointer"
>
Create New Game
</button>
</div>
</main> </main>
); );
} }

BIN
app/twitter-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

View File

@@ -0,0 +1,17 @@
'use client';
type BuyMeACoffeeProps = {
className?: string;
};
export default function BuyMeACoffee({ className = '' }: BuyMeACoffeeProps) {
return (
<a
href="https://www.buymeacoffee.com/mcdoh"
className={`transition hover:drop-shadow-[0_0_3px_#ffd700] ${className}`}
target="_blank"
>
<img src="/img/bmc-button.svg" alt="Buy Me A Coffee" className="h-full" />
</a>
);
}

View File

@@ -1,33 +1,43 @@
'use client'; 'use client';
import { useState } from 'react';
import { useAppContext } from '@/app/AppContext';
import TiltCard from '@/components/TiltCard';
import ToolTip from '@/components/ToolTip'; import ToolTip from '@/components/ToolTip';
import tarokkaCards from '@/constants/tarokkaCards'; import StackTheDeck from '@/components/StackTheDeck';
import getCardInfo from '@/tools/getCardInfo'; import Sheen from '@/components/Sheen';
import getURL from '@/tools/getURL'; import { getCardInfo, getURL } from '@/tools';
import { Layout, Settings, TarokkaGameCard } from '@/types'; import tarokkaCards from '@/constants/tarokkaCards';
import { layout } from '@/constants/tarokka';
import { TarokkaGameCard } from '@/types';
const cardBack = tarokkaCards.find((card) => card.back)!; const cardBack = tarokkaCards.find((card) => card.back)!;
type CardProps = { type CardProps = {
dm: boolean;
card: TarokkaGameCard; card: TarokkaGameCard;
position: Layout; cardIndex: number;
settings: Settings;
flipAction: () => void;
}; };
export default function Card({ dm, card, position, settings, flipAction }: CardProps) { export default function Card({ card, cardIndex }: CardProps) {
const [tooltip, setTooltip] = useState<React.ReactNode>(null);
const { emitFlip, gameData, emitRedraw, setSelectCardIndex } = useAppContext();
const { dmID, settings } = gameData;
const isDM = !!dmID;
const { aria, flipped } = card; const { aria, flipped } = card;
const position = layout[cardIndex];
const handleClick = () => { const handleClick = () => {
if (dm) { if (isDM) {
flipAction(); emitFlip(cardIndex);
} }
}; };
const getTooltip = () => { const getTooltip = () => {
const text = getCardInfo(card, position, dm, settings); const text = getCardInfo(card, position, isDM, settings);
return text.length ? ( return text.length ? (
<> <>
@@ -42,30 +52,50 @@ export default function Card({ dm, card, position, settings, flipAction }: CardP
}; };
return ( return (
<ToolTip content={getTooltip()}> <ToolTip content={tooltip || getTooltip()}>
<div <TiltCard
className={`relative h-[21vh] w-[15vh] perspective transition-transform duration-200 hover:scale-150 z-0 hover:z-10 ${dm ? 'cursor-pointer' : ''} `} className={`h-[21vh] w-[15vh] max-w-[30vw] relative perspective transition-transform duration-200 z-0 hover:z-10 hover:scale-150 ${isDM ? 'cursor-pointer' : ''} `}
onClick={handleClick} cardIndex={cardIndex}
> >
<div <div
className={`transition-transform duration-500 transform-style-preserve-3d ${flipped ? 'rotate-y-180' : ''}`} className={`absolute inset-0 transition-transform duration-500 transform-style-preserve-3d ${flipped ? 'rotate-y-180' : ''}`}
onClick={handleClick}
> >
<div className="absolute group inset-0 backface-hidden"> <div className="absolute inset-0 group backface-hidden">
{isDM && (
<>
<img src={getURL(card, settings)} alt={aria} className="absolute rounded-lg" />
<img
src={getURL(cardBack as TarokkaGameCard, settings)}
alt=""
className={`absolute rounded-lg see-through`}
/>
</>
)}
<img <img
src={getURL(cardBack as TarokkaGameCard, settings)} src={getURL(cardBack as TarokkaGameCard, settings)}
alt="Card Back" alt="Card Back"
className="rounded-lg border border-yellow-500" className={`absolute rounded-lg ${isDM ? 'transition duration-500 group-hover:opacity-0' : ''} ${settings.cardStyle === 'grayscale' ? 'border border-yellow-500/25 group-hover:drop-shadow-[0_0_3px_#ffd700/50]' : ''}`}
/> />
{isDM && !flipped && (
<StackTheDeck
onRedraw={() => emitRedraw(cardIndex)}
onSelect={() => setSelectCardIndex(cardIndex)}
onHover={setTooltip}
/>
)}
<Sheen cardIndex={cardIndex} />
</div> </div>
<div className="absolute group inset-0 backface-hidden rotate-y-180"> <div className="absolute inset-0 backface-hidden rotate-y-180">
<img <img
src={getURL(card, settings)} src={getURL(card, settings)}
alt={aria} alt={aria}
className="rounded-lg border border-yellow-500 " className="rounded-lg border border-yellow-500/25 hover:drop-shadow-[0_0_3px_#ffd700/50]"
/> />
<Sheen cardIndex={cardIndex} />
</div> </div>
</div> </div>
</div> </TiltCard>
</ToolTip> </ToolTip>
); );
} }

68
components/CardSelect.tsx Normal file
View File

@@ -0,0 +1,68 @@
'use client';
import { CircleX } from 'lucide-react';
import { useAppContext } from '@/app/AppContext';
import TarokkaDeck from '@/lib/TarokkaDeck';
import { getURL } from '@/tools';
import { Deck } from '@/types';
const tarokkaDeck = new TarokkaDeck();
type CardSelectProps = {
className?: string;
};
export default function CardSelect({ className = '' }: CardSelectProps) {
const { gameData, emitSelect, 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<HTMLElement>) => {
if (event.target === event.currentTarget) {
close();
}
};
if (!selectDeck) return null;
const cards = selectDeck === 'high' ? tarokkaDeck.getHigh() : tarokkaDeck.getLow();
return (
<div
onClick={handleClose}
className={`fixed inset-0 flex justify-center items-center p-4 bg-black/20 backdrop-blur-sm z-40 ${className}`}
>
<button
className={`fixed top-4 right-4 p-2 transition-all duration-250 text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] cursor-pointer`}
onClick={close}
>
<CircleX className="w-6 h-6" />
</button>
<div
onClick={handleClose}
className={`flex flex-wrap justify-center items-center gap-3 h-dvh w-2/3 overflow-scroll scrollbar-hide p-4`}
>
{cards
.filter(({ id }) => !handIDs.includes(id))
.map((card) => (
<div
key={card.id}
className={`relative h-[21vh] w-[15vh] perspective transition-transform duration-200 hover:scale-150 z-0 hover:z-10`}
onClick={() => emitSelect(card.id)}
>
<img
src={getURL(card, settings)}
alt={card.aria}
className="rounded-lg border border-yellow-500/25 hover:drop-shadow-[0_0_3px_#ffd700/50]"
/>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,22 +1,26 @@
'use client'; 'use client';
import { useState } from 'react'; import { ForwardRefExoticComponent, RefAttributes, useState } from 'react';
import { Copy as CopyIcon, Check as CheckIcon } from 'lucide-react'; import { LucideProps, Copy as CopyIcon, Check as CheckIcon } from 'lucide-react';
import ToolTip from '@/components/ToolTip'; import ToolTip from '@/components/ToolTip';
type CopyButtonProps = { type CopyButtonProps = {
title?: string; title?: string;
copy: string; copy: string;
Icon?: ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>;
tooltip?: string | string[]; tooltip?: string | string[];
className?: string; className?: string;
size?: number;
}; };
export default function CopyButton({ export default function CopyButton({
title, title,
copy, copy,
Icon = CopyIcon,
tooltip = ['Copy', 'Copied'], tooltip = ['Copy', 'Copied'],
className, className,
size = 16,
}: CopyButtonProps) { }: CopyButtonProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -42,9 +46,9 @@ export default function CopyButton({
<div className="flex items-center gap-2 w-full text-sm font-medium"> <div className="flex items-center gap-2 w-full text-sm font-medium">
{title} {title}
{copied ? ( {copied ? (
<CheckIcon className="ml-auto" size={16} /> <CheckIcon className="ml-auto" size={size} />
) : ( ) : (
<CopyIcon className="ml-auto" size={16} /> <Icon className="ml-auto" size={size} />
)} )}
</div> </div>
</ToolTip> </ToolTip>

View File

@@ -0,0 +1,21 @@
'use client';
type GitHubButtonProps = {
className?: string;
};
export default function GitHubButton({ className = '' }: GitHubButtonProps) {
return (
<a
href="https://github.com/mcdoh/tarokka"
className={className}
target="_blank"
rel="noopener noreferrer"
>
<button className="h-full w-full flex flex-row justify-center items-center gap-3 bg-slate-700 rounded-[6px] hover:bg-slate-600 transition cursor-pointer">
<img src="/img/github-mark-white.svg" alt="GitHub" className="h-[22px] w-[22px]" />
<img src="/img/github-logo-white.png" alt="GitHub" className="h-[22px]" />
</button>
</a>
);
}

View File

@@ -1,22 +1,20 @@
'use client'; 'use client';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { ScrollText } from 'lucide-react'; import { CircleX, ScrollText } from 'lucide-react';
import { useAppContext } from '@/app/AppContext';
import CopyButton from '@/components/CopyButton'; import CopyButton from '@/components/CopyButton';
import Scrim from '@/components/Scrim'; import Scrim from '@/components/Scrim';
import getCardInfo from '@/tools/getCardInfo'; import { getCardInfo } from '@/tools';
import { cardMap, layout } from '@/constants/tarokka'; import { cardMap, layout } from '@/constants/tarokka';
import { GameUpdate } from '@/types'; export default function Notes() {
const { gameData } = useAppContext();
const { dmID, cards, settings } = gameData;
type NotesProps = {
gameData: GameUpdate;
show: boolean;
};
export default function Notes({ gameData: { dmID, cards, settings }, show }: NotesProps) {
const isDM = !!dmID; const isDM = !!dmID;
const show = cards.every(({ flipped }) => flipped);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -42,7 +40,7 @@ export default function Notes({ gameData: { dmID, cards, settings }, show }: Not
className={`fixed bottom-4 right-4 z-25 transition-all duration-250 ${show ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`} className={`fixed bottom-4 right-4 z-25 transition-all duration-250 ${show ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
> >
<button <button
className={`text-yellow-400 hover:text-yellow-300 p-2 transition-all duration-250 cursor-pointer ${showNotes ? 'pointer-events-none opacity-0' : 'pointer-events-auto opacity-100'}`} className={`text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] p-2 transition-all duration-250 cursor-pointer ${showNotes ? 'pointer-events-none opacity-0' : 'pointer-events-auto opacity-100'}`}
onClick={() => setOpen((prev) => !prev)} onClick={() => setOpen((prev) => !prev)}
> >
<ScrollText className="w-5 h-5" /> <ScrollText className="w-5 h-5" />
@@ -53,13 +51,24 @@ export default function Notes({ gameData: { dmID, cards, settings }, show }: Not
className={`transition-all duration-250 ${showNotes ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`} className={`transition-all duration-250 ${showNotes ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
> >
<div <div
className={`fixed bottom-4 right-4 transition-all duration-250 bg-slate-800 border border-yellow-400 rounded-lg space-y-2 ${showNotes ? 'w-[33vw] h-[67vh]' : 'w-0 h-0'}`} className={`
fixed bottom-4 right-4
transition-all duration-250
bg-slate-800
border border-yellow-400 rounded-lg
${showNotes ? 'sm:w-[50vw] sm:h-[67vh] w-[80vw] h-[80vh]' : 'w-0 h-0'}
`}
> >
<CopyButton <CopyButton
copy={notes.map((note) => note!.join('\n')).join('\n\n')} copy={notes.map((note) => note!.join('\n')).join('\n\n')}
className="text-yellow-400 hover:drop-shadow-[0_0_1px_#ffd700] absolute top-2 right-2 p-2 transition-all duration-250 bg-black/20 hover:bg-black/40 rounded-full cursor-pointer" className={`
absolute top-2 right-2
cursor-pointer p-2
transition-all duration-250
text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700]
`}
/> />
<div className="text-yellow-400 h-full overflow-scroll p-6 transition-all delay-200 duration-50 ${showNotes ? 'opacity-100' : 'opacity-0'}"> <div className="text-yellow-400 h-full overflow-scroll p-8 transition-all delay-200 duration-50 ${showNotes ? 'opacity-100' : 'opacity-0'}">
{notes.map((note, index) => ( {notes.map((note, index) => (
<div key={index}> <div key={index}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -72,6 +81,17 @@ export default function Notes({ gameData: { dmID, cards, settings }, show }: Not
))} ))}
</div> </div>
</div> </div>
<button
className={`
fixed bottom-4 right-4
cursor-pointer p-2
transition-all duration-250
text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700]
`}
onClick={() => setOpen((prev) => !prev)}
>
<CircleX className="w-5 h-5" />
</button>
</Scrim> </Scrim>
</div> </div>
); );

View File

@@ -13,6 +13,7 @@ export default function Scrim({ children, clickAction, show = true, className =
clickAction(event); clickAction(event);
} }
}; };
if (!show) return null; if (!show) return null;
return ( return (

View File

@@ -1,127 +0,0 @@
'use client';
import { useState } from 'react';
import { Settings as Gear } from 'lucide-react';
import { Cinzel_Decorative } from 'next/font/google';
import CopyButton from '@/components/CopyButton';
import Scrim from '@/components/Scrim';
import Switch from '@/components/Switch';
import { CardStyle, GameUpdate } from '@/types';
const cinzel = Cinzel_Decorative({
variable: '--font-cinzel',
subsets: ['latin'],
weight: '400',
});
type SettingsProps = {
gameData: GameUpdate;
changeAction: (updatedSettings: GameUpdate) => void;
};
const cardStyleOptions: CardStyle[] = ['standard', 'color', 'grayscale'];
export default function Settings({ gameData, changeAction }: SettingsProps) {
const [open, setOpen] = useState(false);
const togglePermission = (key: string) => {
changeAction({
...gameData,
settings: {
...gameData.settings,
[key]: !gameData.settings[key],
},
});
};
const tuneRadio = (cardStyle: CardStyle) => {
changeAction({
...gameData,
settings: {
...gameData.settings,
cardStyle,
},
});
};
const Links = () => (
<>
<CopyButton
title="Copy DM link"
copy={`${location.origin}/${gameData.dmID}`}
tooltip={`${location.origin}/${gameData.dmID}`}
className="flex flex-row content-between w-full py-1 px-2 transition-all duration-250 bg-slate-700 hover:bg-slate-600 hover:text-yellow-300 rounded-lg shadow"
/>
<CopyButton
title="Copy Spectator link"
copy={`${location.origin}/${gameData.spectatorID}`}
tooltip={`${location.origin}/${gameData.spectatorID}`}
className="flex flex-row content-between w-full py-1 px-2 transition-all duration-250 bg-slate-700 hover:bg-slate-600 hover:text-yellow-300 rounded-lg shadow"
/>
</>
);
const Permissions = () => (
<>
{Object.entries(gameData.settings)
.filter(([_key, value]) => typeof value === 'boolean')
.map(([key, value]) => (
<Switch key={key} label={key} value={value} toggleAction={() => togglePermission(key)} />
))}
</>
);
const CardStyle = () => (
<fieldset className="flex flex-col">
<div className="text-xs mb-1">Card style:</div>
<div className="inline-flex overflow-hidden rounded-md w-full">
{cardStyleOptions.map((option, index) => (
<label
key={option}
className={`cursor-pointer px-4 py-2 text-sm font-medium transition
${gameData.settings.cardStyle === option ? 'bg-slate-700 text-yellow-300 font-extrabold' : 'bg-slate-800 hover:bg-slate-700'}
${index === 0 ? 'rounded-l-md' : ''}
${index === cardStyleOptions.length - 1 ? 'rounded-r-md' : ''}
${index !== 0 && 'border-l border-gray-600'}
border border-yellow-500 hover:text-yellow-300
`}
>
<input
type="radio"
name="cardStyle"
value={option}
checked={gameData.settings.cardStyle === option}
onChange={() => tuneRadio(option)}
className="sr-only"
/>
{option}
</label>
))}
</div>
</fieldset>
);
return (
<div className={`fixed top-4 right-4 z-25 ${cinzel.className}`}>
<Scrim
clickAction={() => setOpen((prev) => !prev)}
className={`transition-all duration-250 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
>
<div
className={`fixed top-4 right-4 flex flex-col items-center justify-center bg-slate-800 text-yellow-400 rounded-lg border border-yellow-400 p-6 space-y-2 transition-all duration-250 ${open ? 'opacity-100 w-[350px] h-[300px]' : 'opacity-0 w-0 h-0'}`}
>
<Links />
<Permissions />
<CardStyle />
</div>
</Scrim>
<button
className={`p-2 transition-all duration-250 text-yellow-400 hover:text-yellow-300 cursor-pointer ${open ? 'pointer-events-none opacity-0' : 'pointer-events-auto opacity-100'}`}
onClick={() => setOpen((prev) => !prev)}
>
<Gear className="w-5 h-5" />
</button>
</div>
);
}

View File

@@ -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({ className }: { className?: string }) {
const { gameData, isDM, settings, emitSettings } = useAppContext();
const tuneRadio = (cardStyle: CardStyle) => {
emitSettings({
...gameData,
settings: {
...gameData.settings,
cardStyle,
},
});
};
return isDM ? (
<fieldset className={`flex flex-col w-full ${className}`}>
<div className="text-xs ml-1 mb-1">Card style:</div>
<div className="inline-flex overflow-hidden rounded-md w-full">
{cardStyleOptions.map((option, index) => (
<label
key={option}
className={`
flex justify-center
cursor-pointer
w-full px-3 py-2
text-xs font-medium capitalize
border border-yellow-500
transition hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700]
${settings.cardStyle === option ? 'bg-slate-700 text-yellow-300 font-extrabold' : 'bg-slate-800 hover:bg-slate-700'}
${index === 0 ? 'rounded-l-md' : ''}
${index === cardStyleOptions.length - 1 ? 'rounded-r-md' : ''}
${index !== 0 && 'border-l border-gray-600'}
`}
>
<input
type="radio"
name="cardStyle"
value={option}
checked={settings.cardStyle === option}
onChange={() => tuneRadio(option)}
className="sr-only"
/>
{option}
</label>
))}
</div>
</fieldset>
) : null;
}

View File

@@ -0,0 +1,13 @@
'use client';
import BuyMeACoffee from '@/components/BuyMeACoffee';
import GitHubButton from '@/components/GitHubButton';
export default function CardStyle({ className }: { className?: string }) {
return (
<span className={`w-full flex flex-row justify-between ${className}`}>
<GitHubButton className="h-[35px] w-[125px]" />
<BuyMeACoffee className="h-[35px] w-[125px]" />
</span>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { useAppContext } from '@/app/AppContext';
import CopyButton from '@/components/CopyButton';
export default function Links({ className }: { className?: string }) {
const { gameData, isDM } = useAppContext();
return (
<div className={`w-full flex flex-col justify-between gap-2 ${className}`}>
{isDM && (
<CopyButton
title="Copy DM link"
copy={`${location.origin}/${gameData.dmID}`}
tooltip={`${location.origin}/${gameData.dmID}`}
className="flex flex-row content-between w-full py-1 px-2 transition-all duration-250 bg-slate-700 hover:bg-slate-600 hover:text-yellow-300 rounded-lg shadow"
/>
)}
<CopyButton
title="Copy Spectator link"
copy={`${location.origin}/${gameData.spectatorID}`}
tooltip={`${location.origin}/${gameData.spectatorID}`}
className="flex flex-row content-between w-full py-1 px-2 transition-all duration-250 bg-slate-700 hover:bg-slate-600 hover:text-yellow-300 rounded-lg shadow"
/>
</div>
);
}

View File

@@ -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]) => (
<Switch key={key} label={key} value={value} toggleAction={() => togglePermission(key)} />
))}
</>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { useState } from 'react';
import { CircleX, Settings as Gear } from 'lucide-react';
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';
export default function Settings() {
const [open, setOpen] = useState(false);
const { isDM } = useAppContext();
return (
<div className={`fixed top-4 right-4 z-25`}>
<Scrim
clickAction={() => setOpen((prev) => !prev)}
className={`transition-all duration-250 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
>
<div
className={`
fixed top-4 right-4
flex flex-col items-center justify-between
bg-slate-800 text-yellow-400
rounded-lg border border-yellow-400
h-full p-8
transition-all duration-250
${open ? `opacity-100 ${isDM ? 'w-[350px] max-h-[425px]' : 'w-[325px] max-h-[200px]'}` : 'opacity-0 w-0 max-h-0'}
`}
>
<GameLinks />
<Permissions />
<CardStyle />
<ExternalLinks />
</div>
<button
className={`fixed top-4 right-4 p-2 transition-all duration-250 text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] cursor-pointer`}
onClick={() => setOpen((prev) => !prev)}
>
<CircleX className="w-5 h-5" />
</button>
</Scrim>
<button
className={`p-2 transition-all duration-250 text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] cursor-pointer`}
onClick={() => setOpen((prev) => !prev)}
>
<Gear className="w-5 h-5" />
</button>
</div>
);
}

59
components/Sheen.tsx Normal file
View File

@@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from 'react';
import { useAppContext } from '@/app/AppContext';
import { validTilt } from '@/tools';
const tiltSheen = (sheen: HTMLDivElement, x: number, y: number) => {
const rect = sheen.getBoundingClientRect();
const sheenX = rect.width - x * rect.width;
const sheenY = rect.height - y * rect.height;
sheen.style.opacity = '1';
sheen.style.backgroundImage = `
radial-gradient(
circle at
${sheenX}px ${sheenY}px,
#ffffff44,
#0000000f
)
`;
};
export default function Sheen({ cardIndex, className }: { cardIndex: number; className?: string }) {
const sheenRef = useRef<HTMLDivElement>(null);
const [untilt, setUntilt] = useState(false);
const { tilts } = useAppContext();
useEffect(() => {
const sheen = sheenRef.current;
if (!sheen) return;
const tilt = tilts[cardIndex];
if (validTilt(tilt)) {
setUntilt(false);
tiltSheen(sheen, tilt.percentX, tilt.percentY);
} else {
setUntilt(true);
}
}, [tilts]);
useEffect(() => {
const sheen = sheenRef.current;
if (!sheen || !untilt) return;
sheen.style.opacity = '0';
}, [untilt]);
return (
<div
ref={sheenRef}
className={`
absolute inset-0
rounded-lg pointer-events-none
transition-opacity duration-500
bg-gradient-to-tr from-transparent via-white/20 to-transparent mix-blend-screen opacity-0
${className}
`}
/>
);
}

View File

@@ -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 (
<CopyButton
copy={`${location.origin}/${gameData.spectatorID}`}
tooltip={`Spectator link: ${location.origin}/${gameData.spectatorID}`}
Icon={Eye}
className={`fixed top-3 left-3 p-2 z-25 transition-all duration-250 text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] cursor-pointer`}
size={24}
/>
);
}

View File

@@ -0,0 +1,48 @@
import { GalleryHorizontalEnd, RefreshCw } from 'lucide-react';
interface StackTheDeckProps {
onRedraw: () => void;
onSelect: () => void;
onHover: (state: React.ReactNode) => void;
className?: string;
}
export default function StackTheDeck({
onRedraw,
onSelect,
onHover,
className = '',
}: StackTheDeckProps) {
const curryHandleClick = (action: () => void) => (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
action();
};
return (
<div
className={`absolute top-0 right-0 flex flex-col items-center justify-center bg-black/50 rounded-tr-lg rounded-bl-lg ${className}`}
>
<button
onMouseEnter={() => onHover(<p className="text-yellow-400">Redraw</p>)}
onMouseLeave={() => onHover(null)}
onTouchStart={() => onHover(<p className="text-yellow-400">Redraw</p>)}
onTouchEnd={() => onHover(null)}
className={`p-1 transition-all duration-250 text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] cursor-pointer`}
onClick={curryHandleClick(onRedraw)}
>
<RefreshCw className="w-3 h-3" />
</button>
<button
onMouseEnter={() => onHover(<p className="text-yellow-400">Select</p>)}
onMouseLeave={() => onHover(null)}
onTouchStart={() => onHover(<p className="text-yellow-400">Select</p>)}
onTouchEnd={() => onHover(null)}
className={`p-1 transition-all duration-250 text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] cursor-pointer`}
onClick={curryHandleClick(onSelect)}
>
<GalleryHorizontalEnd className="w-3 h-3" />
</button>
</div>
);
}

View File

@@ -1,25 +1,44 @@
import type { ChangeEventHandler } from 'react';
export interface SwitchProps { export interface SwitchProps {
label: string; label: string;
value: boolean; value: boolean;
toggleAction: (event: React.ChangeEvent<HTMLInputElement>) => void; toggleAction: ChangeEventHandler<HTMLInputElement>;
className?: string;
} }
export default function Switch({ label, value, toggleAction }: SwitchProps) { const nonInitialCaps = /(?!^)([A-Z])/g;
export default function Switch({ label, value, toggleAction, className }: SwitchProps) {
return ( return (
<label className="flex items-center justify-between w-full gap-2 cursor-pointer text-yellow-400 hover:text-yellow-300"> <label
<span className="text-sm capitalize">{label}</span> className={`flex items-center justify-between gap-2 w-full cursor-pointer text-yellow-400 hover:text-yellow-300 ${className}`}
>
<span className="text-sm capitalize">{label.replace(nonInitialCaps, ' $1')}</span>
<div className="relative inline-block w-8 h-4 align-middle select-none transition duration-200 ease-in"> <div className="relative inline-block w-8 h-4 align-middle select-none transition duration-200 ease-in">
<input type="checkbox" checked={value} onChange={toggleAction} className="sr-only" /> <input
<div id={`switch-${label}`}
className={`block w-8 h-4 rounded-full transition ${ type="checkbox"
value ? 'bg-slate-500' : 'bg-slate-600' checked={value}
}`} onChange={toggleAction}
className="sr-only peer"
/> />
<div <div
className={`absolute top-[2px] left-[2px] w-3 h-3 rounded-full transition-all duration-250 ease-out transform className={`
${value ? 'translate-x-4 scale-110' : 'scale-95'} block w-8 h-4 rounded-full
${value ? 'bg-yellow-400' : 'bg-yellow-500'}`} transition-colors duration-200 ease-in
bg-slate-600 peer-checked:bg-slate-500
`}
/>
<div
className={`
absolute top-[2px] left-[2px]
w-3 h-3 rounded-full
transition-all duration-250 ease-out
translate-x-0 scale-95 bg-yellow-500
peer-checked:translate-x-4 peer-checked:scale-110 peer-checked:bg-yellow-400
`}
/> />
</div> </div>
</label> </label>

View File

@@ -0,0 +1,28 @@
'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 { cards } = gameData;
// 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 (
<div className="grid grid-cols-3 grid-rows-3 gap-2 sm:gap-4 md:gap-8 w-fit mx-auto">
{Array.from({ length: 9 })
.map(arrangeCards)
.map((card, index) => (
<div key={index} className="aspect-[2/3]}">
{card && <Card card={card} cardIndex={cardMap[index]} />}
</div>
))}
</div>
);
}

113
components/TiltCard.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react';
import { useAppContext } from '@/app/AppContext';
import { throttle, validTilt } from '@/tools';
import { thirtyFPS } from '@/constants/time';
import type { Tilt } from '@/types';
const ZERO_ROTATION = 'rotateX(0deg) rotateY(0deg)';
export default function TiltCard({
children,
cardIndex,
className = '',
}: {
children: React.ReactNode;
cardIndex: number;
className?: string;
}) {
const cardRef = useRef<HTMLDivElement>(null);
const [untilt, setUntilt] = useState(false);
const { settings, tilts, setLocalTilt } = useAppContext();
useEffect(() => {
const card = cardRef.current;
if (!card) return;
const tilt = tilts[cardIndex];
if (validTilt(tilt)) {
setUntilt(false);
card.style.transform = `rotateX(${tilt.rotateX}deg) rotateY(${tilt.rotateY}deg)`;
} else {
setUntilt(true);
}
}, [tilts]);
useEffect(() => {
const card = cardRef.current;
if (!card || !untilt) return;
card.style.transform = ZERO_ROTATION;
}, [untilt]);
const handleTilt = (x: number, y: number) => {
const card = cardRef.current;
if (!card) return;
const rect = card.getBoundingClientRect();
x -= rect.left;
y -= rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const rotateX = ((y - centerY) / centerY) * -20;
const rotateY = ((x - centerX) / centerX) * 20;
const percentX = x / rect.width;
const percentY = y / rect.height;
const newTilt: Tilt[] = [];
newTilt[cardIndex] = {
percentX,
percentY,
rotateX,
rotateY,
};
setLocalTilt(newTilt);
};
const handleMouseMove = throttle((e: React.MouseEvent) => {
handleTilt(e.clientX, e.clientY);
}, thirtyFPS);
const handleTouchMove = throttle((e: React.TouchEvent) => {
const card = cardRef.current;
const touch = e.touches[0];
if (card && touch) {
const rect = card.getBoundingClientRect();
const x = touch.clientX;
const y = touch.clientY;
if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
handleTilt(x, y);
} else {
setLocalTilt([]);
}
}
}, thirtyFPS);
const handleMouseLeave = () => {
setLocalTilt([]);
};
return (
<div
className={`group ${className}`}
onMouseMove={settings.tilt ? handleMouseMove : undefined}
onTouchMove={settings.tilt ? handleTouchMove : undefined}
onTouchEnd={handleMouseLeave}
onMouseLeave={handleMouseLeave}
>
<div
ref={cardRef}
onAnimationEnd={() => setUntilt(false)}
className={`h-full w-full transition-transform ${untilt ? 'duration-500' : 'duration-0'}`}
>
{children}
</div>
</div>
);
}

View File

@@ -15,8 +15,8 @@ type TooltipProps = {
export default function Tooltip({ export default function Tooltip({
children, children,
content, content,
delay = 500, delay = 250,
mobileDelay = 500, mobileDelay = 250,
offsetX = 20, offsetX = 20,
offsetY = 20, offsetY = 20,
edgeBuffer = 10, edgeBuffer = 10,

33
constants/index.ts Normal file
View File

@@ -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: true,
positionBack: true,
positionFront: true,
prophecy: true,
tilt: true,
remoteTilt: true,
};
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'];

View File

@@ -5,15 +5,18 @@ const tarokkaCards: TarokkaCard[] = [
id: 'back', id: 'back',
name: 'Card Back', name: 'Card Back',
card: 'Back of card', card: 'Back of card',
deck: 'back',
suit: null, suit: null,
aria: 'Back of card', aria: 'Back of card',
description: 'Back of card', description: 'Back of card',
back: true, back: true,
extension: '.png',
}, },
{ {
id: 'swashbuckler', id: 'swashbuckler',
name: 'Swashbuckler', name: 'Swashbuckler',
card: 'One of Coins', card: 'One of Coins',
deck: 'common',
suit: 'Coins', suit: 'Coins',
aria: 'Coins 01 Swashbuckler', aria: 'Coins 01 Swashbuckler',
description: 'Those who like money yet give it up freely; likable rogues and rapscallions', description: 'Those who like money yet give it up freely; likable rogues and rapscallions',
@@ -30,6 +33,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'philanthropist', id: 'philanthropist',
name: 'Philanthropist', name: 'Philanthropist',
card: 'Two of Coins', card: 'Two of Coins',
deck: 'common',
suit: 'Coins', suit: 'Coins',
aria: 'Coins 02 Philanthropist', aria: 'Coins 02 Philanthropist',
description: description:
@@ -47,6 +51,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'trader', id: 'trader',
name: 'Trader', name: 'Trader',
card: 'Three of Coins', card: 'Three of Coins',
deck: 'common',
suit: 'Coins', suit: 'Coins',
aria: 'Coins 03 Trader', aria: 'Coins 03 Trader',
description: 'Commerce; smuggling and black markets; fair and equitable trades', description: 'Commerce; smuggling and black markets; fair and equitable trades',
@@ -63,6 +68,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'merchant', id: 'merchant',
name: 'Merchant', name: 'Merchant',
card: 'Four of Coins', card: 'Four of Coins',
deck: 'common',
suit: 'Coins', suit: 'Coins',
aria: 'Coins 04 Merchant', aria: 'Coins 04 Merchant',
description: description:
@@ -79,6 +85,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'guild-member', id: 'guild-member',
name: 'Guild Member', name: 'Guild Member',
card: 'Five of Coins', card: 'Five of Coins',
deck: 'common',
suit: 'Coins', suit: 'Coins',
aria: 'Coins 05 Guild Member', aria: 'Coins 05 Guild Member',
description: "Like-minded individuals joined together in a common goal; pride in one's work", description: "Like-minded individuals joined together in a common goal; pride in one's work",
@@ -94,6 +101,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'beggar', id: 'beggar',
name: 'Beggar', name: 'Beggar',
card: 'Six of Coins', card: 'Six of Coins',
deck: 'common',
suit: 'Coins', suit: 'Coins',
aria: 'Coins 06 Beggar', aria: 'Coins 06 Beggar',
description: 'Sudden change in economic status or fortune', description: 'Sudden change in economic status or fortune',
@@ -110,6 +118,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'thief', id: 'thief',
name: 'Thief', name: 'Thief',
card: 'Seven of Coins', card: 'Seven of Coins',
deck: 'common',
suit: 'Coins', suit: 'Coins',
aria: 'Coins 07 Thief', aria: 'Coins 07 Thief',
description: description:
@@ -127,6 +136,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'tax-collector', id: 'tax-collector',
name: 'Tax Collector', name: 'Tax Collector',
card: 'Eight of Coins', card: 'Eight of Coins',
deck: 'common',
suit: 'Coins', suit: 'Coins',
aria: 'Coins 08 Tax Collector', aria: 'Coins 08 Tax Collector',
description: 'Corruption; honesty in an otherwise corrupt government or organization', description: 'Corruption; honesty in an otherwise corrupt government or organization',
@@ -144,6 +154,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'miser', id: 'miser',
name: 'Miser', name: 'Miser',
card: 'Nine of Coins', card: 'Nine of Coins',
deck: 'common',
suit: 'Coins', suit: 'Coins',
aria: 'Coins 09 Miser', aria: 'Coins 09 Miser',
description: description:
@@ -160,6 +171,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'rogue', id: 'rogue',
name: 'Rogue', name: 'Rogue',
card: 'Master of Coins', card: 'Master of Coins',
deck: 'common',
suit: 'Coins', suit: 'Coins',
aria: 'Coins 10 Rogue', aria: 'Coins 10 Rogue',
description: description:
@@ -176,6 +188,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'monk', id: 'monk',
name: 'Monk', name: 'Monk',
card: 'One of Glyphs', card: 'One of Glyphs',
deck: 'common',
suit: 'Glyphs', suit: 'Glyphs',
aria: 'Glyphs 01 Monk', aria: 'Glyphs 01 Monk',
description: description:
@@ -193,6 +206,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'missionary', id: 'missionary',
name: 'Missionary', name: 'Missionary',
card: 'Two of Glyphs', card: 'Two of Glyphs',
deck: 'common',
suit: 'Glyphs', suit: 'Glyphs',
aria: 'Glyphs 02 Missionary', aria: 'Glyphs 02 Missionary',
description: description:
@@ -211,6 +225,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'healer', id: 'healer',
name: 'Healer', name: 'Healer',
card: 'Three of Glyphs', card: 'Three of Glyphs',
deck: 'common',
suit: 'Glyphs', suit: 'Glyphs',
aria: 'Glyphs 03 Healer', aria: 'Glyphs 03 Healer',
description: description:
@@ -228,6 +243,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'shepherd', id: 'shepherd',
name: 'Shepherd', name: 'Shepherd',
card: 'Four of Glyphs', card: 'Four of Glyphs',
deck: 'common',
suit: 'Glyphs', suit: 'Glyphs',
aria: 'Glyphs 04 Shepherd', aria: 'Glyphs 04 Shepherd',
description: description:
@@ -245,6 +261,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'druid', id: 'druid',
name: 'Druid', name: 'Druid',
card: 'Five of Glyphs', card: 'Five of Glyphs',
deck: 'common',
suit: 'Glyphs', suit: 'Glyphs',
aria: 'Glyphs 05 Druid', aria: 'Glyphs 05 Druid',
description: description:
@@ -263,6 +280,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'anarchist', id: 'anarchist',
name: 'Anarchist', name: 'Anarchist',
card: 'Six of Glyphs', card: 'Six of Glyphs',
deck: 'common',
suit: 'Glyphs', suit: 'Glyphs',
aria: 'Glyphs 06 Anarchist', aria: 'Glyphs 06 Anarchist',
description: 'A fundamental change brought on by one whose beliefs are being put to the test', description: 'A fundamental change brought on by one whose beliefs are being put to the test',
@@ -279,6 +297,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'charlatan', id: 'charlatan',
name: 'Charlatan', name: 'Charlatan',
card: 'Seven of Glyphs', card: 'Seven of Glyphs',
deck: 'common',
suit: 'Glyphs', suit: 'Glyphs',
aria: 'Glyphs 07 Charlatan', aria: 'Glyphs 07 Charlatan',
description: 'Liars; those who profess to believe one thing but actually believe another', description: 'Liars; those who profess to believe one thing but actually believe another',
@@ -294,6 +313,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'bishop', id: 'bishop',
name: 'Bishop', name: 'Bishop',
card: 'Eight of Glyphs', card: 'Eight of Glyphs',
deck: 'common',
suit: 'Glyphs', suit: 'Glyphs',
aria: 'Glyphs 08 Bishop', aria: 'Glyphs 08 Bishop',
description: 'Strict adherence to a code or a belief; those who plot, plan, and scheme', description: 'Strict adherence to a code or a belief; those who plot, plan, and scheme',
@@ -310,6 +330,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'traitor', id: 'traitor',
name: 'Traitor', name: 'Traitor',
card: 'Nine of Glyphs', card: 'Nine of Glyphs',
deck: 'common',
suit: 'Glyphs', suit: 'Glyphs',
aria: 'Glyphs 09 Traitor', aria: 'Glyphs 09 Traitor',
description: 'Betrayal by someone close and trusted; a weakening or loss of faith', description: 'Betrayal by someone close and trusted; a weakening or loss of faith',
@@ -327,6 +348,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'priest', id: 'priest',
name: 'Priest', name: 'Priest',
card: 'Master of Glyphs', card: 'Master of Glyphs',
deck: 'common',
suit: 'Glyphs', suit: 'Glyphs',
aria: 'Glyphs 10 Priest', aria: 'Glyphs 10 Priest',
description: 'Enlightenment; those who follow a deity, a system of values, or a higher purpose', description: 'Enlightenment; those who follow a deity, a system of values, or a higher purpose',
@@ -343,6 +365,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'transmuter', id: 'transmuter',
name: 'Transmuter', name: 'Transmuter',
card: 'One of Stars', card: 'One of Stars',
deck: 'common',
suit: 'Stars', suit: 'Stars',
aria: 'Stars 01 Transmuter', aria: 'Stars 01 Transmuter',
description: description:
@@ -359,6 +382,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'diviner', id: 'diviner',
name: 'Diviner', name: 'Diviner',
card: 'Two of Stars', card: 'Two of Stars',
deck: 'common',
suit: 'Stars', suit: 'Stars',
aria: 'Stars 02 Diviner', aria: 'Stars 02 Diviner',
description: description:
@@ -376,6 +400,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'enchanter', id: 'enchanter',
name: 'Enchanter', name: 'Enchanter',
card: 'Three of Stars', card: 'Three of Stars',
deck: 'common',
suit: 'Stars', suit: 'Stars',
aria: 'Stars 03 Enchanter', aria: 'Stars 03 Enchanter',
description: 'Inner turmoil that comes from confusion, fear of failure, or false information', description: 'Inner turmoil that comes from confusion, fear of failure, or false information',
@@ -393,6 +418,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'abjurer', id: 'abjurer',
name: 'Abjurer', name: 'Abjurer',
card: 'Four of Stars', card: 'Four of Stars',
deck: 'common',
suit: 'Stars', suit: 'Stars',
aria: 'Stars 04 Abjurer', aria: 'Stars 04 Abjurer',
description: description:
@@ -410,6 +436,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'elementalist', id: 'elementalist',
name: 'Elementalist', name: 'Elementalist',
card: 'Five of Stars', card: 'Five of Stars',
deck: 'common',
suit: 'Stars', suit: 'Stars',
aria: 'Stars 05 Elementalist', aria: 'Stars 05 Elementalist',
description: description:
@@ -428,6 +455,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'evoker', id: 'evoker',
name: 'Evoker', name: 'Evoker',
card: 'Six of Stars', card: 'Six of Stars',
deck: 'common',
suit: 'Stars', suit: 'Stars',
aria: 'Stars 06 Evoker', aria: 'Stars 06 Evoker',
description: description:
@@ -445,6 +473,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'illusionist', id: 'illusionist',
name: 'Illusionist', name: 'Illusionist',
card: 'Seven of Stars', card: 'Seven of Stars',
deck: 'common',
suit: 'Stars', suit: 'Stars',
aria: 'Stars 07 Illusionist', aria: 'Stars 07 Illusionist',
description: description:
@@ -462,6 +491,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'necromancer', id: 'necromancer',
name: 'Necromancer', name: 'Necromancer',
card: 'Eight of Stars', card: 'Eight of Stars',
deck: 'common',
suit: 'Stars', suit: 'Stars',
aria: 'Stars 08 Necromancer', aria: 'Stars 08 Necromancer',
description: 'Unnatural events and unhealthy obsessions; those who follow a destructive path', description: 'Unnatural events and unhealthy obsessions; those who follow a destructive path',
@@ -477,6 +507,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'conjurer', id: 'conjurer',
name: 'Conjurer', name: 'Conjurer',
card: 'Nine of Stars', card: 'Nine of Stars',
deck: 'common',
suit: 'Stars', suit: 'Stars',
aria: 'Stars 09 Conjurer', aria: 'Stars 09 Conjurer',
description: description:
@@ -494,6 +525,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'wizard', id: 'wizard',
name: 'Wizard', name: 'Wizard',
card: 'Master of Stars', card: 'Master of Stars',
deck: 'common',
suit: 'Stars', suit: 'Stars',
aria: 'Stars 10 Wizard', aria: 'Stars 10 Wizard',
description: description:
@@ -511,6 +543,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'avenger', id: 'avenger',
name: 'Avenger', name: 'Avenger',
card: 'One of Swords', card: 'One of Swords',
deck: 'common',
suit: 'Swords', suit: 'Swords',
aria: 'Swords 01 Avenger', aria: 'Swords 01 Avenger',
description: description:
@@ -528,6 +561,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'paladin', id: 'paladin',
name: 'Paladin', name: 'Paladin',
card: 'Two of Swords', card: 'Two of Swords',
deck: 'common',
suit: 'Swords', suit: 'Swords',
aria: 'Swords 02 Paladin', aria: 'Swords 02 Paladin',
description: 'Just and noble warriors; those who live by a code of honor and integrity', description: 'Just and noble warriors; those who live by a code of honor and integrity',
@@ -544,6 +578,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'soldier', id: 'soldier',
name: 'Soldier', name: 'Soldier',
card: 'Three of Swords', card: 'Three of Swords',
deck: 'common',
suit: 'Swords', suit: 'Swords',
aria: 'Swords 03 Soldier', aria: 'Swords 03 Soldier',
description: 'War and sacrifice; the stamina to endure great hardship', description: 'War and sacrifice; the stamina to endure great hardship',
@@ -560,6 +595,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'mercenary', id: 'mercenary',
name: 'Mercenary', name: 'Mercenary',
card: 'Four of Swords', card: 'Four of Swords',
deck: 'common',
suit: 'Swords', suit: 'Swords',
aria: 'Swords 04 Mercenary', aria: 'Swords 04 Mercenary',
description: 'Inner strength and fortitude; those who fight for power or wealth', description: 'Inner strength and fortitude; those who fight for power or wealth',
@@ -575,6 +611,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'myrmidon', id: 'myrmidon',
name: 'Myrmidon', name: 'Myrmidon',
card: 'Five of Swords', card: 'Five of Swords',
deck: 'common',
suit: 'Swords', suit: 'Swords',
aria: 'Swords 05 Myrmidon', aria: 'Swords 05 Myrmidon',
description: description:
@@ -593,6 +630,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'berserker', id: 'berserker',
name: 'Berserker', name: 'Berserker',
card: 'Six of Swords', card: 'Six of Swords',
deck: 'common',
suit: 'Swords', suit: 'Swords',
aria: 'Swords 06 Berserker', aria: 'Swords 06 Berserker',
description: 'The brutal and barbaric side of warfare; bloodlust; those with a bestial nature', description: 'The brutal and barbaric side of warfare; bloodlust; those with a bestial nature',
@@ -610,6 +648,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'hooded-one', id: 'hooded-one',
name: 'Hooded One', name: 'Hooded One',
card: 'Seven of Swords', card: 'Seven of Swords',
deck: 'common',
suit: 'Swords', suit: 'Swords',
aria: 'Swords 07 Hooded One', aria: 'Swords 07 Hooded One',
description: 'Bigotry, intolerance, and xenophobia; a mysterious presence or newcomer', description: 'Bigotry, intolerance, and xenophobia; a mysterious presence or newcomer',
@@ -627,6 +666,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'dictator', id: 'dictator',
name: 'Dictator', name: 'Dictator',
card: 'Eight of Swords', card: 'Eight of Swords',
deck: 'common',
suit: 'Swords', suit: 'Swords',
aria: 'Swords 08 Dictator', aria: 'Swords 08 Dictator',
description: description:
@@ -643,6 +683,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'torturer', id: 'torturer',
name: 'Torturer', name: 'Torturer',
card: 'Nine of Swords', card: 'Nine of Swords',
deck: 'common',
suit: 'Swords', suit: 'Swords',
aria: 'Swords 09 Torturer', aria: 'Swords 09 Torturer',
description: description:
@@ -661,6 +702,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'warrior', id: 'warrior',
name: 'Warrior', name: 'Warrior',
card: 'Master of Swords', card: 'Master of Swords',
deck: 'common',
suit: 'Swords', suit: 'Swords',
aria: 'Swords 10 Warrior', aria: 'Swords 10 Warrior',
description: description:
@@ -678,6 +720,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'artifact', id: 'artifact',
name: 'Artifact', name: 'Artifact',
card: 'The Artifact', card: 'The Artifact',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Artifact', aria: 'High Deck Artifact',
description: description:
@@ -702,6 +745,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'beast', id: 'beast',
name: 'Beast', name: 'Beast',
card: 'The Beast', card: 'The Beast',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Beast', aria: 'High Deck Beast',
description: description:
@@ -727,6 +771,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'broken-one', id: 'broken-one',
name: 'Broken One', name: 'Broken One',
card: 'The Broken One', card: 'The Broken One',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Broken One', aria: 'High Deck Broken One',
description: description:
@@ -758,6 +803,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'darklord', id: 'darklord',
name: 'Darklord', name: 'Darklord',
card: 'The Darklord', card: 'The Darklord',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Darklord', aria: 'High Deck Darklord',
description: description:
@@ -781,6 +827,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'donjon', id: 'donjon',
name: 'Donjon', name: 'Donjon',
card: 'The Donjon', card: 'The Donjon',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Donjon', aria: 'High Deck Donjon',
description: description:
@@ -813,6 +860,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'executioner', id: 'executioner',
name: 'Executioner', name: 'Executioner',
card: 'The Executioner', card: 'The Executioner',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Executioner', aria: 'High Deck Executioner',
description: description:
@@ -839,6 +887,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'ghost', id: 'ghost',
name: 'Ghost', name: 'Ghost',
card: 'The Ghost', card: 'The Ghost',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Ghost', aria: 'High Deck Ghost',
description: description:
@@ -872,6 +921,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'horseman', id: 'horseman',
name: 'Horseman', name: 'Horseman',
card: 'The Horseman', card: 'The Horseman',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Horseman', aria: 'High Deck Horseman',
description: description:
@@ -904,6 +954,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'innocent', id: 'innocent',
name: 'Innocent', name: 'Innocent',
card: 'The Innocent', card: 'The Innocent',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Innocent', aria: 'High Deck Innocent',
description: description:
@@ -936,6 +987,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'marionette', id: 'marionette',
name: 'Marionette', name: 'Marionette',
card: 'The Marionette', card: 'The Marionette',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Marionette', aria: 'High Deck Marionette',
description: description:
@@ -967,6 +1019,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'mists', id: 'mists',
name: 'Mists', name: 'Mists',
card: 'The Mists', card: 'The Mists',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Mists', aria: 'High Deck Mists',
description: description:
@@ -993,6 +1046,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'raven', id: 'raven',
name: 'Raven', name: 'Raven',
card: 'The Raven', card: 'The Raven',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Raven', aria: 'High Deck Raven',
description: description:
@@ -1019,6 +1073,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'seer', id: 'seer',
name: 'Seer', name: 'Seer',
card: 'The Seer', card: 'The Seer',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Seer', aria: 'High Deck Seer',
description: description:
@@ -1045,6 +1100,7 @@ const tarokkaCards: TarokkaCard[] = [
id: 'tempter', id: 'tempter',
name: 'Tempter', name: 'Tempter',
card: 'The Tempter', card: 'The Tempter',
deck: 'high',
suit: 'High Deck', suit: 'High Deck',
aria: 'High Deck Tempter', aria: 'High Deck Tempter',
description: description:

View File

@@ -2,3 +2,5 @@ export const SECOND = 1000;
export const MINUTE = 60 * SECOND; export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE; export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR; export const DAY = 24 * HOUR;
export const thirtyFPS = SECOND / 30;

102
hooks/useSocket.ts Normal file
View File

@@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { socket } from '@/socket';
import type { GameUpdate, Tilt } from '@/types';
interface UseSocketProps {
gameID: string;
setGameData: (gameUpdate: GameUpdate) => void;
setNoGame: (noGame: boolean) => void;
}
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);
});
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);
});
socket.on('join-error', (error) => {
console.error('Error:', error);
setNoGame(true);
});
socket.on('flip-error', (error) => {
console.error('Error:', error);
});
socket.on('disconnect', () => {
setDisconnected(true);
});
}
return () => {
socket.removeAllListeners();
};
}, [gameID, connect]);
const emitFlip = (cardIndex: number) => {
if (disconnected) setConnect(connect + 1);
socket.emit('flip-card', {
gameID,
cardIndex,
});
};
const emitSettings = (gameData: GameUpdate) => {
if (disconnected) setConnect(connect + 1);
socket.emit('settings', {
gameID,
gameData,
});
};
const emitRedraw = (cardIndex: number) => {
if (disconnected) setConnect(connect + 1);
socket.emit('redraw', {
gameID,
cardIndex,
});
};
const emitSelect = (cardIndex: number, cardID: string) => {
if (disconnected) setConnect(connect + 1);
socket.emit('select', {
gameID,
cardIndex,
cardID,
});
};
const emitTilt = (cardIndex: number, tilt: Tilt) => {
if (disconnected) setConnect(connect + 1);
socket.emit('tilt', {
cardIndex,
tilt,
});
};
return {
emitFlip,
emitSettings,
emitRedraw,
emitSelect,
emitTilt,
};
}

View File

@@ -1,8 +1,8 @@
import Deck from '@/lib/TarokkaDeck'; import Deck from '@/lib/TarokkaDeck';
import generateID from '@/tools/simpleID'; import { generateID, parseMilliseconds } from '@/tools';
import parseMilliseconds from '@/tools/parseMilliseconds';
import { MINUTE, HOUR, DAY } from '@/constants/time'; import { HOUR, DAY, SETTINGS } from '@/constants';
import { GameState, GameUpdate, Settings } from '@/types'; import { GameState, GameUpdate, Settings, Tilt } from '@/types';
const deck = new Deck(); const deck = new Deck();
@@ -34,10 +34,10 @@ export default class GameStore {
private totalExpired: number; private totalExpired: number;
private totalUnused: number; private totalUnused: number;
private startUps: Set<string>; private startUps: Set<string>; // homepage socket IDs
private dms: Map<string, GameState>; private dms: Map<string, GameState>; // DM socket ID -> game
private spectators: Map<string, GameState>; private spectators: Map<string, GameState>; // spectator socket ID -> game
private players: Map<string, string>; private players: Map<string, GameState>; // socket ID -> game
constructor() { constructor() {
this.startTime = Date.now(); this.startTime = Date.now();
@@ -50,7 +50,7 @@ export default class GameStore {
this.spectators = new Map(); this.spectators = new Map();
this.players = new Map(); this.players = new Map();
setInterval(() => this.log(), 15 * MINUTE); setInterval(() => this.log(), HOUR);
setInterval(() => this.cleanUp(), HOUR); setInterval(() => this.cleanUp(), HOUR);
setTimeout(() => this.wrapUp(), tilMidnight()); setTimeout(() => this.wrapUp(), tilMidnight());
@@ -84,13 +84,8 @@ export default class GameStore {
players: new Set(), players: new Set(),
cards: deck.getHand(), cards: deck.getHand(),
lastUpdated: Date.now(), lastUpdated: Date.now(),
settings: { settings: SETTINGS,
positionBack: true, tilts: Array.from({ length: 5 }, () => []),
positionFront: true,
prophecy: true,
notes: true,
cardStyle: 'color',
},
}; };
this.totalCreated++; this.totalCreated++;
@@ -106,18 +101,20 @@ export default class GameStore {
game.players.add(playerID); game.players.add(playerID);
game.lastUpdated = Date.now(); game.lastUpdated = Date.now();
this.players.set(playerID, gameID); this.players.set(playerID, game);
return this.gameUpdate(game); return this.gameUpdate(game);
} }
leaveGame(gameID: string, playerID: string): GameState { leaveGame(playerID: string): GameUpdate {
const game = this.getGame(gameID); const game = this.getGameByPlayerID(playerID);
this.players.delete(playerID);
game.players.delete(playerID); game.players.delete(playerID);
this._clearTilts(game, playerID);
game.lastUpdated = Date.now(); game.lastUpdated = Date.now();
return game; return this.gameUpdate(game);
} }
flipCard(gameID: string, cardIndex: number): GameUpdate { flipCard(gameID: string, cardIndex: number): GameUpdate {
@@ -132,6 +129,54 @@ export default class GameStore {
return this.gameUpdate(game); return this.gameUpdate(game);
} }
redraw(gameID: string, cardIndex: number): GameUpdate {
const game = this.getGame(gameID);
const card = game.cards[cardIndex];
if (!card) throw new Error(`Card ${cardIndex} not found`);
game.cards[cardIndex] =
card.suit === 'High Deck' ? deck.drawHigh(game.cards) : deck.drawLow(game.cards);
game.lastUpdated = Date.now();
return this.gameUpdate(game);
}
select(gameID: string, cardIndex: number, cardID: string): GameUpdate {
const game = this.getGame(gameID);
const card = game.cards[cardIndex];
const replacement = deck.select(cardID);
if (!card) throw new Error(`Card ${cardIndex} not found`);
if (!replacement) throw new Error(`Card ${cardID} not found`);
game.cards[cardIndex] = replacement;
game.lastUpdated = Date.now();
return this.gameUpdate(game);
}
tilt(playerID: string, cardIndex: number, tilt: Tilt) {
const game = this.getGameByPlayerID(playerID);
const cardTilts = game.tilts[cardIndex];
if (!cardTilts) throw new Error(`Card tilts ${cardIndex} not found`);
this._clearTilts(game, playerID);
if (tilt.rotateX && tilt.rotateY) {
game.tilts[cardIndex] = [...game.tilts[cardIndex], { ...tilt, playerID }];
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) { updateSettings(gameID: string, settings: Settings) {
const game = this.getGame(gameID); const game = this.getGame(gameID);
@@ -148,24 +193,27 @@ export default class GameStore {
return game; return game;
} }
gameUpdate(game: GameState): GameUpdate { getGameByPlayerID(playerID: string): GameState {
const { dmID, spectatorID, cards, settings } = game; const game = this.players.get(playerID);
return { dmID, spectatorID, cards, settings }; if (!game) throw new Error(`Player ${playerID} not found`);
return game;
} }
playerExit(playerID: string): GameState | null { gameUpdate(game: GameState): GameUpdate {
const { dmID, spectatorID, cards, settings, tilts } = game;
return { dmID, spectatorID, cards, settings, tilts };
}
playerExit(playerID: string): GameUpdate | null {
if (this.startUps.has(playerID)) { if (this.startUps.has(playerID)) {
this.startUps.delete(playerID); this.startUps.delete(playerID);
return null; return null;
} else { } else {
const gameID = this.players.get(playerID); return this.leaveGame(playerID);
if (!gameID) throw new Error(`Player ${playerID} not found`);
this.players.delete(playerID);
return this.leaveGame(gameID, playerID);
} }
} }
@@ -176,7 +224,6 @@ export default class GameStore {
console.log(uptimeLog); console.log(uptimeLog);
console.log(`Games: ${this.dms.size}`); console.log(`Games: ${this.dms.size}`);
console.log(`Players: ${this.players.size}`); console.log(`Players: ${this.players.size}`);
console.log('-'.repeat(uptimeLog.length));
} }
wrapUp() { wrapUp() {
@@ -188,7 +235,6 @@ export default class GameStore {
console.log(`Created: ${this.totalCreated}`); console.log(`Created: ${this.totalCreated}`);
console.log(`Expired: ${this.totalExpired}`); console.log(`Expired: ${this.totalExpired}`);
console.log(`Unused: ${this.totalUnused}`); console.log(`Unused: ${this.totalUnused}`);
console.log('='.repeat(uptimeLog.length));
this.totalCreated = 0; this.totalCreated = 0;
this.totalExpired = 0; this.totalExpired = 0;
@@ -208,12 +254,16 @@ export default class GameStore {
this.totalExpired += expired.length; this.totalExpired += expired.length;
this.totalUnused += unused.length; this.totalUnused += unused.length;
expired.forEach((game) => this.deleteGame(game)); expired.forEach((game) => {
game.players.forEach((playerID) => this.players.delete(playerID));
this.deleteGame(game);
});
unused.forEach((game) => this.deleteGame(game)); unused.forEach((game) => this.deleteGame(game));
} }
deleteGame(game: GameState): void { deleteGame(game: GameState): void {
console.log(Date.now(), 'DELETE', game); console.log(Date.now(), 'DELETE', game.dmID, game.spectatorID);
this.dms.delete(game.dmID); this.dms.delete(game.dmID);
this.spectators.delete(game.spectatorID); this.spectators.delete(game.spectatorID);

View File

@@ -1,4 +1,4 @@
import getRandomItems from '@/tools/getRandomItems'; import { getRandomItems } from '@/tools';
import cards from '@/constants/standardCards'; import cards from '@/constants/standardCards';
import type { StandardCard } from '@/types'; import type { StandardCard } from '@/types';

View File

@@ -1,4 +1,4 @@
import getRandomItems from '@/tools/getRandomItems'; import { getRandomItems } from '@/tools';
import cards from '@/constants/tarokkaCards'; import cards from '@/constants/tarokkaCards';
import type { TarokkaCard, TarokkaGameCard } from '@/types'; import type { TarokkaCard, TarokkaGameCard } from '@/types';
@@ -8,8 +8,8 @@ export default class TarokkaDeck {
private backs: TarokkaCard[] = []; private backs: TarokkaCard[] = [];
constructor() { constructor() {
this.highDeck = cards.filter((card) => !card.back && card.suit === 'High Deck'); this.highDeck = cards.filter((card) => card.deck === 'high');
this.commonDeck = cards.filter((card) => !card.back && card.suit !== 'High Deck'); this.commonDeck = cards.filter((card) => card.deck === 'common');
this.backs = cards.filter((card) => card.back); this.backs = cards.filter((card) => card.back);
} }
@@ -19,6 +19,49 @@ export default class TarokkaDeck {
); );
} }
getLow(): TarokkaGameCard[] {
return this.commonDeck.map((card) => ({ ...card, flipped: false }));
}
getHigh(): TarokkaGameCard[] {
return this.highDeck.map((card) => ({ ...card, flipped: false }));
}
drawLow(exclude: TarokkaGameCard[] = []): TarokkaGameCard {
const excludeIDs = exclude.map(({ id }) => id);
return {
...getRandomItems(
this.commonDeck.filter(({ id }) => !excludeIDs.includes(id)),
1,
)[0],
flipped: false,
};
}
drawHigh(exclude: TarokkaGameCard[] = []): TarokkaGameCard {
const excludeIDs = exclude.map(({ id }) => id);
return {
...getRandomItems(
this.highDeck.filter(({ id }) => !excludeIDs.includes(id)),
1,
)[0],
flipped: false,
};
}
select(id: string): TarokkaGameCard | null {
const card = cards.find((card) => card.id === id);
if (!card) return null;
return {
...card,
flipped: false,
};
}
getBack(): TarokkaCard { getBack(): TarokkaCard {
return this.backs[0]; return this.backs[0];
} }

2048
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
{ {
"name": "tarokka", "name": "tarokka",
"version": "0.1.0", "version": "1.1.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "nodemon", "dev": "nodemon",
"build": "next build && tsc --project tsconfig.server.json", "build": "next build && tsc --project tsconfig.server.json",
"start": "cross-env NODE_ENV=production TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/server.js", "start": "cross-env NODE_ENV=production TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/server.js",
"deploy": "docker buildx build --platform linux/amd64 -t 192.168.0.2:5000/tarokka --push ." "release": "docker buildx build --platform linux/amd64 -t 192.168.0.2:5000/tarokka --push ."
}, },
"dependencies": { "dependencies": {
"cross-env": "^7.0.3", "cross-env": "^7.0.3",

25
proxy.ts Normal file
View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function proxy(request: NextRequest) {
const url = request.nextUrl;
const slug = url.pathname.slice(1);
const blocked = [
'apple-icon.png',
'favicon.ico',
'icon0.svg',
'icon1.png',
'manifest.json',
'opengraph-image.png',
'twitter-image.png',
'web-app-manifest-192x192.png',
'web-app-manifest-512x512.png',
];
if (blocked.includes(slug)) {
return NextResponse.rewrite(request.url);
}
return NextResponse.next();
}

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

22
public/img/bmc-button.svg Executable file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

BIN
public/img/color/back.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 969 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -0,0 +1 @@
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 960 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

BIN
public/img/screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 MiB

BIN
public/img/tarokka.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@@ -3,8 +3,10 @@ import { createServer } from 'http';
import { Server as SocketIOServer, type Socket } from 'socket.io'; import { Server as SocketIOServer, type Socket } from 'socket.io';
import GameStore from '@/lib/GameStore'; import GameStore from '@/lib/GameStore';
import omit from '@/tools/omit'; import { omit } from '@/tools';
import type { ClientUpdate, GameUpdate } from '@/types';
import { thirtyFPS } from '@/constants/time';
import type { ClientUpdate, GameUpdate, Tilt } from '@/types';
const dev = process.env.NODE_ENV !== 'production'; const dev = process.env.NODE_ENV !== 'production';
const hostname = '0.0.0.0'; const hostname = '0.0.0.0';
@@ -15,9 +17,10 @@ const handler = app.getRequestHandler();
const gameStore = new GameStore(); const gameStore = new GameStore();
const timedReleases = {};
app.prepare().then(() => { app.prepare().then(() => {
const httpServer = createServer(handler); const httpServer = createServer(handler);
const io = new SocketIOServer(httpServer); const io = new SocketIOServer(httpServer);
const broadcast = (event: string, gameUpdate: GameUpdate) => { const broadcast = (event: string, gameUpdate: GameUpdate) => {
@@ -25,6 +28,25 @@ app.prepare().then(() => {
io.to(gameUpdate.spectatorID).emit(event, omit(gameUpdate, 'dmID')); 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];
clearTimeout(lastEvent?.to);
if (lastEvent?.embargo >= now) {
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) => { io.on('connection', (socket: Socket) => {
//console.log(Date.now(), `Client connected: ${socket.id}`); //console.log(Date.now(), `Client connected: ${socket.id}`);
@@ -79,6 +101,36 @@ app.prepare().then(() => {
} }
}); });
socket.on('redraw', ({ gameID, cardIndex }: ClientUpdate) => {
try {
//console.log(Date.now(), 'Redraw', { gameID, cardIndex });
const gameUpdate = gameStore.redraw(gameID, cardIndex);
broadcast('game-update', gameUpdate);
} catch (e) {
const error = e instanceof Error ? e.message : e;
console.error(Date.now(), 'Error[redraw]', error);
socket.emit('redraw-error', error);
}
});
socket.on('select', ({ gameID, cardIndex, cardID = '' }: ClientUpdate) => {
try {
//console.log(Date.now(), 'select', { gameID, cardIndex });
const gameUpdate = gameStore.select(gameID, cardIndex, cardID);
broadcast('game-update', gameUpdate);
} catch (e) {
const error = e instanceof Error ? e.message : e;
console.error(Date.now(), 'Error[select]', error);
socket.emit('select-error', error);
}
});
socket.on('settings', ({ gameID, gameData }: { gameID: string; gameData: GameUpdate }) => { socket.on('settings', ({ gameID, gameData }: { gameID: string; gameData: GameUpdate }) => {
try { try {
const gameUpdate = gameStore.updateSettings(gameID, gameData.settings); const gameUpdate = gameStore.updateSettings(gameID, gameData.settings);
@@ -89,6 +141,16 @@ app.prepare().then(() => {
} }
}); });
socket.on('tilt', ({ cardIndex, tilt }: { cardIndex: number; tilt: Tilt }) => {
try {
const gameState = gameStore.tilt(socket.id, cardIndex, tilt);
timedRelease('game-update', gameState, thirtyFPS);
} catch (e) {
const error = e instanceof Error ? e.message : e;
console.error(Date.now(), 'Error[tilt]', error);
}
});
socket.on('disconnect', () => { socket.on('disconnect', () => {
try { try {
const game = gameStore.playerExit(socket.id); const game = gameStore.playerExit(socket.id);

View File

@@ -1,17 +1,17 @@
import { isHighCard, isLowCard } from '@/tools/cardTypes'; import { isHighCard, isLowCard } from '@/tools';
import { Layout, Settings, TarokkaGameCard } from '@/types'; import { Layout, Settings, TarokkaGameCard } from '@/types';
export default function getTooltip( export const getCardInfo = (
card: TarokkaGameCard, card: TarokkaGameCard,
position: Layout, position: Layout,
dm: boolean, dm: boolean,
settings: Settings, settings: Settings,
) { ) => {
const { card: cardName, description, flipped } = card; const { card: cardName, description, flipped } = card;
let text: string[] = []; let text: string[] = [];
if (flipped) { if (dm || flipped) {
if (dm || settings.positionFront) text.push(position.text); if (dm || settings.positionFront) text.push(position.text);
if (dm) text.push(`${cardName}: ${description}`); if (dm) text.push(`${cardName}: ${description}`);
@@ -33,12 +33,10 @@ export default function getTooltip(
// Low deck: Tome, Ravenkind, or Sunsword // Low deck: Tome, Ravenkind, or Sunsword
if (isLowCard(card)) { if (isLowCard(card)) {
if (dm) text.push(card.prophecy.dmText);
if (dm || settings.prophecy) text.push(card.prophecy.playerText); if (dm || settings.prophecy) text.push(card.prophecy.playerText);
if (dm) text.push(card.prophecy.dmText);
} }
} else {
if (dm || settings.positionBack) text.push(position.text);
} }
return text; return text;
} };

View File

@@ -1,4 +1,4 @@
export default function getRandomItems<T>(items: T[], count: number): T[] { export const getRandomItems = <T>(items: T[], count: number): T[] => {
const shuffled = [...items]; const shuffled = [...items];
// Fisher-Yates shuffle // Fisher-Yates shuffle
@@ -8,4 +8,4 @@ export default function getRandomItems<T>(items: T[], count: number): T[] {
} }
return count > shuffled.length ? shuffled : shuffled.slice(0, count); return count > shuffled.length ? shuffled : shuffled.slice(0, count);
} };

View File

@@ -1,8 +1,8 @@
import { cardStyles, standardMap } from '@/constants/tarokka'; import { cardStyles, standardMap } from '@/constants/tarokka';
import { Settings, TarokkaGameCard } from '@/types'; import { Settings, TarokkaCard, TarokkaGameCard } from '@/types';
export default function getURL(card: TarokkaGameCard, settings: Settings) { export const getURL = (card: TarokkaCard | TarokkaGameCard, settings: Settings) => {
const styleConfig = cardStyles[settings.cardStyle]; const styleConfig = cardStyles[settings.cardStyle];
const fileBase = settings.cardStyle === 'standard' ? standardMap[card.id] : card.id; const fileBase = settings.cardStyle === 'standard' ? standardMap[card.id] : card.id;
return `${styleConfig.baseURL}${fileBase}${styleConfig.extension}`; return `${styleConfig.baseURL}${fileBase}${card.extension || styleConfig.extension}`;
} };

11
tools/index.ts Normal file
View File

@@ -0,0 +1,11 @@
export * from '@/tools/cardTypes';
export * from '@/tools/getCardInfo';
export * from '@/tools/getRandomItems';
export * from '@/tools/getURL';
export * from '@/tools/log';
export * from '@/tools/omit';
export * from '@/tools/parseMilliseconds';
export * from '@/tools/reduceTilts';
export * from '@/tools/simpleID';
export * from '@/tools/throttle';
export * from '@/tools/validTilt';

19
tools/log.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* A logging utility designed to be inserted into functional chains.
* Logs all parameters with an optional prefix, then returns the first argument unchanged.
*
* @param {string} [prefix=''] - A label or message to prepend to the logged output.
* @returns {(value: any, ...rest: any[]) => any} - A function that logs its arguments and returns the first one.
*
* @example
* const result = [1, 2, 3]
* .map((n) => n * 2)
* .map(log('doubled:'))
* .filter((n) => n > 2);
*/
export const log =
(prefix: string = ''): ((value: any, ...rest: any[]) => any) =>
(...args: any[]) => {
console.log(prefix, ...args);
return args[0];
};

View File

@@ -1,7 +1,7 @@
export default function omit<T extends Record<string, any>>( export const omit = <T extends Record<string, any>>(
obj: T, obj: T,
propToRemove: keyof T, propToRemove: keyof T,
): Omit<T, typeof propToRemove> { ): Omit<T, typeof propToRemove> => {
const { [propToRemove]: _, ...rest } = obj; const { [propToRemove]: _, ...rest } = obj;
return rest; return rest;
} };

View File

@@ -7,7 +7,7 @@ export interface ParsedMilliseconds {
seconds: number; seconds: number;
} }
export default function parseMilliseconds(timestamp: number): ParsedMilliseconds { export const parseMilliseconds = (timestamp: number): ParsedMilliseconds => {
const days = Math.floor(timestamp / DAY); const days = Math.floor(timestamp / DAY);
timestamp %= DAY; timestamp %= DAY;
@@ -21,4 +21,4 @@ export default function parseMilliseconds(timestamp: number): ParsedMilliseconds
timestamp %= SECOND; timestamp %= SECOND;
return { days, hours, minutes, seconds }; return { days, hours, minutes, seconds };
} };

36
tools/reduceTilts.ts Normal file
View File

@@ -0,0 +1,36 @@
import { validTilt } from '@/tools';
import { GameUpdate, Settings, Tilt } from '@/types';
const combineTilts = (tilts: Tilt[]) =>
tilts.reduce(
({ pX, pY, rX, rY, count }, { percentX, percentY, rotateX, rotateY }) => ({
pX: pX + percentX,
pY: pY + percentY,
rX: rX + rotateX,
rY: rY + rotateY,
count: count + 1,
}),
{ pX: 0, pY: 0, rX: 0, rY: 0, count: 0 },
);
export function reduceTilts(
gameData: GameUpdate,
localTilt: Tilt[],
{ tilt, remoteTilt }: Settings,
): Tilt[] {
const remoteTilts = gameData.tilts;
if (!tilt) return [];
if (!remoteTilt) return localTilt;
return Array.from({ length: 5 }, (_, i) => (localTilt[i] ? [localTilt[i]] : []))
.map((cardTilts, cardIndex) => [...remoteTilts[cardIndex], ...cardTilts])
.map((cardTilts) => cardTilts.filter(validTilt))
.map(combineTilts)
.map(({ pX, pY, rX, rY, count }) => ({
percentX: count ? pX / count : -1,
percentY: count ? pY / count : -1,
rotateX: count ? rX / count : 0,
rotateY: count ? rY / count : 0,
}));
}

View File

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

12
tools/throttle.ts Normal file
View File

@@ -0,0 +1,12 @@
export function throttle(func: Function, threshold: number) {
let lastCall = 0;
return (...args: any[]) => {
const now = Date.now();
if (now - lastCall >= threshold) {
lastCall = now;
func(...args);
}
};
}

9
tools/validTilt.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Tilt } from '@/types';
export const validTilt = (tilt: Tilt) => {
if (!tilt) return false;
const { percentX, percentY, rotateX, rotateY } = tilt;
return percentX >= 0 && percentY >= 0 && !!rotateX && !!rotateY;
};

View File

@@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": false, "strict": false,
@@ -12,7 +16,7 @@
"moduleResolution": "node", "moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@@ -20,10 +24,20 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": [
"./*"
]
}, },
"strictNullChecks": true "strictNullChecks": true
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }

View File

@@ -1,11 +1,21 @@
export type CardStyle = 'standard' | 'color' | 'grayscale'; export type CardStyle = 'standard' | 'color' | 'grayscale';
// all = both + back
export type Deck = 'high' | 'common' | 'both' | 'back' | 'all';
export interface Settings { export interface Settings {
cardStyle: CardStyle;
notes: boolean;
positionBack: boolean; positionBack: boolean;
positionFront: boolean; positionFront: boolean;
prophecy: boolean; prophecy: boolean;
notes: boolean; tilt: boolean;
cardStyle: CardStyle; remoteTilt: boolean;
}
export interface LocalSettings {
tilt: boolean;
remoteTilt: boolean;
} }
export interface StandardCard { export interface StandardCard {
@@ -28,7 +38,9 @@ export interface TarokkaBase {
description: string; description: string;
aria: string; aria: string;
back: boolean; back: boolean;
deck: Deck;
suit: 'Coins' | 'Glyphs' | 'High Deck' | 'Stars' | 'Swords' | null; suit: 'Coins' | 'Glyphs' | 'High Deck' | 'Stars' | 'Swords' | null;
extension?: string;
} }
export interface TarokkaGameBase extends TarokkaBase { export interface TarokkaGameBase extends TarokkaBase {
@@ -77,6 +89,7 @@ export interface GameState {
cards: TarokkaGameCard[]; cards: TarokkaGameCard[];
lastUpdated: number; lastUpdated: number;
settings: Settings; settings: Settings;
tilts: Tilt[][];
} }
export interface GameUpdate { export interface GameUpdate {
@@ -84,11 +97,13 @@ export interface GameUpdate {
spectatorID: string; spectatorID: string;
cards: TarokkaGameCard[]; cards: TarokkaGameCard[];
settings: Settings; settings: Settings;
tilts: Tilt[][];
} }
export interface ClientUpdate { export interface ClientUpdate {
gameID: string; gameID: string;
cardIndex: number; cardIndex: number;
cardID?: string;
} }
export interface Layout { export interface Layout {
@@ -97,3 +112,11 @@ export interface Layout {
name: string; name: string;
text: string; text: string;
} }
export interface Tilt {
playerID?: string;
percentX: number;
percentY: number;
rotateX: number;
rotateY: number;
}