Compare commits

...

33 Commits

Author SHA1 Message Date
Gavin McDonald
aa938f7258 this is a pain in the ass 2025-06-23 15:33:04 -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
Gavin McDonald
2f5807ac53 readme edits 2025-04-26 15:11:04 -04:00
Gavin McDonald
34176ff6d5 fix readme 2025-04-26 15:00:16 -04:00
Gavin McDonald
24062d1a07 Updated README 2025-04-26 09:39:45 -04:00
Gavin McDonald
f7d10a9b4f tweaks 2025-04-25 16:19:40 -04:00
Gavin McDonald
f6749f3146 z-index adjustments 2025-04-24 18:08:23 -04:00
Gavin McDonald
6e2247d6f3 update colors 2025-04-24 17:54:47 -04:00
Gavin McDonald
969d9f5028 fancy fonts 2025-04-24 14:20:07 -04:00
Gavin McDonald
1a4789af4c animate Settings and Notes 2025-04-24 11:24:15 -04:00
Gavin McDonald
3d3cb7a45e update Settings 2025-04-23 15:50:21 -04:00
Gavin McDonald
0c8d2273ea Notes for card info 2025-04-23 15:49:32 -04:00
Gavin McDonald
5b21c560d6 update card info ordering 2025-04-23 15:44:36 -04:00
Gavin McDonald
bd7d42de55 custom classes for ToolTip 2025-04-23 15:41:20 -04:00
Gavin McDonald
90fd231fbd more-customizable CopyButton 2025-04-23 15:39:28 -04:00
57 changed files with 1296 additions and 203 deletions

View File

