Compare commits
33 Commits
c9cb28bed9
...
rtc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa938f7258 | ||
| 59aa904c5a | |||
|
|
06a87381d5 | ||
| 5444e25249 | |||
|
|
c4f4b09f18 | ||
|
|
25493671c5 | ||
|
|
7e8fe9eb79 | ||
|
|
8412ec49a2 | ||
|
|
d4100cc44f | ||
|
|
4448bc9c57 | ||
|
|
a15b80c23e | ||
|
|
2108324cf4 | ||
|
|
8bf8b4c5cb | ||
|
|
493891a8e2 | ||
|
|
af70401916 | ||
|
|
82ccb0f6fb | ||
|
|
6a1f1174a3 | ||
|
|
35afa28e44 | ||
|
|
bc7339439c | ||
|
|
af26a64e8b | ||
|
|
2f5807ac53 | ||
|
|
34176ff6d5 | ||
|
|
24062d1a07 | ||
|
|
f7d10a9b4f | ||
|
|
f6749f3146 | ||
|
|
6e2247d6f3 | ||
|
|
969d9f5028 | ||
|
|
1a4789af4c | ||
|
|
3d3cb7a45e | ||
|
|
0c8d2273ea | ||
|
|
5b21c560d6 | ||
|
|
bd7d42de55 | ||
|
|
90fd231fbd |
45
README.md
@@ -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 Eva’s fortune-telling, revealing a hero’s fate and Strahd’s 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, I’d say this is overkill for what is a relatively small aspect of _Curse of Strahd_ but I had fun making it. It’s 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
|
||||

|
||||
|
||||
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)
|
||||
|
||||
[](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 Eva’s 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
|
||||
```
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 37 KiB |
BIN
app/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
@@ -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
|
After Width: | Height: | Size: 13 KiB |
3
app/icon1.svg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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&D’s 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
@@ -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
|
After Width: | Height: | Size: 747 KiB |
20
app/page.tsx
@@ -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">
|
||||
<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"
|
||||
>
|
||||
Create New Game
|
||||
</button>
|
||||
<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-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
|
After Width: | Height: | Size: 544 KiB |
17
components/BuyMeACoffee.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</ToolTip>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
21
components/GitHubButton.tsx
Normal 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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,66 +47,87 @@ 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}`} />
|
||||
{Object.entries(gameData.settings)
|
||||
.filter(([_key, value]) => typeof value === 'boolean')
|
||||
.map(([key, value]) => (
|
||||
<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>
|
||||
<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'}
|
||||
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 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={`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
|
||||
type="radio"
|
||||
name="cardStyle"
|
||||
value={option}
|
||||
checked={gameData.settings.cardStyle === option}
|
||||
onChange={() => tuneRadio(option)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
>
|
||||
<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-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>
|
||||
);
|
||||
}
|
||||
|
||||
48
components/StackTheDeck.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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
|
After Width: | Height: | Size: 36 KiB |
BIN
public/img/color/back.png
Normal file
|
After Width: | Height: | Size: 969 KiB |
BIN
public/img/github-logo-white.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
1
public/img/github-mark-white.svg
Normal 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 |
BIN
public/img/grayscale/back.png
Normal file
|
After Width: | Height: | Size: 597 KiB |
BIN
public/img/screenshot.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
public/img/tarokka.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/img/tarokka_1024x512.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/img/tarokka_1200x630.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
public/img/tarokka_round_solid.png
Normal file
|
After Width: | Height: | Size: 938 KiB |
BIN
public/img/tarokka_round_solid.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
public/img/tarokka_transparent.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 110 KiB |
@@ -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 |
@@ -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 |
BIN
public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
@@ -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 |
56
server.ts
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||