mirror of
https://github.com/ThisTine/Showdown.git
synced 2026-08-18 23:18:48 +07:00
first commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
@@ -0,0 +1,53 @@
|
||||
# Showdown frontend
|
||||
|
||||
Game-like scrum poker. React + TypeScript + Vite, with the
|
||||
[Motion](https://motion.dev) library for spring-physics animation.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
In dev there is no backend yet, so the app runs in **demo mode**: a local mock
|
||||
room where three western bots join, think, and vote — every animation and flow
|
||||
(deal-in, card flips, consensus confetti, deck editing) is exercisable solo.
|
||||
You'll see a `demo` chip in the top bar.
|
||||
|
||||
- `VITE_WS=1 npm run dev` — talk to a real backend on `localhost:8080`
|
||||
(the Vite proxy forwards `/ws`) instead of the mock.
|
||||
- `npm run build` — typecheck + production build to `dist/`. Production builds
|
||||
always use the real WebSocket at `/ws/room/{id}` on the same host.
|
||||
|
||||
## TV display
|
||||
|
||||
The 📺 button in a room pops up `/room/{id}/tv` — a view-only big-screen
|
||||
spectator display (cast it to a TV): giant room code and join URL, oversized
|
||||
seats and cards, no controls, plus a fullscreen toggle. It connects as a
|
||||
`watch` socket in production; in demo mode it mirrors your game tab over a
|
||||
BroadcastChannel (or runs a self-playing bot loop if no game tab is open).
|
||||
|
||||
## Mobile
|
||||
|
||||
On screens ≤700px the hand switches from the overlapping fan to a flat
|
||||
wrapped grid of full-size cards (~60–84px wide), so every card is a
|
||||
comfortable tap target.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/
|
||||
lib/connection.ts WsConnection (real, reconnecting) + MockConnection (bots)
|
||||
lib/session.ts localStorage profile + stable playerId + room id words
|
||||
lib/decks.ts presets + custom deck parsing
|
||||
lib/router.ts 30-line history router (/ and /room/:id)
|
||||
pages/Home.tsx create room: name, avatar, deck choice
|
||||
pages/Room.tsx the game: top bar, table, hand, deck modal, confetti
|
||||
components/ Table, PlayerSeat, Cards (face/back/flip), CardHand,
|
||||
Results, Confetti, ProfileForm, DeckModal, Logo
|
||||
index.css the whole "toy poker table" design system
|
||||
```
|
||||
|
||||
The wire protocol the mock implements is the same one the Rust backend in
|
||||
`../backend` speaks — see `../PROTOCOL.md`.
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b2e27" />
|
||||
<meta name="description" content="Showdown — scrum poker with friends. No sign-up, no database, all showdown." />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤠</text></svg>" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Lilita+One&family=Nunito:wght@600;700;800;900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<title>Showdown — Scrum Poker</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1775
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "showdown-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"motion": "^12.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { usePath } from "./lib/router";
|
||||
import { Home } from "./pages/Home";
|
||||
import { Room } from "./pages/Room";
|
||||
import { TvRoom } from "./pages/Tv";
|
||||
|
||||
export default function App() {
|
||||
const path = usePath();
|
||||
const roomMatch = path.match(/^\/room\/([\w-]+?)(\/tv)?\/?$/);
|
||||
return (
|
||||
<>
|
||||
<BgScene />
|
||||
{roomMatch ? (
|
||||
roomMatch[2] ? (
|
||||
<TvRoom key={roomMatch[1]} roomId={roomMatch[1]} />
|
||||
) : (
|
||||
<Room key={roomMatch[1]} roomId={roomMatch[1]} />
|
||||
)
|
||||
) : (
|
||||
<Home />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SUITS = [
|
||||
{ ch: "♠", x: 6, y: 16, s: 90, d: 7, delay: 0 },
|
||||
{ ch: "♥", x: 86, y: 12, s: 70, d: 9, delay: 1.2 },
|
||||
{ ch: "♦", x: 12, y: 74, s: 80, d: 8, delay: 0.6 },
|
||||
{ ch: "♣", x: 88, y: 70, s: 100, d: 10, delay: 2 },
|
||||
{ ch: "★", x: 48, y: 88, s: 60, d: 6.5, delay: 1.6 },
|
||||
{ ch: "♠", x: 70, y: 42, s: 50, d: 11, delay: 0.3 },
|
||||
{ ch: "♥", x: 26, y: 40, s: 45, d: 9.5, delay: 2.4 },
|
||||
];
|
||||
|
||||
function BgScene() {
|
||||
return (
|
||||
<div className="bg" aria-hidden>
|
||||
<div className="bg__spotlight" />
|
||||
{SUITS.map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="bg__suit"
|
||||
style={{
|
||||
left: `${s.x}%`,
|
||||
top: `${s.y}%`,
|
||||
fontSize: s.s,
|
||||
animationDuration: `${s.d}s`,
|
||||
animationDelay: `${s.delay}s`,
|
||||
}}
|
||||
>
|
||||
{s.ch}
|
||||
</span>
|
||||
))}
|
||||
<div className="bg__noise" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { CardFace } from "./Cards";
|
||||
|
||||
interface HandProps {
|
||||
deck: string[];
|
||||
myVote: string | null;
|
||||
locked: boolean;
|
||||
onPick(value: string): void;
|
||||
}
|
||||
|
||||
function useMediaQuery(query: string): boolean {
|
||||
const [match, setMatch] = useState(() => matchMedia(query).matches);
|
||||
useEffect(() => {
|
||||
const mq = matchMedia(query);
|
||||
const onChange = () => setMatch(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, [query]);
|
||||
return match;
|
||||
}
|
||||
|
||||
export function CardHand({ deck, myVote, locked, onPick }: HandProps) {
|
||||
// Desktop gets the overlapping fan; phones get a flat wrapped grid with
|
||||
// full-size tap targets (layout switch lives in the matching media query).
|
||||
const fan = useMediaQuery("(min-width: 701px)");
|
||||
const n = deck.length;
|
||||
return (
|
||||
<div className={`hand${locked ? " hand--locked" : ""}`}>
|
||||
<div className="hand__scroller">
|
||||
{deck.map((value, i) => {
|
||||
const rot = fan ? (i - (n - 1) / 2) * 3 : 0;
|
||||
const selected = value === myVote;
|
||||
return (
|
||||
<motion.div
|
||||
key={value}
|
||||
className="hand__slot"
|
||||
style={{ zIndex: selected ? 40 : i }}
|
||||
initial={{ y: 150, opacity: 0, rotate: fan ? 20 : 0 }}
|
||||
animate={{ y: 0, opacity: 1, rotate: 0 }}
|
||||
transition={{ delay: 0.25 + i * 0.045, type: "spring", stiffness: 300, damping: 24 }}
|
||||
>
|
||||
<motion.button
|
||||
type="button"
|
||||
className="hand__card"
|
||||
animate={{
|
||||
y: selected ? (fan ? -22 : -10) : 0,
|
||||
rotate: selected ? 0 : rot,
|
||||
scale: selected ? 1.08 : 1,
|
||||
}}
|
||||
whileHover={{ y: fan ? -14 : -6, scale: 1.05 }}
|
||||
whileTap={{ scale: 0.94, y: -6 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 28 }}
|
||||
onClick={() => onPick(value)}
|
||||
aria-pressed={selected}
|
||||
aria-label={`Vote ${value}`}
|
||||
>
|
||||
<CardFace value={value} size="lg" selected={selected} />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
export type CardSize = "lg" | "sm" | "xs";
|
||||
|
||||
export function CardFace({
|
||||
value,
|
||||
size,
|
||||
selected = false,
|
||||
}: {
|
||||
value: string;
|
||||
size: CardSize;
|
||||
selected?: boolean;
|
||||
}) {
|
||||
const long = [...value].length >= 3;
|
||||
return (
|
||||
<div className={`cardface cardface--${size}${selected ? " cardface--selected" : ""}`}>
|
||||
<span className="cardface__corner cardface__corner--tl">{value}</span>
|
||||
<span className="cardface__star" aria-hidden>
|
||||
★
|
||||
</span>
|
||||
<span className={`cardface__value${long ? " cardface__value--long" : ""}`}>{value}</span>
|
||||
<span className="cardface__corner cardface__corner--br">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardBack({ size }: { size: CardSize }) {
|
||||
return (
|
||||
<div className={`cardback cardback--${size}`}>
|
||||
<span className="cardback__star" aria-hidden>
|
||||
★
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A seat-sized card that flips face-up when the round is revealed. */
|
||||
export function FlipCard({ revealed, value, delay }: { revealed: boolean; value: string; delay: number }) {
|
||||
return (
|
||||
<div className="flip">
|
||||
<motion.div
|
||||
className="flip__inner"
|
||||
initial={false}
|
||||
animate={{ rotateY: revealed ? 180 : 0 }}
|
||||
transition={{ delay: revealed ? delay : 0, type: "spring", stiffness: 280, damping: 22 }}
|
||||
>
|
||||
<div className="flip__face flip__face--back">
|
||||
<CardBack size="sm" />
|
||||
</div>
|
||||
<div className="flip__face flip__face--front">
|
||||
<CardFace value={value} size="sm" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from "react";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
const COLORS = ["#ffc233", "#ff5c4d", "#fff4dc", "#3ddc97", "#58c7f3"];
|
||||
|
||||
interface Piece {
|
||||
id: number;
|
||||
left: number;
|
||||
delay: number;
|
||||
duration: number;
|
||||
color: string;
|
||||
size: number;
|
||||
rotate: number;
|
||||
drift: number;
|
||||
star: boolean;
|
||||
}
|
||||
|
||||
export function Confetti({ count = 90 }: { count?: number }) {
|
||||
const pieces = useMemo<Piece[]>(
|
||||
() =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
left: Math.random() * 100,
|
||||
delay: Math.random() * 0.5,
|
||||
duration: 2.4 + Math.random() * 1.8,
|
||||
color: COLORS[i % COLORS.length],
|
||||
size: 7 + Math.random() * 8,
|
||||
rotate: Math.random() * 720 - 360,
|
||||
drift: 20 + Math.random() * 50,
|
||||
star: Math.random() < 0.14,
|
||||
})),
|
||||
[count],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="confetti" aria-hidden>
|
||||
{pieces.map((p) => (
|
||||
<motion.span
|
||||
key={p.id}
|
||||
className="confetti__piece"
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
width: p.star ? "auto" : p.size,
|
||||
height: p.star ? "auto" : p.size * 0.62,
|
||||
background: p.star ? "transparent" : p.color,
|
||||
color: p.color,
|
||||
fontSize: p.size + 4,
|
||||
}}
|
||||
initial={{ y: "-8vh", opacity: 1, rotate: 0 }}
|
||||
animate={{
|
||||
y: "110vh",
|
||||
x: [0, p.drift, -p.drift, p.drift / 2],
|
||||
rotate: p.rotate,
|
||||
opacity: [1, 1, 1, 0.6],
|
||||
}}
|
||||
transition={{ duration: p.duration, delay: p.delay, ease: "linear" }}
|
||||
>
|
||||
{p.star ? "★" : ""}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { DECK_PRESETS, parseCustomDeck } from "../lib/decks";
|
||||
import { CardFace } from "./Cards";
|
||||
|
||||
interface DeckModalProps {
|
||||
current: string[];
|
||||
onSave(cards: string[]): void;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function DeckModal({ current, onSave, onClose }: DeckModalProps) {
|
||||
const [raw, setRaw] = useState(current.join(", "));
|
||||
const parsed = useMemo(() => parseCustomDeck(raw), [raw]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
className="modal"
|
||||
initial={{ scale: 0.8, y: 30, opacity: 0 }}
|
||||
animate={{ scale: 1, y: 0, opacity: 1 }}
|
||||
exit={{ scale: 0.85, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 26 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="modal__title">🃏 Pick your deck</h2>
|
||||
<div className="deck-pills">
|
||||
{DECK_PRESETS.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
className={`pill${sameDeck(d.cards, parsed) ? " pill--on" : ""}`}
|
||||
onClick={() => setRaw(d.cards.join(", "))}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="field__label">Cards (comma separated)</span>
|
||||
<input className="input" value={raw} onChange={(e) => setRaw(e.target.value)} />
|
||||
</label>
|
||||
<div className="deck-preview">
|
||||
{(parsed ?? []).map((v) => (
|
||||
<CardFace key={v} value={v} size="xs" />
|
||||
))}
|
||||
</div>
|
||||
<p className="modal__hint">Changing the deck starts a fresh round for everyone.</p>
|
||||
<div className="modal__actions">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--gold"
|
||||
disabled={!parsed}
|
||||
onClick={() => parsed && onSave(parsed)}
|
||||
>
|
||||
Save & re-deal
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function sameDeck(a: string[], b: string[] | null): boolean {
|
||||
return b !== null && a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
const LETTERS = [..."SHOWDOWN"];
|
||||
const TILTS = [-5, 3, -2, 4, -3, 2, -4, 5];
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
<div className="logo-big" role="heading" aria-level={1} aria-label="Showdown">
|
||||
<span className="logo-big__star" aria-hidden>
|
||||
★
|
||||
</span>
|
||||
{LETTERS.map((ch, i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="logo-big__letter"
|
||||
aria-hidden
|
||||
initial={{ y: -90, opacity: 0, rotate: -12 }}
|
||||
animate={{ y: 0, opacity: 1, rotate: TILTS[i] }}
|
||||
transition={{ delay: 0.06 * i, type: "spring", stiffness: 380, damping: 16 }}
|
||||
>
|
||||
{ch}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { motion } from "motion/react";
|
||||
import type { Player } from "../types";
|
||||
import { FlipCard } from "./Cards";
|
||||
|
||||
interface SeatProps {
|
||||
player: Player;
|
||||
isMe: boolean;
|
||||
revealed: boolean;
|
||||
/** Seat position, in percent of the table zone. */
|
||||
x: number;
|
||||
y: number;
|
||||
/** Top-half seats render their card below the avatar, toward the felt. */
|
||||
flip: boolean;
|
||||
flipDelay: number;
|
||||
}
|
||||
|
||||
export function PlayerSeat({ player, isMe, revealed, x, y, flip, flipDelay }: SeatProps) {
|
||||
const left = `${x}%`;
|
||||
const top = `${y}%`;
|
||||
return (
|
||||
<motion.div
|
||||
className={`seat${flip ? " seat--flip" : ""}${isMe ? " seat--me" : ""}`}
|
||||
style={{ left, top }}
|
||||
initial={{ opacity: 0, scale: 0, x: "-50%", y: "-50%" }}
|
||||
animate={{ opacity: 1, scale: 1, x: "-50%", y: "-50%", left, top }}
|
||||
exit={{ opacity: 0, scale: 0, x: "-50%", y: "-50%", transition: { duration: 0.25 } }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 25 }}
|
||||
>
|
||||
<div className="seat__cardspot">
|
||||
{player.voted ? (
|
||||
<div className={revealed ? undefined : "wobble"}>
|
||||
<FlipCard revealed={revealed && player.vote !== null} value={player.vote ?? ""} delay={flipDelay} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="seat__waiting">
|
||||
{revealed ? (
|
||||
<span className="seat__novote">—</span>
|
||||
) : (
|
||||
<span className="dots">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="seat__avatar">{player.emoji}</div>
|
||||
<div className="seat__name">{isMe ? "you" : player.name}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Profile } from "../types";
|
||||
|
||||
const AVATARS = ["🤠", "🐴", "🌵", "🦅", "🐺", "🦊", "🐻", "🐍", "⭐", "🦬"];
|
||||
|
||||
interface ProfileFormProps {
|
||||
title: string;
|
||||
cta: string;
|
||||
initial?: Profile | null;
|
||||
onSubmit(profile: Profile): void;
|
||||
/** Extra fields (e.g. the deck picker on the home page). */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function ProfileForm({ title, cta, initial, onSubmit, children }: ProfileFormProps) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [emoji, setEmoji] = useState(
|
||||
() => initial?.emoji ?? AVATARS[Math.floor(Math.random() * AVATARS.length)],
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="poster"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim().slice(0, 16);
|
||||
if (trimmed) onSubmit({ name: trimmed, emoji });
|
||||
}}
|
||||
>
|
||||
<h2 className="poster__title">{title}</h2>
|
||||
<label className="field">
|
||||
<span className="field__label">Outlaw name</span>
|
||||
<input
|
||||
className="input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Billy the Kid"
|
||||
maxLength={16}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<div className="field">
|
||||
<span className="field__label">Pick your face</span>
|
||||
<div className="emoji-grid">
|
||||
{AVATARS.map((a) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a}
|
||||
className={`emoji-opt${a === emoji ? " emoji-opt--on" : ""}`}
|
||||
onClick={() => setEmoji(a)}
|
||||
aria-pressed={a === emoji}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<button type="submit" className="btn btn--gold btn--big poster__cta" disabled={!name.trim()}>
|
||||
{cta}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { motion } from "motion/react";
|
||||
import type { Variants } from "motion/react";
|
||||
import type { RoomState } from "../types";
|
||||
import { CardFace } from "./Cards";
|
||||
|
||||
interface ResultsProps {
|
||||
state: RoomState;
|
||||
consensus: boolean;
|
||||
/** Omitted in spectator (TV) mode — the button is hidden. */
|
||||
onReset?(): void;
|
||||
}
|
||||
|
||||
const pop: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.5, y: 10 },
|
||||
show: { opacity: 1, scale: 1, y: 0, transition: { type: "spring", stiffness: 400, damping: 20 } },
|
||||
};
|
||||
|
||||
export function Results({ state, consensus, onReset }: ResultsProps) {
|
||||
const votes = state.players.map((p) => p.vote).filter((v): v is string => v !== null);
|
||||
const numeric = votes.map(Number).filter((x) => Number.isFinite(x));
|
||||
const avg = numeric.length > 0 ? Math.round((numeric.reduce((a, b) => a + b, 0) / numeric.length) * 10) / 10 : null;
|
||||
|
||||
const dist = (() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const v of votes) counts.set(v, (counts.get(v) ?? 0) + 1);
|
||||
return [...counts.entries()].sort(
|
||||
(a, b) => b[1] - a[1] || state.deck.indexOf(a[0]) - state.deck.indexOf(b[0]),
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="results"
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{ show: { transition: { staggerChildren: 0.1, delayChildren: 0.45 } } }}
|
||||
>
|
||||
{consensus && (
|
||||
<motion.div className="consensus-banner" variants={pop}>
|
||||
🤝 Consensus!
|
||||
</motion.div>
|
||||
)}
|
||||
{avg !== null ? (
|
||||
<motion.div className="results__avg-wrap" variants={pop}>
|
||||
<span className="results__avg-label">average</span>
|
||||
<span className="results__avg">{avg}</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
dist.length > 0 && (
|
||||
<motion.div className="results__avg-wrap" variants={pop}>
|
||||
<span className="results__avg-label">the call</span>
|
||||
<span className="results__avg">{dist[0][0]}</span>
|
||||
</motion.div>
|
||||
)
|
||||
)}
|
||||
<motion.div className="dist" variants={pop}>
|
||||
{dist.map(([value, count]) => (
|
||||
<span key={value} className="dist__chip">
|
||||
<CardFace value={value} size="xs" /> ×{count}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
{onReset && (
|
||||
<motion.button type="button" className="btn btn--gold" variants={pop} onClick={onReset}>
|
||||
🔄 New round
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useMemo } from "react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import type { RoomState } from "../types";
|
||||
import { PlayerSeat } from "./PlayerSeat";
|
||||
import { Results } from "./Results";
|
||||
|
||||
interface TableProps {
|
||||
state: RoomState;
|
||||
playerId: string;
|
||||
consensus: boolean;
|
||||
copied?: boolean;
|
||||
/** View-only (TV display): no reveal/reset/invite controls. */
|
||||
spectator?: boolean;
|
||||
onReveal?(): void;
|
||||
onReset?(): void;
|
||||
onInvite?(): void;
|
||||
}
|
||||
|
||||
export function Table({ state, playerId, consensus, copied, spectator, onReveal, onReset, onInvite }: TableProps) {
|
||||
// Rotate the seating order so the local player always sits at the bottom.
|
||||
const ordered = useMemo(() => {
|
||||
const idx = state.players.findIndex((p) => p.id === playerId);
|
||||
if (idx <= 0) return state.players;
|
||||
return [...state.players.slice(idx), ...state.players.slice(0, idx)];
|
||||
}, [state.players, playerId]);
|
||||
|
||||
// TV seats are much larger, so they sit on a tighter ring to stay on screen.
|
||||
const rx = spectator ? 41 : 44;
|
||||
const ry = spectator ? 38 : 43;
|
||||
const n = Math.max(ordered.length, 1);
|
||||
const seats = ordered.map((player, i) => {
|
||||
const angle = (Math.PI * (90 + (i * 360) / n)) / 180;
|
||||
const sin = Math.sin(angle);
|
||||
return {
|
||||
player,
|
||||
x: 50 + rx * Math.cos(angle),
|
||||
y: 50 + ry * sin,
|
||||
flip: sin < -0.25,
|
||||
};
|
||||
});
|
||||
|
||||
const votedCount = state.players.filter((p) => p.voted).length;
|
||||
const allVoted = state.players.length > 0 && votedCount === state.players.length;
|
||||
const lonely = spectator ? state.players.length === 0 : state.players.length <= 1;
|
||||
const joinUrl = `${location.host}/room/${state.roomId}`;
|
||||
|
||||
return (
|
||||
<div className="table-zone">
|
||||
<div className="table-felt">
|
||||
<div className="table-center">
|
||||
{state.revealed ? (
|
||||
<Results state={state} consensus={consensus} onReset={spectator ? undefined : onReset} />
|
||||
) : lonely ? (
|
||||
spectator ? (
|
||||
<>
|
||||
<div className="center-title">Waitin’ for the posse…</div>
|
||||
<div className="center-sub center-sub--url">join at {joinUrl}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="center-title">It’s lonely out here, partner</div>
|
||||
<div className="center-sub">Rustle up a posse with the invite link</div>
|
||||
<button type="button" className="btn btn--gold" onClick={onInvite}>
|
||||
{copied ? "✓ Copied!" : "🔗 Copy invite link"}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
key={votedCount}
|
||||
className="center-count"
|
||||
initial={{ scale: 1.35 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 15 }}
|
||||
>
|
||||
{votedCount}
|
||||
<span className="center-count__total">/{state.players.length}</span>
|
||||
</motion.div>
|
||||
<div className="center-title">
|
||||
{votedCount === 0 ? "Place your bets!" : allVoted ? "Everyone’s in!" : "Waitin’ on the rest…"}
|
||||
</div>
|
||||
{!spectator && (
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn--coral btn--big${allVoted ? " btn--party" : ""}`}
|
||||
disabled={votedCount === 0}
|
||||
onClick={onReveal}
|
||||
>
|
||||
Reveal
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{seats.map((s, i) => (
|
||||
<PlayerSeat
|
||||
key={s.player.id}
|
||||
player={s.player}
|
||||
isMe={!spectator && s.player.id === playerId}
|
||||
revealed={state.revealed}
|
||||
x={s.x}
|
||||
y={s.y}
|
||||
flip={s.flip}
|
||||
flipDelay={i * 0.09}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,336 @@
|
||||
import type { ClientMessage, ConnStatus, Profile, RoomState, ServerMessage } from "../types";
|
||||
import { DEFAULT_DECK } from "./decks";
|
||||
|
||||
export interface RoomConnection {
|
||||
send(msg: ClientMessage): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface ConnectionHandlers {
|
||||
onState(state: RoomState): void;
|
||||
onStatus(status: ConnStatus): void;
|
||||
}
|
||||
|
||||
export interface ConnectionOptions {
|
||||
/** View-only (TV display): receives state but never takes a seat. */
|
||||
spectator?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* In dev there is no Go backend yet, so the app runs a local mock room with
|
||||
* bot players. Set VITE_WS=1 to test against a real backend during
|
||||
* development. Production builds always use the real WebSocket.
|
||||
*/
|
||||
export function createConnection(
|
||||
roomId: string,
|
||||
playerId: string,
|
||||
profile: Profile,
|
||||
handlers: ConnectionHandlers,
|
||||
options: ConnectionOptions = {},
|
||||
): RoomConnection {
|
||||
const useMock = import.meta.env.DEV && import.meta.env.VITE_WS !== "1";
|
||||
if (options.spectator) {
|
||||
return useMock
|
||||
? new MockSpectatorConnection(roomId, handlers)
|
||||
: new WsConnection(roomId, playerId, profile, handlers, true);
|
||||
}
|
||||
return useMock
|
||||
? new MockConnection(roomId, playerId, profile, handlers)
|
||||
: new WsConnection(roomId, playerId, profile, handlers);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Real WebSocket client — matches PROTOCOL.md for the Go backend. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
class WsConnection implements RoomConnection {
|
||||
private ws: WebSocket | null = null;
|
||||
private queue: ClientMessage[] = [];
|
||||
private closed = false;
|
||||
private attempts = 0;
|
||||
|
||||
constructor(
|
||||
private roomId: string,
|
||||
private playerId: string,
|
||||
private profile: Profile,
|
||||
private handlers: ConnectionHandlers,
|
||||
private spectator = false,
|
||||
) {
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
this.handlers.onStatus("connecting");
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${proto}://${location.host}/ws/room/${encodeURIComponent(this.roomId)}`);
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
this.attempts = 0;
|
||||
this.handlers.onStatus("online");
|
||||
const hello: ClientMessage = this.spectator
|
||||
? { type: "watch" }
|
||||
: { type: "join", playerId: this.playerId, name: this.profile.name, emoji: this.profile.emoji };
|
||||
ws.send(JSON.stringify(hello));
|
||||
for (const msg of this.queue.splice(0)) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data) as ServerMessage;
|
||||
if (msg.type === "state") this.handlers.onState(msg.state);
|
||||
} catch {
|
||||
/* malformed frame — ignore */
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (this.closed) return;
|
||||
this.handlers.onStatus("offline");
|
||||
const backoff = Math.min(10_000, 1_000 * 2 ** this.attempts++);
|
||||
setTimeout(() => {
|
||||
if (!this.closed) this.connect();
|
||||
}, backoff);
|
||||
};
|
||||
}
|
||||
|
||||
send(msg: ClientMessage): void {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
} else {
|
||||
this.queue.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
this.ws?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Mock room with bot players, for developing the UI without a server. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface MockPlayer {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
vote: string | null;
|
||||
bot: boolean;
|
||||
}
|
||||
|
||||
const BOTS: Array<Pick<MockPlayer, "id" | "name" | "emoji">> = [
|
||||
{ id: "bot-1", name: "Calamity Jane", emoji: "🐴" },
|
||||
{ id: "bot-2", name: "Doc Holliday", emoji: "🦅" },
|
||||
{ id: "bot-3", name: "Wild Bill", emoji: "🌵" },
|
||||
{ id: "bot-4", name: "Annie Oakley", emoji: "⭐" },
|
||||
];
|
||||
|
||||
const channelName = (roomId: string) => `showdown:room:${roomId}`;
|
||||
|
||||
class MockConnection implements RoomConnection {
|
||||
private deck = [...DEFAULT_DECK];
|
||||
private revealed = false;
|
||||
private round = 1;
|
||||
private players: MockPlayer[] = [];
|
||||
private timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
/** Index in the deck the bots loosely agree on, re-rolled every round. */
|
||||
private botTargetIndex = 0;
|
||||
private disposed = false;
|
||||
private revealScheduled = false;
|
||||
/** Mirrors state to spectator tabs (the TV view) while in demo mode. */
|
||||
private channel: BroadcastChannel | null = null;
|
||||
|
||||
constructor(
|
||||
private roomId: string,
|
||||
playerId: string,
|
||||
profile: Profile,
|
||||
private handlers: ConnectionHandlers,
|
||||
/** Self-running mode for a standalone TV demo: bots only, auto reveal/reset. */
|
||||
private autopilot = false,
|
||||
) {
|
||||
if (!autopilot && typeof BroadcastChannel !== "undefined") {
|
||||
this.channel = new BroadcastChannel(channelName(roomId));
|
||||
this.channel.onmessage = (e) => {
|
||||
if (e.data?.type === "sync") this.emit();
|
||||
};
|
||||
}
|
||||
if (!autopilot) {
|
||||
this.players.push({ id: playerId, name: profile.name, emoji: profile.emoji, vote: null, bot: false });
|
||||
}
|
||||
this.rollBotTarget();
|
||||
handlers.onStatus("demo");
|
||||
this.emit();
|
||||
const bots = autopilot ? BOTS : BOTS.slice(0, 3);
|
||||
bots.forEach((bot, i) => {
|
||||
this.after(1200 + i * 1400, () => {
|
||||
this.players.push({ ...bot, vote: null, bot: true });
|
||||
this.emit();
|
||||
this.scheduleBotVote(bot.id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
send(msg: ClientMessage): void {
|
||||
switch (msg.type) {
|
||||
case "join":
|
||||
case "watch":
|
||||
break;
|
||||
case "vote": {
|
||||
if (this.revealed) break;
|
||||
const me = this.players.find((p) => !p.bot);
|
||||
if (me) me.vote = msg.value;
|
||||
break;
|
||||
}
|
||||
case "reveal":
|
||||
if (this.players.some((p) => p.vote !== null)) this.revealed = true;
|
||||
break;
|
||||
case "reset":
|
||||
this.startNewRound();
|
||||
break;
|
||||
case "deck":
|
||||
this.deck = msg.cards;
|
||||
this.startNewRound();
|
||||
break;
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.disposed = true;
|
||||
for (const t of this.timers) clearTimeout(t);
|
||||
this.timers.clear();
|
||||
this.channel?.close();
|
||||
this.channel = null;
|
||||
}
|
||||
|
||||
private startNewRound(): void {
|
||||
this.revealed = false;
|
||||
this.revealScheduled = false;
|
||||
this.round += 1;
|
||||
this.rollBotTarget();
|
||||
for (const p of this.players) {
|
||||
p.vote = null;
|
||||
if (p.bot) this.scheduleBotVote(p.id);
|
||||
}
|
||||
}
|
||||
|
||||
private rollBotTarget(): void {
|
||||
const numericCount = this.deck.filter((c) => Number.isFinite(Number(c))).length || this.deck.length;
|
||||
this.botTargetIndex = Math.floor(Math.random() * Math.min(numericCount, this.deck.length));
|
||||
}
|
||||
|
||||
private scheduleBotVote(botId: string): void {
|
||||
this.after(1500 + Math.random() * 4500, () => {
|
||||
const bot = this.players.find((p) => p.id === botId);
|
||||
if (!bot || this.revealed || bot.vote !== null) return;
|
||||
bot.vote = this.pickBotVote();
|
||||
this.emit();
|
||||
this.maybeAutopilot();
|
||||
});
|
||||
}
|
||||
|
||||
/** Standalone TV demo loop: reveal once everyone voted, then start over. */
|
||||
private maybeAutopilot(): void {
|
||||
if (!this.autopilot || this.revealed || this.revealScheduled) return;
|
||||
if (this.players.length < 2 || !this.players.every((p) => p.vote !== null)) return;
|
||||
this.revealScheduled = true;
|
||||
this.after(1800, () => {
|
||||
if (this.revealed) return;
|
||||
this.revealed = true;
|
||||
this.emit();
|
||||
this.after(6000, () => {
|
||||
this.startNewRound();
|
||||
this.emit();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private pickBotVote(): string {
|
||||
const roll = Math.random();
|
||||
let idx = this.botTargetIndex;
|
||||
if (roll > 0.9 && this.deck.includes("?")) return "?";
|
||||
if (roll > 0.6) idx += Math.random() > 0.5 ? 1 : -1;
|
||||
idx = Math.max(0, Math.min(this.deck.length - 1, idx));
|
||||
return this.deck[idx];
|
||||
}
|
||||
|
||||
private after(ms: number, fn: () => void): void {
|
||||
const t = setTimeout(() => {
|
||||
this.timers.delete(t);
|
||||
if (!this.disposed) fn();
|
||||
}, ms);
|
||||
this.timers.add(t);
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
const state: RoomState = {
|
||||
roomId: this.roomId,
|
||||
deck: [...this.deck],
|
||||
revealed: this.revealed,
|
||||
round: this.round,
|
||||
players: this.players.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
emoji: p.emoji,
|
||||
voted: p.vote !== null,
|
||||
vote: this.revealed ? p.vote : null,
|
||||
})),
|
||||
};
|
||||
this.handlers.onState(state);
|
||||
this.channel?.postMessage({ type: "state", state });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spectator in demo mode: mirrors the mock room of an open game tab via
|
||||
* BroadcastChannel. If no game tab answers, falls back to a self-running
|
||||
* bots-only demo so the TV is never blank.
|
||||
*/
|
||||
class MockSpectatorConnection implements RoomConnection {
|
||||
private channel: BroadcastChannel | null = null;
|
||||
private fallback: MockConnection | null = null;
|
||||
private fallbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private hasHost = false;
|
||||
|
||||
constructor(roomId: string, handlers: ConnectionHandlers) {
|
||||
handlers.onStatus("connecting");
|
||||
if (typeof BroadcastChannel !== "undefined") {
|
||||
this.channel = new BroadcastChannel(channelName(roomId));
|
||||
this.channel.onmessage = (e) => {
|
||||
if (e.data?.type !== "state") return;
|
||||
this.hasHost = true;
|
||||
if (this.fallbackTimer) {
|
||||
clearTimeout(this.fallbackTimer);
|
||||
this.fallbackTimer = null;
|
||||
}
|
||||
if (this.fallback) {
|
||||
this.fallback.close();
|
||||
this.fallback = null;
|
||||
}
|
||||
handlers.onStatus("demo");
|
||||
handlers.onState(e.data.state as RoomState);
|
||||
};
|
||||
this.channel.postMessage({ type: "sync" });
|
||||
}
|
||||
this.fallbackTimer = setTimeout(() => {
|
||||
if (!this.hasHost) {
|
||||
this.fallback = new MockConnection(roomId, "", { name: "", emoji: "" }, handlers, true);
|
||||
}
|
||||
}, 900);
|
||||
}
|
||||
|
||||
send(): void {
|
||||
/* view-only */
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.fallbackTimer) clearTimeout(this.fallbackTimer);
|
||||
this.fallback?.close();
|
||||
this.channel?.close();
|
||||
this.channel = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export interface DeckPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
cards: string[];
|
||||
}
|
||||
|
||||
export const DECK_PRESETS: DeckPreset[] = [
|
||||
{
|
||||
id: "fibonacci",
|
||||
label: "Fibonacci",
|
||||
cards: ["0", "1", "2", "3", "5", "8", "13", "21", "34", "?", "☕"],
|
||||
},
|
||||
{
|
||||
id: "tshirt",
|
||||
label: "T-Shirt",
|
||||
cards: ["XS", "S", "M", "L", "XL", "XXL", "?", "☕"],
|
||||
},
|
||||
{
|
||||
id: "powers",
|
||||
label: "Powers of 2",
|
||||
cards: ["1", "2", "4", "8", "16", "32", "64", "?", "☕"],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_DECK = DECK_PRESETS[0].cards;
|
||||
|
||||
const MAX_CARDS = 15;
|
||||
const MAX_CARD_LEN = 4;
|
||||
|
||||
/** Parse a comma/space separated custom deck. Returns null if unusable. */
|
||||
export function parseCustomDeck(raw: string): string[] | null {
|
||||
const seen = new Set<string>();
|
||||
const cards: string[] = [];
|
||||
for (const piece of raw.split(/[,\s]+/)) {
|
||||
const v = piece.trim();
|
||||
if (!v || [...v].length > MAX_CARD_LEN || seen.has(v)) continue;
|
||||
seen.add(v);
|
||||
cards.push(v);
|
||||
if (cards.length === MAX_CARDS) break;
|
||||
}
|
||||
return cards.length >= 2 ? cards : null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { RoomState } from "../types";
|
||||
|
||||
export function isConsensus(state: RoomState | null): boolean {
|
||||
if (!state?.revealed) return false;
|
||||
const votes = state.players.map((p) => p.vote).filter((v): v is string => v !== null);
|
||||
return votes.length >= 2 && votes.every((v) => v === votes[0]);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function navigate(to: string): void {
|
||||
history.pushState({}, "", to);
|
||||
dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
|
||||
export function usePath(): string {
|
||||
const [path, setPath] = useState(location.pathname);
|
||||
useEffect(() => {
|
||||
const onPop = () => setPath(location.pathname);
|
||||
addEventListener("popstate", onPop);
|
||||
return () => removeEventListener("popstate", onPop);
|
||||
}, []);
|
||||
return path;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Profile } from "../types";
|
||||
|
||||
const PROFILE_KEY = "showdown:profile";
|
||||
const PLAYER_ID_KEY = "showdown:pid";
|
||||
const PENDING_DECK_KEY = "showdown:pendingDeck";
|
||||
|
||||
export function loadProfile(): Profile | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(PROFILE_KEY);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw);
|
||||
if (typeof p?.name === "string" && typeof p?.emoji === "string" && p.name) {
|
||||
return { name: p.name, emoji: p.emoji };
|
||||
}
|
||||
} catch {
|
||||
/* corrupted storage — treat as signed out */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function saveProfile(p: Profile): void {
|
||||
localStorage.setItem(PROFILE_KEY, JSON.stringify(p));
|
||||
}
|
||||
|
||||
/** Stable per-browser identity so a refresh re-claims the same seat. */
|
||||
export function getPlayerId(): string {
|
||||
let id = localStorage.getItem(PLAYER_ID_KEY);
|
||||
if (!id) {
|
||||
id =
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `p-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
localStorage.setItem(PLAYER_ID_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** The deck chosen on the home page, applied right after the room creator joins. */
|
||||
export function stashPendingDeck(cards: string[]): void {
|
||||
sessionStorage.setItem(PENDING_DECK_KEY, JSON.stringify(cards));
|
||||
}
|
||||
|
||||
export function takePendingDeck(): string[] | null {
|
||||
const raw = sessionStorage.getItem(PENDING_DECK_KEY);
|
||||
if (!raw) return null;
|
||||
sessionStorage.removeItem(PENDING_DECK_KEY);
|
||||
try {
|
||||
const cards = JSON.parse(raw);
|
||||
if (Array.isArray(cards) && cards.every((c) => typeof c === "string")) {
|
||||
return cards;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ADJECTIVES = ["dusty", "rowdy", "lucky", "sneaky", "golden", "wild", "lone", "rusty", "swift", "fancy"];
|
||||
const NOUNS = ["cactus", "saloon", "wagon", "sheriff", "coyote", "mustang", "nugget", "lasso", "spur", "tumbleweed"];
|
||||
|
||||
export function generateRoomId(): string {
|
||||
const pick = (arr: string[]) => arr[Math.floor(Math.random() * arr.length)];
|
||||
const num = String(Math.floor(Math.random() * 90) + 10);
|
||||
return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${num}`;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MotionConfig } from "motion/react";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<MotionConfig reducedMotion="user">
|
||||
<App />
|
||||
</MotionConfig>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import type { Profile } from "../types";
|
||||
import { DECK_PRESETS, DEFAULT_DECK, parseCustomDeck } from "../lib/decks";
|
||||
import { generateRoomId, loadProfile, saveProfile, stashPendingDeck } from "../lib/session";
|
||||
import { navigate } from "../lib/router";
|
||||
import { Logo } from "../components/Logo";
|
||||
import { ProfileForm } from "../components/ProfileForm";
|
||||
import { CardBack, CardFace } from "../components/Cards";
|
||||
|
||||
export function Home() {
|
||||
const [deckId, setDeckId] = useState(DECK_PRESETS[0].id);
|
||||
const [customRaw, setCustomRaw] = useState("1, 2, 3, 5, 8");
|
||||
|
||||
const chosenDeck =
|
||||
deckId === "custom"
|
||||
? parseCustomDeck(customRaw)
|
||||
: (DECK_PRESETS.find((d) => d.id === deckId)?.cards ?? DEFAULT_DECK);
|
||||
|
||||
const create = (profile: Profile) => {
|
||||
saveProfile(profile);
|
||||
stashPendingDeck(chosenDeck ?? DEFAULT_DECK);
|
||||
navigate(`/room/${generateRoomId()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="home">
|
||||
<header className="hero">
|
||||
<HeroCards />
|
||||
<Logo />
|
||||
<motion.p
|
||||
className="tagline"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.55 }}
|
||||
>
|
||||
Saddle up. Point stories. Settle the score.
|
||||
</motion.p>
|
||||
</header>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.45, type: "spring", stiffness: 220, damping: 22 }}
|
||||
>
|
||||
<ProfileForm title="Join the posse" cta="Deal me in →" initial={loadProfile()} onSubmit={create}>
|
||||
<div className="field">
|
||||
<span className="field__label">Deck</span>
|
||||
<div className="deck-pills">
|
||||
{DECK_PRESETS.map((d) => (
|
||||
<button
|
||||
key={d.id}
|
||||
type="button"
|
||||
className={`pill${deckId === d.id ? " pill--on" : ""}`}
|
||||
onClick={() => setDeckId(d.id)}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`pill${deckId === "custom" ? " pill--on" : ""}`}
|
||||
onClick={() => setDeckId("custom")}
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
</div>
|
||||
{deckId === "custom" && (
|
||||
<input
|
||||
className="input"
|
||||
value={customRaw}
|
||||
onChange={(e) => setCustomRaw(e.target.value)}
|
||||
placeholder="1, 2, 3, 5, 8"
|
||||
/>
|
||||
)}
|
||||
<div className="deck-preview">
|
||||
{(chosenDeck ?? []).map((v) => (
|
||||
<CardFace key={v} value={v} size="xs" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ProfileForm>
|
||||
</motion.div>
|
||||
|
||||
<p className="footnote">
|
||||
No sign-up. No database. Rooms vanish like tumbleweed when everyone leaves.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroCards() {
|
||||
return (
|
||||
<div className="hero-cards" aria-hidden>
|
||||
<motion.div
|
||||
className="hero-card hero-card--l"
|
||||
initial={{ y: 120, opacity: 0, rotate: -40 }}
|
||||
animate={{ y: 0, opacity: 1, rotate: -16 }}
|
||||
transition={{ delay: 0.5, type: "spring", stiffness: 220, damping: 18 }}
|
||||
>
|
||||
<CardFace value="13" size="lg" />
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="hero-card hero-card--r"
|
||||
initial={{ y: 120, opacity: 0, rotate: 40 }}
|
||||
animate={{ y: 0, opacity: 1, rotate: 14 }}
|
||||
transition={{ delay: 0.62, type: "spring", stiffness: 220, damping: 18 }}
|
||||
>
|
||||
<CardBack size="lg" />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import type { ConnStatus, Profile, RoomState } from "../types";
|
||||
import { createConnection } from "../lib/connection";
|
||||
import type { RoomConnection } from "../lib/connection";
|
||||
import { getPlayerId, loadProfile, saveProfile, takePendingDeck } from "../lib/session";
|
||||
import { isConsensus } from "../lib/game";
|
||||
import { navigate } from "../lib/router";
|
||||
import { Table } from "../components/Table";
|
||||
import { CardHand } from "../components/CardHand";
|
||||
import { DeckModal } from "../components/DeckModal";
|
||||
import { Confetti } from "../components/Confetti";
|
||||
import { Logo } from "../components/Logo";
|
||||
import { ProfileForm } from "../components/ProfileForm";
|
||||
|
||||
export function Room({ roomId }: { roomId: string }) {
|
||||
const playerId = useMemo(getPlayerId, []);
|
||||
const [profile, setProfile] = useState<Profile | null>(loadProfile);
|
||||
const [state, setState] = useState<RoomState | null>(null);
|
||||
const [status, setStatus] = useState<ConnStatus>("connecting");
|
||||
const [myVote, setMyVote] = useState<string | null>(null);
|
||||
const [deckOpen, setDeckOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const connRef = useRef<RoomConnection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profile) return;
|
||||
const conn = createConnection(roomId, playerId, profile, {
|
||||
onState: setState,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
connRef.current = conn;
|
||||
const pending = takePendingDeck();
|
||||
if (pending) conn.send({ type: "deck", cards: pending });
|
||||
return () => {
|
||||
conn.close();
|
||||
connRef.current = null;
|
||||
};
|
||||
}, [roomId, profile, playerId]);
|
||||
|
||||
// A new round (reset or deck change) clears the local selection.
|
||||
const round = state?.round;
|
||||
useEffect(() => {
|
||||
setMyVote(null);
|
||||
}, [round]);
|
||||
|
||||
const consensus = useMemo(() => isConsensus(state), [state]);
|
||||
|
||||
const castVote = (value: string) => {
|
||||
if (!state || state.revealed) return;
|
||||
const next = myVote === value ? null : value;
|
||||
setMyVote(next);
|
||||
connRef.current?.send({ type: "vote", value: next });
|
||||
};
|
||||
|
||||
const openTv = () => {
|
||||
const url = `${location.origin}/room/${roomId}/tv`;
|
||||
if (!window.open(url, "showdown-tv", "popup=yes,width=1280,height=800")) {
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
const copyInvite = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(location.href);
|
||||
} catch {
|
||||
/* clipboard unavailable — the room code in the URL still works */
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="gate">
|
||||
<Logo />
|
||||
<ProfileForm
|
||||
title="Take a seat"
|
||||
cta="Join the game →"
|
||||
onSubmit={(p) => {
|
||||
saveProfile(p);
|
||||
setProfile(p);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="room">
|
||||
<header className="topbar">
|
||||
<a
|
||||
className="topbar__logo"
|
||||
href="/"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate("/");
|
||||
}}
|
||||
>
|
||||
<span className="topbar__star">★</span>
|
||||
<span className="topbar__word">SHOWDOWN</span>
|
||||
</a>
|
||||
<button type="button" className={`room-pill${copied ? " room-pill--copied" : ""}`} onClick={copyInvite}>
|
||||
{copied ? "✓ Link copied!" : `🔗 ${roomId}`}
|
||||
</button>
|
||||
<div className="topbar__right">
|
||||
{status === "demo" && <span className="demo-chip">demo</span>}
|
||||
<span className={`status-dot status-dot--${status}`} title={status} />
|
||||
<button type="button" className="btn btn--ghost btn--small" onClick={openTv} title="Open the TV display">
|
||||
📺<span className="btn-label"> TV</span>
|
||||
</button>
|
||||
<button type="button" className="btn btn--ghost btn--small" onClick={() => setDeckOpen(true)} title="Change the deck">
|
||||
🃏<span className="btn-label"> Deck</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{status === "offline" && <div className="toast">Lost connection — wranglin’ it back…</div>}
|
||||
|
||||
{state ? (
|
||||
<>
|
||||
<Table
|
||||
state={state}
|
||||
playerId={playerId}
|
||||
consensus={consensus}
|
||||
copied={copied}
|
||||
onReveal={() => connRef.current?.send({ type: "reveal" })}
|
||||
onReset={() => connRef.current?.send({ type: "reset" })}
|
||||
onInvite={copyInvite}
|
||||
/>
|
||||
<CardHand deck={state.deck} myVote={myVote} locked={state.revealed} onPick={castVote} />
|
||||
</>
|
||||
) : (
|
||||
<div className="loading">Shufflin’ the deck…</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{deckOpen && state && (
|
||||
<DeckModal
|
||||
current={state.deck}
|
||||
onClose={() => setDeckOpen(false)}
|
||||
onSave={(cards) => {
|
||||
connRef.current?.send({ type: "deck", cards });
|
||||
setDeckOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{consensus && state && <Confetti key={state.round} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ConnStatus, RoomState } from "../types";
|
||||
import { createConnection } from "../lib/connection";
|
||||
import { isConsensus } from "../lib/game";
|
||||
import { Table } from "../components/Table";
|
||||
import { Confetti } from "../components/Confetti";
|
||||
|
||||
/** Big-screen spectator view: no seat, no controls, just the showdown. */
|
||||
export function TvRoom({ roomId }: { roomId: string }) {
|
||||
const [state, setState] = useState<RoomState | null>(null);
|
||||
const [status, setStatus] = useState<ConnStatus>("connecting");
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const conn = createConnection(
|
||||
roomId,
|
||||
"tv",
|
||||
{ name: "TV", emoji: "📺" },
|
||||
{ onState: setState, onStatus: setStatus },
|
||||
{ spectator: true },
|
||||
);
|
||||
return () => conn.close();
|
||||
}, [roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => setFullscreen(Boolean(document.fullscreenElement));
|
||||
document.addEventListener("fullscreenchange", onChange);
|
||||
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||
}, []);
|
||||
|
||||
const consensus = useMemo(() => isConsensus(state), [state]);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tv">
|
||||
<header className="tvbar">
|
||||
<div className="tvbar__logo">
|
||||
<span className="topbar__star">★</span>
|
||||
<span>SHOWDOWN</span>
|
||||
</div>
|
||||
<div className="tvbar__room">
|
||||
<div className="tvbar__code">{roomId}</div>
|
||||
<div className="tvbar__url">join at {location.host}/room/{roomId}</div>
|
||||
</div>
|
||||
<div className="tvbar__right">
|
||||
{status === "demo" && <span className="demo-chip">demo</span>}
|
||||
<span className={`status-dot status-dot--${status}`} title={status} />
|
||||
<button type="button" className="btn btn--ghost btn--small" onClick={toggleFullscreen}>
|
||||
{fullscreen ? "✕ Exit" : "⛶ Fullscreen"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{state ? (
|
||||
<Table state={state} playerId="" consensus={consensus} spectator />
|
||||
) : (
|
||||
<div className="loading">Tunin’ in…</div>
|
||||
)}
|
||||
|
||||
{consensus && state && <Confetti key={state.round} count={140} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Shared shapes for the Showdown room protocol.
|
||||
* The Go backend mirrors these — see PROTOCOL.md at the repo root.
|
||||
*/
|
||||
|
||||
export interface Player {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji: string;
|
||||
/** True once the player has picked a card this round. */
|
||||
voted: boolean;
|
||||
/** The actual card. Null until the round is revealed (server hides it). */
|
||||
vote: string | null;
|
||||
}
|
||||
|
||||
export interface RoomState {
|
||||
roomId: string;
|
||||
deck: string[];
|
||||
revealed: boolean;
|
||||
/** Increments on every reset / deck change. Lets clients detect new rounds. */
|
||||
round: number;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: "join"; playerId: string; name: string; emoji: string }
|
||||
| { type: "watch" }
|
||||
| { type: "vote"; value: string | null }
|
||||
| { type: "reveal" }
|
||||
| { type: "reset" }
|
||||
| { type: "deck"; cards: string[] };
|
||||
|
||||
export type ServerMessage = { type: "state"; state: RoomState };
|
||||
|
||||
export type ConnStatus = "connecting" | "online" | "offline" | "demo";
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
emoji: string;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"moduleDetection": "force",
|
||||
"useDefineForClassFields": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
// Forwarded to the Go backend once it exists.
|
||||
"/ws": {
|
||||
target: "ws://localhost:8080",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user