@@ -1,28 +1,37 @@
# Custom server with TypeScript + Nodemon example
# 🃏 Tarokka
The example shows how you can use [TypeScript](https://typescriptlang.com) on both the server and the client while using [Nodemon](https://nodemon.io/) to live reload the server code without affecting the Next.js universal code.
**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.
Server entry point is `server.ts` in development and `dist/server.js` in production.
The `dist` directory should be added to `.gitignore`.
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.
## Deploy your own
![screenshot](public/img/screenshot.png)
Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) or preview live with [StackBlitz](https://stackblitz.com/github/vercel/next.js/tree/canary/examples/custom-server)
You can see it live at:
👉 [https://tarokka.app](https://tarokka.app)
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/next.js/tree/canary/examples/custom-server)
---
## How to use
## ✨ Features
Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example:
- 🔮 **Faithful to the Tarokka Deck**: Supports all cards and positions used by Madam Evas reading.
- 💬 Dynamic prophecy rendering based on card and position
- 🎨 Multiple card styles
- 🧙 Separate DM and Spectator Views
- ⚙️ DM can toggle what information is visible to players.
- 🃏Every action (flipping cards, settings changes) is broadcast live to connected users.
- 🌐 Fully browser-based — no accounts or installs
- 📱 Mobile-friendly UI
- 🔁 WebSocket-Powered Real-Time Sync
---
## 🚀 Getting Started
### Development
```bash
npx create-next-app --example custom-server custom-server-app
```
```bash
yarn create next-app --example custom-server custom-server-app
```
```bash
pnpm create next-app --example custom-server custom-server-app
git clone https://github.com/mcdoh/tarokka.git
cd tarokka
npm install
npm run dev
```

View File

@@ -2,20 +2,27 @@
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { socket } from '@/socket';
import useSocket from '@/hooks/useSocket';
import useRTC from '@/hooks/useRTC';
import { Eye } from 'lucide-react';
import Settings from '@/components/Settings';
import Card from '@/components/Card';
import CopyButton from '@/components/CopyButton';
import Notes from '@/components/Notes';
import NotFound from '@/components/NotFound';
import Settings from '@/components/Settings';
import CardSelect from '@/components/CardSelect';
import { cardMap, layout } from '@/constants/tarokka';
import type { GameUpdate, ClientUpdate } from '@/types';
import type { Deck, GameUpdate } from '@/types';
export default function GamePage() {
const { gameID: gameIDParam } = useParams();
const [gameID, setGameID] = useState('');
const [noGame, setNoGame] = useState(false);
const [selectCard, setSelectCard] = useState(-1);
const [gameData, setGameData] = useState<GameUpdate>({
dmID: '',
spectatorID: '',
@@ -31,6 +38,11 @@ export default function GamePage() {
const { dmID, cards, settings } = gameData;
const isDM = !!dmID;
const selectDeck: Deck | null = selectCard >= 0 ? cards[selectCard].deck : null;
const socket = useSocket({ gameID, setGameData, setNoGame });
const rtc = useRTC(socket);
console.log('useRTC:', rtc);
useEffect(() => {
if (gameIDParam) {
@@ -38,44 +50,10 @@ export default function GamePage() {
}
}, [gameIDParam]);
useEffect(() => {
if (gameID) {
socket.emit('join', gameID);
const select = (cardIndex: number, cardID: string) => {
setSelectCard(-1);
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]);
const flipCard = (cardIndex: number) => {
const flip: ClientUpdate = {
gameID,
cardIndex,
};
socket.emit('flip-card', flip);
};
const handleSettings = (gameData: GameUpdate) => {
socket.emit('settings', { gameID, gameData });
socket.select(cardIndex, cardID);
};
// map our five Tarokka cards to their proper locations in a 3x3 grid
@@ -87,7 +65,17 @@ export default function GamePage() {
<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">
{isDM && <Settings gameData={gameData} changeAction={handleSettings} />}
{isDM && (
<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}
/>
)}
{isDM && <Settings gameData={gameData} changeAction={socket.handleSettings} />}
<div className="grid grid-cols-3 grid-rows-3 gap-8 w-fit mx-auto">
{Array.from({ length: 9 })
.map(arrangeCards)
@@ -99,12 +87,22 @@ export default function GamePage() {
card={card}
position={layout[cardMap[index]]}
settings={settings}
flipAction={() => flipCard(cardMap[index])}
flipAction={() => socket.flipCard(cardMap[index])}
redrawAction={() => socket.redraw(cardMap[index])}
selectAction={() => setSelectCard(cardMap[index])}
/>
)}
</div>
))}
</div>
<Notes gameData={gameData} show={cards.every(({ flipped }) => flipped)} />
<CardSelect
show={selectDeck}
hand={cards}
settings={settings}
closeAction={() => setSelectCard(-1)}
selectAction={(cardID) => select(selectCard, cardID)}
/>
</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 {
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,20 +1,33 @@
import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import { Pirata_One, Eagle_Lake, Cinzel_Decorative } from 'next/font/google';
import './globals.css';
const geistSans = Geist({
variable: '--font-geist-sans',
const pirataOne = Pirata_One({
variable: '--font-pirata',
subsets: ['latin'],
weight: '400',
});
const geistMono = Geist_Mono({
variable: '--font-geist-mono',
const eagleLake = Eagle_Lake({
variable: '--font-eagle-lake',
subsets: ['latin'],
weight: '400',
});
const cinzel = Cinzel_Decorative({
variable: '--font-cinzel',
subsets: ['latin'],
weight: '400',
});
export const metadata: Metadata = {
title: 'Tarokka',
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({
@@ -23,8 +36,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
<html
lang="en"
className={`${pirataOne.variable} ${eagleLake.variable} ${cinzel.variable} antialiased`}
>
<body className={`${eagleLake.className} antialiased`}>{children}</body>
</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 (
<main className="min-h-screen flex items-center justify-center bg-[url('/img/table3.png')] bg-cover bg-center">
<main className="min-h-screen flex justify-center items-center text-yellow-400 bg-[url('/img/table3.png')] bg-cover bg-center">
<div className="flex flex-col items-center gap-8 text-center">
<h1 className="text-5xl font-bold text-center text-primary">Tarokka</h1>
<p className="text-l text-center w-[350px] m-auto">
Online Tarokka readings for <em>Dungeons & Dragons: Curse of Strahd</em>.
</p>
<button
onClick={handleCreateGame}
className="bg-gray-800 hover:bg-gray-700 text-white text-lg px-6 py-3 rounded-xl shadow transition cursor-pointer"
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>
);
}

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,6 +1,9 @@
'use client';
import { useState } from 'react';
import TiltCard from '@/components/TiltCard';
import ToolTip from '@/components/ToolTip';
import StackTheDeck from '@/components/StackTheDeck';
import tarokkaCards from '@/constants/tarokkaCards';
import getCardInfo from '@/tools/getCardInfo';
import getURL from '@/tools/getURL';
@@ -15,9 +18,21 @@ type CardProps = {
position: Layout;
settings: Settings;
flipAction: () => void;
redrawAction: () => void;
selectAction: () => void;
};
export default function Card({ dm, card, position, settings, flipAction }: CardProps) {
export default function Card({
dm,
card,
position,
settings,
flipAction,
redrawAction,
selectAction,
}: CardProps) {
const [tooltip, setTooltip] = useState<React.ReactNode>(null);
const { aria, flipped } = card;
const handleClick = () => {
@@ -33,8 +48,8 @@ export default function Card({ dm, card, position, settings, flipAction }: CardP
<>
{text.map((t, i) => (
<div key={i}>
<p>{t}</p>
{i < text.length - 1 && <hr className="my-2 border-gray-300" />}
<p className="text-yellow-400">{t}</p>
{i < text.length - 1 && <hr className="my-2 border-yellow-400" />}
</div>
))}
</>
@@ -42,30 +57,47 @@ export default function Card({ dm, card, position, settings, flipAction }: CardP
};
return (
<ToolTip content={getTooltip()}>
<div
className={`relative h-[21vh] w-[15vh] perspective transition-transform duration-200 hover:scale-150 z-0 hover:z-10 ${dm ? 'cursor-pointer' : ''} `}
<ToolTip content={tooltip || getTooltip()}>
<TiltCard
className={`h-[21vh] w-[15vh] relative perspective transition-transform duration-200 z-0 hover:z-10 hover:scale-150 ${dm ? 'cursor-pointer' : ''} `}
onClick={handleClick}
>
<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' : ''}`}
>
<div className="absolute group inset-0 backface-hidden">
<div className="absolute inset-0 group backface-hidden">
{dm && (
<>
<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
src={getURL(cardBack as TarokkaGameCard, settings)}
alt="Card Back"
className="rounded-lg border border-gray-600"
className={`absolute rounded-lg ${dm ? 'transition duration-500 group-hover:opacity-0' : ''} ${settings.cardStyle === 'grayscale' ? 'border border-yellow-500/25 group-hover:drop-shadow-[0_0_3px_#ffd700/50]' : ''}`}
/>
{dm && !flipped && (
<StackTheDeck
onRedraw={redrawAction}
onSelect={() => selectAction()}
onHover={setTooltip}
/>
)}
</div>
<div className="absolute group inset-0 backface-hidden rotate-y-180">
<div className="absolute inset-0 backface-hidden rotate-y-180">
<img
src={getURL(card, settings)}
alt={aria}
className="rounded-lg border border-gray-600 "
className="rounded-lg border border-yellow-500/25 hover:drop-shadow-[0_0_3px_#ffd700/50]"
/>
</div>
</div>
</div>
</TiltCard>
</ToolTip>
);
}

73
components/CardSelect.tsx Normal file
View File

@@ -0,0 +1,73 @@
'use client';
import { CircleX } from 'lucide-react';
import TarokkaDeck from '@/lib/TarokkaDeck';
import getURL from '@/tools/getURL';
import { Deck, Settings, TarokkaGameCard } from '@/types';
const tarokkaDeck = new TarokkaDeck();
type CardSelectProps = {
closeAction: () => void;
selectAction: (cardID: string) => void;
hand: TarokkaGameCard[];
settings: Settings;
show: Deck | null;
className?: string;
};
export default function CardSelect({
closeAction,
selectAction,
hand,
settings,
show,
className = '',
}: CardSelectProps) {
const handIDs = hand.map(({ id }) => id);
const handleClose = (event: React.MouseEvent<HTMLElement>) => {
if (event.target === event.currentTarget) {
closeAction();
}
};
if (!show) return null;
const cards = show === '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={closeAction}
>
<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={() => selectAction(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,16 +1,27 @@
'use client';
import { useState } from 'react';
import { Copy as CopyIcon, Check as CheckIcon } from 'lucide-react';
import { ForwardRefExoticComponent, RefAttributes, useState } from 'react';
import { LucideProps, Copy as CopyIcon, Check as CheckIcon } from 'lucide-react';
import ToolTip from '@/components/ToolTip';
type CopyButtonProps = {
title: string;
title?: string;
copy: string;
Icon?: ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>;
tooltip?: string | string[];
className?: string;
size?: number;
};
export default function CopyButton({ title, copy }: CopyButtonProps) {
export default function CopyButton({
title,
copy,
Icon = CopyIcon,
tooltip = ['Copy', 'Copied'],
className,
size = 16,
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
@@ -23,21 +34,24 @@ export default function CopyButton({ title, copy }: CopyButtonProps) {
}
};
const ttContent = (
<span className="text-yellow-300">
{Array.isArray(tooltip) && tooltip.length > 1 ? (copied ? tooltip[1] : tooltip[0]) : tooltip}
</span>
);
return (
<ToolTip content={copy}>
<button
onClick={handleCopy}
className="w-full py-1 px-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg flex flex-col items-start gap-1 shadow transition-all cursor-pointer"
>
<button onClick={handleCopy} className={`cursor-pointer ${className}`}>
<ToolTip content={ttContent} className="w-full font-yellow-400">
<div className="flex items-center gap-2 w-full text-sm font-medium">
{`Copy ${title}`}
{title}
{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>
</button>
</ToolTip>
</button>
);
}

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>
);
}

78
components/Notes.tsx Normal file
View File

@@ -0,0 +1,78 @@
'use client';
import { useMemo, useState } from 'react';
import { ScrollText } from 'lucide-react';
import CopyButton from '@/components/CopyButton';
import Scrim from '@/components/Scrim';
import getCardInfo from '@/tools/getCardInfo';
import { cardMap, layout } from '@/constants/tarokka';
import { GameUpdate } from '@/types';
type NotesProps = {
gameData: GameUpdate;
show: boolean;
};
export default function Notes({ gameData: { dmID, cards, settings }, show }: NotesProps) {
const isDM = !!dmID;
const [open, setOpen] = useState(false);
const notes: (string[] | undefined)[] = useMemo(
() =>
Array.from({ length: 9 })
.map((_cell: unknown, index: number) => cards[cardMap[index]])
.map((card, index) =>
card ? getCardInfo(card, layout[cardMap[index]], isDM, settings) : null,
)
.map(
(_cell: unknown, index: number, cards) =>
cards[Object.keys(cardMap).find((key) => cardMap[key] === index) || 0],
)
.filter((truthy) => truthy),
[cards, isDM, settings],
);
const showNotes = show && open && (isDM || settings.notes);
return (
<div
className={`fixed bottom-4 right-4 z-25 transition-all duration-250 ${show ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
>
<button
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)}
>
<ScrollText className="w-5 h-5" />
</button>
<Scrim
clickAction={() => setOpen((prev) => !prev)}
className={`transition-all duration-250 ${showNotes ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
>
<div
className={`fixed bottom-4 right-4 transition-all duration-250 bg-slate-800 border border-yellow-400 rounded-lg space-y-2 ${showNotes ? 'sm:w-[33vw] sm:h-[67vh] w-[80vw] h-[80vh]' : 'w-0 h-0'}`}
>
<CopyButton
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"
/>
<div className="text-yellow-400 h-full overflow-scroll p-6 transition-all delay-200 duration-50 ${showNotes ? 'opacity-100' : 'opacity-0'}">
{notes.map((note, index) => (
<div key={index}>
<div className="flex flex-col gap-2">
{note!.map((blurb, index) => (
<p key={index}>{blurb}</p>
))}
</div>
{index < notes.length - 1 && <hr className="my-3 border-yellow-400" />}
</div>
))}
</div>
</div>
</Scrim>
</div>
);
}

27
components/Scrim.tsx Normal file
View File

@@ -0,0 +1,27 @@
'use client';
type ScrimProps = {
children: React.ReactNode;
clickAction: (event: React.MouseEvent<HTMLDivElement>) => void;
show?: boolean;
className?: string;
};
export default function Scrim({ children, clickAction, show = true, className = '' }: ScrimProps) {
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
clickAction(event);
}
};
if (!show) return null;
return (
<div
onClick={handleClick}
className={`fixed inset-0 bg-black/20 backdrop-blur-sm z-40 ${className}`}
>
{children}
</div>
);
}

View File

@@ -1,23 +1,30 @@
'use client';
import { useState } from 'react';
import { Settings as Gear, X } from 'lucide-react';
import { Settings as Gear } from 'lucide-react';
import { Cinzel_Decorative } from 'next/font/google';
import BuyMeACoffee from '@/components/BuyMeACoffee';
import CopyButton from '@/components/CopyButton';
import GitHubButton from '@/components/GitHubButton';
import Scrim from '@/components/Scrim';
import Switch from '@/components/Switch';
import { CardStyle, GameUpdate } from '@/types';
type PermissionTogglePanelProps = {
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 PermissionTogglePanel({
gameData,
changeAction,
}: PermissionTogglePanelProps) {
export default function Settings({ gameData, changeAction }: SettingsProps) {
const [open, setOpen] = useState(false);
const togglePermission = (key: string) => {
@@ -40,49 +47,46 @@ export default function PermissionTogglePanel({
});
};
return (
<div className="fixed top-4 right-4 z-50">
{!open && (
<button
className="p-2 text-gray-100 hover:text-gray-300 cursor-pointer"
onClick={() => setOpen((prev) => !prev)}
>
<Gear className="w-5 h-5" />
</button>
)}
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"
/>
</>
);
{open && (
<div className="relative text-gray-100 bg-gray-800 shadow-lg rounded-lg border border-gray-500 p-6 space-y-2">
<button
className="absolute top-1 right-1 p-1 hover:text-gray-300 cursor-pointer"
onClick={() => setOpen((prev) => !prev)}
>
<X className="w-4 h-4" />
</button>
<CopyButton title="DM link" copy={`${location.origin}/${gameData.dmID}`} />
<CopyButton title="Spectator link" copy={`${location.origin}/${gameData.spectatorID}`} />
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)}
/>
<Switch key={key} label={key} value={value} toggleAction={() => togglePermission(key)} />
))}
<fieldset className="flex flex-col">
<div className="text-xs text-gray-400 mb-1">Card style:</div>
</>
);
const CardStyle = () => (
<fieldset className="flex flex-col w-full">
<div className="text-xs my-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-gray-500 text-white' : 'bg-gray-800 text-gray-300 hover:bg-gray-700'}
className={`flex justify-center items-center cursor-pointer w-full 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-gray-600
border border-yellow-500 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700]
`}
>
<input
@@ -98,8 +102,32 @@ export default function PermissionTogglePanel({
))}
</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-evenly bg-slate-800 text-yellow-400 rounded-lg border border-yellow-400 py-3 px-4 transition-all duration-250 ${open ? 'opacity-100 w-[350px] h-[350px]' : 'opacity-0 w-0 h-0'}`}
>
<Links />
<Permissions />
<CardStyle />
<span className="w-full flex flex-row justify-evenly">
<GitHubButton className="h-[35px] w-[125px]" />
<BuyMeACoffee className="h-[35px] w-[125px]" />
</span>
</div>
)}
</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 ${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,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

@@ -6,20 +6,20 @@ export interface SwitchProps {
export default function Switch({ label, value, toggleAction }: SwitchProps) {
return (
<label className="flex items-center justify-between w-full gap-2 cursor-pointer">
<label className="flex items-center justify-between w-full gap-2 cursor-pointer text-yellow-400 hover:text-yellow-300">
<span className="text-sm capitalize">{label}</span>
<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" />
<div
className={`block w-8 h-4 rounded-full transition ${
value ? 'bg-gray-500' : 'bg-gray-600'
value ? 'bg-slate-500' : 'bg-slate-600'
}`}
/>
<div
className={`absolute top-[2px] left-[2px] w-3 h-3 rounded-full transition-transform duration-200 ease-out transform
${value ? 'translate-x-4 scale-110 shadow-[0_0_2px_2px_rgba(255,255,255,0.4)]' : 'scale-95'}
${value ? 'bg-gray-100' : 'bg-gray-400'}`}
className={`absolute top-[2px] left-[2px] w-3 h-3 rounded-full transition-all duration-250 ease-out transform
${value ? 'translate-x-4 scale-110' : 'scale-95'}
${value ? 'bg-yellow-400' : 'bg-yellow-500'}`}
/>
</div>
</label>

48
components/TiltCard.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { useRef } from 'react';
export default function TiltCard({
children,
className = '',
onClick = () => {},
}: {
children: React.ReactNode;
className?: string;
onClick: (event: React.MouseEvent) => void;
}) {
const cardRef = useRef<HTMLDivElement>(null);
const handleMouseMove = (e: React.MouseEvent) => {
const card = cardRef.current;
if (!card) return;
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const rotateX = ((y - centerY) / centerY) * -20;
const rotateY = ((x - centerX) / centerX) * 20;
card.style.transform = `rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
};
const handleMouseLeave = () => {
const card = cardRef.current;
if (!card) return;
card.style.transform = `rotateX(0deg) rotateY(0deg)`;
};
return (
<div
className={`${className}`}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={onClick}
>
<div ref={cardRef} className={`h-full w-full transition-transform duration-0`}>
{children}
</div>
</div>
);
}

View File

@@ -9,16 +9,18 @@ type TooltipProps = {
offsetX?: number;
offsetY?: number;
edgeBuffer?: number;
className?: string;
};
export default function Tooltip({
children,
content,
delay = 500,
mobileDelay = 500,
delay = 250,
mobileDelay = 250,
offsetX = 20,
offsetY = 20,
edgeBuffer = 10,
className,
}: TooltipProps) {
const ttRef = useRef<HTMLDivElement | null>(null);
const [show, setShow] = useState(false);
@@ -67,12 +69,13 @@ export default function Tooltip({
onMouseMove={handleMouseMove}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
className={className}
>
{children}
</div>
<div
ref={ttRef}
className={`fixed max-w-[35vh] pointer-events-none z-50 text-xs bg-black text-white rounded-xl border border-gray-300 px-2 py-1 transition-opacity duration-250 ${content && show ? 'opacity-100' : 'opacity-0'}`}
className={`fixed max-w-[35vh] pointer-events-none z-50 text-xs bg-[#1e293b] rounded-lg border border-yellow-500 px-2 py-1 transition-opacity duration-250 ${content && show ? 'opacity-100' : 'opacity-0'}`}
style={{
top: `${pos.y + offsetY}px`,
left: `${pos.x + offsetX}px`,

View File

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

95
hooks/useChatGPT.ts Normal file
View File

@@ -0,0 +1,95 @@
import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
interface CursorPosition {
x: number;
y: number;
}
interface PeerMouseHook {
cursors: Record<string, CursorPosition>;
}
export function usePeerMouse(roomId: string): PeerMouseHook {
const [cursors, setCursors] = useState<Record<string, CursorPosition>>({});
const socketRef = useRef<Socket | null>(null);
const peers = useRef<Record<string, RTCPeerConnection>>({});
const channels = useRef<Record<string, RTCDataChannel>>({});
useEffect(() => {
const socket = io();
socketRef.current = socket;
socket.emit('join-room', roomId);
socket.on('new-peer', async (peerId: string) => {
const pc = createPeer(peerId, true);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('signal', { to: peerId, data: { description: pc.localDescription } });
});
socket.on('signal', async ({ from, data }) => {
const pc = peers.current[from] || createPeer(from, false);
if (data.description) {
await pc.setRemoteDescription(data.description);
if (data.description.type === 'offer') {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('signal', { to: from, data: { description: pc.localDescription } });
}
}
if (data.candidate) {
await pc.addIceCandidate(data.candidate);
}
});
function createPeer(peerId: string, isInitiator: boolean): RTCPeerConnection {
const pc = new RTCPeerConnection();
if (isInitiator) {
const channel = pc.createDataChannel('mouse');
setupChannel(peerId, channel);
} else {
pc.ondatachannel = (e) => setupChannel(peerId, e.channel);
}
pc.onicecandidate = (e) => {
if (e.candidate) {
socket.emit('signal', { to: peerId, data: { candidate: e.candidate } });
}
};
peers.current[peerId] = pc;
return pc;
}
function setupChannel(peerId: string, channel: RTCDataChannel) {
channels.current[peerId] = channel;
channel.onmessage = (e) => {
const pos = JSON.parse(e.data);
setCursors((prev) => ({ ...prev, [peerId]: pos }));
};
}
function handleMouseMove(e: MouseEvent) {
const pos = JSON.stringify({ x: e.clientX, y: e.clientY });
Object.values(channels.current).forEach((ch) => {
if (ch.readyState === 'open') ch.send(pos);
});
}
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
socket.disconnect();
Object.values(peers.current).forEach((pc) => pc.close());
};
}, [roomId]);
return { cursors };
}

54
hooks/useRTC.ts Normal file
View File

@@ -0,0 +1,54 @@
import { useEffect, useState } from 'react';
import RTCPeer from '@/lib/RTCPeer';
import type { UseSocket } from '@/hooks/useSocket';
import type {} from '@/types';
// interface UseSocketProps {
// gameID: string;
// setGameData: (gameUpdate: GameUpdate) => void;
// setNoGame: (noGame: boolean) => void;
// }
const channelName = 'tilt';
export default function useRTC({
ready,
registerAnsweredReceiver,
registerOfferredReceiver,
rtcAnswer: sendAnswer,
rtcOffer: sendOffer,
}: UseSocket) {
const [peers, setPeers] = useState<RTCPeer[]>([]);
const answerHandler = (answer: RTCSessionDescriptionInit) => {
console.log('[useRTC] answer received', answer);
console.log('[useRTC] peers:', peers.length);
const peer = peers[0];
console.log('peer:', peer);
peer.onAnswer(answer);
};
const offerHandler = (offer: RTCSessionDescriptionInit) => {
console.log('[useRTC] offer received', offer);
setPeers((peers) => {
peers.push(new RTCPeer({ channelName, offer, sendAnswer, sendOffer }));
return peers;
});
};
useEffect(() => {
if (ready) {
console.log('-=-= SETTING THINGS UP =-=-');
registerAnsweredReceiver(answerHandler);
registerOfferredReceiver(offerHandler);
setPeers([new RTCPeer({ channelName, sendAnswer, sendOffer })]);
}
}, [ready]);
return {
count: peers.length,
};
}

123
hooks/useSocket.ts Normal file
View File

@@ -0,0 +1,123 @@
import { useEffect, useRef, useState } from 'react';
import { socket } from '@/socket';
import type { GameUpdate } from '@/types';
export interface UseSocketProps {
gameID: string;
setGameData: (gameUpdate: GameUpdate) => void;
setNoGame: (noGame: boolean) => void;
}
export interface UseSocket {
ready: boolean;
flipCard: (cardIndex: number) => void;
handleSettings: (cardData: GameUpdate) => void;
redraw: (cardIndex: number) => void;
rtcAnswer: (answer: RTCSessionDescriptionInit) => void;
registerAnsweredReceiver: (receiver: (answer: RTCSessionDescriptionInit) => void) => void;
rtcOffer: (offer: RTCSessionDescriptionInit) => void;
registerOfferredReceiver: (receiver: (offer: RTCSessionDescriptionInit) => void) => void;
select: (cardIndex: number, cardID: string) => void;
}
export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketProps): UseSocket {
const [ready, setReady] = useState(false);
const answerRef = useRef<(answer: RTCSessionDescriptionInit) => void>(null);
const offerRef = useRef<(offer: RTCSessionDescriptionInit) => void>(null);
useEffect(() => {
if (gameID) {
socket.emit('join', gameID);
socket.on('init', (data: GameUpdate) => {
setReady(true);
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);
});
socket.on('rtc-answered', (answered: RTCSessionDescriptionInit) => {
if (answerRef.current) answerRef.current(answered);
});
socket.on('rtc-offered', (offered: RTCSessionDescriptionInit) => {
if (offerRef.current) {
offerRef.current(offered);
}
});
}
return () => {
socket.removeAllListeners();
};
}, [gameID]);
const flipCard = (cardIndex: number) => {
console.log('flip-card', {
gameID,
cardIndex,
});
socket.emit('flip-card', {
gameID,
cardIndex,
});
};
const handleSettings = (gameData: GameUpdate) => {
socket.emit('settings', {
gameID,
gameData,
});
};
const redraw = (cardIndex: number) => {
socket.emit('redraw', {
gameID,
cardIndex,
});
};
const rtcAnswer = (answer: RTCSessionDescriptionInit) => {
console.log('rtc-answer', { gameID, answer });
socket.emit('rtc-answer', { gameID, answer });
};
const rtcOffer = (offer: RTCSessionDescriptionInit) => {
console.log('rtc-offer', { gameID, offer });
socket.emit('rtc-offer', { gameID, offer });
};
const select = (cardIndex: number, cardID: string) => {
socket.emit('select', {
gameID,
cardIndex,
cardID,
});
};
return {
ready,
flipCard,
handleSettings,
redraw,
rtcAnswer,
registerAnsweredReceiver: (receiver: (obj: RTCSessionDescriptionInit) => void[]) =>
(answerRef.current = receiver),
rtcOffer,
registerOfferredReceiver: (receiver: (obj: RTCSessionDescriptionInit) => void[]) =>
(offerRef.current = receiver),
select,
};
}

View File

@@ -1,7 +1,7 @@
import Deck from '@/lib/TarokkaDeck';
import generateID from '@/tools/simpleID';
import parseMilliseconds from '@/tools/parseMilliseconds';
import { MINUTE, HOUR, DAY } from '@/constants/time';
import { HOUR, DAY } from '@/constants/time';
import { GameState, GameUpdate, Settings } from '@/types';
const deck = new Deck();
@@ -34,10 +34,10 @@ export default class GameStore {
private totalExpired: number;
private totalUnused: number;
private startUps: Set<string>;
private dms: Map<string, GameState>;
private spectators: Map<string, GameState>;
private players: Map<string, string>;
private startUps: Set<string>; // homepage socket IDs
private dms: Map<string, GameState>; // DM socket ID -> game
private spectators: Map<string, GameState>; // spectator socket ID -> game
private players: Map<string, GameState>; // socket ID -> game
constructor() {
this.startTime = Date.now();
@@ -50,7 +50,7 @@ export default class GameStore {
this.spectators = new Map();
this.players = new Map();
setInterval(() => this.log(), 15 * MINUTE);
setInterval(() => this.log(), HOUR);
setInterval(() => this.cleanUp(), HOUR);
setTimeout(() => this.wrapUp(), tilMidnight());
@@ -106,14 +106,12 @@ export default class GameStore {
game.players.add(playerID);
game.lastUpdated = Date.now();
this.players.set(playerID, gameID);
this.players.set(playerID, game);
return this.gameUpdate(game);
}
leaveGame(gameID: string, playerID: string): GameState {
const game = this.getGame(gameID);
leaveGame(game: GameState, playerID: string): GameState {
game.players.delete(playerID);
game.lastUpdated = Date.now();
@@ -132,6 +130,33 @@ export default class GameStore {
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);
}
updateSettings(gameID: string, settings: Settings) {
const game = this.getGame(gameID);
@@ -160,12 +185,12 @@ export default class GameStore {
return null;
} else {
const gameID = this.players.get(playerID);
const game = this.players.get(playerID);
if (!gameID) throw new Error(`Player ${playerID} not found`);
if (!game) throw new Error(`Player ${playerID} not found`);
this.players.delete(playerID);
return this.leaveGame(gameID, playerID);
return this.leaveGame(game, playerID);
}
}
@@ -176,7 +201,6 @@ export default class GameStore {
console.log(uptimeLog);
console.log(`Games: ${this.dms.size}`);
console.log(`Players: ${this.players.size}`);
console.log('-'.repeat(uptimeLog.length));
}
wrapUp() {
@@ -188,7 +212,6 @@ export default class GameStore {
console.log(`Created: ${this.totalCreated}`);
console.log(`Expired: ${this.totalExpired}`);
console.log(`Unused: ${this.totalUnused}`);
console.log('='.repeat(uptimeLog.length));
this.totalCreated = 0;
this.totalExpired = 0;
@@ -208,12 +231,16 @@ export default class GameStore {
this.totalExpired += expired.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));
}
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.spectators.delete(game.spectatorID);

131
lib/RTCPeer.ts Normal file
View File

@@ -0,0 +1,131 @@
const servers = {
iceServers: [
{ url: 'stun:stun01.sipphone.com' },
{ url: 'stun:stun.ekiga.net' },
{ url: 'stun:stun.fwdnet.net' },
{ url: 'stun:stun.ideasip.com' },
{ url: 'stun:stun.iptel.org' },
{ url: 'stun:stun.rixtelecom.se' },
{ url: 'stun:stun.schlund.de' },
{ url: 'stun:stun.l.google.com:19302' },
{ url: 'stun:stun1.l.google.com:19302' },
{ url: 'stun:stun2.l.google.com:19302' },
{ url: 'stun:stun3.l.google.com:19302' },
{ url: 'stun:stun4.l.google.com:19302' },
{ url: 'stun:stunserver.org' },
{ url: 'stun:stun.softjoys.com' },
{ url: 'stun:stun.voiparound.com' },
{ url: 'stun:stun.voipbuster.com' },
{ url: 'stun:stun.voipstunt.com' },
{ url: 'stun:stun.voxgratia.org' },
{ url: 'stun:stun.xten.com' },
// {
// url: 'turn:numb.viagenie.ca',
// credential: 'muazkh',
// username: 'webrtc@live.com',
// },
// {
// url: 'turn:192.158.29.39:3478?transport=udp',
// credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=',
// username: '28224511:1379330808',
// },
// {
// url: 'turn:192.158.29.39:3478?transport=tcp',
// credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA=',
// username: '28224511:1379330808',
// },
],
};
const pcConstraints = {
optional: [{ DtlsSrtpKeyAgreement: true }],
};
export interface RTCPeerProps {
channelName: string;
offer?: RTCSessionDescriptionInit;
sendAnswer: (offer: RTCSessionDescriptionInit) => void;
sendOffer: (offer: RTCSessionDescriptionInit) => void;
}
export default class RTCPeer {
channelName: string;
peerConnection: RTCPeerConnection;
channel: RTCDataChannel;
sendAnswer: (offer: RTCSessionDescriptionInit) => void;
sendOffer: (offer: RTCSessionDescriptionInit) => void;
constructor({ channelName, offer, sendAnswer, sendOffer }: RTCPeerProps) {
this.sendOffer = sendOffer;
this.sendAnswer = sendAnswer;
this.channelName = channelName;
this.peerConnection = new RTCPeerConnection(); //(servers, pcConstraints);
this.peerConnection.onicecandidate = offer
? this.#handleIceCandidateAnswer
: this.#handleIceCandidateOffer;
this.#createDataChannel();
if (offer) {
console.log('answer');
this.peerConnection.setRemoteDescription(offer);
this.peerConnection.createAnswer().then((answer) => {
this.peerConnection.setLocalDescription(answer);
});
} else {
console.log('call');
this.peerConnection.createOffer().then((offer) => {
this.peerConnection.setLocalDescription(offer);
});
}
}
onAnswer = (answer: RTCSessionDescriptionInit) => {
this.peerConnection.setRemoteDescription(answer);
};
#handleIceCandidateAnswer = (event: RTCPeerConnectionIceEvent) => {
if (!event.candidate) {
const answer = this.peerConnection.localDescription;
console.log('send-answer', { answer });
if (answer) {
this.sendAnswer(answer);
}
}
};
#handleIceCandidateOffer = (event: RTCPeerConnectionIceEvent) => {
if (!event.candidate) {
const offer = this.peerConnection.localDescription;
if (offer) {
console.log('send-offer', { offer });
this.sendOffer(offer);
}
}
};
#createDataChannel = () => {
try {
this.channel = this.peerConnection.createDataChannel(this.channelName);
this.channel.onopen = () => {
console.log('Receive Channel[onopen]:', this.channel.readyState);
};
this.channel.onmessage = (event: MessageEvent) => {
console.log('Receive Channel[onmessage]:', event.data);
};
this.channel.onclose = () => {
console.log('Receive Channel[onclose]:', this.channel.readyState);
};
} catch (error) {
console.error('[RTCPeer|#createDataChannel] ERROR', error);
}
};
}

View File

@@ -8,8 +8,8 @@ export default class TarokkaDeck {
private backs: TarokkaCard[] = [];
constructor() {
this.highDeck = cards.filter((card) => !card.back && card.suit === 'High Deck');
this.commonDeck = cards.filter((card) => !card.back && card.suit !== 'High Deck');
this.highDeck = cards.filter((card) => card.deck === 'high');
this.commonDeck = cards.filter((card) => card.deck === 'common');
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 {
return this.backs[0];
}

25
middleware.ts Normal file
View File

@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(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

@@ -6,7 +6,7 @@
"dev": "nodemon",
"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",
"deploy": "docker buildx build --platform linux/amd64 -t nasty.mcmorgans:5000/tarokka --push ."
"release": "docker buildx build --platform linux/amd64 -t 192.168.0.2:5000/tarokka --push ."
},
"dependencies": {
"cross-env": "^7.0.3",

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: 2.5 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

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

@@ -79,6 +79,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 }) => {
try {
const gameUpdate = gameStore.updateSettings(gameID, gameData.settings);
@@ -89,6 +119,32 @@ app.prepare().then(() => {
}
});
socket.on('rtc-answer', ({ gameID, answer }: { gameID: string; answer: any }) => {
try {
const gameState = gameStore.getGame(gameID);
console.log('[rtc-answer]', gameID);
io.to(gameState.dmID).emit('rtc-answered', answer);
io.to(gameState.spectatorID).emit('rtc-answered', answer);
} catch (e) {
const error = e instanceof Error ? e.message : e;
console.error(Date.now(), 'Error[rtc-answer]', error);
}
});
socket.on('rtc-offer', ({ gameID, offer }: { gameID: string; offer: any }) => {
try {
const gameState = gameStore.getGame(gameID);
console.log('[rtc-offer]', gameID);
io.to(gameState.dmID).emit('rtc-offered', offer);
io.to(gameState.spectatorID).emit('rtc-offered', offer);
} catch (e) {
const error = e instanceof Error ? e.message : e;
console.error(Date.now(), 'Error[rtc-offer]', error);
}
});
socket.on('disconnect', () => {
try {
const game = gameStore.playerExit(socket.id);

View File

@@ -11,7 +11,7 @@ export default function getTooltip(
let text: string[] = [];
if (flipped) {
if (dm || flipped) {
if (dm || settings.positionFront) text.push(position.text);
if (dm) text.push(`${cardName}: ${description}`);
@@ -19,25 +19,23 @@ export default function getTooltip(
if (isHighCard(card)) {
// High deck ally
if (position.id === 'ally') {
if (dm) text.push(`Ally: ${card.prophecy.allies[0].ally}`);
if (dm) text.push(card.prophecy.allies[0].dmText);
if (dm || settings.prophecy) text.push(card.prophecy.allies[0].playerText);
if (dm) text.push(card.prophecy.allies[0].dmText);
if (dm) text.push(`Ally: ${card.prophecy.allies[0].ally}`);
}
// High deck Strahd
if (position.id === 'strahd') {
if (dm) text.push(card.prophecy.strahd.dmText);
if (dm || settings.prophecy) text.push(card.prophecy.strahd.playerText);
if (dm) text.push(card.prophecy.strahd.dmText);
}
}
// Low deck: Tome, Ravenkind, or Sunsword
if (isLowCard(card)) {
if (dm) text.push(card.prophecy.dmText);
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;

View File

@@ -1,8 +1,8 @@
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 default function getURL(card: TarokkaCard | TarokkaGameCard, settings: Settings) {
const styleConfig = cardStyles[settings.cardStyle];
const fileBase = settings.cardStyle === 'standard' ? standardMap[card.id] : card.id;
return `${styleConfig.baseURL}${fileBase}${styleConfig.extension}`;
return `${styleConfig.baseURL}${fileBase}${card.extension || styleConfig.extension}`;
}

View File

@@ -1,5 +1,8 @@
export type CardStyle = 'standard' | 'color' | 'grayscale';
// all = both + back
export type Deck = 'high' | 'common' | 'both' | 'back' | 'all';
export interface Settings {
positionBack: boolean;
positionFront: boolean;
@@ -28,7 +31,9 @@ export interface TarokkaBase {
description: string;
aria: string;
back: boolean;
deck: Deck;
suit: 'Coins' | 'Glyphs' | 'High Deck' | 'Stars' | 'Swords' | null;
extension?: string;
}
export interface TarokkaGameBase extends TarokkaBase {
@@ -89,6 +94,7 @@ export interface GameUpdate {
export interface ClientUpdate {
gameID: string;
cardIndex: number;
cardID?: string;
}
export interface Layout {