Compare commits
40 Commits
c9cb28bed9
...
use-reduce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b0c853f1 | ||
|
|
c6e316a1f8 | ||
|
|
d3eb6f1b46 | ||
|
|
8cbf281ef8 | ||
|
|
fc0466ae89 | ||
|
|
22f949aaf8 | ||
|
|
f0aee17ea0 | ||
| fa352238bb | |||
| 59aa904c5a | |||
|
|
06a87381d5 | ||
| 5444e25249 | |||
|
|
c4f4b09f18 | ||
|
|
25493671c5 | ||
|
|
7e8fe9eb79 | ||
|
|
8412ec49a2 | ||
|
|
d4100cc44f | ||
|
|
4448bc9c57 | ||
|
|
a15b80c23e | ||
|
|
2108324cf4 | ||
|
|
8bf8b4c5cb | ||
|
|
493891a8e2 | ||
|
|
af70401916 | ||
|
|
82ccb0f6fb | ||
|
|
6a1f1174a3 | ||
|
|
35afa28e44 | ||
|
|
bc7339439c | ||
|
|
af26a64e8b | ||
|
|
2f5807ac53 | ||
|
|
34176ff6d5 | ||
|
|
24062d1a07 | ||
|
|
f7d10a9b4f | ||
|
|
f6749f3146 | ||
|
|
6e2247d6f3 | ||
|
|
969d9f5028 | ||
|
|
1a4789af4c | ||
|
|
3d3cb7a45e | ||
|
|
0c8d2273ea | ||
|
|
5b21c560d6 | ||
|
|
bd7d42de55 | ||
|
|
90fd231fbd |
45
README.md
@@ -1,28 +1,37 @@
|
||||
# Custom server with TypeScript + Nodemon example
|
||||
# 🃏 Tarokka
|
||||
|
||||
The example shows how you can use [TypeScript](https://typescriptlang.com) on both the server and the client while using [Nodemon](https://nodemon.io/) to live reload the server code without affecting the Next.js universal code.
|
||||
**Tarokka** is a real-time Tarokka card reading app for _Dungeons & Dragons: Curse of Strahd_. It simulates Madam Eva’s fortune-telling, revealing a hero’s fate and Strahd’s secrets, and is built to deliver an authentic, immersive experience for DMs and players alike.
|
||||
|
||||
Server entry point is `server.ts` in development and `dist/server.js` in production.
|
||||
The `dist` directory should be added to `.gitignore`.
|
||||
To be honest, I’d say this is overkill for what is a relatively small aspect of _Curse of Strahd_ but I had fun making it. It’s a [Next.js](https://nextjs.org/) app running a custom server in order to employ [Socket.IO](https://socket.io/). When a new session is created the DM (dungeon master) is presented a game with the cards shuffled and laid out on the table. There is a link available for sharing the session to any spectators. The DM has access to settings that allow for limiting what info spectators have access to (card purpose, prophecy, notes) and the style of cards used.
|
||||
|
||||
## Deploy your own
|
||||

|
||||
|
||||
Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) or preview live with [StackBlitz](https://stackblitz.com/github/vercel/next.js/tree/canary/examples/custom-server)
|
||||
You can see it live at:
|
||||
👉 [https://tarokka.app](https://tarokka.app)
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https://github.com/vercel/next.js/tree/canary/examples/custom-server)
|
||||
---
|
||||
|
||||
## How to use
|
||||
## ✨ Features
|
||||
|
||||
Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example:
|
||||
- 🔮 **Faithful to the Tarokka Deck**: Supports all cards and positions used by Madam Eva’s reading.
|
||||
- 💬 Dynamic prophecy rendering based on card and position
|
||||
- 🎨 Multiple card styles
|
||||
- 🧙 Separate DM and Spectator Views
|
||||
- ⚙️ DM can toggle what information is visible to players.
|
||||
- 🃏Every action (flipping cards, settings changes) is broadcast live to connected users.
|
||||
- 🌐 Fully browser-based — no accounts or installs
|
||||
- 📱 Mobile-friendly UI
|
||||
- 🔁 WebSocket-Powered Real-Time Sync
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npx create-next-app --example custom-server custom-server-app
|
||||
```
|
||||
|
||||
```bash
|
||||
yarn create next-app --example custom-server custom-server-app
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm create next-app --example custom-server custom-server-app
|
||||
git clone https://github.com/mcdoh/tarokka.git
|
||||
cd tarokka
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
91
app/AppContext.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import useSocket from '@/hooks/useSocket';
|
||||
import { reduceTilts } from '@/tools';
|
||||
|
||||
import { GAME_START, LOCAL_DEFAULTS } from '@/constants';
|
||||
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||
import type { GameUpdate, LocalSettings, Settings, Tilt } from '@/types';
|
||||
|
||||
const AppContext = createContext<AppContext | undefined>(undefined);
|
||||
|
||||
export interface AppContext {
|
||||
gameData: GameUpdate;
|
||||
isDM: boolean;
|
||||
noGame: boolean;
|
||||
selectCardIndex: number;
|
||||
settings: Settings;
|
||||
tilts: Tilt[];
|
||||
emitFlip: (cardIndex: number) => void;
|
||||
emitSettings: (gameData: GameUpdate) => void;
|
||||
emitRedraw: (cardIndex: number) => void;
|
||||
emitSelect: (cardID: string) => void;
|
||||
setGameID: (gameID: string) => void;
|
||||
setLocalSettings: Dispatch<SetStateAction<LocalSettings>>;
|
||||
setSelectCardIndex: (cardIndex: number) => void;
|
||||
setLocalTilt: (tilt: Tilt[]) => void;
|
||||
}
|
||||
|
||||
export function AppProvider({ children }: { children: ReactNode }) {
|
||||
const [gameData, setGameData] = useState<GameUpdate>({ ...GAME_START });
|
||||
const [localSettings, setLocalSettings] = useState<LocalSettings>(() => ({ ...LOCAL_DEFAULTS }));
|
||||
const [gameID, setGameID] = useState('');
|
||||
const [noGame, setNoGame] = useState(false);
|
||||
const [selectCardIndex, setSelectCardIndex] = useState(-1);
|
||||
const [localTilt, setLocalTilt] = useState<Tilt[]>([]);
|
||||
|
||||
const { emitFlip, emitRedraw, emitSelect, emitSettings, emitTilt } = useSocket({
|
||||
gameID,
|
||||
setGameData,
|
||||
setNoGame,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (localSettings.remoteTilt) {
|
||||
const cardIndex = localTilt.findIndex((tilt) => !!tilt);
|
||||
|
||||
if (localTilt[cardIndex]) {
|
||||
emitTilt(cardIndex, localTilt[cardIndex]);
|
||||
} else {
|
||||
// cardIndex does not matter
|
||||
// all tilts for this user will be cleared
|
||||
emitTilt(0, { percentX: -1, percentY: -1, rotateX: 0, rotateY: 0 });
|
||||
}
|
||||
}
|
||||
}, [localTilt, localSettings]);
|
||||
|
||||
const handleSelect = (cardID: string) => {
|
||||
setSelectCardIndex(-1);
|
||||
|
||||
emitSelect(selectCardIndex, cardID);
|
||||
};
|
||||
|
||||
const { dmID } = gameData;
|
||||
const isDM = !!dmID;
|
||||
|
||||
const appInterface = {
|
||||
gameData,
|
||||
isDM,
|
||||
noGame,
|
||||
selectCardIndex,
|
||||
settings: { ...gameData.settings, ...localSettings },
|
||||
tilts: reduceTilts(gameData, localTilt),
|
||||
emitFlip,
|
||||
emitSettings,
|
||||
emitRedraw,
|
||||
emitSelect: handleSelect,
|
||||
setGameID,
|
||||
setLocalSettings,
|
||||
setSelectCardIndex,
|
||||
setLocalTilt,
|
||||
};
|
||||
|
||||
return <AppContext.Provider value={appInterface}>{children}</AppContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAppContext(): AppContext {
|
||||
const context = useContext(AppContext);
|
||||
if (!context) throw new Error('useAppContext must be used within AppProvider');
|
||||
return context;
|
||||
}
|
||||
@@ -1,110 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { socket } from '@/socket';
|
||||
|
||||
import Settings from '@/components/Settings';
|
||||
import Card from '@/components/Card';
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import CardSelect from '@/components/CardSelect';
|
||||
import Notes from '@/components/Notes';
|
||||
import NotFound from '@/components/NotFound';
|
||||
import { cardMap, layout } from '@/constants/tarokka';
|
||||
|
||||
import type { GameUpdate, ClientUpdate } from '@/types';
|
||||
import Settings from '@/components/Settings/index';
|
||||
import { SpectatorLink } from '@/components/SpectatorLink';
|
||||
import TarokkaGrid from '@/components/TarokkaGrid';
|
||||
|
||||
export default function GamePage() {
|
||||
const { gameID: gameIDParam } = useParams();
|
||||
|
||||
const [gameID, setGameID] = useState('');
|
||||
const [noGame, setNoGame] = useState(false);
|
||||
const [gameData, setGameData] = useState<GameUpdate>({
|
||||
dmID: '',
|
||||
spectatorID: '',
|
||||
cards: [],
|
||||
settings: {
|
||||
positionBack: false,
|
||||
positionFront: false,
|
||||
prophecy: false,
|
||||
notes: false,
|
||||
cardStyle: 'color',
|
||||
},
|
||||
});
|
||||
|
||||
const { dmID, cards, settings } = gameData;
|
||||
const isDM = !!dmID;
|
||||
|
||||
useEffect(() => {
|
||||
if (gameIDParam) {
|
||||
setGameID(Array.isArray(gameIDParam) ? gameIDParam[0] : gameIDParam);
|
||||
}
|
||||
}, [gameIDParam]);
|
||||
const { noGame, setGameID } = useAppContext();
|
||||
const { gameID } = useParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (gameID) {
|
||||
socket.emit('join', gameID);
|
||||
|
||||
socket.on('init', (data: GameUpdate) => {
|
||||
setGameData(data);
|
||||
});
|
||||
|
||||
socket.on('game-update', (data: GameUpdate) => {
|
||||
setGameData(data);
|
||||
});
|
||||
|
||||
socket.on('join-error', (error) => {
|
||||
console.error('Error:', error);
|
||||
setNoGame(true);
|
||||
});
|
||||
|
||||
socket.on('flip-error', (error) => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
setGameID(Array.isArray(gameID) ? gameID[0] : gameID);
|
||||
}
|
||||
|
||||
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
|
||||
// common deck cards: left, top, and right
|
||||
// high deck cards: bottom and center
|
||||
const arrangeCards = (_cell: unknown, index: number) => cards[cardMap[index]];
|
||||
|
||||
return noGame ? (
|
||||
<NotFound />
|
||||
) : cards ? (
|
||||
) : (
|
||||
<main className="min-h-screen flex flex-col items-center justify-center gap-4 bg-[url('/img/table3.png')] bg-cover bg-center">
|
||||
{isDM && <Settings gameData={gameData} changeAction={handleSettings} />}
|
||||
<div className="grid grid-cols-3 grid-rows-3 gap-8 w-fit mx-auto">
|
||||
{Array.from({ length: 9 })
|
||||
.map(arrangeCards)
|
||||
.map((card, index) => (
|
||||
<div key={index} className="aspect-[2/3]}">
|
||||
{card && (
|
||||
<Card
|
||||
dm={isDM}
|
||||
card={card}
|
||||
position={layout[cardMap[index]]}
|
||||
settings={settings}
|
||||
flipAction={() => flipCard(cardMap[index])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<SpectatorLink />
|
||||
<Settings />
|
||||
<TarokkaGrid />
|
||||
<Notes />
|
||||
<CardSelect />
|
||||
</main>
|
||||
) : null;
|
||||
);
|
||||
}
|
||||
|
||||
BIN
app/apple-icon.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
app/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
@@ -40,3 +40,21 @@ body {
|
||||
.rotate-y-180 {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.see-through {
|
||||
mask-image: radial-gradient(circle at center, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 1) 75%);
|
||||
mask-size: cover;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-image: radial-gradient(circle at center, rgba(0, 0, 0, 0) 25%, rgba(0, 0, 0, 1) 75%);
|
||||
-webkit-mask-size: cover;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
BIN
app/icon0.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
3
app/icon1.svg
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -1,20 +1,34 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { Pirata_One, Eagle_Lake, Cinzel_Decorative } from 'next/font/google';
|
||||
import { AppProvider } from '@/app/AppContext';
|
||||
import './globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
const pirataOne = Pirata_One({
|
||||
variable: '--font-pirata',
|
||||
subsets: ['latin'],
|
||||
weight: '400',
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
const eagleLake = Eagle_Lake({
|
||||
variable: '--font-eagle-lake',
|
||||
subsets: ['latin'],
|
||||
weight: '400',
|
||||
});
|
||||
|
||||
const cinzel = Cinzel_Decorative({
|
||||
variable: '--font-cinzel',
|
||||
subsets: ['latin'],
|
||||
weight: '400',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Tarokka',
|
||||
description: 'Fortune telling for D&D’s Curse of Strahd',
|
||||
metadataBase: new URL('https://tarokka.app'),
|
||||
appleWebApp: {
|
||||
title: 'Tarokka',
|
||||
statusBarStyle: 'black-translucent',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,8 +37,13 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
|
||||
<html
|
||||
lang="en"
|
||||
className={`${pirataOne.variable} ${eagleLake.variable} ${cinzel.variable} antialiased`}
|
||||
>
|
||||
<body className={`${eagleLake.className} antialiased`}>
|
||||
<AppProvider>{children}</AppProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
22
app/manifest.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Tarokka",
|
||||
"short_name": "Tarokka",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#000000",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
||||
BIN
app/opengraph-image.png
Normal file
|
After Width: | Height: | Size: 747 KiB |
20
app/page.tsx
@@ -16,13 +16,19 @@ export default function Home() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-[url('/img/table3.png')] bg-cover bg-center">
|
||||
<button
|
||||
onClick={handleCreateGame}
|
||||
className="bg-gray-800 hover:bg-gray-700 text-white text-lg px-6 py-3 rounded-xl shadow transition cursor-pointer"
|
||||
>
|
||||
Create New Game
|
||||
</button>
|
||||
<main className="min-h-screen flex justify-center items-center text-yellow-400 bg-[url('/img/table3.png')] bg-cover bg-center">
|
||||
<div className="flex flex-col items-center gap-8 text-center">
|
||||
<h1 className="text-5xl font-bold text-center text-primary">Tarokka</h1>
|
||||
<p className="text-l text-center w-[350px] m-auto">
|
||||
Online Tarokka readings for <em>Dungeons & Dragons: Curse of Strahd</em>.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCreateGame}
|
||||
className="bg-slate-800 hover:bg-slate-700 border border-yellow-500/25 hover:drop-shadow-[0_0_3px_rgba(255,215,0,0.5)] hover:text-yellow-300 text-lg px-6 py-3 rounded-lg shadow transition-all duration-250 cursor-pointer"
|
||||
>
|
||||
Create New Game
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
app/twitter-image.png
Normal file
|
After Width: | Height: | Size: 544 KiB |
17
components/BuyMeACoffee.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
type BuyMeACoffeeProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function BuyMeACoffee({ className = '' }: BuyMeACoffeeProps) {
|
||||
return (
|
||||
<a
|
||||
href="https://www.buymeacoffee.com/mcdoh"
|
||||
className={`transition hover:drop-shadow-[0_0_3px_#ffd700] ${className}`}
|
||||
target="_blank"
|
||||
>
|
||||
<img src="/img/bmc-button.svg" alt="Buy Me A Coffee" className="h-full" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import TiltCard from '@/components/TiltCard';
|
||||
import ToolTip from '@/components/ToolTip';
|
||||
import tarokkaCards from '@/constants/tarokkaCards';
|
||||
import getCardInfo from '@/tools/getCardInfo';
|
||||
import getURL from '@/tools/getURL';
|
||||
import StackTheDeck from '@/components/StackTheDeck';
|
||||
import Sheen from '@/components/Sheen';
|
||||
import { getCardInfo, getURL } from '@/tools';
|
||||
|
||||
import { Layout, Settings, TarokkaGameCard } from '@/types';
|
||||
import tarokkaCards from '@/constants/tarokkaCards';
|
||||
import { layout } from '@/constants/tarokka';
|
||||
|
||||
import { TarokkaGameCard } from '@/types';
|
||||
|
||||
const cardBack = tarokkaCards.find((card) => card.back)!;
|
||||
|
||||
type CardProps = {
|
||||
dm: boolean;
|
||||
card: TarokkaGameCard;
|
||||
position: Layout;
|
||||
settings: Settings;
|
||||
flipAction: () => void;
|
||||
cardIndex: number;
|
||||
};
|
||||
|
||||
export default function Card({ dm, card, position, settings, flipAction }: CardProps) {
|
||||
export default function Card({ card, cardIndex }: CardProps) {
|
||||
const [tooltip, setTooltip] = useState<React.ReactNode>(null);
|
||||
const { emitFlip, gameData, emitRedraw, setSelectCardIndex } = useAppContext();
|
||||
|
||||
const { dmID, settings } = gameData;
|
||||
const isDM = !!dmID;
|
||||
|
||||
const { aria, flipped } = card;
|
||||
const position = layout[cardIndex];
|
||||
|
||||
const handleClick = () => {
|
||||
if (dm) {
|
||||
flipAction();
|
||||
if (isDM) {
|
||||
emitFlip(cardIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const getTooltip = () => {
|
||||
const text = getCardInfo(card, position, dm, settings);
|
||||
const text = getCardInfo(card, position, isDM, settings);
|
||||
|
||||
return text.length ? (
|
||||
<>
|
||||
{text.map((t, i) => (
|
||||
<div key={i}>
|
||||
<p>{t}</p>
|
||||
{i < text.length - 1 && <hr className="my-2 border-gray-300" />}
|
||||
<p className="text-yellow-400">{t}</p>
|
||||
{i < text.length - 1 && <hr className="my-2 border-yellow-400" />}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
@@ -42,30 +52,50 @@ export default function Card({ dm, card, position, settings, flipAction }: CardP
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolTip content={getTooltip()}>
|
||||
<div
|
||||
className={`relative h-[21vh] w-[15vh] perspective transition-transform duration-200 hover:scale-150 z-0 hover:z-10 ${dm ? 'cursor-pointer' : ''} `}
|
||||
onClick={handleClick}
|
||||
<ToolTip content={tooltip || getTooltip()}>
|
||||
<TiltCard
|
||||
className={`h-[21vh] w-[15vh] relative perspective transition-transform duration-200 z-0 hover:z-10 hover:scale-150 ${isDM ? 'cursor-pointer' : ''} `}
|
||||
cardIndex={cardIndex}
|
||||
>
|
||||
<div
|
||||
className={`transition-transform duration-500 transform-style-preserve-3d ${flipped ? 'rotate-y-180' : ''}`}
|
||||
className={`absolute inset-0 transition-transform duration-500 transform-style-preserve-3d ${flipped ? 'rotate-y-180' : ''}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="absolute group inset-0 backface-hidden">
|
||||
<div className="absolute inset-0 group backface-hidden">
|
||||
{isDM && (
|
||||
<>
|
||||
<img src={getURL(card, settings)} alt={aria} className="absolute rounded-lg" />
|
||||
<img
|
||||
src={getURL(cardBack as TarokkaGameCard, settings)}
|
||||
alt=""
|
||||
className={`absolute rounded-lg see-through`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<img
|
||||
src={getURL(cardBack as TarokkaGameCard, settings)}
|
||||
alt="Card Back"
|
||||
className="rounded-lg border border-gray-600"
|
||||
className={`absolute rounded-lg ${isDM ? 'transition duration-500 group-hover:opacity-0' : ''} ${settings.cardStyle === 'grayscale' ? 'border border-yellow-500/25 group-hover:drop-shadow-[0_0_3px_#ffd700/50]' : ''}`}
|
||||
/>
|
||||
{isDM && !flipped && (
|
||||
<StackTheDeck
|
||||
onRedraw={() => emitRedraw(cardIndex)}
|
||||
onSelect={() => setSelectCardIndex(cardIndex)}
|
||||
onHover={setTooltip}
|
||||
/>
|
||||
)}
|
||||
<Sheen cardIndex={cardIndex} />
|
||||
</div>
|
||||
<div className="absolute group inset-0 backface-hidden rotate-y-180">
|
||||
<div className="absolute inset-0 backface-hidden rotate-y-180">
|
||||
<img
|
||||
src={getURL(card, settings)}
|
||||
alt={aria}
|
||||
className="rounded-lg border border-gray-600 "
|
||||
className="rounded-lg border border-yellow-500/25 hover:drop-shadow-[0_0_3px_#ffd700/50]"
|
||||
/>
|
||||
<Sheen cardIndex={cardIndex} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TiltCard>
|
||||
</ToolTip>
|
||||
);
|
||||
}
|
||||
|
||||
68
components/CardSelect.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { CircleX } from 'lucide-react';
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import TarokkaDeck from '@/lib/TarokkaDeck';
|
||||
import { getURL } from '@/tools';
|
||||
|
||||
import { Deck } from '@/types';
|
||||
|
||||
const tarokkaDeck = new TarokkaDeck();
|
||||
|
||||
type CardSelectProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function CardSelect({ className = '' }: CardSelectProps) {
|
||||
const { gameData, emitSelect, selectCardIndex, setSelectCardIndex } = useAppContext();
|
||||
const { cards: hand, settings } = gameData;
|
||||
|
||||
const handIDs = hand.map(({ id }) => id);
|
||||
const selectDeck: Deck | null = selectCardIndex >= 0 ? hand[selectCardIndex].deck : null;
|
||||
|
||||
const close = () => setSelectCardIndex(-1);
|
||||
|
||||
const handleClose = (event: React.MouseEvent<HTMLElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
if (!selectDeck) return null;
|
||||
|
||||
const cards = selectDeck === 'high' ? tarokkaDeck.getHigh() : tarokkaDeck.getLow();
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClose}
|
||||
className={`fixed inset-0 flex justify-center items-center p-4 bg-black/20 backdrop-blur-sm z-40 ${className}`}
|
||||
>
|
||||
<button
|
||||
className={`fixed top-4 right-4 p-2 transition-all duration-250 text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] cursor-pointer`}
|
||||
onClick={close}
|
||||
>
|
||||
<CircleX className="w-6 h-6" />
|
||||
</button>
|
||||
<div
|
||||
onClick={handleClose}
|
||||
className={`flex flex-wrap justify-center items-center gap-3 h-dvh w-2/3 overflow-scroll scrollbar-hide p-4`}
|
||||
>
|
||||
{cards
|
||||
.filter(({ id }) => !handIDs.includes(id))
|
||||
.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className={`relative h-[21vh] w-[15vh] perspective transition-transform duration-200 hover:scale-150 z-0 hover:z-10`}
|
||||
onClick={() => emitSelect(card.id)}
|
||||
>
|
||||
<img
|
||||
src={getURL(card, settings)}
|
||||
alt={card.aria}
|
||||
className="rounded-lg border border-yellow-500/25 hover:drop-shadow-[0_0_3px_#ffd700/50]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Copy as CopyIcon, Check as CheckIcon } from 'lucide-react';
|
||||
import { ForwardRefExoticComponent, RefAttributes, useState } from 'react';
|
||||
import { LucideProps, Copy as CopyIcon, Check as CheckIcon } from 'lucide-react';
|
||||
|
||||
import ToolTip from '@/components/ToolTip';
|
||||
|
||||
type CopyButtonProps = {
|
||||
title: string;
|
||||
title?: string;
|
||||
copy: string;
|
||||
Icon?: ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>;
|
||||
tooltip?: string | string[];
|
||||
className?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export default function CopyButton({ title, copy }: CopyButtonProps) {
|
||||
export default function CopyButton({
|
||||
title,
|
||||
copy,
|
||||
Icon = CopyIcon,
|
||||
tooltip = ['Copy', 'Copied'],
|
||||
className,
|
||||
size = 16,
|
||||
}: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
@@ -23,21 +34,24 @@ export default function CopyButton({ title, copy }: CopyButtonProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const ttContent = (
|
||||
<span className="text-yellow-300">
|
||||
{Array.isArray(tooltip) && tooltip.length > 1 ? (copied ? tooltip[1] : tooltip[0]) : tooltip}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<ToolTip content={copy}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="w-full py-1 px-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg flex flex-col items-start gap-1 shadow transition-all cursor-pointer"
|
||||
>
|
||||
<button onClick={handleCopy} className={`cursor-pointer ${className}`}>
|
||||
<ToolTip content={ttContent} className="w-full font-yellow-400">
|
||||
<div className="flex items-center gap-2 w-full text-sm font-medium">
|
||||
{`Copy ${title}`}
|
||||
{title}
|
||||
{copied ? (
|
||||
<CheckIcon className="ml-auto" size={16} />
|
||||
<CheckIcon className="ml-auto" size={size} />
|
||||
) : (
|
||||
<CopyIcon className="ml-auto" size={16} />
|
||||
<Icon className="ml-auto" size={size} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</ToolTip>
|
||||
</ToolTip>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
21
components/GitHubButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
type GitHubButtonProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function GitHubButton({ className = '' }: GitHubButtonProps) {
|
||||
return (
|
||||
<a
|
||||
href="https://github.com/mcdoh/tarokka"
|
||||
className={className}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<button className="h-full w-full flex flex-row justify-center items-center gap-3 bg-slate-700 rounded-[6px] hover:bg-slate-600 transition cursor-pointer">
|
||||
<img src="/img/github-mark-white.svg" alt="GitHub" className="h-[22px] w-[22px]" />
|
||||
<img src="/img/github-logo-white.png" alt="GitHub" className="h-[22px]" />
|
||||
</button>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
98
components/Notes.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { CircleX, ScrollText } from 'lucide-react';
|
||||
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import Scrim from '@/components/Scrim';
|
||||
import { getCardInfo } from '@/tools';
|
||||
import { cardMap, layout } from '@/constants/tarokka';
|
||||
|
||||
export default function Notes() {
|
||||
const { gameData } = useAppContext();
|
||||
const { dmID, cards, settings } = gameData;
|
||||
|
||||
const isDM = !!dmID;
|
||||
const show = cards.every(({ flipped }) => flipped);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const notes: (string[] | undefined)[] = useMemo(
|
||||
() =>
|
||||
Array.from({ length: 9 })
|
||||
.map((_cell: unknown, index: number) => cards[cardMap[index]])
|
||||
.map((card, index) =>
|
||||
card ? getCardInfo(card, layout[cardMap[index]], isDM, settings) : null,
|
||||
)
|
||||
.map(
|
||||
(_cell: unknown, index: number, cards) =>
|
||||
cards[Object.keys(cardMap).find((key) => cardMap[key] === index) || 0],
|
||||
)
|
||||
.filter((truthy) => truthy),
|
||||
[cards, isDM, settings],
|
||||
);
|
||||
|
||||
const showNotes = show && open && (isDM || settings.notes);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 z-25 transition-all duration-250 ${show ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
|
||||
>
|
||||
<button
|
||||
className={`text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] p-2 transition-all duration-250 cursor-pointer ${showNotes ? 'pointer-events-none opacity-0' : 'pointer-events-auto opacity-100'}`}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<ScrollText className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<Scrim
|
||||
clickAction={() => setOpen((prev) => !prev)}
|
||||
className={`transition-all duration-250 ${showNotes ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
fixed bottom-4 right-4
|
||||
transition-all duration-250
|
||||
bg-slate-800
|
||||
border border-yellow-400 rounded-lg
|
||||
${showNotes ? 'sm:w-[50vw] sm:h-[67vh] w-[80vw] h-[80vh]' : 'w-0 h-0'}
|
||||
`}
|
||||
>
|
||||
<CopyButton
|
||||
copy={notes.map((note) => note!.join('\n')).join('\n\n')}
|
||||
className={`
|
||||
absolute top-2 right-2
|
||||
cursor-pointer p-2
|
||||
transition-all duration-250
|
||||
text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700]
|
||||
`}
|
||||
/>
|
||||
<div className="text-yellow-400 h-full overflow-scroll p-8 transition-all delay-200 duration-50 ${showNotes ? 'opacity-100' : 'opacity-0'}">
|
||||
{notes.map((note, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex flex-col gap-2">
|
||||
{note!.map((blurb, index) => (
|
||||
<p key={index}>{blurb}</p>
|
||||
))}
|
||||
</div>
|
||||
{index < notes.length - 1 && <hr className="my-3 border-yellow-400" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className={`
|
||||
fixed bottom-4 right-4
|
||||
cursor-pointer p-2
|
||||
transition-all duration-250
|
||||
text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700]
|
||||
`}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<CircleX className="w-5 h-5" />
|
||||
</button>
|
||||
</Scrim>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
components/Scrim.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
type ScrimProps = {
|
||||
children: React.ReactNode;
|
||||
clickAction: (event: React.MouseEvent<HTMLDivElement>) => void;
|
||||
show?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Scrim({ children, clickAction, show = true, className = '' }: ScrimProps) {
|
||||
const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
clickAction(event);
|
||||
}
|
||||
};
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={`fixed inset-0 bg-black/20 backdrop-blur-sm z-40 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Settings as Gear, X } from 'lucide-react';
|
||||
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
import Switch from '@/components/Switch';
|
||||
import { CardStyle, GameUpdate } from '@/types';
|
||||
|
||||
type PermissionTogglePanelProps = {
|
||||
gameData: GameUpdate;
|
||||
changeAction: (updatedSettings: GameUpdate) => void;
|
||||
};
|
||||
|
||||
const cardStyleOptions: CardStyle[] = ['standard', 'color', 'grayscale'];
|
||||
|
||||
export default function PermissionTogglePanel({
|
||||
gameData,
|
||||
changeAction,
|
||||
}: PermissionTogglePanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const togglePermission = (key: string) => {
|
||||
changeAction({
|
||||
...gameData,
|
||||
settings: {
|
||||
...gameData.settings,
|
||||
[key]: !gameData.settings[key],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const tuneRadio = (cardStyle: CardStyle) => {
|
||||
changeAction({
|
||||
...gameData,
|
||||
settings: {
|
||||
...gameData.settings,
|
||||
cardStyle,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
{!open && (
|
||||
<button
|
||||
className="p-2 text-gray-100 hover:text-gray-300 cursor-pointer"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<Gear className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="relative text-gray-100 bg-gray-800 shadow-lg rounded-lg border border-gray-500 p-6 space-y-2">
|
||||
<button
|
||||
className="absolute top-1 right-1 p-1 hover:text-gray-300 cursor-pointer"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<CopyButton title="DM link" copy={`${location.origin}/${gameData.dmID}`} />
|
||||
<CopyButton title="Spectator link" copy={`${location.origin}/${gameData.spectatorID}`} />
|
||||
{Object.entries(gameData.settings)
|
||||
.filter(([_key, value]) => typeof value === 'boolean')
|
||||
.map(([key, value]) => (
|
||||
<Switch
|
||||
key={key}
|
||||
label={key}
|
||||
value={value}
|
||||
toggleAction={() => togglePermission(key)}
|
||||
/>
|
||||
))}
|
||||
<fieldset className="flex flex-col">
|
||||
<div className="text-xs text-gray-400 mb-1">Card style:</div>
|
||||
<div className="inline-flex overflow-hidden rounded-md w-full">
|
||||
{cardStyleOptions.map((option, index) => (
|
||||
<label
|
||||
key={option}
|
||||
className={`cursor-pointer px-4 py-2 text-sm font-medium transition
|
||||
${gameData.settings.cardStyle === option ? 'bg-gray-500 text-white' : 'bg-gray-800 text-gray-300 hover:bg-gray-700'}
|
||||
${index === 0 ? 'rounded-l-md' : ''}
|
||||
${index === cardStyleOptions.length - 1 ? 'rounded-r-md' : ''}
|
||||
${index !== 0 && 'border-l border-gray-600'}
|
||||
border border-gray-600
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="cardStyle"
|
||||
value={option}
|
||||
checked={gameData.settings.cardStyle === option}
|
||||
onChange={() => tuneRadio(option)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
components/Settings/CardStyle.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import type { CardStyle } from '@/types';
|
||||
|
||||
const cardStyleOptions: CardStyle[] = ['standard', 'color', 'grayscale'];
|
||||
|
||||
export default function CardStyle({ className }: { className?: string }) {
|
||||
const { gameData, isDM, settings, emitSettings } = useAppContext();
|
||||
|
||||
const tuneRadio = (cardStyle: CardStyle) => {
|
||||
emitSettings({
|
||||
...gameData,
|
||||
settings: {
|
||||
...gameData.settings,
|
||||
cardStyle,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return isDM ? (
|
||||
<fieldset className={`flex flex-col w-full ${className}`}>
|
||||
<div className="text-xs ml-1 mb-1">Card style:</div>
|
||||
<div className="inline-flex overflow-hidden rounded-md w-full">
|
||||
{cardStyleOptions.map((option, index) => (
|
||||
<label
|
||||
key={option}
|
||||
className={`
|
||||
flex justify-center
|
||||
cursor-pointer
|
||||
w-full px-3 py-2
|
||||
text-xs font-medium
|
||||
border border-yellow-500
|
||||
transition hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700]
|
||||
${settings.cardStyle === option ? 'bg-slate-700 text-yellow-300 font-extrabold' : 'bg-slate-800 hover:bg-slate-700'}
|
||||
${index === 0 ? 'rounded-l-md' : ''}
|
||||
${index === cardStyleOptions.length - 1 ? 'rounded-r-md' : ''}
|
||||
${index !== 0 && 'border-l border-gray-600'}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="cardStyle"
|
||||
value={option}
|
||||
checked={settings.cardStyle === option}
|
||||
onChange={() => tuneRadio(option)}
|
||||
className="sr-only"
|
||||
/>
|
||||
{option}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
) : null;
|
||||
}
|
||||
13
components/Settings/ExternalLinks.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import BuyMeACoffee from '@/components/BuyMeACoffee';
|
||||
import GitHubButton from '@/components/GitHubButton';
|
||||
|
||||
export default function CardStyle({ className }: { className?: string }) {
|
||||
return (
|
||||
<span className={`w-full flex flex-row justify-between ${className}`}>
|
||||
<GitHubButton className="h-[35px] w-[125px]" />
|
||||
<BuyMeACoffee className="h-[35px] w-[125px]" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
27
components/Settings/GameLinks.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
|
||||
export default function Links({ className }: { className?: string }) {
|
||||
const { gameData, isDM } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className={`w-full flex flex-col justify-between gap-2 ${className}`}>
|
||||
{isDM && (
|
||||
<CopyButton
|
||||
title="Copy DM link"
|
||||
copy={`${location.origin}/${gameData.dmID}`}
|
||||
tooltip={`${location.origin}/${gameData.dmID}`}
|
||||
className="flex flex-row content-between w-full py-1 px-2 transition-all duration-250 bg-slate-700 hover:bg-slate-600 hover:text-yellow-300 rounded-lg shadow"
|
||||
/>
|
||||
)}
|
||||
<CopyButton
|
||||
title="Copy Spectator link"
|
||||
copy={`${location.origin}/${gameData.spectatorID}`}
|
||||
tooltip={`${location.origin}/${gameData.spectatorID}`}
|
||||
className="flex flex-row content-between w-full py-1 px-2 transition-all duration-250 bg-slate-700 hover:bg-slate-600 hover:text-yellow-300 rounded-lg shadow"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
components/Settings/Permissions.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import Switch from '@/components/Switch';
|
||||
import { LOCAL_SETTINGS, SPECTATOR_SETTINGS } from '@/constants';
|
||||
|
||||
export default function Permissions() {
|
||||
const { gameData, isDM, settings, emitSettings, setLocalSettings } = useAppContext();
|
||||
|
||||
const togglePermission = (key: string) => {
|
||||
if (LOCAL_SETTINGS.includes(key)) {
|
||||
setLocalSettings((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
} else if (isDM) {
|
||||
emitSettings({
|
||||
...gameData,
|
||||
settings: {
|
||||
...gameData.settings,
|
||||
[key]: !gameData.settings[key],
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(settings)
|
||||
.filter(([_key, value]) => typeof value === 'boolean')
|
||||
.filter(([key]) => isDM || SPECTATOR_SETTINGS.includes(key))
|
||||
.map(([key, value]) => (
|
||||
<Switch key={key} label={key} value={value} toggleAction={() => togglePermission(key)} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
62
components/Settings/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CircleX, Settings as Gear } from 'lucide-react';
|
||||
import { Cinzel_Decorative } from 'next/font/google';
|
||||
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import Scrim from '@/components/Scrim';
|
||||
|
||||
import CardStyle from './CardStyle';
|
||||
import ExternalLinks from './ExternalLinks';
|
||||
import GameLinks from './GameLinks';
|
||||
import Permissions from './Permissions';
|
||||
|
||||
const cinzel = Cinzel_Decorative({
|
||||
variable: '--font-cinzel',
|
||||
subsets: ['latin'],
|
||||
weight: '400',
|
||||
});
|
||||
|
||||
export default function Settings() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { isDM } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className={`fixed top-4 right-4 z-25 ${cinzel.className}`}>
|
||||
<Scrim
|
||||
clickAction={() => setOpen((prev) => !prev)}
|
||||
className={`transition-all duration-250 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
fixed top-4 right-4
|
||||
flex flex-col items-center justify-between
|
||||
bg-slate-800 text-yellow-400
|
||||
rounded-lg border border-yellow-400
|
||||
h-full p-8
|
||||
transition-all duration-250
|
||||
${open ? `opacity-100 ${isDM ? 'w-[350px] max-h-[425px]' : 'w-[325px] max-h-[200px]'}` : 'opacity-0 w-0 max-h-0'}
|
||||
`}
|
||||
>
|
||||
<GameLinks />
|
||||
<Permissions />
|
||||
<CardStyle />
|
||||
<ExternalLinks />
|
||||
</div>
|
||||
<button
|
||||
className={`fixed top-4 right-4 p-2 transition-all duration-250 text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] cursor-pointer`}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<CircleX className="w-5 h-5" />
|
||||
</button>
|
||||
</Scrim>
|
||||
<button
|
||||
className={`p-2 transition-all duration-250 text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] cursor-pointer`}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
<Gear className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
components/Sheen.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import { validTilt } from '@/tools';
|
||||
|
||||
const tiltSheen = (sheen: HTMLDivElement, x: number, y: number) => {
|
||||
const rect = sheen.getBoundingClientRect();
|
||||
const sheenX = rect.width - x * rect.width;
|
||||
const sheenY = rect.height - y * rect.height;
|
||||
|
||||
sheen.style.opacity = '1';
|
||||
sheen.style.backgroundImage = `
|
||||
radial-gradient(
|
||||
circle at
|
||||
${sheenX}px ${sheenY}px,
|
||||
#ffffff44,
|
||||
#0000000f
|
||||
)
|
||||
`;
|
||||
};
|
||||
|
||||
export default function Sheen({ cardIndex, className }: { cardIndex: number; className?: string }) {
|
||||
const sheenRef = useRef<HTMLDivElement>(null);
|
||||
const [untilt, setUntilt] = useState(false);
|
||||
const { tilts } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
const sheen = sheenRef.current;
|
||||
if (!sheen) return;
|
||||
|
||||
const tilt = tilts[cardIndex];
|
||||
|
||||
if (validTilt(tilt)) {
|
||||
setUntilt(false);
|
||||
tiltSheen(sheen, tilt.percentX, tilt.percentY);
|
||||
} else {
|
||||
setUntilt(true);
|
||||
}
|
||||
}, [tilts]);
|
||||
|
||||
useEffect(() => {
|
||||
const sheen = sheenRef.current;
|
||||
if (!sheen || !untilt) return;
|
||||
|
||||
sheen.style.opacity = '0';
|
||||
}, [untilt]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sheenRef}
|
||||
className={`
|
||||
absolute inset-0
|
||||
rounded-lg pointer-events-none
|
||||
transition-opacity duration-500
|
||||
bg-gradient-to-tr from-transparent via-white/20 to-transparent mix-blend-screen opacity-0
|
||||
${className}
|
||||
`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
components/SpectatorLink.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { Eye } from 'lucide-react';
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import CopyButton from '@/components/CopyButton';
|
||||
|
||||
export function SpectatorLink() {
|
||||
const { gameData } = useAppContext();
|
||||
|
||||
return (
|
||||
<CopyButton
|
||||
copy={`${location.origin}/${gameData.spectatorID}`}
|
||||
tooltip={`Spectator link: ${location.origin}/${gameData.spectatorID}`}
|
||||
Icon={Eye}
|
||||
className={`fixed top-3 left-3 p-2 z-25 transition-all duration-250 text-yellow-400 hover:text-yellow-300 hover:drop-shadow-[0_0_3px_#ffd700] cursor-pointer`}
|
||||
size={24}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,44 @@
|
||||
import type { ChangeEventHandler } from 'react';
|
||||
|
||||
export interface SwitchProps {
|
||||
label: string;
|
||||
value: boolean;
|
||||
toggleAction: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
toggleAction: ChangeEventHandler<HTMLInputElement>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Switch({ label, value, toggleAction }: SwitchProps) {
|
||||
const nonInitialCaps = /(?!^)([A-Z])/g;
|
||||
|
||||
export default function Switch({ label, value, toggleAction, className }: SwitchProps) {
|
||||
return (
|
||||
<label className="flex items-center justify-between w-full gap-2 cursor-pointer">
|
||||
<span className="text-sm capitalize">{label}</span>
|
||||
<label
|
||||
className={`flex items-center justify-between gap-2 w-full cursor-pointer text-yellow-400 hover:text-yellow-300 ${className}`}
|
||||
>
|
||||
<span className="text-sm capitalize">{label.replace(nonInitialCaps, ' $1')}</span>
|
||||
|
||||
<div className="relative inline-block w-8 h-4 align-middle select-none transition duration-200 ease-in">
|
||||
<input type="checkbox" checked={value} onChange={toggleAction} className="sr-only" />
|
||||
<div
|
||||
className={`block w-8 h-4 rounded-full transition ${
|
||||
value ? 'bg-gray-500' : 'bg-gray-600'
|
||||
}`}
|
||||
<input
|
||||
id={`switch-${label}`}
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
onChange={toggleAction}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div
|
||||
className={`absolute top-[2px] left-[2px] w-3 h-3 rounded-full transition-transform duration-200 ease-out transform
|
||||
${value ? 'translate-x-4 scale-110 shadow-[0_0_2px_2px_rgba(255,255,255,0.4)]' : 'scale-95'}
|
||||
${value ? 'bg-gray-100' : 'bg-gray-400'}`}
|
||||
className={`
|
||||
block w-8 h-4 rounded-full
|
||||
transition-colors duration-200 ease-in
|
||||
bg-slate-600 peer-checked:bg-slate-500
|
||||
`}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
absolute top-[2px] left-[2px]
|
||||
w-3 h-3 rounded-full
|
||||
transition-all duration-250 ease-out
|
||||
translate-x-0 scale-95 bg-yellow-500
|
||||
peer-checked:translate-x-4 peer-checked:scale-110 peer-checked:bg-yellow-400
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
28
components/TarokkaGrid.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import Card from '@/components/Card';
|
||||
import { cardMap } from '@/constants/tarokka';
|
||||
import type {} from '@/types';
|
||||
|
||||
export default function TarokkaGrid() {
|
||||
const { gameData } = useAppContext();
|
||||
const { cards } = gameData;
|
||||
|
||||
// map our five Tarokka cards to their proper locations in a 3x3 grid
|
||||
// common deck cards: left, top, and right
|
||||
// high deck cards: bottom and center
|
||||
const arrangeCards = (_cell: unknown, index: number) => cards[cardMap[index]];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 grid-rows-3 gap-8 w-fit mx-auto">
|
||||
{Array.from({ length: 9 })
|
||||
.map(arrangeCards)
|
||||
.map((card, index) => (
|
||||
<div key={index} className="aspect-[2/3]}">
|
||||
{card && <Card card={card} cardIndex={cardMap[index]} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
components/TiltCard.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAppContext } from '@/app/AppContext';
|
||||
import { throttle, validTilt } from '@/tools';
|
||||
|
||||
import { thirtyFPS } from '@/constants/time';
|
||||
import type { Tilt } from '@/types';
|
||||
|
||||
const ZERO_ROTATION = 'rotateX(0deg) rotateY(0deg)';
|
||||
|
||||
export default function TiltCard({
|
||||
children,
|
||||
cardIndex,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cardIndex: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const [untilt, setUntilt] = useState(false);
|
||||
const { settings, tilts, setLocalTilt } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
if (!card) return;
|
||||
|
||||
const tilt = tilts[cardIndex];
|
||||
|
||||
if (validTilt(tilt)) {
|
||||
setUntilt(false);
|
||||
card.style.transform = `rotateX(${tilt.rotateX}deg) rotateY(${tilt.rotateY}deg)`;
|
||||
} else {
|
||||
setUntilt(true);
|
||||
}
|
||||
}, [tilts]);
|
||||
|
||||
useEffect(() => {
|
||||
const card = cardRef.current;
|
||||
if (!card || !untilt) return;
|
||||
|
||||
card.style.transform = ZERO_ROTATION;
|
||||
}, [untilt]);
|
||||
|
||||
const handleMouseMove = throttle((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;
|
||||
const percentX = x / rect.width;
|
||||
const percentY = y / rect.height;
|
||||
|
||||
const newTilt: Tilt[] = [];
|
||||
newTilt[cardIndex] = {
|
||||
percentX,
|
||||
percentY,
|
||||
rotateX,
|
||||
rotateY,
|
||||
};
|
||||
|
||||
setLocalTilt(newTilt);
|
||||
}, thirtyFPS);
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setLocalTilt([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group ${className}`}
|
||||
onMouseMove={settings.tilt ? handleMouseMove : undefined}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div
|
||||
ref={cardRef}
|
||||
onAnimationEnd={() => setUntilt(false)}
|
||||
className={`h-full w-full transition-transform ${untilt ? 'duration-500' : 'duration-0'}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,16 +9,18 @@ type TooltipProps = {
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
edgeBuffer?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function Tooltip({
|
||||
children,
|
||||
content,
|
||||
delay = 500,
|
||||
mobileDelay = 500,
|
||||
delay = 250,
|
||||
mobileDelay = 250,
|
||||
offsetX = 20,
|
||||
offsetY = 20,
|
||||
edgeBuffer = 10,
|
||||
className,
|
||||
}: TooltipProps) {
|
||||
const ttRef = useRef<HTMLDivElement | null>(null);
|
||||
const [show, setShow] = useState(false);
|
||||
@@ -67,12 +69,13 @@ export default function Tooltip({
|
||||
onMouseMove={handleMouseMove}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
ref={ttRef}
|
||||
className={`fixed max-w-[35vh] pointer-events-none z-50 text-xs bg-black text-white rounded-xl border border-gray-300 px-2 py-1 transition-opacity duration-250 ${content && show ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`fixed max-w-[35vh] pointer-events-none z-50 text-xs bg-[#1e293b] rounded-lg border border-yellow-500 px-2 py-1 transition-opacity duration-250 ${content && show ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={{
|
||||
top: `${pos.y + offsetY}px`,
|
||||
left: `${pos.x + offsetX}px`,
|
||||
|
||||
33
constants/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export * from '@/constants/standardCards';
|
||||
export * from '@/constants/tarokka';
|
||||
export * from '@/constants/tarokkaCards';
|
||||
export * from '@/constants/time';
|
||||
|
||||
import type { GameUpdate, LocalSettings, Settings } from '@/types';
|
||||
|
||||
export const SETTINGS: Settings = {
|
||||
cardStyle: 'color',
|
||||
notes: true,
|
||||
positionBack: true,
|
||||
positionFront: true,
|
||||
prophecy: true,
|
||||
tilt: true,
|
||||
remoteTilt: true,
|
||||
};
|
||||
|
||||
export const GAME_START: GameUpdate = {
|
||||
dmID: '',
|
||||
spectatorID: '',
|
||||
cards: [],
|
||||
settings: SETTINGS,
|
||||
tilts: Array.from({ length: 5 }, () => []),
|
||||
};
|
||||
|
||||
export const LOCAL_DEFAULTS: LocalSettings = {
|
||||
tilt: true,
|
||||
remoteTilt: true,
|
||||
};
|
||||
|
||||
export const LOCAL_SETTINGS = ['tilt', 'remoteTilt'];
|
||||
|
||||
export const SPECTATOR_SETTINGS = ['tilt', 'remoteTilt'];
|
||||
@@ -5,15 +5,18 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'back',
|
||||
name: 'Card Back',
|
||||
card: 'Back of card',
|
||||
deck: 'back',
|
||||
suit: null,
|
||||
aria: 'Back of card',
|
||||
description: 'Back of card',
|
||||
back: true,
|
||||
extension: '.png',
|
||||
},
|
||||
{
|
||||
id: 'swashbuckler',
|
||||
name: 'Swashbuckler',
|
||||
card: 'One of Coins',
|
||||
deck: 'common',
|
||||
suit: 'Coins',
|
||||
aria: 'Coins 01 Swashbuckler',
|
||||
description: 'Those who like money yet give it up freely; likable rogues and rapscallions',
|
||||
@@ -30,6 +33,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'philanthropist',
|
||||
name: 'Philanthropist',
|
||||
card: 'Two of Coins',
|
||||
deck: 'common',
|
||||
suit: 'Coins',
|
||||
aria: 'Coins 02 Philanthropist',
|
||||
description:
|
||||
@@ -47,6 +51,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'trader',
|
||||
name: 'Trader',
|
||||
card: 'Three of Coins',
|
||||
deck: 'common',
|
||||
suit: 'Coins',
|
||||
aria: 'Coins 03 Trader',
|
||||
description: 'Commerce; smuggling and black markets; fair and equitable trades',
|
||||
@@ -63,6 +68,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'merchant',
|
||||
name: 'Merchant',
|
||||
card: 'Four of Coins',
|
||||
deck: 'common',
|
||||
suit: 'Coins',
|
||||
aria: 'Coins 04 Merchant',
|
||||
description:
|
||||
@@ -79,6 +85,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'guild-member',
|
||||
name: 'Guild Member',
|
||||
card: 'Five of Coins',
|
||||
deck: 'common',
|
||||
suit: 'Coins',
|
||||
aria: 'Coins 05 Guild Member',
|
||||
description: "Like-minded individuals joined together in a common goal; pride in one's work",
|
||||
@@ -94,6 +101,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'beggar',
|
||||
name: 'Beggar',
|
||||
card: 'Six of Coins',
|
||||
deck: 'common',
|
||||
suit: 'Coins',
|
||||
aria: 'Coins 06 Beggar',
|
||||
description: 'Sudden change in economic status or fortune',
|
||||
@@ -110,6 +118,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'thief',
|
||||
name: 'Thief',
|
||||
card: 'Seven of Coins',
|
||||
deck: 'common',
|
||||
suit: 'Coins',
|
||||
aria: 'Coins 07 Thief',
|
||||
description:
|
||||
@@ -127,6 +136,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'tax-collector',
|
||||
name: 'Tax Collector',
|
||||
card: 'Eight of Coins',
|
||||
deck: 'common',
|
||||
suit: 'Coins',
|
||||
aria: 'Coins 08 Tax Collector',
|
||||
description: 'Corruption; honesty in an otherwise corrupt government or organization',
|
||||
@@ -144,6 +154,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'miser',
|
||||
name: 'Miser',
|
||||
card: 'Nine of Coins',
|
||||
deck: 'common',
|
||||
suit: 'Coins',
|
||||
aria: 'Coins 09 Miser',
|
||||
description:
|
||||
@@ -160,6 +171,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'rogue',
|
||||
name: 'Rogue',
|
||||
card: 'Master of Coins',
|
||||
deck: 'common',
|
||||
suit: 'Coins',
|
||||
aria: 'Coins 10 Rogue',
|
||||
description:
|
||||
@@ -176,6 +188,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'monk',
|
||||
name: 'Monk',
|
||||
card: 'One of Glyphs',
|
||||
deck: 'common',
|
||||
suit: 'Glyphs',
|
||||
aria: 'Glyphs 01 Monk',
|
||||
description:
|
||||
@@ -193,6 +206,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'missionary',
|
||||
name: 'Missionary',
|
||||
card: 'Two of Glyphs',
|
||||
deck: 'common',
|
||||
suit: 'Glyphs',
|
||||
aria: 'Glyphs 02 Missionary',
|
||||
description:
|
||||
@@ -211,6 +225,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'healer',
|
||||
name: 'Healer',
|
||||
card: 'Three of Glyphs',
|
||||
deck: 'common',
|
||||
suit: 'Glyphs',
|
||||
aria: 'Glyphs 03 Healer',
|
||||
description:
|
||||
@@ -228,6 +243,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'shepherd',
|
||||
name: 'Shepherd',
|
||||
card: 'Four of Glyphs',
|
||||
deck: 'common',
|
||||
suit: 'Glyphs',
|
||||
aria: 'Glyphs 04 Shepherd',
|
||||
description:
|
||||
@@ -245,6 +261,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'druid',
|
||||
name: 'Druid',
|
||||
card: 'Five of Glyphs',
|
||||
deck: 'common',
|
||||
suit: 'Glyphs',
|
||||
aria: 'Glyphs 05 Druid',
|
||||
description:
|
||||
@@ -263,6 +280,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'anarchist',
|
||||
name: 'Anarchist',
|
||||
card: 'Six of Glyphs',
|
||||
deck: 'common',
|
||||
suit: 'Glyphs',
|
||||
aria: 'Glyphs 06 Anarchist',
|
||||
description: 'A fundamental change brought on by one whose beliefs are being put to the test',
|
||||
@@ -279,6 +297,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'charlatan',
|
||||
name: 'Charlatan',
|
||||
card: 'Seven of Glyphs',
|
||||
deck: 'common',
|
||||
suit: 'Glyphs',
|
||||
aria: 'Glyphs 07 Charlatan',
|
||||
description: 'Liars; those who profess to believe one thing but actually believe another',
|
||||
@@ -294,6 +313,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'bishop',
|
||||
name: 'Bishop',
|
||||
card: 'Eight of Glyphs',
|
||||
deck: 'common',
|
||||
suit: 'Glyphs',
|
||||
aria: 'Glyphs 08 Bishop',
|
||||
description: 'Strict adherence to a code or a belief; those who plot, plan, and scheme',
|
||||
@@ -310,6 +330,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'traitor',
|
||||
name: 'Traitor',
|
||||
card: 'Nine of Glyphs',
|
||||
deck: 'common',
|
||||
suit: 'Glyphs',
|
||||
aria: 'Glyphs 09 Traitor',
|
||||
description: 'Betrayal by someone close and trusted; a weakening or loss of faith',
|
||||
@@ -327,6 +348,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'priest',
|
||||
name: 'Priest',
|
||||
card: 'Master of Glyphs',
|
||||
deck: 'common',
|
||||
suit: 'Glyphs',
|
||||
aria: 'Glyphs 10 Priest',
|
||||
description: 'Enlightenment; those who follow a deity, a system of values, or a higher purpose',
|
||||
@@ -343,6 +365,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'transmuter',
|
||||
name: 'Transmuter',
|
||||
card: 'One of Stars',
|
||||
deck: 'common',
|
||||
suit: 'Stars',
|
||||
aria: 'Stars 01 Transmuter',
|
||||
description:
|
||||
@@ -359,6 +382,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'diviner',
|
||||
name: 'Diviner',
|
||||
card: 'Two of Stars',
|
||||
deck: 'common',
|
||||
suit: 'Stars',
|
||||
aria: 'Stars 02 Diviner',
|
||||
description:
|
||||
@@ -376,6 +400,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'enchanter',
|
||||
name: 'Enchanter',
|
||||
card: 'Three of Stars',
|
||||
deck: 'common',
|
||||
suit: 'Stars',
|
||||
aria: 'Stars 03 Enchanter',
|
||||
description: 'Inner turmoil that comes from confusion, fear of failure, or false information',
|
||||
@@ -393,6 +418,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'abjurer',
|
||||
name: 'Abjurer',
|
||||
card: 'Four of Stars',
|
||||
deck: 'common',
|
||||
suit: 'Stars',
|
||||
aria: 'Stars 04 Abjurer',
|
||||
description:
|
||||
@@ -410,6 +436,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'elementalist',
|
||||
name: 'Elementalist',
|
||||
card: 'Five of Stars',
|
||||
deck: 'common',
|
||||
suit: 'Stars',
|
||||
aria: 'Stars 05 Elementalist',
|
||||
description:
|
||||
@@ -428,6 +455,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'evoker',
|
||||
name: 'Evoker',
|
||||
card: 'Six of Stars',
|
||||
deck: 'common',
|
||||
suit: 'Stars',
|
||||
aria: 'Stars 06 Evoker',
|
||||
description:
|
||||
@@ -445,6 +473,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'illusionist',
|
||||
name: 'Illusionist',
|
||||
card: 'Seven of Stars',
|
||||
deck: 'common',
|
||||
suit: 'Stars',
|
||||
aria: 'Stars 07 Illusionist',
|
||||
description:
|
||||
@@ -462,6 +491,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'necromancer',
|
||||
name: 'Necromancer',
|
||||
card: 'Eight of Stars',
|
||||
deck: 'common',
|
||||
suit: 'Stars',
|
||||
aria: 'Stars 08 Necromancer',
|
||||
description: 'Unnatural events and unhealthy obsessions; those who follow a destructive path',
|
||||
@@ -477,6 +507,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'conjurer',
|
||||
name: 'Conjurer',
|
||||
card: 'Nine of Stars',
|
||||
deck: 'common',
|
||||
suit: 'Stars',
|
||||
aria: 'Stars 09 Conjurer',
|
||||
description:
|
||||
@@ -494,6 +525,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'wizard',
|
||||
name: 'Wizard',
|
||||
card: 'Master of Stars',
|
||||
deck: 'common',
|
||||
suit: 'Stars',
|
||||
aria: 'Stars 10 Wizard',
|
||||
description:
|
||||
@@ -511,6 +543,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'avenger',
|
||||
name: 'Avenger',
|
||||
card: 'One of Swords',
|
||||
deck: 'common',
|
||||
suit: 'Swords',
|
||||
aria: 'Swords 01 Avenger',
|
||||
description:
|
||||
@@ -528,6 +561,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'paladin',
|
||||
name: 'Paladin',
|
||||
card: 'Two of Swords',
|
||||
deck: 'common',
|
||||
suit: 'Swords',
|
||||
aria: 'Swords 02 Paladin',
|
||||
description: 'Just and noble warriors; those who live by a code of honor and integrity',
|
||||
@@ -544,6 +578,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'soldier',
|
||||
name: 'Soldier',
|
||||
card: 'Three of Swords',
|
||||
deck: 'common',
|
||||
suit: 'Swords',
|
||||
aria: 'Swords 03 Soldier',
|
||||
description: 'War and sacrifice; the stamina to endure great hardship',
|
||||
@@ -560,6 +595,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'mercenary',
|
||||
name: 'Mercenary',
|
||||
card: 'Four of Swords',
|
||||
deck: 'common',
|
||||
suit: 'Swords',
|
||||
aria: 'Swords 04 Mercenary',
|
||||
description: 'Inner strength and fortitude; those who fight for power or wealth',
|
||||
@@ -575,6 +611,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'myrmidon',
|
||||
name: 'Myrmidon',
|
||||
card: 'Five of Swords',
|
||||
deck: 'common',
|
||||
suit: 'Swords',
|
||||
aria: 'Swords 05 Myrmidon',
|
||||
description:
|
||||
@@ -593,6 +630,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'berserker',
|
||||
name: 'Berserker',
|
||||
card: 'Six of Swords',
|
||||
deck: 'common',
|
||||
suit: 'Swords',
|
||||
aria: 'Swords 06 Berserker',
|
||||
description: 'The brutal and barbaric side of warfare; bloodlust; those with a bestial nature',
|
||||
@@ -610,6 +648,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'hooded-one',
|
||||
name: 'Hooded One',
|
||||
card: 'Seven of Swords',
|
||||
deck: 'common',
|
||||
suit: 'Swords',
|
||||
aria: 'Swords 07 Hooded One',
|
||||
description: 'Bigotry, intolerance, and xenophobia; a mysterious presence or newcomer',
|
||||
@@ -627,6 +666,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'dictator',
|
||||
name: 'Dictator',
|
||||
card: 'Eight of Swords',
|
||||
deck: 'common',
|
||||
suit: 'Swords',
|
||||
aria: 'Swords 08 Dictator',
|
||||
description:
|
||||
@@ -643,6 +683,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'torturer',
|
||||
name: 'Torturer',
|
||||
card: 'Nine of Swords',
|
||||
deck: 'common',
|
||||
suit: 'Swords',
|
||||
aria: 'Swords 09 Torturer',
|
||||
description:
|
||||
@@ -661,6 +702,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'warrior',
|
||||
name: 'Warrior',
|
||||
card: 'Master of Swords',
|
||||
deck: 'common',
|
||||
suit: 'Swords',
|
||||
aria: 'Swords 10 Warrior',
|
||||
description:
|
||||
@@ -678,6 +720,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'artifact',
|
||||
name: 'Artifact',
|
||||
card: 'The Artifact',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Artifact',
|
||||
description:
|
||||
@@ -702,6 +745,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'beast',
|
||||
name: 'Beast',
|
||||
card: 'The Beast',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Beast',
|
||||
description:
|
||||
@@ -727,6 +771,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'broken-one',
|
||||
name: 'Broken One',
|
||||
card: 'The Broken One',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Broken One',
|
||||
description:
|
||||
@@ -758,6 +803,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'darklord',
|
||||
name: 'Darklord',
|
||||
card: 'The Darklord',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Darklord',
|
||||
description:
|
||||
@@ -781,6 +827,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'donjon',
|
||||
name: 'Donjon',
|
||||
card: 'The Donjon',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Donjon',
|
||||
description:
|
||||
@@ -813,6 +860,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'executioner',
|
||||
name: 'Executioner',
|
||||
card: 'The Executioner',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Executioner',
|
||||
description:
|
||||
@@ -839,6 +887,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'ghost',
|
||||
name: 'Ghost',
|
||||
card: 'The Ghost',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Ghost',
|
||||
description:
|
||||
@@ -872,6 +921,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'horseman',
|
||||
name: 'Horseman',
|
||||
card: 'The Horseman',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Horseman',
|
||||
description:
|
||||
@@ -904,6 +954,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'innocent',
|
||||
name: 'Innocent',
|
||||
card: 'The Innocent',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Innocent',
|
||||
description:
|
||||
@@ -936,6 +987,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'marionette',
|
||||
name: 'Marionette',
|
||||
card: 'The Marionette',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Marionette',
|
||||
description:
|
||||
@@ -967,6 +1019,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'mists',
|
||||
name: 'Mists',
|
||||
card: 'The Mists',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Mists',
|
||||
description:
|
||||
@@ -993,6 +1046,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'raven',
|
||||
name: 'Raven',
|
||||
card: 'The Raven',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Raven',
|
||||
description:
|
||||
@@ -1019,6 +1073,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'seer',
|
||||
name: 'Seer',
|
||||
card: 'The Seer',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Seer',
|
||||
description:
|
||||
@@ -1045,6 +1100,7 @@ const tarokkaCards: TarokkaCard[] = [
|
||||
id: 'tempter',
|
||||
name: 'Tempter',
|
||||
card: 'The Tempter',
|
||||
deck: 'high',
|
||||
suit: 'High Deck',
|
||||
aria: 'High Deck Tempter',
|
||||
description:
|
||||
|
||||
@@ -2,3 +2,5 @@ export const SECOND = 1000;
|
||||
export const MINUTE = 60 * SECOND;
|
||||
export const HOUR = 60 * MINUTE;
|
||||
export const DAY = 24 * HOUR;
|
||||
|
||||
export const thirtyFPS = SECOND / 30;
|
||||
|
||||
102
hooks/useSocket.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { socket } from '@/socket';
|
||||
import type { GameUpdate, Tilt } from '@/types';
|
||||
|
||||
interface UseSocketProps {
|
||||
gameID: string;
|
||||
setGameData: (gameUpdate: GameUpdate) => void;
|
||||
setNoGame: (noGame: boolean) => void;
|
||||
}
|
||||
|
||||
export default function useSocket({ gameID, setGameData, setNoGame }: UseSocketProps) {
|
||||
const [connect, setConnect] = useState(1);
|
||||
const [disconnected, setDisconnected] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (gameID) {
|
||||
socket.emit('join', gameID);
|
||||
|
||||
socket.on('init', (data: GameUpdate) => {
|
||||
setDisconnected(false);
|
||||
setGameData(data);
|
||||
});
|
||||
|
||||
socket.on('game-update', (data: GameUpdate) => {
|
||||
// remove user's own tilts in favor of local values
|
||||
data.tilts = data.tilts.map((card) => card.filter((tilt) => tilt.playerID !== socket.id));
|
||||
setGameData(data);
|
||||
});
|
||||
|
||||
socket.on('join-error', (error) => {
|
||||
console.error('Error:', error);
|
||||
setNoGame(true);
|
||||
});
|
||||
|
||||
socket.on('flip-error', (error) => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
setDisconnected(true);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket.removeAllListeners();
|
||||
};
|
||||
}, [gameID, connect]);
|
||||
|
||||
const emitFlip = (cardIndex: number) => {
|
||||
if (disconnected) setConnect(connect + 1);
|
||||
|
||||
socket.emit('flip-card', {
|
||||
gameID,
|
||||
cardIndex,
|
||||
});
|
||||
};
|
||||
|
||||
const emitSettings = (gameData: GameUpdate) => {
|
||||
if (disconnected) setConnect(connect + 1);
|
||||
|
||||
socket.emit('settings', {
|
||||
gameID,
|
||||
gameData,
|
||||
});
|
||||
};
|
||||
|
||||
const emitRedraw = (cardIndex: number) => {
|
||||
if (disconnected) setConnect(connect + 1);
|
||||
|
||||
socket.emit('redraw', {
|
||||
gameID,
|
||||
cardIndex,
|
||||
});
|
||||
};
|
||||
|
||||
const emitSelect = (cardIndex: number, cardID: string) => {
|
||||
if (disconnected) setConnect(connect + 1);
|
||||
|
||||
socket.emit('select', {
|
||||
gameID,
|
||||
cardIndex,
|
||||
cardID,
|
||||
});
|
||||
};
|
||||
|
||||
const emitTilt = (cardIndex: number, tilt: Tilt) => {
|
||||
if (disconnected) setConnect(connect + 1);
|
||||
|
||||
socket.emit('tilt', {
|
||||
cardIndex,
|
||||
tilt,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
emitFlip,
|
||||
emitSettings,
|
||||
emitRedraw,
|
||||
emitSelect,
|
||||
emitTilt,
|
||||
};
|
||||
}
|
||||
118
lib/GameStore.ts
@@ -1,8 +1,8 @@
|
||||
import Deck from '@/lib/TarokkaDeck';
|
||||
import generateID from '@/tools/simpleID';
|
||||
import parseMilliseconds from '@/tools/parseMilliseconds';
|
||||
import { MINUTE, HOUR, DAY } from '@/constants/time';
|
||||
import { GameState, GameUpdate, Settings } from '@/types';
|
||||
import { generateID, parseMilliseconds } from '@/tools';
|
||||
|
||||
import { HOUR, DAY, SETTINGS } from '@/constants';
|
||||
import { GameState, GameUpdate, Settings, Tilt } from '@/types';
|
||||
|
||||
const deck = new Deck();
|
||||
|
||||
@@ -34,10 +34,10 @@ export default class GameStore {
|
||||
private totalExpired: number;
|
||||
private totalUnused: number;
|
||||
|
||||
private startUps: Set<string>;
|
||||
private dms: Map<string, GameState>;
|
||||
private spectators: Map<string, GameState>;
|
||||
private players: Map<string, string>;
|
||||
private startUps: Set<string>; // homepage socket IDs
|
||||
private dms: Map<string, GameState>; // DM socket ID -> game
|
||||
private spectators: Map<string, GameState>; // spectator socket ID -> game
|
||||
private players: Map<string, GameState>; // socket ID -> game
|
||||
|
||||
constructor() {
|
||||
this.startTime = Date.now();
|
||||
@@ -50,7 +50,7 @@ export default class GameStore {
|
||||
this.spectators = new Map();
|
||||
this.players = new Map();
|
||||
|
||||
setInterval(() => this.log(), 15 * MINUTE);
|
||||
setInterval(() => this.log(), HOUR);
|
||||
setInterval(() => this.cleanUp(), HOUR);
|
||||
|
||||
setTimeout(() => this.wrapUp(), tilMidnight());
|
||||
@@ -84,13 +84,8 @@ export default class GameStore {
|
||||
players: new Set(),
|
||||
cards: deck.getHand(),
|
||||
lastUpdated: Date.now(),
|
||||
settings: {
|
||||
positionBack: true,
|
||||
positionFront: true,
|
||||
prophecy: true,
|
||||
notes: true,
|
||||
cardStyle: 'color',
|
||||
},
|
||||
settings: SETTINGS,
|
||||
tilts: Array.from({ length: 5 }, () => []),
|
||||
};
|
||||
|
||||
this.totalCreated++;
|
||||
@@ -106,18 +101,20 @@ export default class GameStore {
|
||||
|
||||
game.players.add(playerID);
|
||||
game.lastUpdated = Date.now();
|
||||
this.players.set(playerID, gameID);
|
||||
this.players.set(playerID, game);
|
||||
|
||||
return this.gameUpdate(game);
|
||||
}
|
||||
|
||||
leaveGame(gameID: string, playerID: string): GameState {
|
||||
const game = this.getGame(gameID);
|
||||
leaveGame(playerID: string): GameUpdate {
|
||||
const game = this.getGameByPlayerID(playerID);
|
||||
|
||||
this.players.delete(playerID);
|
||||
game.players.delete(playerID);
|
||||
this._clearTilts(game, playerID);
|
||||
game.lastUpdated = Date.now();
|
||||
|
||||
return game;
|
||||
return this.gameUpdate(game);
|
||||
}
|
||||
|
||||
flipCard(gameID: string, cardIndex: number): GameUpdate {
|
||||
@@ -132,6 +129,54 @@ export default class GameStore {
|
||||
return this.gameUpdate(game);
|
||||
}
|
||||
|
||||
redraw(gameID: string, cardIndex: number): GameUpdate {
|
||||
const game = this.getGame(gameID);
|
||||
const card = game.cards[cardIndex];
|
||||
|
||||
if (!card) throw new Error(`Card ${cardIndex} not found`);
|
||||
|
||||
game.cards[cardIndex] =
|
||||
card.suit === 'High Deck' ? deck.drawHigh(game.cards) : deck.drawLow(game.cards);
|
||||
game.lastUpdated = Date.now();
|
||||
|
||||
return this.gameUpdate(game);
|
||||
}
|
||||
|
||||
select(gameID: string, cardIndex: number, cardID: string): GameUpdate {
|
||||
const game = this.getGame(gameID);
|
||||
const card = game.cards[cardIndex];
|
||||
const replacement = deck.select(cardID);
|
||||
|
||||
if (!card) throw new Error(`Card ${cardIndex} not found`);
|
||||
if (!replacement) throw new Error(`Card ${cardID} not found`);
|
||||
|
||||
game.cards[cardIndex] = replacement;
|
||||
game.lastUpdated = Date.now();
|
||||
|
||||
return this.gameUpdate(game);
|
||||
}
|
||||
|
||||
tilt(playerID: string, cardIndex: number, tilt: Tilt) {
|
||||
const game = this.getGameByPlayerID(playerID);
|
||||
const cardTilts = game.tilts[cardIndex];
|
||||
|
||||
if (!cardTilts) throw new Error(`Card tilts ${cardIndex} not found`);
|
||||
|
||||
this._clearTilts(game, playerID);
|
||||
|
||||
if (tilt.rotateX && tilt.rotateY) {
|
||||
game.tilts[cardIndex] = [...game.tilts[cardIndex], { ...tilt, playerID }];
|
||||
game.lastUpdated = Date.now();
|
||||
}
|
||||
|
||||
return this.gameUpdate(game);
|
||||
}
|
||||
|
||||
_clearTilts(game: GameState, playerID: string) {
|
||||
game.tilts = game.tilts.map((card) => card.filter((tilt) => tilt.playerID !== playerID));
|
||||
game.lastUpdated = Date.now();
|
||||
}
|
||||
|
||||
updateSettings(gameID: string, settings: Settings) {
|
||||
const game = this.getGame(gameID);
|
||||
|
||||
@@ -148,24 +193,27 @@ export default class GameStore {
|
||||
return game;
|
||||
}
|
||||
|
||||
gameUpdate(game: GameState): GameUpdate {
|
||||
const { dmID, spectatorID, cards, settings } = game;
|
||||
getGameByPlayerID(playerID: string): GameState {
|
||||
const game = this.players.get(playerID);
|
||||
|
||||
return { dmID, spectatorID, cards, settings };
|
||||
if (!game) throw new Error(`Player ${playerID} not found`);
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
playerExit(playerID: string): GameState | null {
|
||||
gameUpdate(game: GameState): GameUpdate {
|
||||
const { dmID, spectatorID, cards, settings, tilts } = game;
|
||||
|
||||
return { dmID, spectatorID, cards, settings, tilts };
|
||||
}
|
||||
|
||||
playerExit(playerID: string): GameUpdate | null {
|
||||
if (this.startUps.has(playerID)) {
|
||||
this.startUps.delete(playerID);
|
||||
|
||||
return null;
|
||||
} else {
|
||||
const gameID = this.players.get(playerID);
|
||||
|
||||
if (!gameID) throw new Error(`Player ${playerID} not found`);
|
||||
|
||||
this.players.delete(playerID);
|
||||
return this.leaveGame(gameID, playerID);
|
||||
return this.leaveGame(playerID);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,7 +224,6 @@ export default class GameStore {
|
||||
console.log(uptimeLog);
|
||||
console.log(`Games: ${this.dms.size}`);
|
||||
console.log(`Players: ${this.players.size}`);
|
||||
console.log('-'.repeat(uptimeLog.length));
|
||||
}
|
||||
|
||||
wrapUp() {
|
||||
@@ -188,7 +235,6 @@ export default class GameStore {
|
||||
console.log(`Created: ${this.totalCreated}`);
|
||||
console.log(`Expired: ${this.totalExpired}`);
|
||||
console.log(`Unused: ${this.totalUnused}`);
|
||||
console.log('='.repeat(uptimeLog.length));
|
||||
|
||||
this.totalCreated = 0;
|
||||
this.totalExpired = 0;
|
||||
@@ -208,12 +254,16 @@ export default class GameStore {
|
||||
this.totalExpired += expired.length;
|
||||
this.totalUnused += unused.length;
|
||||
|
||||
expired.forEach((game) => this.deleteGame(game));
|
||||
expired.forEach((game) => {
|
||||
game.players.forEach((playerID) => this.players.delete(playerID));
|
||||
this.deleteGame(game);
|
||||
});
|
||||
|
||||
unused.forEach((game) => this.deleteGame(game));
|
||||
}
|
||||
|
||||
deleteGame(game: GameState): void {
|
||||
console.log(Date.now(), 'DELETE', game);
|
||||
console.log(Date.now(), 'DELETE', game.dmID, game.spectatorID);
|
||||
|
||||
this.dms.delete(game.dmID);
|
||||
this.spectators.delete(game.spectatorID);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import getRandomItems from '@/tools/getRandomItems';
|
||||
import { getRandomItems } from '@/tools';
|
||||
import cards from '@/constants/standardCards';
|
||||
import type { StandardCard } from '@/types';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import getRandomItems from '@/tools/getRandomItems';
|
||||
import { getRandomItems } from '@/tools';
|
||||
import cards from '@/constants/tarokkaCards';
|
||||
import type { TarokkaCard, TarokkaGameCard } from '@/types';
|
||||
|
||||
@@ -8,8 +8,8 @@ export default class TarokkaDeck {
|
||||
private backs: TarokkaCard[] = [];
|
||||
|
||||
constructor() {
|
||||
this.highDeck = cards.filter((card) => !card.back && card.suit === 'High Deck');
|
||||
this.commonDeck = cards.filter((card) => !card.back && card.suit !== 'High Deck');
|
||||
this.highDeck = cards.filter((card) => card.deck === 'high');
|
||||
this.commonDeck = cards.filter((card) => card.deck === 'common');
|
||||
this.backs = cards.filter((card) => card.back);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,49 @@ export default class TarokkaDeck {
|
||||
);
|
||||
}
|
||||
|
||||
getLow(): TarokkaGameCard[] {
|
||||
return this.commonDeck.map((card) => ({ ...card, flipped: false }));
|
||||
}
|
||||
|
||||
getHigh(): TarokkaGameCard[] {
|
||||
return this.highDeck.map((card) => ({ ...card, flipped: false }));
|
||||
}
|
||||
|
||||
drawLow(exclude: TarokkaGameCard[] = []): TarokkaGameCard {
|
||||
const excludeIDs = exclude.map(({ id }) => id);
|
||||
|
||||
return {
|
||||
...getRandomItems(
|
||||
this.commonDeck.filter(({ id }) => !excludeIDs.includes(id)),
|
||||
1,
|
||||
)[0],
|
||||
flipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
drawHigh(exclude: TarokkaGameCard[] = []): TarokkaGameCard {
|
||||
const excludeIDs = exclude.map(({ id }) => id);
|
||||
|
||||
return {
|
||||
...getRandomItems(
|
||||
this.highDeck.filter(({ id }) => !excludeIDs.includes(id)),
|
||||
1,
|
||||
)[0],
|
||||
flipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
select(id: string): TarokkaGameCard | null {
|
||||
const card = cards.find((card) => card.id === id);
|
||||
|
||||
if (!card) return null;
|
||||
|
||||
return {
|
||||
...card,
|
||||
flipped: false,
|
||||
};
|
||||
}
|
||||
|
||||
getBack(): TarokkaCard {
|
||||
return this.backs[0];
|
||||
}
|
||||
|
||||
25
middleware.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const url = request.nextUrl;
|
||||
const slug = url.pathname.slice(1);
|
||||
|
||||
const blocked = [
|
||||
'apple-icon.png',
|
||||
'favicon.ico',
|
||||
'icon0.svg',
|
||||
'icon1.png',
|
||||
'manifest.json',
|
||||
'opengraph-image.png',
|
||||
'twitter-image.png',
|
||||
'web-app-manifest-192x192.png',
|
||||
'web-app-manifest-512x512.png',
|
||||
];
|
||||
|
||||
if (blocked.includes(slug)) {
|
||||
return NextResponse.rewrite(request.url);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "tarokka",
|
||||
"version": "0.1.0",
|
||||
"version": "1.1.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nodemon",
|
||||
"build": "next build && tsc --project tsconfig.server.json",
|
||||
"start": "cross-env NODE_ENV=production TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/server.js",
|
||||
"deploy": "docker buildx build --platform linux/amd64 -t nasty.mcmorgans:5000/tarokka --push ."
|
||||
"release": "docker buildx build --platform linux/amd64 -t 192.168.0.2:5000/tarokka --push ."
|
||||
},
|
||||
"dependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
22
public/img/bmc-button.svg
Executable file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/img/color/back.png
Normal file
|
After Width: | Height: | Size: 969 KiB |
BIN
public/img/github-logo-white.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
1
public/img/github-mark-white.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 960 B |
BIN
public/img/grayscale/back.png
Normal file
|
After Width: | Height: | Size: 597 KiB |
BIN
public/img/screenshot.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
public/img/tarokka.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
public/img/tarokka_1024x512.webp
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/img/tarokka_1200x630.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
public/img/tarokka_round_solid.png
Normal file
|
After Width: | Height: | Size: 938 KiB |
BIN
public/img/tarokka_round_solid.webp
Normal file
|
After Width: | Height: | Size: 126 KiB |
BIN
public/img/tarokka_transparent.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 110 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
BIN
public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
68
server.ts
@@ -3,8 +3,10 @@ import { createServer } from 'http';
|
||||
import { Server as SocketIOServer, type Socket } from 'socket.io';
|
||||
|
||||
import GameStore from '@/lib/GameStore';
|
||||
import omit from '@/tools/omit';
|
||||
import type { ClientUpdate, GameUpdate } from '@/types';
|
||||
import { omit } from '@/tools';
|
||||
|
||||
import { thirtyFPS } from '@/constants/time';
|
||||
import type { ClientUpdate, GameUpdate, Tilt } from '@/types';
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const hostname = '0.0.0.0';
|
||||
@@ -15,9 +17,10 @@ const handler = app.getRequestHandler();
|
||||
|
||||
const gameStore = new GameStore();
|
||||
|
||||
const timedReleases = {};
|
||||
|
||||
app.prepare().then(() => {
|
||||
const httpServer = createServer(handler);
|
||||
|
||||
const io = new SocketIOServer(httpServer);
|
||||
|
||||
const broadcast = (event: string, gameUpdate: GameUpdate) => {
|
||||
@@ -25,6 +28,25 @@ app.prepare().then(() => {
|
||||
io.to(gameUpdate.spectatorID).emit(event, omit(gameUpdate, 'dmID'));
|
||||
};
|
||||
|
||||
const timedRelease = (event: string, gameUpdate: GameUpdate, threshold: number) => {
|
||||
const now = Date.now();
|
||||
const lastEvent = timedReleases[event];
|
||||
clearTimeout(lastEvent?.to);
|
||||
|
||||
if (lastEvent?.embargo >= now) {
|
||||
const embargo = lastEvent.embargo - now;
|
||||
|
||||
const to = setTimeout(() => {
|
||||
broadcast(event, gameUpdate);
|
||||
}, embargo);
|
||||
|
||||
timedReleases[event] = { embargo, to };
|
||||
} else {
|
||||
broadcast(event, gameUpdate);
|
||||
timedReleases[event] = { embargo: now + threshold };
|
||||
}
|
||||
};
|
||||
|
||||
io.on('connection', (socket: Socket) => {
|
||||
//console.log(Date.now(), `Client connected: ${socket.id}`);
|
||||
|
||||
@@ -79,6 +101,36 @@ app.prepare().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('redraw', ({ gameID, cardIndex }: ClientUpdate) => {
|
||||
try {
|
||||
//console.log(Date.now(), 'Redraw', { gameID, cardIndex });
|
||||
|
||||
const gameUpdate = gameStore.redraw(gameID, cardIndex);
|
||||
|
||||
broadcast('game-update', gameUpdate);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : e;
|
||||
|
||||
console.error(Date.now(), 'Error[redraw]', error);
|
||||
socket.emit('redraw-error', error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('select', ({ gameID, cardIndex, cardID = '' }: ClientUpdate) => {
|
||||
try {
|
||||
//console.log(Date.now(), 'select', { gameID, cardIndex });
|
||||
|
||||
const gameUpdate = gameStore.select(gameID, cardIndex, cardID);
|
||||
|
||||
broadcast('game-update', gameUpdate);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : e;
|
||||
|
||||
console.error(Date.now(), 'Error[select]', error);
|
||||
socket.emit('select-error', error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('settings', ({ gameID, gameData }: { gameID: string; gameData: GameUpdate }) => {
|
||||
try {
|
||||
const gameUpdate = gameStore.updateSettings(gameID, gameData.settings);
|
||||
@@ -89,6 +141,16 @@ app.prepare().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('tilt', ({ cardIndex, tilt }: { cardIndex: number; tilt: Tilt }) => {
|
||||
try {
|
||||
const gameState = gameStore.tilt(socket.id, cardIndex, tilt);
|
||||
timedRelease('game-update', gameState, thirtyFPS);
|
||||
} catch (e) {
|
||||
const error = e instanceof Error ? e.message : e;
|
||||
console.error(Date.now(), 'Error[tilt]', error);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
try {
|
||||
const game = gameStore.playerExit(socket.id);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { isHighCard, isLowCard } from '@/tools/cardTypes';
|
||||
import { isHighCard, isLowCard } from '@/tools';
|
||||
import { Layout, Settings, TarokkaGameCard } from '@/types';
|
||||
|
||||
export default function getTooltip(
|
||||
export const getCardInfo = (
|
||||
card: TarokkaGameCard,
|
||||
position: Layout,
|
||||
dm: boolean,
|
||||
settings: Settings,
|
||||
) {
|
||||
) => {
|
||||
const { card: cardName, description, flipped } = card;
|
||||
|
||||
let text: string[] = [];
|
||||
|
||||
if (flipped) {
|
||||
if (dm || flipped) {
|
||||
if (dm || settings.positionFront) text.push(position.text);
|
||||
|
||||
if (dm) text.push(`${cardName}: ${description}`);
|
||||
@@ -19,26 +19,24 @@ export default function getTooltip(
|
||||
if (isHighCard(card)) {
|
||||
// High deck ally
|
||||
if (position.id === 'ally') {
|
||||
if (dm) text.push(`Ally: ${card.prophecy.allies[0].ally}`);
|
||||
if (dm) text.push(card.prophecy.allies[0].dmText);
|
||||
if (dm || settings.prophecy) text.push(card.prophecy.allies[0].playerText);
|
||||
if (dm) text.push(card.prophecy.allies[0].dmText);
|
||||
if (dm) text.push(`Ally: ${card.prophecy.allies[0].ally}`);
|
||||
}
|
||||
|
||||
// High deck Strahd
|
||||
if (position.id === 'strahd') {
|
||||
if (dm) text.push(card.prophecy.strahd.dmText);
|
||||
if (dm || settings.prophecy) text.push(card.prophecy.strahd.playerText);
|
||||
if (dm) text.push(card.prophecy.strahd.dmText);
|
||||
}
|
||||
}
|
||||
|
||||
// Low deck: Tome, Ravenkind, or Sunsword
|
||||
if (isLowCard(card)) {
|
||||
if (dm) text.push(card.prophecy.dmText);
|
||||
if (dm || settings.prophecy) text.push(card.prophecy.playerText);
|
||||
if (dm) text.push(card.prophecy.dmText);
|
||||
}
|
||||
} else {
|
||||
if (dm || settings.positionBack) text.push(position.text);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default function getRandomItems<T>(items: T[], count: number): T[] {
|
||||
export const getRandomItems = <T>(items: T[], count: number): T[] => {
|
||||
const shuffled = [...items];
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
@@ -8,4 +8,4 @@ export default function getRandomItems<T>(items: T[], count: number): T[] {
|
||||
}
|
||||
|
||||
return count > shuffled.length ? shuffled : shuffled.slice(0, count);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { cardStyles, standardMap } from '@/constants/tarokka';
|
||||
import { Settings, TarokkaGameCard } from '@/types';
|
||||
import { Settings, TarokkaCard, TarokkaGameCard } from '@/types';
|
||||
|
||||
export default function getURL(card: TarokkaGameCard, settings: Settings) {
|
||||
export const getURL = (card: TarokkaCard | TarokkaGameCard, settings: Settings) => {
|
||||
const styleConfig = cardStyles[settings.cardStyle];
|
||||
const fileBase = settings.cardStyle === 'standard' ? standardMap[card.id] : card.id;
|
||||
return `${styleConfig.baseURL}${fileBase}${styleConfig.extension}`;
|
||||
}
|
||||
return `${styleConfig.baseURL}${fileBase}${card.extension || styleConfig.extension}`;
|
||||
};
|
||||
|
||||
11
tools/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export * from '@/tools/cardTypes';
|
||||
export * from '@/tools/getCardInfo';
|
||||
export * from '@/tools/getRandomItems';
|
||||
export * from '@/tools/getURL';
|
||||
export * from '@/tools/log';
|
||||
export * from '@/tools/omit';
|
||||
export * from '@/tools/parseMilliseconds';
|
||||
export * from '@/tools/reduceTilts';
|
||||
export * from '@/tools/simpleID';
|
||||
export * from '@/tools/throttle';
|
||||
export * from '@/tools/validTilt';
|
||||
19
tools/log.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* A logging utility designed to be inserted into functional chains.
|
||||
* Logs all parameters with an optional prefix, then returns the first argument unchanged.
|
||||
*
|
||||
* @param {string} [prefix=''] - A label or message to prepend to the logged output.
|
||||
* @returns {(value: any, ...rest: any[]) => any} - A function that logs its arguments and returns the first one.
|
||||
*
|
||||
* @example
|
||||
* const result = [1, 2, 3]
|
||||
* .map((n) => n * 2)
|
||||
* .map(log('doubled:'))
|
||||
* .filter((n) => n > 2);
|
||||
*/
|
||||
export const log =
|
||||
(prefix: string = ''): ((value: any, ...rest: any[]) => any) =>
|
||||
(...args: any[]) => {
|
||||
console.log(prefix, ...args);
|
||||
return args[0];
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
export default function omit<T extends Record<string, any>>(
|
||||
export const omit = <T extends Record<string, any>>(
|
||||
obj: T,
|
||||
propToRemove: keyof T,
|
||||
): Omit<T, typeof propToRemove> {
|
||||
): Omit<T, typeof propToRemove> => {
|
||||
const { [propToRemove]: _, ...rest } = obj;
|
||||
return rest;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface ParsedMilliseconds {
|
||||
seconds: number;
|
||||
}
|
||||
|
||||
export default function parseMilliseconds(timestamp: number): ParsedMilliseconds {
|
||||
export const parseMilliseconds = (timestamp: number): ParsedMilliseconds => {
|
||||
const days = Math.floor(timestamp / DAY);
|
||||
timestamp %= DAY;
|
||||
|
||||
@@ -21,4 +21,4 @@ export default function parseMilliseconds(timestamp: number): ParsedMilliseconds
|
||||
timestamp %= SECOND;
|
||||
|
||||
return { days, hours, minutes, seconds };
|
||||
}
|
||||
};
|
||||
|
||||
34
tools/reduceTilts.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { log, validTilt } from '@/tools';
|
||||
import { GameUpdate, Tilt } from '@/types';
|
||||
|
||||
const combineTilts = (tilts: Tilt[]) =>
|
||||
tilts.reduce(
|
||||
({ pX, pY, rX, rY, count }, { percentX, percentY, rotateX, rotateY }) => ({
|
||||
pX: pX + percentX,
|
||||
pY: pY + percentY,
|
||||
rX: rX + rotateX,
|
||||
rY: rY + rotateY,
|
||||
count: count + 1,
|
||||
}),
|
||||
{ pX: 0, pY: 0, rX: 0, rY: 0, count: 0 },
|
||||
);
|
||||
|
||||
export function reduceTilts(gameData: GameUpdate, localTilt: Tilt[]): Tilt[] {
|
||||
const remoteTilts = gameData.tilts;
|
||||
const tiltEnabled = gameData.settings.tilt;
|
||||
const remoteTiltEnabled = gameData.settings.remoteTilt;
|
||||
|
||||
if (!tiltEnabled) return [];
|
||||
if (!remoteTiltEnabled) return Array.from({ length: 5 }, (_, i) => localTilt[i]);
|
||||
|
||||
return Array.from({ length: 5 }, (_, i) => (localTilt[i] ? [localTilt[i]] : []))
|
||||
.map((cardTilts, cardIndex) => [...remoteTilts[cardIndex], ...cardTilts])
|
||||
.map((cardTilts) => cardTilts.filter(validTilt))
|
||||
.map(combineTilts)
|
||||
.map(({ pX, pY, rX, rY, count }) => ({
|
||||
percentX: count ? pX / count : -1,
|
||||
percentY: count ? pY / count : -1,
|
||||
rotateX: count ? rX / count : 0,
|
||||
rotateY: count ? rY / count : 0,
|
||||
}));
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import getRandomItems from '@/tools/getRandomItems';
|
||||
import { getRandomItems } from '@/tools';
|
||||
|
||||
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
const generateID = (length: number = 6) => {
|
||||
export const generateID = (length: number = 6) => {
|
||||
return getRandomItems(alphabet.split(''), length).join('');
|
||||
};
|
||||
|
||||
export default generateID;
|
||||
|
||||
12
tools/throttle.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function throttle(func: Function, threshold: number) {
|
||||
let lastCall = 0;
|
||||
|
||||
return (...args: any[]) => {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastCall >= threshold) {
|
||||
lastCall = now;
|
||||
func(...args);
|
||||
}
|
||||
};
|
||||
}
|
||||
4
tools/validTilt.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Tilt } from '@/types';
|
||||
|
||||
export const validTilt = ({ percentX, percentY, rotateX, rotateY }: Tilt) =>
|
||||
percentX >= 0 && percentY >= 0 && !!rotateX && !!rotateY;
|
||||
@@ -1,11 +1,21 @@
|
||||
export type CardStyle = 'standard' | 'color' | 'grayscale';
|
||||
|
||||
// all = both + back
|
||||
export type Deck = 'high' | 'common' | 'both' | 'back' | 'all';
|
||||
|
||||
export interface Settings {
|
||||
cardStyle: CardStyle;
|
||||
notes: boolean;
|
||||
positionBack: boolean;
|
||||
positionFront: boolean;
|
||||
prophecy: boolean;
|
||||
notes: boolean;
|
||||
cardStyle: CardStyle;
|
||||
tilt: boolean;
|
||||
remoteTilt: boolean;
|
||||
}
|
||||
|
||||
export interface LocalSettings {
|
||||
tilt: boolean;
|
||||
remoteTilt: boolean;
|
||||
}
|
||||
|
||||
export interface StandardCard {
|
||||
@@ -28,7 +38,9 @@ export interface TarokkaBase {
|
||||
description: string;
|
||||
aria: string;
|
||||
back: boolean;
|
||||
deck: Deck;
|
||||
suit: 'Coins' | 'Glyphs' | 'High Deck' | 'Stars' | 'Swords' | null;
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export interface TarokkaGameBase extends TarokkaBase {
|
||||
@@ -77,6 +89,7 @@ export interface GameState {
|
||||
cards: TarokkaGameCard[];
|
||||
lastUpdated: number;
|
||||
settings: Settings;
|
||||
tilts: Tilt[][];
|
||||
}
|
||||
|
||||
export interface GameUpdate {
|
||||
@@ -84,11 +97,13 @@ export interface GameUpdate {
|
||||
spectatorID: string;
|
||||
cards: TarokkaGameCard[];
|
||||
settings: Settings;
|
||||
tilts: Tilt[][];
|
||||
}
|
||||
|
||||
export interface ClientUpdate {
|
||||
gameID: string;
|
||||
cardIndex: number;
|
||||
cardID?: string;
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
@@ -97,3 +112,11 @@ export interface Layout {
|
||||
name: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Tilt {
|
||||
playerID?: string;
|
||||
percentX: number;
|
||||
percentY: number;
|
||||
rotateX: number;
|
||||
rotateY: number;
|
||||
}
|
||||
|
||||