Compare commits
17 Commits
jazz-react
...
briscola
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cba7e20c0a | ||
|
|
d4c45fe2bd | ||
|
|
3a45c18cf5 | ||
|
|
79e3611897 | ||
|
|
9c50120110 | ||
|
|
447c7ace39 | ||
|
|
ffa53f2ab6 | ||
|
|
81bf8785cc | ||
|
|
304cb1bd15 | ||
|
|
eb3063b855 | ||
|
|
f0db269b0d | ||
|
|
48a8bd2c92 | ||
|
|
ff96a490f4 | ||
|
|
7834f8a672 | ||
|
|
969d3d5026 | ||
|
|
98cc5e1294 | ||
|
|
269673c15b |
@@ -12,7 +12,8 @@
|
||||
"**/ios/**",
|
||||
"**/android/**",
|
||||
"packages/jazz-svelte/**",
|
||||
"examples/*svelte*/**"
|
||||
"examples/*svelte*/**",
|
||||
"examples/briscola/src/routeTree.gen.ts"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
|
||||
2
examples/briscola/.env.example
Normal file
2
examples/briscola/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_JAZZ_WORKER_ACCOUNT=
|
||||
JAZZ_WORKER_SECRET=
|
||||
21
examples/briscola/.gitignore
vendored
Normal file
21
examples/briscola/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Local
|
||||
.DS_Store
|
||||
*.local
|
||||
*.log*
|
||||
|
||||
# Dist
|
||||
node_modules
|
||||
dist/
|
||||
.vinxi
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
|
||||
.env
|
||||
38
examples/briscola/README.md
Normal file
38
examples/briscola/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Jazz Briscola
|
||||
|
||||
This is a simplified implementation of the Italian card game [Briscola](https://en.wikipedia.org/wiki/Briscola), written using Jazz.
|
||||
|
||||
While most Jazz apps don't need workers, in this game players must not be able to see each other's cards. This is a good example of when a worker is useful. In this case, the worker acts as a dealer, revealing the cards to each player as needed.
|
||||
|
||||
In general this showcases how workers can be used to moderate access to coValues.
|
||||
|
||||
The communication between the dealer and the players is done using the [Inbox API](#), which is an abstraction over the Jazz API that allows for easy communication between workers and clients.
|
||||
|
||||
## Setup
|
||||
|
||||
First of we need to create a new account for the dealer:
|
||||
|
||||
```bash
|
||||
pnpx jazz-run account create --name "Dealer"
|
||||
```
|
||||
|
||||
This will print an account ID and a secret key:
|
||||
|
||||
```
|
||||
# Credentials for Jazz account "Dealer":
|
||||
JAZZ_WORKER_ACCOUNT=co_xxxx
|
||||
JAZZ_WORKER_SECRET=sealerSecret_xxx
|
||||
```
|
||||
use these to create a `.env` file based on the `.env.example` file and fill in the `VITE_JAZZ_WORKER_ACCOUNT` and `JAZZ_WORKER_SECRET` fields.
|
||||
|
||||
We can then start the dealer worker:
|
||||
|
||||
```bash
|
||||
pnpm dev:worker
|
||||
```
|
||||
|
||||
and the client:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
21
examples/briscola/components.json
Normal file
21
examples/briscola/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
12
examples/briscola/index.html
Normal file
12
examples/briscola/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="bg-green-800">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Jazz Briscola</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="min-h-screen"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
47
examples/briscola/package.json
Normal file
47
examples/briscola/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "jazz-example-briscola",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"dev": "vite --port=3001",
|
||||
"dev:worker": "tsx --env-file=.env ./src/worker.ts",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"start": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/router-plugin": "^1.87.13",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.27",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^5.4.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@tanstack/react-router": "^1.87.12",
|
||||
"@tanstack/router-devtools": "^1.87.12",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.0.0",
|
||||
"jazz-nodejs": "workspace:*",
|
||||
"jazz-react": "workspace:*",
|
||||
"jazz-tools": "workspace:*",
|
||||
"lucide-react": "^0.468.0",
|
||||
"motion": "^11.14.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tsx": "^4.19.2"
|
||||
}
|
||||
}
|
||||
6
examples/briscola/postcss.config.js
Normal file
6
examples/briscola/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
119
examples/briscola/src/components/how-to-play-content.tsx
Normal file
119
examples/briscola/src/components/how-to-play-content.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
export function HowToPlayContent() {
|
||||
return (
|
||||
<div className="">
|
||||
<h3 className="font-semibold text-lg">Objective:</h3>
|
||||
<p className="mt-2">
|
||||
The goal is to score the most points by winning tricks containing
|
||||
high-value cards. The game is played until all card are played.
|
||||
</p>
|
||||
|
||||
<h3 className="font-semibold text-lg mt-6">The deck</h3>
|
||||
<p className="mt-2">
|
||||
A deck with 40 cards is used, split into four suits:
|
||||
<ul className="list-disc list-inside">
|
||||
<li className="list-item">Coins (Denari)</li>
|
||||
<li className="list-item">Cups (Coppe)</li>
|
||||
<li className="list-item">Swords (Spade)</li>
|
||||
<li className="list-item">Clubs (Bastoni)</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p className="mt-2">Each suit has cards numbered from 1 to 10.</p>
|
||||
|
||||
<h3 className="font-semibold text-lg mt-6">Card values</h3>
|
||||
<p className="mt-2">
|
||||
Each card has a point value:
|
||||
<ul className="list-disc list-inside">
|
||||
<li className="list-item">Ace (1): 11 points</li>
|
||||
<li className="list-item">Three (3): 10 points</li>
|
||||
<li className="list-item">Eight (8): 2 points</li>
|
||||
<li className="list-item">Nine (9): 3 points</li>
|
||||
<li className="list-item">Ten (10): 4 points</li>
|
||||
<li className="list-item">All others (2, 4-7): 0 points</li>
|
||||
</ul>
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
There are <span className="font-semibold">120 total points</span> in the
|
||||
deck.
|
||||
</p>
|
||||
|
||||
<h3 className="font-semibold text-lg mt-6">Gameplay</h3>
|
||||
<p className="mt-2">
|
||||
<ol className="list-inside list-decimal">
|
||||
<li>
|
||||
<span className="font-semibold">Starting the game:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
3 cards are dealt to each player.
|
||||
</li>
|
||||
<li className="list-item ml-4">
|
||||
1 card is placed face-up on the table, on the bottom of the draw
|
||||
pile, indicating the trump suit. (Briscola)
|
||||
</li>
|
||||
<li className="list-item ml-4">
|
||||
One player is randomly chosen to start the game.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span className="font-semibold">Starting a trick:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
Players play one card each in turn, trying to win the trick.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span className="font-semibold">Winning a trick:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
The highest card of the trump suit wins the trick.
|
||||
</li>
|
||||
<li className="list-item ml-4">
|
||||
If no trump cards are played, the highest card of the leading
|
||||
suit wins.
|
||||
</li>
|
||||
<li className="list-item ml-4">
|
||||
The leading suit is the suit of the first card played in the
|
||||
current trick.
|
||||
</li>
|
||||
<li className="list-item ml-4">
|
||||
The winner collects the cards, which are placed face-up in their
|
||||
scoring pile.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span className="font-semibold">Drawing cards:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
After each trick, a new card is dealt to each player (starting
|
||||
with the trick winner).
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span className="font-semibold">Continuing play:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
The winner of the previous trick leads the next round.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<span className="font-semibold">End of the game:</span>
|
||||
<ul className="list-inside list-disc">
|
||||
<li className="list-item ml-4">
|
||||
Play continues until all cards are played.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
examples/briscola/src/components/playing-card.tsx
Normal file
75
examples/briscola/src/components/playing-card.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Card, Suit } from "@/schema";
|
||||
import type { co } from "jazz-tools";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import bastoni from "../img/bastoni.svg?url";
|
||||
import coppe from "../img/coppe.svg?url";
|
||||
import denari from "../img/denari.svg?url";
|
||||
import spade from "../img/spade.svg?url";
|
||||
|
||||
interface Props {
|
||||
card: co<Card>;
|
||||
faceDown?: boolean;
|
||||
className?: string;
|
||||
layoutId?: string;
|
||||
}
|
||||
export function PlayingCard({
|
||||
card,
|
||||
className,
|
||||
faceDown = false,
|
||||
layoutId,
|
||||
}: Props) {
|
||||
const cardImage = getCardImage(card.data?.suit!);
|
||||
if (!faceDown && card.data?.value === undefined && card.data?.suit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"block aspect-card w-[150px] bg-white touch-none rounded-lg shadow-lg p-2 border",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...(faceDown && {
|
||||
backgroundImage: `url(https://placecats.com/150/243)`,
|
||||
backgroundSize: "cover",
|
||||
}),
|
||||
}}
|
||||
layoutId={layoutId}
|
||||
>
|
||||
<div className="border-zinc-400 border rounded-lg h-full px-1 flex flex-col ">
|
||||
{!faceDown && (
|
||||
<>
|
||||
<div className="text-4xl font-bold text-black self-start">
|
||||
{card.data?.value}
|
||||
</div>
|
||||
<div className="grow flex justify-center items-center">
|
||||
<img
|
||||
src={cardImage}
|
||||
className="pointer-events-none max-h-[140px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-black rotate-180 transform self-end">
|
||||
{card.data?.value}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function getCardImage(suit: typeof Suit) {
|
||||
switch (suit) {
|
||||
case "C":
|
||||
return coppe;
|
||||
case "D":
|
||||
return denari;
|
||||
case "S":
|
||||
return spade;
|
||||
case "B":
|
||||
return bastoni;
|
||||
}
|
||||
}
|
||||
82
examples/briscola/src/components/ui/button.tsx
Normal file
82
examples/briscola/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
loading?: boolean;
|
||||
loadingText?: string;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
disabled,
|
||||
asChild = false,
|
||||
loading = false,
|
||||
loadingText,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
disabled={loading || disabled}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
{loadingText}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Comp>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
86
examples/briscola/src/components/ui/card.tsx
Normal file
86
examples/briscola/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
120
examples/briscola/src/components/ui/dialog.tsx
Normal file
120
examples/briscola/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
22
examples/briscola/src/components/ui/input.tsx
Normal file
22
examples/briscola/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
24
examples/briscola/src/components/ui/label.tsx
Normal file
24
examples/briscola/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
29
examples/briscola/src/components/ui/separator.tsx
Normal file
29
examples/briscola/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
1
examples/briscola/src/constants.ts
Normal file
1
examples/briscola/src/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const WORKER_ID = import.meta.env.VITE_JAZZ_WORKER_ACCOUNT;
|
||||
7
examples/briscola/src/img/bastoni.svg
Normal file
7
examples/briscola/src/img/bastoni.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 66 KiB |
144
examples/briscola/src/img/coppe.svg
Normal file
144
examples/briscola/src/img/coppe.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 109 KiB |
95
examples/briscola/src/img/denari.svg
Normal file
95
examples/briscola/src/img/denari.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 22 KiB |
86
examples/briscola/src/img/spade.svg
Normal file
86
examples/briscola/src/img/spade.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 69 KiB |
66
examples/briscola/src/index.css
Normal file
66
examples/briscola/src/index.css
Normal file
@@ -0,0 +1,66 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply text-foreground;
|
||||
}
|
||||
}
|
||||
25
examples/briscola/src/jazz.tsx
Normal file
25
examples/briscola/src/jazz.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { DemoAuthBasicUI, createJazzReactApp, useDemoAuth } from "jazz-react";
|
||||
|
||||
const Jazz = createJazzReactApp();
|
||||
export const {
|
||||
useAccount,
|
||||
useCoState,
|
||||
experimental: { useInboxSender },
|
||||
} = Jazz;
|
||||
|
||||
export function JazzAndAuth({ children }: { children: React.ReactNode }) {
|
||||
const [auth, authState] = useDemoAuth();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Jazz.Provider
|
||||
auth={auth}
|
||||
// replace `you@example.com` with your email as a temporary API key
|
||||
peer="wss://cloud.jazz.tools/?key=you@example.com"
|
||||
>
|
||||
{children}
|
||||
</Jazz.Provider>
|
||||
<DemoAuthBasicUI appName="Briscola" state={authState} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
6
examples/briscola/src/lib/utils.ts
Normal file
6
examples/briscola/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
27
examples/briscola/src/main.tsx
Normal file
27
examples/briscola/src/main.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { JazzAndAuth, useAccount } from "./jazz";
|
||||
import { router } from "./router";
|
||||
|
||||
import "./index.css";
|
||||
|
||||
const rootElement = document.getElementById("app")!;
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = createRoot(rootElement);
|
||||
root.render(<App />);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<JazzAndAuth>
|
||||
<Router />
|
||||
</JazzAndAuth>
|
||||
);
|
||||
}
|
||||
|
||||
function Router() {
|
||||
const { me } = useAccount();
|
||||
|
||||
return <RouterProvider router={router} context={{ me }} />;
|
||||
}
|
||||
207
examples/briscola/src/routeTree.gen.ts
Normal file
207
examples/briscola/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as HowToPlayImport } from './routes/how-to-play'
|
||||
import { Route as GameImport } from './routes/game'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as WaitingRoomWaitingRoomIdImport } from './routes/waiting-room.$waitingRoomId'
|
||||
import { Route as GameGameIdImport } from './routes/game/$gameId'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const HowToPlayRoute = HowToPlayImport.update({
|
||||
id: '/how-to-play',
|
||||
path: '/how-to-play',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const GameRoute = GameImport.update({
|
||||
id: '/game',
|
||||
path: '/game',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const WaitingRoomWaitingRoomIdRoute = WaitingRoomWaitingRoomIdImport.update({
|
||||
id: '/waiting-room/$waitingRoomId',
|
||||
path: '/waiting-room/$waitingRoomId',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const GameGameIdRoute = GameGameIdImport.update({
|
||||
id: '/$gameId',
|
||||
path: '/$gameId',
|
||||
getParentRoute: () => GameRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/game': {
|
||||
id: '/game'
|
||||
path: '/game'
|
||||
fullPath: '/game'
|
||||
preLoaderRoute: typeof GameImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/how-to-play': {
|
||||
id: '/how-to-play'
|
||||
path: '/how-to-play'
|
||||
fullPath: '/how-to-play'
|
||||
preLoaderRoute: typeof HowToPlayImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/game/$gameId': {
|
||||
id: '/game/$gameId'
|
||||
path: '/$gameId'
|
||||
fullPath: '/game/$gameId'
|
||||
preLoaderRoute: typeof GameGameIdImport
|
||||
parentRoute: typeof GameImport
|
||||
}
|
||||
'/waiting-room/$waitingRoomId': {
|
||||
id: '/waiting-room/$waitingRoomId'
|
||||
path: '/waiting-room/$waitingRoomId'
|
||||
fullPath: '/waiting-room/$waitingRoomId'
|
||||
preLoaderRoute: typeof WaitingRoomWaitingRoomIdImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
interface GameRouteChildren {
|
||||
GameGameIdRoute: typeof GameGameIdRoute
|
||||
}
|
||||
|
||||
const GameRouteChildren: GameRouteChildren = {
|
||||
GameGameIdRoute: GameGameIdRoute,
|
||||
}
|
||||
|
||||
const GameRouteWithChildren = GameRoute._addFileChildren(GameRouteChildren)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/game': typeof GameRouteWithChildren
|
||||
'/how-to-play': typeof HowToPlayRoute
|
||||
'/game/$gameId': typeof GameGameIdRoute
|
||||
'/waiting-room/$waitingRoomId': typeof WaitingRoomWaitingRoomIdRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/game': typeof GameRouteWithChildren
|
||||
'/how-to-play': typeof HowToPlayRoute
|
||||
'/game/$gameId': typeof GameGameIdRoute
|
||||
'/waiting-room/$waitingRoomId': typeof WaitingRoomWaitingRoomIdRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/game': typeof GameRouteWithChildren
|
||||
'/how-to-play': typeof HowToPlayRoute
|
||||
'/game/$gameId': typeof GameGameIdRoute
|
||||
'/waiting-room/$waitingRoomId': typeof WaitingRoomWaitingRoomIdRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/game'
|
||||
| '/how-to-play'
|
||||
| '/game/$gameId'
|
||||
| '/waiting-room/$waitingRoomId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/game'
|
||||
| '/how-to-play'
|
||||
| '/game/$gameId'
|
||||
| '/waiting-room/$waitingRoomId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/game'
|
||||
| '/how-to-play'
|
||||
| '/game/$gameId'
|
||||
| '/waiting-room/$waitingRoomId'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
GameRoute: typeof GameRouteWithChildren
|
||||
HowToPlayRoute: typeof HowToPlayRoute
|
||||
WaitingRoomWaitingRoomIdRoute: typeof WaitingRoomWaitingRoomIdRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
GameRoute: GameRouteWithChildren,
|
||||
HowToPlayRoute: HowToPlayRoute,
|
||||
WaitingRoomWaitingRoomIdRoute: WaitingRoomWaitingRoomIdRoute,
|
||||
}
|
||||
|
||||
export const routeTree = rootRoute
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
/* ROUTE_MANIFEST_START
|
||||
{
|
||||
"routes": {
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/game",
|
||||
"/how-to-play",
|
||||
"/waiting-room/$waitingRoomId"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/game": {
|
||||
"filePath": "game.tsx",
|
||||
"children": [
|
||||
"/game/$gameId"
|
||||
]
|
||||
},
|
||||
"/how-to-play": {
|
||||
"filePath": "how-to-play.tsx"
|
||||
},
|
||||
"/game/$gameId": {
|
||||
"filePath": "game/$gameId.tsx",
|
||||
"parent": "/game"
|
||||
},
|
||||
"/waiting-room/$waitingRoomId": {
|
||||
"filePath": "waiting-room.$waitingRoomId.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
ROUTE_MANIFEST_END */
|
||||
18
examples/briscola/src/router.tsx
Normal file
18
examples/briscola/src/router.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createRouter } from "@tanstack/react-router";
|
||||
|
||||
import { JazzAndAuth } from "./jazz";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
me: undefined!,
|
||||
},
|
||||
Wrap: JazzAndAuth,
|
||||
});
|
||||
20
examples/briscola/src/routes/__root.tsx
Normal file
20
examples/briscola/src/routes/__root.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { useAccount } from "@/jazz";
|
||||
import { Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
||||
|
||||
interface RouterContext {
|
||||
me: ReturnType<typeof useAccount>["me"];
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
examples/briscola/src/routes/game.tsx
Normal file
11
examples/briscola/src/routes/game.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/game")({
|
||||
beforeLoad: async ({ context: { me } }) => {
|
||||
if (!me) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
317
examples/briscola/src/routes/game/$gameId.tsx
Normal file
317
examples/briscola/src/routes/game/$gameId.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type Card, Game, PlayIntent, type Player } from "@/schema";
|
||||
import * as RadioGroup from "@radix-ui/react-radio-group";
|
||||
import { createFileRoute, notFound, redirect } from "@tanstack/react-router";
|
||||
import { Group, type ID } from "jazz-tools";
|
||||
import { AnimatePresence, LayoutGroup, Reorder, motion } from "motion/react";
|
||||
import type { FormEventHandler, ReactNode } from "react";
|
||||
|
||||
import { HowToPlayContent } from "@/components/how-to-play-content";
|
||||
import { PlayingCard } from "@/components/playing-card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { WORKER_ID } from "@/constants";
|
||||
import { useCoState, useInboxSender } from "@/jazz";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/game/$gameId")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ params: { gameId }, context: { me } }) => {
|
||||
// !FIXME: this is useless, the layout takes care of this
|
||||
if (!me) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
});
|
||||
}
|
||||
const game = await Game.load(gameId as ID<Game>, me, {});
|
||||
|
||||
if (!game) {
|
||||
throw notFound();
|
||||
}
|
||||
},
|
||||
// TODO: better loading screen
|
||||
pendingComponent: () => <div>...</div>,
|
||||
// TODO: better not found page
|
||||
notFoundComponent: () => <div>Game not found</div>,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { gameId } = Route.useParams();
|
||||
const playCard = useInboxSender(WORKER_ID);
|
||||
|
||||
const game = useCoState(Game, gameId as ID<Game>, {
|
||||
// TODO: load intent only for current user
|
||||
deck: [{}],
|
||||
playedCard: { data: {} },
|
||||
player1: {
|
||||
hand: [{ data: {}, meta: {} }],
|
||||
scoredCards: [{ data: {} }],
|
||||
account: {},
|
||||
},
|
||||
player2: {
|
||||
hand: [{ data: {}, meta: {} }],
|
||||
scoredCards: [{ data: {} }],
|
||||
account: {},
|
||||
},
|
||||
activePlayer: { account: {} },
|
||||
});
|
||||
|
||||
// TODO: loading
|
||||
if (!game) return null;
|
||||
|
||||
const [opponent, me] = game.player1.account.isMe
|
||||
? [game.player2, game.player1]
|
||||
: [game.player1, game.player2];
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!game.activePlayer.account.isMe) {
|
||||
alert("not your turn!");
|
||||
return;
|
||||
}
|
||||
|
||||
const playedCard = new FormData(e.target as HTMLFormElement).get(
|
||||
"play",
|
||||
) as string;
|
||||
|
||||
const playedCardValue: number = Number.parseInt(playedCard?.slice(1));
|
||||
const playedCardSuit: string = playedCard?.[0];
|
||||
|
||||
const pc = me.hand.find(
|
||||
(card) =>
|
||||
card.data?.value === playedCardValue &&
|
||||
card.data.suit === playedCardSuit,
|
||||
);
|
||||
if (!pc) {
|
||||
return;
|
||||
}
|
||||
|
||||
playCard(
|
||||
PlayIntent.create(
|
||||
{ type: "play", card: pc, game },
|
||||
{ owner: Group.create({ owner: me.account }) },
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<div className="flex flex-col h-screen p-2 ">
|
||||
<PlayerArea player={opponent}>
|
||||
<ul className="flex gap-2 flex-row-reverse place-content-center ">
|
||||
<AnimatePresence>
|
||||
{opponent.hand.getSorted().map((card) => {
|
||||
if (!card) return null;
|
||||
|
||||
return (
|
||||
<motion.li key={card?.id} layout>
|
||||
<PlayingCard card={card} faceDown layoutId={card?.id} />
|
||||
</motion.li>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</ul>
|
||||
</PlayerArea>
|
||||
|
||||
<div className="container mx-auto grow items-center justify-center grid grid-cols-2">
|
||||
<div className="relative flex justify-center items-center">
|
||||
{game.deck[0] && (
|
||||
<PlayingCard
|
||||
className="rotate-[88deg] left-1/2 absolute"
|
||||
card={game.deck[0]}
|
||||
layoutId={`${game.deck[0]?.id}`}
|
||||
/>
|
||||
)}
|
||||
<CardStack cards={game.deck.slice(1)} faceDown />
|
||||
</div>
|
||||
|
||||
<div className="relative h-full items-center flex justify-center">
|
||||
<AnimatePresence>
|
||||
{game.playedCard && (
|
||||
<motion.div
|
||||
className="absolute"
|
||||
key={game.playedCard.id}
|
||||
animate={{
|
||||
rotate: 0,
|
||||
}}
|
||||
>
|
||||
<PlayingCard
|
||||
card={game.playedCard}
|
||||
layoutId={`${game.playedCard?.id}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<PlayerArea player={me}>
|
||||
<div className="">
|
||||
<RadioGroup.Root
|
||||
className="flex place-content-center"
|
||||
aria-label="Play card"
|
||||
orientation="horizontal"
|
||||
loop
|
||||
name="play"
|
||||
required
|
||||
asChild
|
||||
>
|
||||
<Reorder.Group
|
||||
axis="x"
|
||||
values={me.hand.getSorted()}
|
||||
onReorder={(cards) => {
|
||||
cards.forEach((card, i) => {
|
||||
if (!card?.meta) return;
|
||||
card.meta.index = i;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{me.hand
|
||||
.getSorted()
|
||||
.filter(
|
||||
(card) =>
|
||||
card?.data?.value !== undefined &&
|
||||
card.data.suit !== undefined,
|
||||
)
|
||||
.map((card, i, cards) => {
|
||||
if (!card) return null;
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
key={`${card?.data?.suit}${card?.data?.value}`}
|
||||
value={card}
|
||||
initial={{
|
||||
translateY: 800,
|
||||
}}
|
||||
animate={{
|
||||
rotate: i * 15 - (15 * (cards.length - 1)) / 2,
|
||||
translateY: 0,
|
||||
}}
|
||||
whileDrag={{ scale: 1.1 }}
|
||||
exit={{
|
||||
scale: 1.1,
|
||||
}}
|
||||
layout
|
||||
layoutId={`${card?.id}`}
|
||||
>
|
||||
<RadioGroup.Item
|
||||
value={`${card?.data?.suit}${card?.data?.value}`}
|
||||
className="relative data-[state=checked]:translate-y-[-10px] data-[state=checked]:scale-110 data-[state=unchecked]:translate-y-0 transition-transform"
|
||||
asChild
|
||||
>
|
||||
<motion.button>
|
||||
<PlayingCard card={card} />
|
||||
</motion.button>
|
||||
</RadioGroup.Item>
|
||||
</Reorder.Item>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</Reorder.Group>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</PlayerArea>
|
||||
</form>
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardStackProps {
|
||||
cards: Card[];
|
||||
className?: string;
|
||||
faceDown?: boolean;
|
||||
}
|
||||
|
||||
function CardStack({ cards, className, faceDown = false }: CardStackProps) {
|
||||
return (
|
||||
<div className={cn("relative w-[150px] h-[245px]", className)}>
|
||||
<AnimatePresence>
|
||||
{cards.map((card) => (
|
||||
<motion.div key={card?.id} className="absolute" animate layout>
|
||||
<PlayingCard card={card} faceDown={faceDown} layoutId={card?.id} />
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlayerAreaProps {
|
||||
player: Player;
|
||||
children: ReactNode;
|
||||
}
|
||||
function PlayerArea({ children, player }: PlayerAreaProps) {
|
||||
return (
|
||||
<div className={cn("flex", !player.account?.isMe && "flex-row-reverse")}>
|
||||
<div className="flex items-center w-1/3 justify-around flex-col">
|
||||
{player.account?.isMe && (
|
||||
<>
|
||||
<Button size="lg" type="submit">
|
||||
Play selected card
|
||||
</Button>
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button size="sm" type="button" variant="link">
|
||||
<InfoIcon />
|
||||
How to play?
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>How to play Briscola</DialogTitle>
|
||||
<DialogDescription>
|
||||
<HowToPlayContent />
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-1/3">{children}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-center flex-col items-center w-1/3 gap-2",
|
||||
!player.account?.isMe && "flex-col-reverse",
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">
|
||||
Score:{" "}
|
||||
<span className="font-semibold">
|
||||
{getScore(player.scoredCards?.map((c) => c!) ?? [])}
|
||||
</span>
|
||||
</span>
|
||||
<CardStack cards={player.scoredCards?.map((c) => c!) ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getScore(cards: Card[]) {
|
||||
return cards.reduce((acc, card) => {
|
||||
switch (card.data?.value) {
|
||||
case 3:
|
||||
return acc + 10;
|
||||
case 1:
|
||||
return acc + 11;
|
||||
case 10:
|
||||
return acc + 4;
|
||||
case 9:
|
||||
return acc + 3;
|
||||
case 8:
|
||||
return acc + 2;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
}
|
||||
38
examples/briscola/src/routes/how-to-play.tsx
Normal file
38
examples/briscola/src/routes/how-to-play.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { HowToPlayContent } from "@/components/how-to-play-content";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/how-to-play")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="flex flex-col w-full place-items-center justify-center p-2 min-h-screen">
|
||||
<Card className="max-w-screen-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>How to Play?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<HowToPlayContent />
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end">
|
||||
<Button asChild variant="link">
|
||||
<Link to="/">
|
||||
<ArrowLeftIcon className="w-5 h-5" />
|
||||
Play
|
||||
</Link>
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
examples/briscola/src/routes/index.tsx
Normal file
81
examples/briscola/src/routes/index.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { WORKER_ID } from "@/constants";
|
||||
import { useAccount, useInboxSender } from "@/jazz";
|
||||
import { CreateGameRequest } from "@/schema";
|
||||
import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { Group } from "jazz-tools";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: HomeComponent,
|
||||
});
|
||||
|
||||
function HomeComponent() {
|
||||
const createGame = useInboxSender(WORKER_ID);
|
||||
const { me } = useAccount();
|
||||
const navigate = useNavigate({ from: "/" });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onNewGameClick = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
const waitingRoomId = await createGame(
|
||||
CreateGameRequest.create(
|
||||
{
|
||||
type: "createGame",
|
||||
},
|
||||
{ owner: Group.create({ owner: me }) },
|
||||
),
|
||||
);
|
||||
|
||||
if (!waitingRoomId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate({ to: `/waiting-room/${waitingRoomId}` });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col w-full place-items-center justify-center p-2">
|
||||
<Card className="w-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle>Welcome to Jazz Briscola</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-center p-4">
|
||||
<Button
|
||||
onClick={onNewGameClick}
|
||||
loading={isLoading}
|
||||
loadingText="Creating game..."
|
||||
className="w-full"
|
||||
>
|
||||
New Game
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<Button variant="link" asChild>
|
||||
<Link to="/how-to-play">How to play?</Link>
|
||||
</Button>
|
||||
<Button variant="link" asChild className="text-muted-foreground">
|
||||
<Link
|
||||
href="https://en.wikipedia.org/wiki/Briscola"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Briscola on Wikipedia
|
||||
<ExternalLinkIcon className="w-4 h-4 ml-1" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
examples/briscola/src/routes/waiting-room.$waitingRoomId.tsx
Normal file
103
examples/briscola/src/routes/waiting-room.$waitingRoomId.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { WORKER_ID } from "@/constants";
|
||||
import { JoinGameRequest, WaitingRoom } from "@/schema";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { Group, type ID, InboxSender } from "jazz-tools";
|
||||
import { ClipboardCopyIcon, Loader2Icon } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export const Route = createFileRoute("/waiting-room/$waitingRoomId")({
|
||||
component: RouteComponent,
|
||||
loader: async ({ context: { me }, params: { waitingRoomId } }) => {
|
||||
if (!me) {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
const waitingRoom = await WaitingRoom.load(
|
||||
waitingRoomId as ID<WaitingRoom>,
|
||||
me,
|
||||
{ account1: {}, account2: {}, game: {} },
|
||||
);
|
||||
|
||||
if (!waitingRoom) {
|
||||
throw redirect({ to: "/" });
|
||||
}
|
||||
|
||||
// If the waiting room already has a game, redirect to the game
|
||||
if (waitingRoom?.game) {
|
||||
throw redirect({ to: `/game/${waitingRoom.game.id}` });
|
||||
}
|
||||
|
||||
if (!waitingRoom?.account1?.isMe) {
|
||||
const sender = await InboxSender.load<JoinGameRequest, WaitingRoom>(
|
||||
WORKER_ID,
|
||||
me,
|
||||
);
|
||||
sender.sendMessage(
|
||||
JoinGameRequest.create(
|
||||
{ type: "joinGame", waitingRoom },
|
||||
{ owner: Group.create({ owner: me }) },
|
||||
),
|
||||
);
|
||||
}
|
||||
return { waitingRoom };
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { waitingRoom } = Route.useLoaderData();
|
||||
const navigate = Route.useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (!waitingRoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
return waitingRoom?.subscribe({ game: {} }, () => {
|
||||
if (waitingRoom.game) {
|
||||
navigate({ to: `/game/${waitingRoom.game.id}` });
|
||||
}
|
||||
});
|
||||
}, [waitingRoom]);
|
||||
|
||||
const onCopyClick = () => {
|
||||
navigator.clipboard.writeText(window.location.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col w-full place-items-center justify-center p-2">
|
||||
<Card className="w-[500px]">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Loader2Icon className="animate-spin inline h-8 w-8 mr-2" />
|
||||
Waiting for opponent to join the game
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Share this link with your friend to join the game. The game will
|
||||
automatically start once they join.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex">
|
||||
<Input
|
||||
className="w-full border bg-muted rounded-e-none"
|
||||
readOnly
|
||||
value={`${window.location}`}
|
||||
/>
|
||||
<Button onClick={onCopyClick} className="rounded-s-none">
|
||||
<ClipboardCopyIcon className="w-5 h-5" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
examples/briscola/src/schema.ts
Normal file
111
examples/briscola/src/schema.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Account, CoList, CoMap, SchemaUnion, co } from "jazz-tools";
|
||||
|
||||
export const CardValues = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const;
|
||||
|
||||
export const CardValue = co.literal(...CardValues);
|
||||
|
||||
export const Suits = ["S", "B", "C", "D"] as const;
|
||||
|
||||
export const Suit = co.literal(...Suits);
|
||||
|
||||
export class CardMeta extends CoMap {
|
||||
index = co.optional.number;
|
||||
}
|
||||
export class CardData extends CoMap {
|
||||
value = CardValue;
|
||||
suit = Suit;
|
||||
}
|
||||
|
||||
export class Card extends CoMap {
|
||||
data = co.ref(CardData);
|
||||
meta = co.optional.ref(CardMeta);
|
||||
}
|
||||
|
||||
export class CardList extends CoList.Of(co.ref(Card)) {
|
||||
getSorted() {
|
||||
return this.toSorted(
|
||||
(a, b) => (a?.meta?.index ?? 0) - (b?.meta?.index ?? 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Player extends CoMap {
|
||||
account = co.ref(Account);
|
||||
hand = co.ref(CardList); // write Tavolo - read me - quando il Game mi da le carte le scrive qui, quando valida la giocata la toglie da qui
|
||||
scoredCards = co.ref(CardList); // write Tavolo - read everyone -
|
||||
}
|
||||
|
||||
export class Game extends CoMap {
|
||||
deck = co.ref(CardList);
|
||||
|
||||
briscola = co.literal(...Suits);
|
||||
/**
|
||||
* The card that was played in the current turn.
|
||||
*/
|
||||
playedCard = co.optional.ref(Card);
|
||||
|
||||
activePlayer = co.ref(Player);
|
||||
player1 = co.ref(Player);
|
||||
player2 = co.ref(Player);
|
||||
|
||||
/**
|
||||
* Given a player, returns the opponent in the current game.
|
||||
*/
|
||||
getOpponent(player: Player) {
|
||||
// TODO: player may be unrelated to this game
|
||||
const opponent =
|
||||
player.account?.id === this.player1?.account?.id
|
||||
? this.player2
|
||||
: this.player1;
|
||||
|
||||
if (!opponent) {
|
||||
throw new Error("Opponent not found");
|
||||
}
|
||||
|
||||
return opponent.ensureLoaded({
|
||||
account: {},
|
||||
hand: [{}],
|
||||
scoredCards: [{}],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class GameList extends CoList.Of(co.ref(Game)) {}
|
||||
|
||||
export class WaitingRoom extends CoMap {
|
||||
account1 = co.ref(Account);
|
||||
account2 = co.optional.ref(Account);
|
||||
game = co.optional.ref(Game);
|
||||
}
|
||||
|
||||
class BaseInboxMessage extends CoMap {
|
||||
type = co.literal("play", "createGame", "joinGame");
|
||||
}
|
||||
|
||||
export class PlayIntent extends BaseInboxMessage {
|
||||
type = co.literal("play");
|
||||
card = co.ref(Card);
|
||||
game = co.ref(Game);
|
||||
}
|
||||
|
||||
export class CreateGameRequest extends BaseInboxMessage {
|
||||
type = co.literal("createGame");
|
||||
}
|
||||
|
||||
export class JoinGameRequest extends BaseInboxMessage {
|
||||
type = co.literal("joinGame");
|
||||
waitingRoom = co.ref(WaitingRoom);
|
||||
}
|
||||
|
||||
export const InboxMessage = SchemaUnion.Of<BaseInboxMessage>((raw) => {
|
||||
switch (raw.get("type")) {
|
||||
case "play":
|
||||
return PlayIntent;
|
||||
case "createGame":
|
||||
return CreateGameRequest;
|
||||
case "joinGame":
|
||||
return JoinGameRequest;
|
||||
default:
|
||||
throw new Error("Unknown request type");
|
||||
}
|
||||
});
|
||||
1
examples/briscola/src/vite-env.d.ts
vendored
Normal file
1
examples/briscola/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
331
examples/briscola/src/worker.ts
Normal file
331
examples/briscola/src/worker.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
Card,
|
||||
CardData,
|
||||
CardList,
|
||||
CardMeta,
|
||||
CardValues,
|
||||
Game,
|
||||
InboxMessage,
|
||||
JoinGameRequest,
|
||||
PlayIntent,
|
||||
Player,
|
||||
Suits,
|
||||
WaitingRoom,
|
||||
} from "@/schema";
|
||||
import { startWorker } from "jazz-nodejs";
|
||||
import { Account, Group, type ID } from "jazz-tools";
|
||||
|
||||
const {
|
||||
worker,
|
||||
experimental: { inbox },
|
||||
} = await startWorker({
|
||||
accountID: process.env.VITE_JAZZ_WORKER_ACCOUNT,
|
||||
syncServer: "wss://cloud.jazz.tools/?key=you@example.com",
|
||||
});
|
||||
|
||||
inbox.subscribe(
|
||||
InboxMessage,
|
||||
async (message, senderID) => {
|
||||
const playerAccount = await Account.load(senderID, worker, {});
|
||||
if (!playerAccount) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case "play":
|
||||
console.log("play message from", senderID);
|
||||
handlePlayIntent(senderID, message.castAs(PlayIntent));
|
||||
break;
|
||||
case "createGame":
|
||||
console.log("create game message from", senderID);
|
||||
|
||||
const waitingRoomGroup = Group.create({ owner: worker });
|
||||
waitingRoomGroup.addMember("everyone", "reader");
|
||||
const waitingRoom = WaitingRoom.create(
|
||||
{ account1: playerAccount },
|
||||
{ owner: waitingRoomGroup },
|
||||
);
|
||||
|
||||
console.log("waiting room created with id:", waitingRoom.id);
|
||||
|
||||
return waitingRoom;
|
||||
case "joinGame":
|
||||
console.log("join game message from", senderID);
|
||||
const joinGameRequest = message.castAs(JoinGameRequest);
|
||||
if (
|
||||
!joinGameRequest.waitingRoom ||
|
||||
!joinGameRequest.waitingRoom.account1
|
||||
) {
|
||||
console.error("No waiting room in join game request");
|
||||
return;
|
||||
}
|
||||
joinGameRequest.waitingRoom.account2 = playerAccount;
|
||||
|
||||
const game = await createGame({
|
||||
account1: joinGameRequest.waitingRoom.account1,
|
||||
account2: joinGameRequest.waitingRoom.account2,
|
||||
});
|
||||
console.log("game created with id:", game.id);
|
||||
|
||||
joinGameRequest.waitingRoom.game = game;
|
||||
return joinGameRequest.waitingRoom;
|
||||
}
|
||||
},
|
||||
{ retries: 3 },
|
||||
);
|
||||
|
||||
interface CreateGameParams {
|
||||
account1: Account;
|
||||
account2: Account;
|
||||
}
|
||||
async function createGame({ account1, account2 }: CreateGameParams) {
|
||||
const publicReadOnly = Group.create({ owner: worker });
|
||||
publicReadOnly.addMember(account1, "reader");
|
||||
publicReadOnly.addMember(account2, "reader");
|
||||
|
||||
const player1 = createPlayer({ account: account1 });
|
||||
const player2 = createPlayer({ account: account2 });
|
||||
|
||||
const deck = createDeck({ publicReadOnlyGroup: publicReadOnly });
|
||||
|
||||
while (player1.hand && player1.hand?.length < 3) {
|
||||
await drawCard(player1, deck);
|
||||
}
|
||||
|
||||
while (player2.hand && player2.hand?.length < 3) {
|
||||
await drawCard(player2, deck);
|
||||
}
|
||||
|
||||
const game = Game.create(
|
||||
{
|
||||
deck,
|
||||
briscola: deck[0]?.data?.suit!,
|
||||
activePlayer: player1,
|
||||
player1: player1,
|
||||
player2: player2,
|
||||
},
|
||||
{ owner: publicReadOnly },
|
||||
);
|
||||
|
||||
await game.waitForSync();
|
||||
|
||||
return game;
|
||||
}
|
||||
|
||||
interface CreatePlayerParams {
|
||||
account: Account;
|
||||
}
|
||||
function createPlayer({ account }: CreatePlayerParams) {
|
||||
const publicRead = Group.create({ owner: worker });
|
||||
publicRead.addMember("everyone", "reader");
|
||||
|
||||
const player = Player.create(
|
||||
{
|
||||
scoredCards: CardList.create([], {
|
||||
owner: publicRead,
|
||||
}),
|
||||
account,
|
||||
hand: CardList.create([], { owner: publicRead }),
|
||||
},
|
||||
{ owner: publicRead },
|
||||
);
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
interface CreateDeckParams {
|
||||
publicReadOnlyGroup: Group;
|
||||
}
|
||||
function createDeck({ publicReadOnlyGroup }: CreateDeckParams) {
|
||||
const allCards = Suits.flatMap((suit) => {
|
||||
return CardValues.map((value) => {
|
||||
return { value, suit };
|
||||
});
|
||||
});
|
||||
shuffle(allCards);
|
||||
|
||||
const deck = CardList.create(
|
||||
allCards.map((card, i) => {
|
||||
const cardDataGroup = Group.create({ owner: worker });
|
||||
|
||||
return Card.create(
|
||||
{
|
||||
// The first card is the briscola, it should be visible by everyone,
|
||||
// so we make its owner the public read-only group.
|
||||
data: CardData.create(card, {
|
||||
owner: i === 0 ? publicReadOnlyGroup : cardDataGroup,
|
||||
}),
|
||||
},
|
||||
{ owner: publicReadOnlyGroup },
|
||||
);
|
||||
}),
|
||||
{ owner: publicReadOnlyGroup },
|
||||
);
|
||||
|
||||
return deck;
|
||||
}
|
||||
|
||||
async function drawCard(player: Player, deck: CardList) {
|
||||
const card = deck.pop();
|
||||
|
||||
const playerAccount = (await player.ensureLoaded({ account: {} }))?.account;
|
||||
|
||||
if (!playerAccount) {
|
||||
console.error("failed to load player account");
|
||||
return;
|
||||
}
|
||||
|
||||
if (card) {
|
||||
const metaGroup = Group.create({ owner: worker });
|
||||
metaGroup.addMember("everyone", "reader");
|
||||
metaGroup.addMember(playerAccount, "writer");
|
||||
// Create the card meta. This is visible to everyone.
|
||||
// It's used to sort the cards in the UI.
|
||||
card.meta = CardMeta.create({}, { owner: metaGroup });
|
||||
// Add the player to the card's data group so that
|
||||
// the player can read the card data.
|
||||
card.data?._owner.castAs(Group).addMember(playerAccount, "reader");
|
||||
player.hand?.push(card);
|
||||
}
|
||||
}
|
||||
|
||||
// Fisher–Yates shuffle
|
||||
function shuffle(array: unknown[]) {
|
||||
let currentIndex = array.length;
|
||||
|
||||
while (currentIndex) {
|
||||
const randomIndex = Math.floor(Math.random() * currentIndex);
|
||||
currentIndex--;
|
||||
|
||||
[array[currentIndex], array[randomIndex]] = [
|
||||
array[randomIndex],
|
||||
array[currentIndex],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function getCardValue(card: CardData) {
|
||||
switch (card.value) {
|
||||
case 1:
|
||||
return 20;
|
||||
case 3:
|
||||
return 15;
|
||||
default:
|
||||
return card.value;
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePlayIntent(senderId: ID<Account>, playIntent: PlayIntent) {
|
||||
const playedCard = await playIntent.ensureLoaded({ card: { data: {} } });
|
||||
if (!playedCard) {
|
||||
console.log("No card in play intent");
|
||||
return;
|
||||
}
|
||||
|
||||
const state = await playIntent.ensureLoaded({
|
||||
game: {
|
||||
playedCard: { data: {} },
|
||||
deck: [{ data: {} }],
|
||||
activePlayer: { account: {} },
|
||||
player1: { hand: [{ data: {} }], scoredCards: [{}] },
|
||||
player2: { hand: [{ data: {} }], scoredCards: [{}] },
|
||||
},
|
||||
});
|
||||
|
||||
if (!state?.game) {
|
||||
console.log("No game found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.game.activePlayer.account.id !== senderId) {
|
||||
console.log("Not player's turn");
|
||||
return;
|
||||
}
|
||||
|
||||
const publicReadOnly = state.game?.deck._owner.castAs(Group);
|
||||
|
||||
const player = state.game.activePlayer;
|
||||
const opponent = await state.game.getOpponent(player);
|
||||
|
||||
if (!opponent) {
|
||||
console.error("failed to get opponent");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"player",
|
||||
player.account?.id,
|
||||
"played",
|
||||
playedCard.card.data?.value,
|
||||
playedCard.card.data?.suit,
|
||||
);
|
||||
|
||||
const cardIndex =
|
||||
player.hand?.findIndex((card) => {
|
||||
return card?.id === playedCard.card.id;
|
||||
}) ?? -1;
|
||||
|
||||
if (cardIndex === -1) {
|
||||
console.log("Card not found in player's hand");
|
||||
return;
|
||||
}
|
||||
|
||||
// remove the card from player's hand
|
||||
player.hand?.splice(cardIndex, 1);
|
||||
|
||||
// make the newly played card's data visible to everyone by extending its group with
|
||||
// the public read-only group so that everyone can see the card.
|
||||
const group = await playedCard.card.data._owner
|
||||
.castAs(Group)
|
||||
.ensureLoaded({});
|
||||
group?.extend(publicReadOnly);
|
||||
|
||||
// If there's already a card on the table, it means both players have played.
|
||||
if (state.game.playedCard) {
|
||||
// Check who's this turn's winner
|
||||
let winner: Player;
|
||||
// If both cards have the same suit, the one with the highest value wins
|
||||
if (state.game.playedCard.data?.suit === playedCard.card.data?.suit) {
|
||||
winner =
|
||||
getCardValue(playedCard.card.data) >
|
||||
getCardValue(state.game.playedCard.data)
|
||||
? player
|
||||
: opponent;
|
||||
} else {
|
||||
// else the active player wins only if they played a briscola.
|
||||
// (we already know the other player didn't)
|
||||
if (playedCard.card.data.suit === state.game.briscola) {
|
||||
winner = player;
|
||||
} else {
|
||||
winner = opponent;
|
||||
}
|
||||
}
|
||||
|
||||
// Put the cards in the winner's scored cards pile.
|
||||
winner.scoredCards?.push(state.game.playedCard, playedCard.card);
|
||||
|
||||
// The winner of the round always draws first.
|
||||
if (state.game.deck.length > 0) {
|
||||
drawCard(winner, state.game.deck);
|
||||
|
||||
const opponent = await state.game.getOpponent(winner);
|
||||
if (!opponent) {
|
||||
console.error("failed to get opponent");
|
||||
return;
|
||||
}
|
||||
drawCard(opponent, state.game.deck);
|
||||
}
|
||||
|
||||
// @ts-expect-error types are wonky
|
||||
state.game.activePlayer = winner;
|
||||
// And finally, remove the played card from the table.
|
||||
delete state.game.playedCard;
|
||||
|
||||
// TODO: if there are no more cards in the deck and both players have played all their cards, end the game.
|
||||
} else {
|
||||
// else, just put the card on the table and switch the active player.
|
||||
state.game.playedCard = playedCard.card;
|
||||
|
||||
state.game.activePlayer = opponent;
|
||||
}
|
||||
}
|
||||
61
examples/briscola/tailwind.config.js
Normal file
61
examples/briscola/tailwind.config.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
colors: {
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
chart: {
|
||||
1: "hsl(var(--chart-1))",
|
||||
2: "hsl(var(--chart-2))",
|
||||
3: "hsl(var(--chart-3))",
|
||||
4: "hsl(var(--chart-4))",
|
||||
5: "hsl(var(--chart-5))",
|
||||
},
|
||||
},
|
||||
|
||||
aspectRatio: {
|
||||
card: "2584/4181",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
};
|
||||
14
examples/briscola/tsconfig.json
Normal file
14
examples/briscola/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
examples/briscola/vite.config.ts
Normal file
14
examples/briscola/vite.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { resolve } from "path";
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [TanStackRouterVite({}), react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
11034
pnpm-lock.yaml
generated
11034
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user