Compare commits
22 Commits
24062d1a07
...
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 |
17
README.md
@@ -1,26 +1,27 @@
|
|||||||
````
|
|
||||||
# 🃏 Tarokka
|
# 🃏 Tarokka
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||

|
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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
You can see it live at:
|
You can see it live at:
|
||||||
👉 [https://tarokka.mcmorgans.us](https://tarokka.mcmorgans.us)
|
👉 [https://tarokka.app](https://tarokka.app)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
- 🔮 **Faithful to the Tarokka Deck**: Supports all cards and positions used by Madam Eva's reading.
|
- 🔮 **Faithful to the Tarokka Deck**: Supports all cards and positions used by Madam Eva’s reading.
|
||||||
- 💬 Dynamic prophecy rendering based on card and position
|
- 💬 Dynamic prophecy rendering based on card and position
|
||||||
- 🎨 Multiple card styles
|
- 🎨 Multiple card styles
|
||||||
- 🧙 **Separate DM and Spectator Views**:
|
- 🧙 Separate DM and Spectator Views
|
||||||
- ⚙️ DM can toggle what information is visible to players.
|
- ⚙️ DM can toggle what information is visible to players.
|
||||||
- Every action (flipping cards, settings changes) is broadcast live to connected users.
|
- 🃏Every action (flipping cards, settings changes) is broadcast live to connected users.
|
||||||
- 🌐 Fully browser-based — no accounts or installs
|
- 🌐 Fully browser-based — no accounts or installs
|
||||||
- 📱 Mobile-friendly UI
|
- 📱 Mobile-friendly UI
|
||||||
- 🔁 **WebSocket-Powered Real-Time Sync**:
|
- 🔁 WebSocket-Powered Real-Time Sync
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -33,4 +34,4 @@ git clone https://github.com/mcdoh/tarokka.git
|
|||||||
cd tarokka
|
cd tarokka
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
````
|
```
|
||||||
|
|||||||
@@ -2,21 +2,27 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { socket } from '@/socket';
|
import 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 Card from '@/components/Card';
|
||||||
|
import CopyButton from '@/components/CopyButton';
|
||||||
import Notes from '@/components/Notes';
|
import Notes from '@/components/Notes';
|
||||||
import NotFound from '@/components/NotFound';
|
import NotFound from '@/components/NotFound';
|
||||||
|
import Settings from '@/components/Settings';
|
||||||
|
import CardSelect from '@/components/CardSelect';
|
||||||
|
|
||||||
import { cardMap, layout } from '@/constants/tarokka';
|
import { cardMap, layout } from '@/constants/tarokka';
|
||||||
|
|
||||||
import type { GameUpdate, ClientUpdate } from '@/types';
|
import type { Deck, GameUpdate } from '@/types';
|
||||||
|
|
||||||
export default function GamePage() {
|
export default function GamePage() {
|
||||||
const { gameID: gameIDParam } = useParams();
|
const { gameID: gameIDParam } = useParams();
|
||||||
|
|
||||||
const [gameID, setGameID] = useState('');
|
const [gameID, setGameID] = useState('');
|
||||||
const [noGame, setNoGame] = useState(false);
|
const [noGame, setNoGame] = useState(false);
|
||||||
|
const [selectCard, setSelectCard] = useState(-1);
|
||||||
const [gameData, setGameData] = useState<GameUpdate>({
|
const [gameData, setGameData] = useState<GameUpdate>({
|
||||||
dmID: '',
|
dmID: '',
|
||||||
spectatorID: '',
|
spectatorID: '',
|
||||||
@@ -32,6 +38,11 @@ export default function GamePage() {
|
|||||||
|
|
||||||
const { dmID, cards, settings } = gameData;
|
const { dmID, cards, settings } = gameData;
|
||||||
const isDM = !!dmID;
|
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(() => {
|
useEffect(() => {
|
||||||
if (gameIDParam) {
|
if (gameIDParam) {
|
||||||
@@ -39,44 +50,10 @@ export default function GamePage() {
|
|||||||
}
|
}
|
||||||
}, [gameIDParam]);
|
}, [gameIDParam]);
|
||||||
|
|
||||||
useEffect(() => {
|
const select = (cardIndex: number, cardID: string) => {
|
||||||
if (gameID) {
|
setSelectCard(-1);
|
||||||
socket.emit('join', gameID);
|
|
||||||
|
|
||||||
socket.on('init', (data: GameUpdate) => {
|
socket.select(cardIndex, cardID);
|
||||||
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 });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// map our five Tarokka cards to their proper locations in a 3x3 grid
|
// map our five Tarokka cards to their proper locations in a 3x3 grid
|
||||||
@@ -88,7 +65,17 @@ export default function GamePage() {
|
|||||||
<NotFound />
|
<NotFound />
|
||||||
) : cards ? (
|
) : cards ? (
|
||||||
<main className="min-h-screen flex flex-col items-center justify-center gap-4 bg-[url('/img/table3.png')] bg-cover bg-center">
|
<main className="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">
|
<div className="grid grid-cols-3 grid-rows-3 gap-8 w-fit mx-auto">
|
||||||
{Array.from({ length: 9 })
|
{Array.from({ length: 9 })
|
||||||
.map(arrangeCards)
|
.map(arrangeCards)
|
||||||
@@ -100,13 +87,22 @@ export default function GamePage() {
|
|||||||
card={card}
|
card={card}
|
||||||
position={layout[cardMap[index]]}
|
position={layout[cardMap[index]]}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
flipAction={() => flipCard(cardMap[index])}
|
flipAction={() => socket.flipCard(cardMap[index])}
|
||||||
|
redrawAction={() => socket.redraw(cardMap[index])}
|
||||||
|
selectAction={() => setSelectCard(cardMap[index])}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Notes gameData={gameData} show={cards.every(({ flipped }) => flipped)} />
|
<Notes gameData={gameData} show={cards.every(({ flipped }) => flipped)} />
|
||||||
|
<CardSelect
|
||||||
|
show={selectDeck}
|
||||||
|
hand={cards}
|
||||||
|
settings={settings}
|
||||||
|
closeAction={() => setSelectCard(-1)}
|
||||||
|
selectAction={(cardID) => select(selectCard, cardID)}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
) : null;
|
) : 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 {
|
.rotate-y-180 {
|
||||||
transform: rotateY(180deg);
|
transform: rotateY(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.see-through {
|
||||||
|
mask-image: radial-gradient(circle at center, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 1) 75%);
|
||||||
|
mask-size: cover;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-image: radial-gradient(circle at center, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 1) 75%);
|
||||||
|
-webkit-mask-size: cover;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|||||||
BIN
app/icon0.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
3
app/icon1.svg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -23,6 +23,11 @@ const cinzel = Cinzel_Decorative({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Tarokka',
|
title: 'Tarokka',
|
||||||
description: 'Fortune telling for D&D’s Curse of Strahd',
|
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({
|
export default function RootLayout({
|
||||||
|
|||||||
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 |
10
app/page.tsx
@@ -16,13 +16,19 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen flex items-center justify-center bg-[url('/img/table3.png')] bg-cover bg-center">
|
<main className="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
|
<button
|
||||||
onClick={handleCreateGame}
|
onClick={handleCreateGame}
|
||||||
className="bg-slate-800 hover:bg-slate-700 text-yellow-400 hover:text-yellow-300 text-lg px-6 py-3 rounded-lg shadow transition-all duration-250 cursor-pointer"
|
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
|
Create New Game
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</main>
|
</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';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import TiltCard from '@/components/TiltCard';
|
||||||
import ToolTip from '@/components/ToolTip';
|
import ToolTip from '@/components/ToolTip';
|
||||||
|
import StackTheDeck from '@/components/StackTheDeck';
|
||||||
import tarokkaCards from '@/constants/tarokkaCards';
|
import tarokkaCards from '@/constants/tarokkaCards';
|
||||||
import getCardInfo from '@/tools/getCardInfo';
|
import getCardInfo from '@/tools/getCardInfo';
|
||||||
import getURL from '@/tools/getURL';
|
import getURL from '@/tools/getURL';
|
||||||
@@ -15,9 +18,21 @@ type CardProps = {
|
|||||||
position: Layout;
|
position: Layout;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
flipAction: () => void;
|
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 { aria, flipped } = card;
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
@@ -42,30 +57,47 @@ export default function Card({ dm, card, position, settings, flipAction }: CardP
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolTip content={getTooltip()}>
|
<ToolTip content={tooltip || getTooltip()}>
|
||||||
<div
|
<TiltCard
|
||||||
className={`relative h-[21vh] w-[15vh] perspective transition-transform duration-200 hover:scale-150 z-0 hover:z-10 ${dm ? 'cursor-pointer' : ''} `}
|
className={`h-[21vh] w-[15vh] relative perspective transition-transform duration-200 z-0 hover:z-10 hover:scale-150 ${dm ? 'cursor-pointer' : ''} `}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`transition-transform duration-500 transform-style-preserve-3d ${flipped ? 'rotate-y-180' : ''}`}
|
className={`absolute inset-0 transition-transform duration-500 transform-style-preserve-3d ${flipped ? 'rotate-y-180' : ''}`}
|
||||||
>
|
>
|
||||||
<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
|
<img
|
||||||
src={getURL(cardBack as TarokkaGameCard, settings)}
|
src={getURL(cardBack as TarokkaGameCard, settings)}
|
||||||
alt="Card Back"
|
alt="Card Back"
|
||||||
className="rounded-lg border border-yellow-500"
|
className={`absolute rounded-lg ${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>
|
||||||
<div className="absolute group inset-0 backface-hidden rotate-y-180">
|
<div className="absolute inset-0 backface-hidden rotate-y-180">
|
||||||
<img
|
<img
|
||||||
src={getURL(card, settings)}
|
src={getURL(card, settings)}
|
||||||
alt={aria}
|
alt={aria}
|
||||||
className="rounded-lg border border-yellow-500 "
|
className="rounded-lg border border-yellow-500/25 hover:drop-shadow-[0_0_3px_#ffd700/50]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TiltCard>
|
||||||
</ToolTip>
|
</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,22 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { ForwardRefExoticComponent, RefAttributes, useState } from 'react';
|
||||||
import { Copy as CopyIcon, Check as CheckIcon } from 'lucide-react';
|
import { LucideProps, Copy as CopyIcon, Check as CheckIcon } from 'lucide-react';
|
||||||
|
|
||||||
import ToolTip from '@/components/ToolTip';
|
import ToolTip from '@/components/ToolTip';
|
||||||
|
|
||||||
type CopyButtonProps = {
|
type CopyButtonProps = {
|
||||||
title?: string;
|
title?: string;
|
||||||
copy: string;
|
copy: string;
|
||||||
|
Icon?: ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>;
|
||||||
tooltip?: string | string[];
|
tooltip?: string | string[];
|
||||||
className?: string;
|
className?: string;
|
||||||
|
size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CopyButton({
|
export default function CopyButton({
|
||||||
title,
|
title,
|
||||||
copy,
|
copy,
|
||||||
|
Icon = CopyIcon,
|
||||||
tooltip = ['Copy', 'Copied'],
|
tooltip = ['Copy', 'Copied'],
|
||||||
className,
|
className,
|
||||||
|
size = 16,
|
||||||
}: CopyButtonProps) {
|
}: CopyButtonProps) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
@@ -42,9 +46,9 @@ export default function CopyButton({
|
|||||||
<div className="flex items-center gap-2 w-full text-sm font-medium">
|
<div className="flex items-center gap-2 w-full text-sm font-medium">
|
||||||
{title}
|
{title}
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<CheckIcon className="ml-auto" size={16} />
|
<CheckIcon className="ml-auto" size={size} />
|
||||||
) : (
|
) : (
|
||||||
<CopyIcon className="ml-auto" size={16} />
|
<Icon className="ml-auto" size={size} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ export default function Notes({ gameData: { dmID, cards, settings }, show }: Not
|
|||||||
className={`fixed bottom-4 right-4 z-25 transition-all duration-250 ${show ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
|
className={`fixed bottom-4 right-4 z-25 transition-all duration-250 ${show ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className={`text-yellow-400 hover:text-yellow-300 p-2 transition-all duration-250 cursor-pointer ${showNotes ? 'pointer-events-none opacity-0' : 'pointer-events-auto opacity-100'}`}
|
className={`text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] p-2 transition-all duration-250 cursor-pointer ${showNotes ? 'pointer-events-none opacity-0' : 'pointer-events-auto opacity-100'}`}
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
>
|
>
|
||||||
<ScrollText className="w-5 h-5" />
|
<ScrollText className="w-5 h-5" />
|
||||||
@@ -53,7 +53,7 @@ export default function Notes({ gameData: { dmID, cards, settings }, show }: Not
|
|||||||
className={`transition-all duration-250 ${showNotes ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
|
className={`transition-all duration-250 ${showNotes ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`fixed bottom-4 right-4 transition-all duration-250 bg-slate-800 border border-yellow-400 rounded-lg space-y-2 ${showNotes ? 'w-[33vw] h-[67vh]' : 'w-0 h-0'}`}
|
className={`fixed bottom-4 right-4 transition-all duration-250 bg-slate-800 border border-yellow-400 rounded-lg space-y-2 ${showNotes ? 'sm:w-[33vw] sm:h-[67vh] w-[80vw] h-[80vh]' : 'w-0 h-0'}`}
|
||||||
>
|
>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
copy={notes.map((note) => note!.join('\n')).join('\n\n')}
|
copy={notes.map((note) => note!.join('\n')).join('\n\n')}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export default function Scrim({ children, clickAction, show = true, className =
|
|||||||
clickAction(event);
|
clickAction(event);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useState } from 'react';
|
|||||||
import { Settings as Gear } from 'lucide-react';
|
import { Settings as Gear } from 'lucide-react';
|
||||||
import { Cinzel_Decorative } from 'next/font/google';
|
import { Cinzel_Decorative } from 'next/font/google';
|
||||||
|
|
||||||
|
import BuyMeACoffee from '@/components/BuyMeACoffee';
|
||||||
import CopyButton from '@/components/CopyButton';
|
import CopyButton from '@/components/CopyButton';
|
||||||
|
import GitHubButton from '@/components/GitHubButton';
|
||||||
import Scrim from '@/components/Scrim';
|
import Scrim from '@/components/Scrim';
|
||||||
import Switch from '@/components/Switch';
|
import Switch from '@/components/Switch';
|
||||||
import { CardStyle, GameUpdate } from '@/types';
|
import { CardStyle, GameUpdate } from '@/types';
|
||||||
@@ -73,18 +75,18 @@ export default function Settings({ gameData, changeAction }: SettingsProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const CardStyle = () => (
|
const CardStyle = () => (
|
||||||
<fieldset className="flex flex-col">
|
<fieldset className="flex flex-col w-full">
|
||||||
<div className="text-xs mb-1">Card style:</div>
|
<div className="text-xs my-1">Card style:</div>
|
||||||
<div className="inline-flex overflow-hidden rounded-md w-full">
|
<div className="inline-flex overflow-hidden rounded-md w-full">
|
||||||
{cardStyleOptions.map((option, index) => (
|
{cardStyleOptions.map((option, index) => (
|
||||||
<label
|
<label
|
||||||
key={option}
|
key={option}
|
||||||
className={`cursor-pointer px-4 py-2 text-sm font-medium transition
|
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'}
|
${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 === 0 ? 'rounded-l-md' : ''}
|
||||||
${index === cardStyleOptions.length - 1 ? 'rounded-r-md' : ''}
|
${index === cardStyleOptions.length - 1 ? 'rounded-r-md' : ''}
|
||||||
${index !== 0 && 'border-l border-gray-600'}
|
${index !== 0 && 'border-l border-gray-600'}
|
||||||
border border-yellow-500 hover:text-yellow-300
|
border border-yellow-500 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700]
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -109,15 +111,19 @@ export default function Settings({ gameData, changeAction }: SettingsProps) {
|
|||||||
className={`transition-all duration-250 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
|
className={`transition-all duration-250 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`fixed top-4 right-4 flex flex-col items-center justify-center bg-slate-800 text-yellow-400 rounded-lg border border-yellow-400 p-6 space-y-2 transition-all duration-250 ${open ? 'opacity-100 w-[350px] h-[300px]' : 'opacity-0 w-0 h-0'}`}
|
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 />
|
<Links />
|
||||||
<Permissions />
|
<Permissions />
|
||||||
<CardStyle />
|
<CardStyle />
|
||||||
|
<span className="w-full flex flex-row justify-evenly">
|
||||||
|
<GitHubButton className="h-[35px] w-[125px]" />
|
||||||
|
<BuyMeACoffee className="h-[35px] w-[125px]" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Scrim>
|
</Scrim>
|
||||||
<button
|
<button
|
||||||
className={`p-2 transition-all duration-250 text-yellow-400 hover:text-yellow-300 cursor-pointer ${open ? 'pointer-events-none opacity-0' : 'pointer-events-auto opacity-100'}`}
|
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)}
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
>
|
>
|
||||||
<Gear className="w-5 h-5" />
|
<Gear className="w-5 h-5" />
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@ type TooltipProps = {
|
|||||||
export default function Tooltip({
|
export default function Tooltip({
|
||||||
children,
|
children,
|
||||||
content,
|
content,
|
||||||
delay = 500,
|
delay = 250,
|
||||||
mobileDelay = 500,
|
mobileDelay = 250,
|
||||||
offsetX = 20,
|
offsetX = 20,
|
||||||
offsetY = 20,
|
offsetY = 20,
|
||||||
edgeBuffer = 10,
|
edgeBuffer = 10,
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'back',
|
id: 'back',
|
||||||
name: 'Card Back',
|
name: 'Card Back',
|
||||||
card: 'Back of card',
|
card: 'Back of card',
|
||||||
|
deck: 'back',
|
||||||
suit: null,
|
suit: null,
|
||||||
aria: 'Back of card',
|
aria: 'Back of card',
|
||||||
description: 'Back of card',
|
description: 'Back of card',
|
||||||
back: true,
|
back: true,
|
||||||
|
extension: '.png',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'swashbuckler',
|
id: 'swashbuckler',
|
||||||
name: 'Swashbuckler',
|
name: 'Swashbuckler',
|
||||||
card: 'One of Coins',
|
card: 'One of Coins',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Coins',
|
suit: 'Coins',
|
||||||
aria: 'Coins 01 Swashbuckler',
|
aria: 'Coins 01 Swashbuckler',
|
||||||
description: 'Those who like money yet give it up freely; likable rogues and rapscallions',
|
description: 'Those who like money yet give it up freely; likable rogues and rapscallions',
|
||||||
@@ -30,6 +33,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'philanthropist',
|
id: 'philanthropist',
|
||||||
name: 'Philanthropist',
|
name: 'Philanthropist',
|
||||||
card: 'Two of Coins',
|
card: 'Two of Coins',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Coins',
|
suit: 'Coins',
|
||||||
aria: 'Coins 02 Philanthropist',
|
aria: 'Coins 02 Philanthropist',
|
||||||
description:
|
description:
|
||||||
@@ -47,6 +51,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'trader',
|
id: 'trader',
|
||||||
name: 'Trader',
|
name: 'Trader',
|
||||||
card: 'Three of Coins',
|
card: 'Three of Coins',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Coins',
|
suit: 'Coins',
|
||||||
aria: 'Coins 03 Trader',
|
aria: 'Coins 03 Trader',
|
||||||
description: 'Commerce; smuggling and black markets; fair and equitable trades',
|
description: 'Commerce; smuggling and black markets; fair and equitable trades',
|
||||||
@@ -63,6 +68,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'merchant',
|
id: 'merchant',
|
||||||
name: 'Merchant',
|
name: 'Merchant',
|
||||||
card: 'Four of Coins',
|
card: 'Four of Coins',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Coins',
|
suit: 'Coins',
|
||||||
aria: 'Coins 04 Merchant',
|
aria: 'Coins 04 Merchant',
|
||||||
description:
|
description:
|
||||||
@@ -79,6 +85,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'guild-member',
|
id: 'guild-member',
|
||||||
name: 'Guild Member',
|
name: 'Guild Member',
|
||||||
card: 'Five of Coins',
|
card: 'Five of Coins',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Coins',
|
suit: 'Coins',
|
||||||
aria: 'Coins 05 Guild Member',
|
aria: 'Coins 05 Guild Member',
|
||||||
description: "Like-minded individuals joined together in a common goal; pride in one's work",
|
description: "Like-minded individuals joined together in a common goal; pride in one's work",
|
||||||
@@ -94,6 +101,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'beggar',
|
id: 'beggar',
|
||||||
name: 'Beggar',
|
name: 'Beggar',
|
||||||
card: 'Six of Coins',
|
card: 'Six of Coins',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Coins',
|
suit: 'Coins',
|
||||||
aria: 'Coins 06 Beggar',
|
aria: 'Coins 06 Beggar',
|
||||||
description: 'Sudden change in economic status or fortune',
|
description: 'Sudden change in economic status or fortune',
|
||||||
@@ -110,6 +118,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'thief',
|
id: 'thief',
|
||||||
name: 'Thief',
|
name: 'Thief',
|
||||||
card: 'Seven of Coins',
|
card: 'Seven of Coins',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Coins',
|
suit: 'Coins',
|
||||||
aria: 'Coins 07 Thief',
|
aria: 'Coins 07 Thief',
|
||||||
description:
|
description:
|
||||||
@@ -127,6 +136,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'tax-collector',
|
id: 'tax-collector',
|
||||||
name: 'Tax Collector',
|
name: 'Tax Collector',
|
||||||
card: 'Eight of Coins',
|
card: 'Eight of Coins',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Coins',
|
suit: 'Coins',
|
||||||
aria: 'Coins 08 Tax Collector',
|
aria: 'Coins 08 Tax Collector',
|
||||||
description: 'Corruption; honesty in an otherwise corrupt government or organization',
|
description: 'Corruption; honesty in an otherwise corrupt government or organization',
|
||||||
@@ -144,6 +154,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'miser',
|
id: 'miser',
|
||||||
name: 'Miser',
|
name: 'Miser',
|
||||||
card: 'Nine of Coins',
|
card: 'Nine of Coins',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Coins',
|
suit: 'Coins',
|
||||||
aria: 'Coins 09 Miser',
|
aria: 'Coins 09 Miser',
|
||||||
description:
|
description:
|
||||||
@@ -160,6 +171,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'rogue',
|
id: 'rogue',
|
||||||
name: 'Rogue',
|
name: 'Rogue',
|
||||||
card: 'Master of Coins',
|
card: 'Master of Coins',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Coins',
|
suit: 'Coins',
|
||||||
aria: 'Coins 10 Rogue',
|
aria: 'Coins 10 Rogue',
|
||||||
description:
|
description:
|
||||||
@@ -176,6 +188,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'monk',
|
id: 'monk',
|
||||||
name: 'Monk',
|
name: 'Monk',
|
||||||
card: 'One of Glyphs',
|
card: 'One of Glyphs',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Glyphs',
|
suit: 'Glyphs',
|
||||||
aria: 'Glyphs 01 Monk',
|
aria: 'Glyphs 01 Monk',
|
||||||
description:
|
description:
|
||||||
@@ -193,6 +206,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'missionary',
|
id: 'missionary',
|
||||||
name: 'Missionary',
|
name: 'Missionary',
|
||||||
card: 'Two of Glyphs',
|
card: 'Two of Glyphs',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Glyphs',
|
suit: 'Glyphs',
|
||||||
aria: 'Glyphs 02 Missionary',
|
aria: 'Glyphs 02 Missionary',
|
||||||
description:
|
description:
|
||||||
@@ -211,6 +225,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'healer',
|
id: 'healer',
|
||||||
name: 'Healer',
|
name: 'Healer',
|
||||||
card: 'Three of Glyphs',
|
card: 'Three of Glyphs',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Glyphs',
|
suit: 'Glyphs',
|
||||||
aria: 'Glyphs 03 Healer',
|
aria: 'Glyphs 03 Healer',
|
||||||
description:
|
description:
|
||||||
@@ -228,6 +243,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'shepherd',
|
id: 'shepherd',
|
||||||
name: 'Shepherd',
|
name: 'Shepherd',
|
||||||
card: 'Four of Glyphs',
|
card: 'Four of Glyphs',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Glyphs',
|
suit: 'Glyphs',
|
||||||
aria: 'Glyphs 04 Shepherd',
|
aria: 'Glyphs 04 Shepherd',
|
||||||
description:
|
description:
|
||||||
@@ -245,6 +261,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'druid',
|
id: 'druid',
|
||||||
name: 'Druid',
|
name: 'Druid',
|
||||||
card: 'Five of Glyphs',
|
card: 'Five of Glyphs',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Glyphs',
|
suit: 'Glyphs',
|
||||||
aria: 'Glyphs 05 Druid',
|
aria: 'Glyphs 05 Druid',
|
||||||
description:
|
description:
|
||||||
@@ -263,6 +280,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'anarchist',
|
id: 'anarchist',
|
||||||
name: 'Anarchist',
|
name: 'Anarchist',
|
||||||
card: 'Six of Glyphs',
|
card: 'Six of Glyphs',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Glyphs',
|
suit: 'Glyphs',
|
||||||
aria: 'Glyphs 06 Anarchist',
|
aria: 'Glyphs 06 Anarchist',
|
||||||
description: 'A fundamental change brought on by one whose beliefs are being put to the test',
|
description: 'A fundamental change brought on by one whose beliefs are being put to the test',
|
||||||
@@ -279,6 +297,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'charlatan',
|
id: 'charlatan',
|
||||||
name: 'Charlatan',
|
name: 'Charlatan',
|
||||||
card: 'Seven of Glyphs',
|
card: 'Seven of Glyphs',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Glyphs',
|
suit: 'Glyphs',
|
||||||
aria: 'Glyphs 07 Charlatan',
|
aria: 'Glyphs 07 Charlatan',
|
||||||
description: 'Liars; those who profess to believe one thing but actually believe another',
|
description: 'Liars; those who profess to believe one thing but actually believe another',
|
||||||
@@ -294,6 +313,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'bishop',
|
id: 'bishop',
|
||||||
name: 'Bishop',
|
name: 'Bishop',
|
||||||
card: 'Eight of Glyphs',
|
card: 'Eight of Glyphs',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Glyphs',
|
suit: 'Glyphs',
|
||||||
aria: 'Glyphs 08 Bishop',
|
aria: 'Glyphs 08 Bishop',
|
||||||
description: 'Strict adherence to a code or a belief; those who plot, plan, and scheme',
|
description: 'Strict adherence to a code or a belief; those who plot, plan, and scheme',
|
||||||
@@ -310,6 +330,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'traitor',
|
id: 'traitor',
|
||||||
name: 'Traitor',
|
name: 'Traitor',
|
||||||
card: 'Nine of Glyphs',
|
card: 'Nine of Glyphs',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Glyphs',
|
suit: 'Glyphs',
|
||||||
aria: 'Glyphs 09 Traitor',
|
aria: 'Glyphs 09 Traitor',
|
||||||
description: 'Betrayal by someone close and trusted; a weakening or loss of faith',
|
description: 'Betrayal by someone close and trusted; a weakening or loss of faith',
|
||||||
@@ -327,6 +348,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'priest',
|
id: 'priest',
|
||||||
name: 'Priest',
|
name: 'Priest',
|
||||||
card: 'Master of Glyphs',
|
card: 'Master of Glyphs',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Glyphs',
|
suit: 'Glyphs',
|
||||||
aria: 'Glyphs 10 Priest',
|
aria: 'Glyphs 10 Priest',
|
||||||
description: 'Enlightenment; those who follow a deity, a system of values, or a higher purpose',
|
description: 'Enlightenment; those who follow a deity, a system of values, or a higher purpose',
|
||||||
@@ -343,6 +365,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'transmuter',
|
id: 'transmuter',
|
||||||
name: 'Transmuter',
|
name: 'Transmuter',
|
||||||
card: 'One of Stars',
|
card: 'One of Stars',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Stars',
|
suit: 'Stars',
|
||||||
aria: 'Stars 01 Transmuter',
|
aria: 'Stars 01 Transmuter',
|
||||||
description:
|
description:
|
||||||
@@ -359,6 +382,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'diviner',
|
id: 'diviner',
|
||||||
name: 'Diviner',
|
name: 'Diviner',
|
||||||
card: 'Two of Stars',
|
card: 'Two of Stars',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Stars',
|
suit: 'Stars',
|
||||||
aria: 'Stars 02 Diviner',
|
aria: 'Stars 02 Diviner',
|
||||||
description:
|
description:
|
||||||
@@ -376,6 +400,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'enchanter',
|
id: 'enchanter',
|
||||||
name: 'Enchanter',
|
name: 'Enchanter',
|
||||||
card: 'Three of Stars',
|
card: 'Three of Stars',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Stars',
|
suit: 'Stars',
|
||||||
aria: 'Stars 03 Enchanter',
|
aria: 'Stars 03 Enchanter',
|
||||||
description: 'Inner turmoil that comes from confusion, fear of failure, or false information',
|
description: 'Inner turmoil that comes from confusion, fear of failure, or false information',
|
||||||
@@ -393,6 +418,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'abjurer',
|
id: 'abjurer',
|
||||||
name: 'Abjurer',
|
name: 'Abjurer',
|
||||||
card: 'Four of Stars',
|
card: 'Four of Stars',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Stars',
|
suit: 'Stars',
|
||||||
aria: 'Stars 04 Abjurer',
|
aria: 'Stars 04 Abjurer',
|
||||||
description:
|
description:
|
||||||
@@ -410,6 +436,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'elementalist',
|
id: 'elementalist',
|
||||||
name: 'Elementalist',
|
name: 'Elementalist',
|
||||||
card: 'Five of Stars',
|
card: 'Five of Stars',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Stars',
|
suit: 'Stars',
|
||||||
aria: 'Stars 05 Elementalist',
|
aria: 'Stars 05 Elementalist',
|
||||||
description:
|
description:
|
||||||
@@ -428,6 +455,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'evoker',
|
id: 'evoker',
|
||||||
name: 'Evoker',
|
name: 'Evoker',
|
||||||
card: 'Six of Stars',
|
card: 'Six of Stars',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Stars',
|
suit: 'Stars',
|
||||||
aria: 'Stars 06 Evoker',
|
aria: 'Stars 06 Evoker',
|
||||||
description:
|
description:
|
||||||
@@ -445,6 +473,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'illusionist',
|
id: 'illusionist',
|
||||||
name: 'Illusionist',
|
name: 'Illusionist',
|
||||||
card: 'Seven of Stars',
|
card: 'Seven of Stars',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Stars',
|
suit: 'Stars',
|
||||||
aria: 'Stars 07 Illusionist',
|
aria: 'Stars 07 Illusionist',
|
||||||
description:
|
description:
|
||||||
@@ -462,6 +491,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'necromancer',
|
id: 'necromancer',
|
||||||
name: 'Necromancer',
|
name: 'Necromancer',
|
||||||
card: 'Eight of Stars',
|
card: 'Eight of Stars',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Stars',
|
suit: 'Stars',
|
||||||
aria: 'Stars 08 Necromancer',
|
aria: 'Stars 08 Necromancer',
|
||||||
description: 'Unnatural events and unhealthy obsessions; those who follow a destructive path',
|
description: 'Unnatural events and unhealthy obsessions; those who follow a destructive path',
|
||||||
@@ -477,6 +507,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'conjurer',
|
id: 'conjurer',
|
||||||
name: 'Conjurer',
|
name: 'Conjurer',
|
||||||
card: 'Nine of Stars',
|
card: 'Nine of Stars',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Stars',
|
suit: 'Stars',
|
||||||
aria: 'Stars 09 Conjurer',
|
aria: 'Stars 09 Conjurer',
|
||||||
description:
|
description:
|
||||||
@@ -494,6 +525,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'wizard',
|
id: 'wizard',
|
||||||
name: 'Wizard',
|
name: 'Wizard',
|
||||||
card: 'Master of Stars',
|
card: 'Master of Stars',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Stars',
|
suit: 'Stars',
|
||||||
aria: 'Stars 10 Wizard',
|
aria: 'Stars 10 Wizard',
|
||||||
description:
|
description:
|
||||||
@@ -511,6 +543,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'avenger',
|
id: 'avenger',
|
||||||
name: 'Avenger',
|
name: 'Avenger',
|
||||||
card: 'One of Swords',
|
card: 'One of Swords',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Swords',
|
suit: 'Swords',
|
||||||
aria: 'Swords 01 Avenger',
|
aria: 'Swords 01 Avenger',
|
||||||
description:
|
description:
|
||||||
@@ -528,6 +561,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'paladin',
|
id: 'paladin',
|
||||||
name: 'Paladin',
|
name: 'Paladin',
|
||||||
card: 'Two of Swords',
|
card: 'Two of Swords',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Swords',
|
suit: 'Swords',
|
||||||
aria: 'Swords 02 Paladin',
|
aria: 'Swords 02 Paladin',
|
||||||
description: 'Just and noble warriors; those who live by a code of honor and integrity',
|
description: 'Just and noble warriors; those who live by a code of honor and integrity',
|
||||||
@@ -544,6 +578,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'soldier',
|
id: 'soldier',
|
||||||
name: 'Soldier',
|
name: 'Soldier',
|
||||||
card: 'Three of Swords',
|
card: 'Three of Swords',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Swords',
|
suit: 'Swords',
|
||||||
aria: 'Swords 03 Soldier',
|
aria: 'Swords 03 Soldier',
|
||||||
description: 'War and sacrifice; the stamina to endure great hardship',
|
description: 'War and sacrifice; the stamina to endure great hardship',
|
||||||
@@ -560,6 +595,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'mercenary',
|
id: 'mercenary',
|
||||||
name: 'Mercenary',
|
name: 'Mercenary',
|
||||||
card: 'Four of Swords',
|
card: 'Four of Swords',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Swords',
|
suit: 'Swords',
|
||||||
aria: 'Swords 04 Mercenary',
|
aria: 'Swords 04 Mercenary',
|
||||||
description: 'Inner strength and fortitude; those who fight for power or wealth',
|
description: 'Inner strength and fortitude; those who fight for power or wealth',
|
||||||
@@ -575,6 +611,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'myrmidon',
|
id: 'myrmidon',
|
||||||
name: 'Myrmidon',
|
name: 'Myrmidon',
|
||||||
card: 'Five of Swords',
|
card: 'Five of Swords',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Swords',
|
suit: 'Swords',
|
||||||
aria: 'Swords 05 Myrmidon',
|
aria: 'Swords 05 Myrmidon',
|
||||||
description:
|
description:
|
||||||
@@ -593,6 +630,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'berserker',
|
id: 'berserker',
|
||||||
name: 'Berserker',
|
name: 'Berserker',
|
||||||
card: 'Six of Swords',
|
card: 'Six of Swords',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Swords',
|
suit: 'Swords',
|
||||||
aria: 'Swords 06 Berserker',
|
aria: 'Swords 06 Berserker',
|
||||||
description: 'The brutal and barbaric side of warfare; bloodlust; those with a bestial nature',
|
description: 'The brutal and barbaric side of warfare; bloodlust; those with a bestial nature',
|
||||||
@@ -610,6 +648,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'hooded-one',
|
id: 'hooded-one',
|
||||||
name: 'Hooded One',
|
name: 'Hooded One',
|
||||||
card: 'Seven of Swords',
|
card: 'Seven of Swords',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Swords',
|
suit: 'Swords',
|
||||||
aria: 'Swords 07 Hooded One',
|
aria: 'Swords 07 Hooded One',
|
||||||
description: 'Bigotry, intolerance, and xenophobia; a mysterious presence or newcomer',
|
description: 'Bigotry, intolerance, and xenophobia; a mysterious presence or newcomer',
|
||||||
@@ -627,6 +666,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'dictator',
|
id: 'dictator',
|
||||||
name: 'Dictator',
|
name: 'Dictator',
|
||||||
card: 'Eight of Swords',
|
card: 'Eight of Swords',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Swords',
|
suit: 'Swords',
|
||||||
aria: 'Swords 08 Dictator',
|
aria: 'Swords 08 Dictator',
|
||||||
description:
|
description:
|
||||||
@@ -643,6 +683,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'torturer',
|
id: 'torturer',
|
||||||
name: 'Torturer',
|
name: 'Torturer',
|
||||||
card: 'Nine of Swords',
|
card: 'Nine of Swords',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Swords',
|
suit: 'Swords',
|
||||||
aria: 'Swords 09 Torturer',
|
aria: 'Swords 09 Torturer',
|
||||||
description:
|
description:
|
||||||
@@ -661,6 +702,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'warrior',
|
id: 'warrior',
|
||||||
name: 'Warrior',
|
name: 'Warrior',
|
||||||
card: 'Master of Swords',
|
card: 'Master of Swords',
|
||||||
|
deck: 'common',
|
||||||
suit: 'Swords',
|
suit: 'Swords',
|
||||||
aria: 'Swords 10 Warrior',
|
aria: 'Swords 10 Warrior',
|
||||||
description:
|
description:
|
||||||
@@ -678,6 +720,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'artifact',
|
id: 'artifact',
|
||||||
name: 'Artifact',
|
name: 'Artifact',
|
||||||
card: 'The Artifact',
|
card: 'The Artifact',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Artifact',
|
aria: 'High Deck Artifact',
|
||||||
description:
|
description:
|
||||||
@@ -702,6 +745,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'beast',
|
id: 'beast',
|
||||||
name: 'Beast',
|
name: 'Beast',
|
||||||
card: 'The Beast',
|
card: 'The Beast',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Beast',
|
aria: 'High Deck Beast',
|
||||||
description:
|
description:
|
||||||
@@ -727,6 +771,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'broken-one',
|
id: 'broken-one',
|
||||||
name: 'Broken One',
|
name: 'Broken One',
|
||||||
card: 'The Broken One',
|
card: 'The Broken One',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Broken One',
|
aria: 'High Deck Broken One',
|
||||||
description:
|
description:
|
||||||
@@ -758,6 +803,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'darklord',
|
id: 'darklord',
|
||||||
name: 'Darklord',
|
name: 'Darklord',
|
||||||
card: 'The Darklord',
|
card: 'The Darklord',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Darklord',
|
aria: 'High Deck Darklord',
|
||||||
description:
|
description:
|
||||||
@@ -781,6 +827,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'donjon',
|
id: 'donjon',
|
||||||
name: 'Donjon',
|
name: 'Donjon',
|
||||||
card: 'The Donjon',
|
card: 'The Donjon',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Donjon',
|
aria: 'High Deck Donjon',
|
||||||
description:
|
description:
|
||||||
@@ -813,6 +860,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'executioner',
|
id: 'executioner',
|
||||||
name: 'Executioner',
|
name: 'Executioner',
|
||||||
card: 'The Executioner',
|
card: 'The Executioner',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Executioner',
|
aria: 'High Deck Executioner',
|
||||||
description:
|
description:
|
||||||
@@ -839,6 +887,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'ghost',
|
id: 'ghost',
|
||||||
name: 'Ghost',
|
name: 'Ghost',
|
||||||
card: 'The Ghost',
|
card: 'The Ghost',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Ghost',
|
aria: 'High Deck Ghost',
|
||||||
description:
|
description:
|
||||||
@@ -872,6 +921,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'horseman',
|
id: 'horseman',
|
||||||
name: 'Horseman',
|
name: 'Horseman',
|
||||||
card: 'The Horseman',
|
card: 'The Horseman',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Horseman',
|
aria: 'High Deck Horseman',
|
||||||
description:
|
description:
|
||||||
@@ -904,6 +954,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'innocent',
|
id: 'innocent',
|
||||||
name: 'Innocent',
|
name: 'Innocent',
|
||||||
card: 'The Innocent',
|
card: 'The Innocent',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Innocent',
|
aria: 'High Deck Innocent',
|
||||||
description:
|
description:
|
||||||
@@ -936,6 +987,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'marionette',
|
id: 'marionette',
|
||||||
name: 'Marionette',
|
name: 'Marionette',
|
||||||
card: 'The Marionette',
|
card: 'The Marionette',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Marionette',
|
aria: 'High Deck Marionette',
|
||||||
description:
|
description:
|
||||||
@@ -967,6 +1019,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'mists',
|
id: 'mists',
|
||||||
name: 'Mists',
|
name: 'Mists',
|
||||||
card: 'The Mists',
|
card: 'The Mists',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Mists',
|
aria: 'High Deck Mists',
|
||||||
description:
|
description:
|
||||||
@@ -993,6 +1046,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'raven',
|
id: 'raven',
|
||||||
name: 'Raven',
|
name: 'Raven',
|
||||||
card: 'The Raven',
|
card: 'The Raven',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Raven',
|
aria: 'High Deck Raven',
|
||||||
description:
|
description:
|
||||||
@@ -1019,6 +1073,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'seer',
|
id: 'seer',
|
||||||
name: 'Seer',
|
name: 'Seer',
|
||||||
card: 'The Seer',
|
card: 'The Seer',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Seer',
|
aria: 'High Deck Seer',
|
||||||
description:
|
description:
|
||||||
@@ -1045,6 +1100,7 @@ const tarokkaCards: TarokkaCard[] = [
|
|||||||
id: 'tempter',
|
id: 'tempter',
|
||||||
name: 'Tempter',
|
name: 'Tempter',
|
||||||
card: 'The Tempter',
|
card: 'The Tempter',
|
||||||
|
deck: 'high',
|
||||||
suit: 'High Deck',
|
suit: 'High Deck',
|
||||||
aria: 'High Deck Tempter',
|
aria: 'High Deck Tempter',
|
||||||
description:
|
description:
|
||||||
|
|||||||
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 Deck from '@/lib/TarokkaDeck';
|
||||||
import generateID from '@/tools/simpleID';
|
import generateID from '@/tools/simpleID';
|
||||||
import parseMilliseconds from '@/tools/parseMilliseconds';
|
import parseMilliseconds from '@/tools/parseMilliseconds';
|
||||||
import { MINUTE, HOUR, DAY } from '@/constants/time';
|
import { HOUR, DAY } from '@/constants/time';
|
||||||
import { GameState, GameUpdate, Settings } from '@/types';
|
import { GameState, GameUpdate, Settings } from '@/types';
|
||||||
|
|
||||||
const deck = new Deck();
|
const deck = new Deck();
|
||||||
@@ -34,10 +34,10 @@ export default class GameStore {
|
|||||||
private totalExpired: number;
|
private totalExpired: number;
|
||||||
private totalUnused: number;
|
private totalUnused: number;
|
||||||
|
|
||||||
private startUps: Set<string>;
|
private startUps: Set<string>; // homepage socket IDs
|
||||||
private dms: Map<string, GameState>;
|
private dms: Map<string, GameState>; // DM socket ID -> game
|
||||||
private spectators: Map<string, GameState>;
|
private spectators: Map<string, GameState>; // spectator socket ID -> game
|
||||||
private players: Map<string, string>;
|
private players: Map<string, GameState>; // socket ID -> game
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.startTime = Date.now();
|
this.startTime = Date.now();
|
||||||
@@ -50,7 +50,7 @@ export default class GameStore {
|
|||||||
this.spectators = new Map();
|
this.spectators = new Map();
|
||||||
this.players = new Map();
|
this.players = new Map();
|
||||||
|
|
||||||
setInterval(() => this.log(), 15 * MINUTE);
|
setInterval(() => this.log(), HOUR);
|
||||||
setInterval(() => this.cleanUp(), HOUR);
|
setInterval(() => this.cleanUp(), HOUR);
|
||||||
|
|
||||||
setTimeout(() => this.wrapUp(), tilMidnight());
|
setTimeout(() => this.wrapUp(), tilMidnight());
|
||||||
@@ -106,14 +106,12 @@ export default class GameStore {
|
|||||||
|
|
||||||
game.players.add(playerID);
|
game.players.add(playerID);
|
||||||
game.lastUpdated = Date.now();
|
game.lastUpdated = Date.now();
|
||||||
this.players.set(playerID, gameID);
|
this.players.set(playerID, game);
|
||||||
|
|
||||||
return this.gameUpdate(game);
|
return this.gameUpdate(game);
|
||||||
}
|
}
|
||||||
|
|
||||||
leaveGame(gameID: string, playerID: string): GameState {
|
leaveGame(game: GameState, playerID: string): GameState {
|
||||||
const game = this.getGame(gameID);
|
|
||||||
|
|
||||||
game.players.delete(playerID);
|
game.players.delete(playerID);
|
||||||
game.lastUpdated = Date.now();
|
game.lastUpdated = Date.now();
|
||||||
|
|
||||||
@@ -132,6 +130,33 @@ export default class GameStore {
|
|||||||
return this.gameUpdate(game);
|
return this.gameUpdate(game);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
redraw(gameID: string, cardIndex: number): GameUpdate {
|
||||||
|
const game = this.getGame(gameID);
|
||||||
|
const card = game.cards[cardIndex];
|
||||||
|
|
||||||
|
if (!card) throw new Error(`Card ${cardIndex} not found`);
|
||||||
|
|
||||||
|
game.cards[cardIndex] =
|
||||||
|
card.suit === 'High Deck' ? deck.drawHigh(game.cards) : deck.drawLow(game.cards);
|
||||||
|
game.lastUpdated = Date.now();
|
||||||
|
|
||||||
|
return this.gameUpdate(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
select(gameID: string, cardIndex: number, cardID: string): GameUpdate {
|
||||||
|
const game = this.getGame(gameID);
|
||||||
|
const card = game.cards[cardIndex];
|
||||||
|
const replacement = deck.select(cardID);
|
||||||
|
|
||||||
|
if (!card) throw new Error(`Card ${cardIndex} not found`);
|
||||||
|
if (!replacement) throw new Error(`Card ${cardID} not found`);
|
||||||
|
|
||||||
|
game.cards[cardIndex] = replacement;
|
||||||
|
game.lastUpdated = Date.now();
|
||||||
|
|
||||||
|
return this.gameUpdate(game);
|
||||||
|
}
|
||||||
|
|
||||||
updateSettings(gameID: string, settings: Settings) {
|
updateSettings(gameID: string, settings: Settings) {
|
||||||
const game = this.getGame(gameID);
|
const game = this.getGame(gameID);
|
||||||
|
|
||||||
@@ -160,12 +185,12 @@ export default class GameStore {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
} else {
|
} 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);
|
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(uptimeLog);
|
||||||
console.log(`Games: ${this.dms.size}`);
|
console.log(`Games: ${this.dms.size}`);
|
||||||
console.log(`Players: ${this.players.size}`);
|
console.log(`Players: ${this.players.size}`);
|
||||||
console.log('-'.repeat(uptimeLog.length));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
wrapUp() {
|
wrapUp() {
|
||||||
@@ -188,7 +212,6 @@ export default class GameStore {
|
|||||||
console.log(`Created: ${this.totalCreated}`);
|
console.log(`Created: ${this.totalCreated}`);
|
||||||
console.log(`Expired: ${this.totalExpired}`);
|
console.log(`Expired: ${this.totalExpired}`);
|
||||||
console.log(`Unused: ${this.totalUnused}`);
|
console.log(`Unused: ${this.totalUnused}`);
|
||||||
console.log('='.repeat(uptimeLog.length));
|
|
||||||
|
|
||||||
this.totalCreated = 0;
|
this.totalCreated = 0;
|
||||||
this.totalExpired = 0;
|
this.totalExpired = 0;
|
||||||
@@ -208,12 +231,16 @@ export default class GameStore {
|
|||||||
this.totalExpired += expired.length;
|
this.totalExpired += expired.length;
|
||||||
this.totalUnused += unused.length;
|
this.totalUnused += unused.length;
|
||||||
|
|
||||||
expired.forEach((game) => this.deleteGame(game));
|
expired.forEach((game) => {
|
||||||
|
game.players.forEach((playerID) => this.players.delete(playerID));
|
||||||
|
this.deleteGame(game);
|
||||||
|
});
|
||||||
|
|
||||||
unused.forEach((game) => this.deleteGame(game));
|
unused.forEach((game) => this.deleteGame(game));
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteGame(game: GameState): void {
|
deleteGame(game: GameState): void {
|
||||||
console.log(Date.now(), 'DELETE', game);
|
console.log(Date.now(), 'DELETE', game.dmID, game.spectatorID);
|
||||||
|
|
||||||
this.dms.delete(game.dmID);
|
this.dms.delete(game.dmID);
|
||||||
this.spectators.delete(game.spectatorID);
|
this.spectators.delete(game.spectatorID);
|
||||||
|
|||||||
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[] = [];
|
private backs: TarokkaCard[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.highDeck = cards.filter((card) => !card.back && card.suit === 'High Deck');
|
this.highDeck = cards.filter((card) => card.deck === 'high');
|
||||||
this.commonDeck = cards.filter((card) => !card.back && card.suit !== 'High Deck');
|
this.commonDeck = cards.filter((card) => card.deck === 'common');
|
||||||
this.backs = cards.filter((card) => card.back);
|
this.backs = cards.filter((card) => card.back);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +19,49 @@ export default class TarokkaDeck {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getLow(): TarokkaGameCard[] {
|
||||||
|
return this.commonDeck.map((card) => ({ ...card, flipped: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
getHigh(): TarokkaGameCard[] {
|
||||||
|
return this.highDeck.map((card) => ({ ...card, flipped: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
drawLow(exclude: TarokkaGameCard[] = []): TarokkaGameCard {
|
||||||
|
const excludeIDs = exclude.map(({ id }) => id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...getRandomItems(
|
||||||
|
this.commonDeck.filter(({ id }) => !excludeIDs.includes(id)),
|
||||||
|
1,
|
||||||
|
)[0],
|
||||||
|
flipped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
drawHigh(exclude: TarokkaGameCard[] = []): TarokkaGameCard {
|
||||||
|
const excludeIDs = exclude.map(({ id }) => id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...getRandomItems(
|
||||||
|
this.highDeck.filter(({ id }) => !excludeIDs.includes(id)),
|
||||||
|
1,
|
||||||
|
)[0],
|
||||||
|
flipped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
select(id: string): TarokkaGameCard | null {
|
||||||
|
const card = cards.find((card) => card.id === id);
|
||||||
|
|
||||||
|
if (!card) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...card,
|
||||||
|
flipped: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
getBack(): TarokkaCard {
|
getBack(): TarokkaCard {
|
||||||
return this.backs[0];
|
return this.backs[0];
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
"dev": "nodemon",
|
||||||
"build": "next build && tsc --project tsconfig.server.json",
|
"build": "next build && tsc --project tsconfig.server.json",
|
||||||
"start": "cross-env NODE_ENV=production TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/server.js",
|
"start": "cross-env NODE_ENV=production TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/server.js",
|
||||||
"deploy": "docker buildx build --platform linux/amd64 -t 192.168.0.2:5000/tarokka --push ."
|
"release": "docker buildx build --platform linux/amd64 -t 192.168.0.2:5000/tarokka --push ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
|
|||||||
@@ -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 |
|
Before Width: | Height: | Size: 2.5 MiB 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 }) => {
|
socket.on('settings', ({ gameID, gameData }: { gameID: string; gameData: GameUpdate }) => {
|
||||||
try {
|
try {
|
||||||
const gameUpdate = gameStore.updateSettings(gameID, gameData.settings);
|
const gameUpdate = gameStore.updateSettings(gameID, gameData.settings);
|
||||||
@@ -89,6 +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', () => {
|
socket.on('disconnect', () => {
|
||||||
try {
|
try {
|
||||||
const game = gameStore.playerExit(socket.id);
|
const game = gameStore.playerExit(socket.id);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default function getTooltip(
|
|||||||
|
|
||||||
let text: string[] = [];
|
let text: string[] = [];
|
||||||
|
|
||||||
if (flipped) {
|
if (dm || flipped) {
|
||||||
if (dm || settings.positionFront) text.push(position.text);
|
if (dm || settings.positionFront) text.push(position.text);
|
||||||
|
|
||||||
if (dm) text.push(`${cardName}: ${description}`);
|
if (dm) text.push(`${cardName}: ${description}`);
|
||||||
@@ -33,11 +33,9 @@ export default function getTooltip(
|
|||||||
|
|
||||||
// Low deck: Tome, Ravenkind, or Sunsword
|
// Low deck: Tome, Ravenkind, or Sunsword
|
||||||
if (isLowCard(card)) {
|
if (isLowCard(card)) {
|
||||||
if (dm) text.push(card.prophecy.dmText);
|
|
||||||
if (dm || settings.prophecy) text.push(card.prophecy.playerText);
|
if (dm || settings.prophecy) text.push(card.prophecy.playerText);
|
||||||
|
if (dm) text.push(card.prophecy.dmText);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if (dm || settings.positionBack) text.push(position.text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { cardStyles, standardMap } from '@/constants/tarokka';
|
import { cardStyles, standardMap } from '@/constants/tarokka';
|
||||||
import { Settings, TarokkaGameCard } from '@/types';
|
import { Settings, TarokkaCard, TarokkaGameCard } from '@/types';
|
||||||
|
|
||||||
export default function getURL(card: TarokkaGameCard, settings: Settings) {
|
export default function getURL(card: TarokkaCard | TarokkaGameCard, settings: Settings) {
|
||||||
const styleConfig = cardStyles[settings.cardStyle];
|
const styleConfig = cardStyles[settings.cardStyle];
|
||||||
const fileBase = settings.cardStyle === 'standard' ? standardMap[card.id] : card.id;
|
const fileBase = settings.cardStyle === 'standard' ? standardMap[card.id] : card.id;
|
||||||
return `${styleConfig.baseURL}${fileBase}${styleConfig.extension}`;
|
return `${styleConfig.baseURL}${fileBase}${card.extension || styleConfig.extension}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
export type CardStyle = 'standard' | 'color' | 'grayscale';
|
export type CardStyle = 'standard' | 'color' | 'grayscale';
|
||||||
|
|
||||||
|
// all = both + back
|
||||||
|
export type Deck = 'high' | 'common' | 'both' | 'back' | 'all';
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
positionBack: boolean;
|
positionBack: boolean;
|
||||||
positionFront: boolean;
|
positionFront: boolean;
|
||||||
@@ -28,7 +31,9 @@ export interface TarokkaBase {
|
|||||||
description: string;
|
description: string;
|
||||||
aria: string;
|
aria: string;
|
||||||
back: boolean;
|
back: boolean;
|
||||||
|
deck: Deck;
|
||||||
suit: 'Coins' | 'Glyphs' | 'High Deck' | 'Stars' | 'Swords' | null;
|
suit: 'Coins' | 'Glyphs' | 'High Deck' | 'Stars' | 'Swords' | null;
|
||||||
|
extension?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TarokkaGameBase extends TarokkaBase {
|
export interface TarokkaGameBase extends TarokkaBase {
|
||||||
@@ -89,6 +94,7 @@ export interface GameUpdate {
|
|||||||
export interface ClientUpdate {
|
export interface ClientUpdate {
|
||||||
gameID: string;
|
gameID: string;
|
||||||
cardIndex: number;
|
cardIndex: number;
|
||||||
|
cardID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Layout {
|
export interface Layout {
|
||||||
|
|||||||