Redesign game lobby and room creation flow
- Split chess and blackjack setup into guided creation steps - Add chess time control presets and reusable lobby room metrics - Improve room filtering, ordering, and live connection state
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,35 +1,169 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useParams, useNavigate, useLocation } from "react-router-dom";
|
import { useLocation, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock3,
|
||||||
|
Coins,
|
||||||
|
Copy,
|
||||||
|
Eye,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
Shield,
|
||||||
|
Sparkles,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
import { useGameRoom } from "../lib/useGameRoom";
|
import { useGameRoom } from "../lib/useGameRoom";
|
||||||
|
import { CHESS_TIME_CONTROL_LABELS } from "./chess/timeControls";
|
||||||
import { gameUIRegistry } from "./registry";
|
import { gameUIRegistry } from "./registry";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
|
|
||||||
function CopyInviteLink({ url }: { url: string }) {
|
function stateChip(status: "waiting" | "playing" | "finished") {
|
||||||
const [copied, setCopied] = useState(false);
|
if (status === "waiting") return "border-warning/25 bg-warning/12 text-warning";
|
||||||
function copy() {
|
if (status === "playing") return "border-success/25 bg-success/12 text-success";
|
||||||
navigator.clipboard.writeText(url).then(() => {
|
return "border-white/10 bg-card text-text-tertiary";
|
||||||
setCopied(true);
|
}
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
});
|
function stateLabel(status: "waiting" | "playing" | "finished") {
|
||||||
}
|
if (status === "waiting") return "Waiting";
|
||||||
|
if (status === "playing") return "Live";
|
||||||
|
return "Finished";
|
||||||
|
}
|
||||||
|
|
||||||
|
function gameSurfaceClass(slug: string): string {
|
||||||
|
return slug === "blackjack"
|
||||||
|
? "border-emerald-500/20 bg-[radial-gradient(circle_at_top,rgba(34,197,94,0.14),transparent_42%),linear-gradient(180deg,rgba(6,95,70,0.34),rgba(6,78,59,0.08))]"
|
||||||
|
: "border-primary/20 bg-[radial-gradient(circle_at_top,rgba(233,195,73,0.14),transparent_42%),linear-gradient(180deg,rgba(61,46,0,0.24),rgba(61,46,0,0.06))]";
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageState({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
actionLabel,
|
||||||
|
onAction,
|
||||||
|
loading = false,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
actionLabel: string;
|
||||||
|
onAction: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="rounded-[32px] border border-white/10 bg-card/70 px-6 py-12 text-center shadow-[0_24px_80px_rgba(0,0,0,0.2)]">
|
||||||
<div className="text-xs text-text-disabled mb-1">Share this link to invite:</div>
|
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
<div className="flex items-center gap-2 w-full max-w-sm">
|
{loading ? <Loader2 className="h-6 w-6 animate-spin" /> : <Sparkles className="h-6 w-6" />}
|
||||||
<span className="flex-1 font-mono bg-card rounded-lg px-2 py-1.5 text-[11px] text-text-tertiary truncate">
|
</div>
|
||||||
{url}
|
<h1 className="mt-5 font-display text-3xl font-semibold text-foreground">{title}</h1>
|
||||||
</span>
|
<p className="mx-auto mt-2 max-w-xl text-sm text-text-tertiary">{body}</p>
|
||||||
|
<button
|
||||||
|
onClick={onAction}
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2.5 text-sm font-semibold text-on-primary transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyInviteCard({ url }: { url: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function copy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1800);
|
||||||
|
} catch {
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-card/70 p-5">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.2em] text-text-disabled">Invite Link</div>
|
||||||
|
<div className="mt-2 text-sm text-text-secondary">
|
||||||
|
Share this room URL to bring another player directly into the waiting room.
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 rounded-2xl border border-white/10 bg-black/10 p-3">
|
||||||
|
<div className="truncate font-mono text-xs text-text-secondary">{url}</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={copy}
|
onClick={copy}
|
||||||
className={`shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-colors ${
|
className={cn(
|
||||||
|
"mt-4 inline-flex w-full items-center justify-center gap-2 rounded-2xl border px-4 py-3 text-sm font-semibold transition-colors",
|
||||||
copied
|
copied
|
||||||
? "bg-success/15 text-success"
|
? "border-success/20 bg-success/10 text-success"
|
||||||
: "bg-raised text-text-tertiary hover:text-foreground"
|
: "border-white/10 bg-white/5 text-foreground hover:bg-white/10",
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
{copied ? "Copied!" : "Copy"}
|
{copied ? <CheckCircle2 className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||||
|
{copied ? "Invite Copied" : "Copy Invite Link"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactRoomBar({
|
||||||
|
roomCode,
|
||||||
|
state,
|
||||||
|
isSpectator,
|
||||||
|
playerCount,
|
||||||
|
maxPlayers,
|
||||||
|
spectatorCount,
|
||||||
|
facts,
|
||||||
|
onExit,
|
||||||
|
}: {
|
||||||
|
roomCode: string;
|
||||||
|
state: "waiting" | "playing" | "finished";
|
||||||
|
isSpectator: boolean;
|
||||||
|
playerCount: number;
|
||||||
|
maxPlayers: number;
|
||||||
|
spectatorCount: number;
|
||||||
|
facts: Array<{ label: string; value: string }>;
|
||||||
|
onExit: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="sticky top-[4.5rem] z-30 md:top-6">
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-background/82 p-3 shadow-[0_18px_50px_rgba(0,0,0,0.28)] backdrop-blur-xl">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<span className={cn("rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em]", stateChip(state))}>
|
||||||
|
{stateLabel(state)}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-text-secondary">
|
||||||
|
Room {roomCode}
|
||||||
|
</span>
|
||||||
|
{isSpectator && (
|
||||||
|
<span className="rounded-full border border-info/20 bg-info/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-info">
|
||||||
|
Spectating
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onExit}
|
||||||
|
className="rounded-full border border-white/10 bg-black/10 px-3 py-1.5 text-sm text-text-secondary transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
Leave
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/10 px-3 py-1.5 text-xs text-text-secondary">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
{playerCount}/{maxPlayers} players
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/10 px-3 py-1.5 text-xs text-text-secondary">
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
{spectatorCount} spectators
|
||||||
|
</span>
|
||||||
|
{facts.map(fact => (
|
||||||
|
<span key={fact.label} className="rounded-full border border-white/10 bg-black/10 px-3 py-1.5 text-xs text-text-secondary">
|
||||||
|
{fact.label}: {fact.value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -41,114 +175,212 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
const preferAs = (location.state as { preferAs?: "player" | "spectator" } | null)?.preferAs ?? "player";
|
const preferAs = (location.state as { preferAs?: "player" | "spectator" } | null)?.preferAs ?? "player";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
gameState, players, spectators, roomStatus,
|
gameState,
|
||||||
isSpectator, gameOver, roundResult, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, startGame, roomOptions,
|
players,
|
||||||
|
spectators,
|
||||||
|
roomStatus,
|
||||||
|
isSpectator,
|
||||||
|
gameOver,
|
||||||
|
roundResult,
|
||||||
|
error,
|
||||||
|
sendAction,
|
||||||
|
leaveRoom,
|
||||||
|
sessionReplaced,
|
||||||
|
rejoin,
|
||||||
|
fillRoom,
|
||||||
|
startGame,
|
||||||
|
roomOptions,
|
||||||
} = useGameRoom(roomId!, userId, role, preferAs);
|
} = useGameRoom(roomId!, userId, role, preferAs);
|
||||||
|
|
||||||
const betAmount = roomOptions.betAmount ?? 0;
|
|
||||||
|
|
||||||
const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined;
|
const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined;
|
||||||
|
|
||||||
|
function exitRoom() {
|
||||||
|
leaveRoom();
|
||||||
|
navigate("/games");
|
||||||
|
}
|
||||||
|
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-16">
|
<MessageState
|
||||||
<div className="text-lg font-display font-semibold mb-2">Unknown Game</div>
|
title="Unknown Game"
|
||||||
<p className="text-sm text-text-tertiary mb-4">The game type "{gameSlug}" doesn't exist.</p>
|
body={`The game type "${gameSlug}" is not registered in the panel.`}
|
||||||
<button onClick={() => navigate("/games")} className="text-sm text-primary hover:underline">
|
actionLabel="Back to Games"
|
||||||
Back to Games
|
onAction={() => navigate("/games")}
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roomStatus === "not_found") {
|
if (roomStatus === "not_found") {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-16">
|
<MessageState
|
||||||
<div className="text-lg font-display font-semibold mb-2">Room Not Found</div>
|
title="Room Not Found"
|
||||||
<p className="text-sm text-text-tertiary mb-4">This room no longer exists or has expired.</p>
|
body="This room no longer exists, has expired, or was already cleaned up by the server."
|
||||||
<button onClick={() => navigate("/games")} className="text-sm text-primary hover:underline">
|
actionLabel="Back to Games"
|
||||||
Back to Games
|
onAction={() => navigate("/games")}
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roomStatus === "connecting") {
|
if (roomStatus === "connecting") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
<MessageState
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-text-tertiary" />
|
title={preferAs === "spectator" ? "Joining As Spectator" : "Joining Room"}
|
||||||
<p className="text-sm text-text-tertiary">
|
body="The panel is restoring the room state and syncing your latest seat information."
|
||||||
{preferAs === "spectator" ? "Joining as spectator..." : "Joining room..."}
|
actionLabel="Back to Games"
|
||||||
</p>
|
onAction={() => navigate("/games")}
|
||||||
</div>
|
loading
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const GameComponent = plugin.component;
|
const GameComponent = plugin.component;
|
||||||
|
const roomCode = roomId?.slice(0, 8).toUpperCase() ?? "";
|
||||||
|
const hostPlayer = players[0] ?? null;
|
||||||
|
const isHost = hostPlayer?.discordId === userId;
|
||||||
|
const betAmount = roomOptions.betAmount ?? 0;
|
||||||
|
const timeControl = typeof roomOptions.timeControl === "string"
|
||||||
|
? CHESS_TIME_CONTROL_LABELS[roomOptions.timeControl] ?? roomOptions.timeControl
|
||||||
|
: null;
|
||||||
|
const readyToStart = players.length >= plugin.minPlayers;
|
||||||
|
const startHint = plugin.manualStart
|
||||||
|
? isHost
|
||||||
|
? readyToStart
|
||||||
|
? "You can start the game now."
|
||||||
|
: `Need ${plugin.minPlayers} player${plugin.minPlayers === 1 ? "" : "s"} before you can start.`
|
||||||
|
: `${hostPlayer?.username ?? "The host"} will start the game when the table is ready.`
|
||||||
|
: `This room starts automatically once ${plugin.maxPlayers} seats are filled.`;
|
||||||
|
|
||||||
|
const roomFacts = useMemo(() => {
|
||||||
|
const facts = [
|
||||||
|
{ label: "Format", value: plugin.manualStart ? "Manual start" : "Auto start" },
|
||||||
|
{ label: "Seats", value: plugin.minPlayers === plugin.maxPlayers ? `${plugin.maxPlayers}` : `${plugin.minPlayers}-${plugin.maxPlayers}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (timeControl) facts.push({ label: "Clock", value: timeControl });
|
||||||
|
if (betAmount > 0) facts.push({ label: "Stake", value: `${betAmount} AU` });
|
||||||
|
|
||||||
|
return facts;
|
||||||
|
}, [betAmount, plugin.manualStart, plugin.maxPlayers, plugin.minPlayers, timeControl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between gap-3 mb-4 md:mb-6">
|
<section className={cn("overflow-hidden rounded-[32px] border p-5 shadow-[0_24px_80px_rgba(0,0,0,0.22)] sm:p-6", gameSurfaceClass(plugin.slug))}>
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<span className="text-xl shrink-0">{plugin.icon}</span>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h1 className="font-display text-base font-semibold truncate">{plugin.name}</h1>
|
|
||||||
<div className="flex items-center gap-2 text-xs text-text-tertiary">
|
|
||||||
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold ${
|
|
||||||
roomStatus === "waiting" ? "bg-warning/15 text-warning"
|
|
||||||
: roomStatus === "playing" ? "bg-success/15 text-success"
|
|
||||||
: "bg-card text-text-tertiary"
|
|
||||||
}`}>
|
|
||||||
{roomStatus === "waiting" ? "Waiting" : roomStatus === "playing" ? "Playing" : "Finished"}
|
|
||||||
</span>
|
|
||||||
{isSpectator && <span className="text-text-disabled">Spectating</span>}
|
|
||||||
{betAmount > 0 && (
|
|
||||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-warning/15 text-warning">
|
|
||||||
{betAmount} AU{players.length > 1 ? `/player` : ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>👁 {spectators.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { leaveRoom(); navigate("/games"); }}
|
onClick={exitRoom}
|
||||||
className="rounded-md px-3 py-1.5 text-sm font-medium bg-raised text-text-tertiary hover:text-foreground transition-colors shrink-0"
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/10 px-3 py-1.5 text-sm text-text-secondary transition-colors hover:text-foreground"
|
||||||
>
|
>
|
||||||
Leave
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Games
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={exitRoom}
|
||||||
|
className="rounded-full border border-white/10 bg-black/10 px-3 py-1.5 text-sm text-text-secondary transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
Leave Room
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px] xl:items-start">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<span className="text-4xl">{plugin.icon}</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h1 className="font-display text-3xl font-semibold text-foreground sm:text-4xl">{plugin.name}</h1>
|
||||||
|
<span className={cn("rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em]", stateChip(roomStatus))}>
|
||||||
|
{stateLabel(roomStatus)}
|
||||||
|
</span>
|
||||||
|
{isSpectator && (
|
||||||
|
<span className="rounded-full border border-info/20 bg-info/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-info">
|
||||||
|
Spectating
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-text-secondary">{plugin.description}</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<span className="rounded-full border border-white/10 bg-black/10 px-3 py-1.5 text-xs text-text-secondary">
|
||||||
|
Room ID {roomCode}
|
||||||
|
</span>
|
||||||
|
{roomFacts.map(fact => (
|
||||||
|
<span key={fact.label} className="rounded-full border border-white/10 bg-black/10 px-3 py-1.5 text-xs text-text-secondary">
|
||||||
|
{fact.label}: {fact.value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/12 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-text-disabled">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
Players
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold text-foreground">{players.length}/{plugin.maxPlayers}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/12 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-text-disabled">
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
Spectators
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-lg font-semibold text-foreground">{spectators.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/12 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.18em] text-text-disabled">
|
||||||
|
{betAmount > 0 ? <Coins className="h-3.5 w-3.5" /> : <Clock3 className="h-3.5 w-3.5" />}
|
||||||
|
{betAmount > 0 ? "Stake" : "Launch"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm font-semibold text-foreground">
|
||||||
|
{betAmount > 0 ? `${betAmount} AU${plugin.slug === "blackjack" ? " / hand" : ""}` : plugin.manualStart ? "Host starts" : "Auto starts"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-black/12 p-4">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.2em] text-text-disabled">Room Notes</div>
|
||||||
|
<div className="mt-3 text-sm text-text-secondary">{startHint}</div>
|
||||||
|
{hostPlayer && (
|
||||||
|
<div className="mt-4 flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/10 px-4 py-3 text-sm">
|
||||||
|
<span className="text-text-tertiary">Host</span>
|
||||||
|
<span className="font-semibold text-foreground">{hostPlayer.username}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
{sessionReplaced && (
|
{sessionReplaced && (
|
||||||
<div className="mb-4 rounded-xl bg-warning/10 px-4 py-3 flex items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-warning/20 bg-warning/10 px-4 py-3">
|
||||||
<p className="text-sm text-warning">
|
<div className="text-sm text-warning">
|
||||||
You opened this game in another tab. Actions from this tab are disabled.
|
Another tab claimed this room session. Actions from this tab are currently disabled.
|
||||||
</p>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={rejoin}
|
onClick={rejoin}
|
||||||
className="shrink-0 text-xs font-medium text-warning underline hover:no-underline"
|
className="rounded-full border border-warning/20 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.18em] text-warning transition-colors hover:bg-warning/10"
|
||||||
>
|
>
|
||||||
Rejoin here
|
Rejoin Here
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 rounded-xl bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
<div className="rounded-2xl border border-destructive/20 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{gameOver && (
|
{gameOver && (
|
||||||
<div className="mb-4 rounded-xl bg-primary/10 px-4 py-3">
|
<div className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3">
|
||||||
<div className="text-sm font-semibold text-primary">
|
<div className="text-sm font-semibold text-primary">
|
||||||
{gameOver.winner
|
{gameOver.winner
|
||||||
? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}`
|
? `Winner: ${players.find(player => player.discordId === gameOver.winner)?.username ?? gameOver.winner}`
|
||||||
: "Draw!"}
|
: "Draw"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-text-tertiary mt-1">{gameOver.reason}</div>
|
<div className="mt-1 text-sm text-text-secondary">{gameOver.reason}</div>
|
||||||
{gameOver.payout && (
|
{gameOver.payout && (
|
||||||
<div className="text-xs font-semibold text-warning mt-1.5">
|
<div className="mt-2 text-sm font-semibold text-warning">
|
||||||
{gameOver.payout.refunded
|
{gameOver.payout.refunded
|
||||||
? `Wager refunded: ${gameOver.payout.amount} AU`
|
? `Wager refunded: ${gameOver.payout.amount} AU`
|
||||||
: `Payout: ${gameOver.payout.amount} AU`}
|
: `Payout: ${gameOver.payout.amount} AU`}
|
||||||
@@ -156,60 +388,150 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{roomStatus === "finished" && (
|
|
||||||
<div className="mt-4 text-center">
|
|
||||||
<button
|
|
||||||
onClick={() => { leaveRoom(); navigate("/games"); }}
|
|
||||||
className="rounded-xl bg-primary text-on-primary px-5 py-2 text-sm font-label font-medium hover:opacity-90 transition-colors"
|
|
||||||
>
|
|
||||||
Back to Lobby
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</section>
|
||||||
|
|
||||||
{roomStatus === "waiting" && (
|
{roomStatus === "waiting" && (
|
||||||
<div className="bg-card rounded-xl p-5 md:p-8">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<div className="text-sm font-semibold mb-4 text-center">
|
<section className="rounded-[32px] border border-white/10 bg-card/70 p-5 shadow-[0_20px_60px_rgba(0,0,0,0.16)] sm:p-6">
|
||||||
Waiting for players ({players.length}/{plugin.maxPlayers})
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.2em] text-text-disabled">Waiting Room</div>
|
||||||
|
<h2 className="mt-3 font-display text-3xl font-semibold text-foreground">
|
||||||
|
{plugin.manualStart ? "Seat players and launch when ready" : "Filling seats before the game begins"}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm text-text-tertiary">
|
||||||
|
{plugin.manualStart
|
||||||
|
? "Hosts can keep collecting players and spectators, then start the table manually."
|
||||||
|
: "The game will start automatically as soon as every required player seat is occupied."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 justify-center mb-6 flex-wrap">
|
<div className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-right">
|
||||||
{Array.from({ length: Math.max(players.length + 1, plugin.minPlayers ?? 1, Math.min(plugin.maxPlayers, 4)) }).map((_, i) => {
|
<div className="text-[11px] uppercase tracking-[0.18em] text-text-disabled">Seats Filled</div>
|
||||||
if (i >= plugin.maxPlayers) return null;
|
<div className="mt-1 text-2xl font-semibold text-foreground">{players.length}/{plugin.maxPlayers}</div>
|
||||||
const player = players[i];
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{Array.from({ length: plugin.maxPlayers }).map((_, index) => {
|
||||||
|
const player = players[index];
|
||||||
|
const isSeatHost = index === 0 && !!player;
|
||||||
|
const isMe = player?.discordId === userId;
|
||||||
return (
|
return (
|
||||||
<div key={i} className={`flex flex-col items-center gap-2 px-4 py-3 rounded-xl ${player ? "bg-primary/10" : "bg-surface"}`}>
|
<div
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold ${player ? "bg-primary/20 text-primary" : "bg-surface text-text-disabled animate-pulse"}`}>
|
key={`${player?.discordId ?? "empty"}-${index}`}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[28px] border p-4 transition-colors",
|
||||||
|
player
|
||||||
|
? "border-white/10 bg-black/12"
|
||||||
|
: "border-dashed border-white/10 bg-black/6",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"flex h-12 w-12 items-center justify-center rounded-2xl text-lg font-semibold",
|
||||||
|
player ? "bg-primary/12 text-primary" : "bg-card text-text-disabled",
|
||||||
|
)}>
|
||||||
{player ? player.username[0]?.toUpperCase() : "?"}
|
{player ? player.username[0]?.toUpperCase() : "?"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs font-medium">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
{player ? player.username : <span className="text-text-disabled">Waiting...</span>}
|
{isSeatHost && (
|
||||||
|
<span className="rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-primary">
|
||||||
|
Host
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isMe && (
|
||||||
|
<span className="rounded-full border border-info/20 bg-info/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-info">
|
||||||
|
You
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-text-disabled">
|
</div>
|
||||||
Player {i + 1}
|
<div className="mt-4 text-lg font-semibold text-foreground">
|
||||||
|
{player ? player.username : "Open Seat"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-text-tertiary">
|
||||||
|
{player ? `Player ${index + 1}` : "Share the invite to fill this seat."}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<CopyInviteLink url={window.location.href} />
|
</section>
|
||||||
{plugin.manualStart && players.length >= (plugin.minPlayers ?? 1) && players[0]?.discordId === userId && (
|
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<CopyInviteCard url={window.location.href} />
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-card/70 p-5">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.2em] text-text-disabled">Room Settings</div>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{roomFacts.map(fact => (
|
||||||
|
<div key={fact.label} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/10 px-4 py-3 text-sm">
|
||||||
|
<span className="text-text-tertiary">{fact.label}</span>
|
||||||
|
<span className="font-semibold text-foreground">{fact.value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-card/70 p-5">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.2em] text-text-disabled">Actions</div>
|
||||||
|
<div className="mt-3 text-sm text-text-secondary">{startHint}</div>
|
||||||
|
|
||||||
|
{plugin.manualStart && isHost && (
|
||||||
<button
|
<button
|
||||||
onClick={startGame}
|
onClick={startGame}
|
||||||
className="mt-4 w-full max-w-sm mx-auto block rounded-xl bg-primary text-on-primary px-4 py-2.5 text-sm font-label font-semibold hover:opacity-90 transition-colors"
|
disabled={!readyToStart}
|
||||||
|
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 py-3 text-sm font-semibold text-on-primary transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
>
|
>
|
||||||
Start Game ({players.length} player{players.length !== 1 ? "s" : ""})
|
<Play className="h-4 w-4" />
|
||||||
|
Start Game
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{role === "admin" && players.length < plugin.maxPlayers && (
|
{role === "admin" && players.length < plugin.maxPlayers && (
|
||||||
<button
|
<button
|
||||||
onClick={fillRoom}
|
onClick={fillRoom}
|
||||||
className="mt-4 w-full max-w-sm rounded-md px-4 py-2 text-sm font-medium bg-warning/10 text-warning border border-warning/30 hover:bg-warning/20 transition-colors"
|
className="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-warning/20 bg-warning/10 px-4 py-3 text-sm font-semibold text-warning transition hover:bg-warning/15"
|
||||||
>
|
>
|
||||||
Start Solo Test
|
<Shield className="h-4 w-4" />
|
||||||
|
Fill Room For Test
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-card/70 p-5">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.2em] text-text-disabled">Spectators</div>
|
||||||
|
{spectators.length === 0 ? (
|
||||||
|
<div className="mt-3 rounded-2xl border border-dashed border-white/10 bg-black/8 px-4 py-5 text-sm text-text-tertiary">
|
||||||
|
No spectators yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{spectators.map(spectator => (
|
||||||
|
<div key={spectator.discordId} className="flex items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/10 px-4 py-3 text-sm">
|
||||||
|
<span className="font-medium text-foreground">{spectator.username}</span>
|
||||||
|
<span className="text-text-tertiary">Watching</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(roomStatus === "playing" || roomStatus === "finished") && (
|
||||||
|
<CompactRoomBar
|
||||||
|
roomCode={roomCode}
|
||||||
|
state={roomStatus}
|
||||||
|
isSpectator={isSpectator}
|
||||||
|
playerCount={players.length}
|
||||||
|
maxPlayers={plugin.maxPlayers}
|
||||||
|
spectatorCount={spectators.length}
|
||||||
|
facts={roomFacts}
|
||||||
|
onExit={exitRoom}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(roomStatus === "playing" || roomStatus === "finished") && gameState != null && (
|
{(roomStatus === "playing" || roomStatus === "finished") && gameState != null && (
|
||||||
@@ -223,6 +545,17 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) {
|
|||||||
roomOptions={roomOptions}
|
roomOptions={roomOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{roomStatus === "finished" && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={exitRoom}
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2.5 text-sm font-semibold text-on-primary transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
Back to Lobby
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -452,6 +452,126 @@ function EmptySeatCard({ onSit }: { onSit: () => void }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MobileSeatRail({
|
||||||
|
seatedPlayers,
|
||||||
|
activePlayerId,
|
||||||
|
myPlayerId,
|
||||||
|
betAmount,
|
||||||
|
canSitDown,
|
||||||
|
onSit,
|
||||||
|
}: {
|
||||||
|
seatedPlayers: Array<{ playerId: string; seat: PlayerSeatView; name: string }>;
|
||||||
|
activePlayerId: string | null;
|
||||||
|
myPlayerId: string;
|
||||||
|
betAmount: number;
|
||||||
|
canSitDown: boolean;
|
||||||
|
onSit: () => void;
|
||||||
|
}) {
|
||||||
|
const seatCards = Array.from({ length: 6 }, (_, index) => {
|
||||||
|
const entry = seatedPlayers[index] ?? null;
|
||||||
|
if (!entry) return { kind: "empty" as const, index };
|
||||||
|
|
||||||
|
const { playerId, seat, name } = entry;
|
||||||
|
const isActive = activePlayerId === playerId;
|
||||||
|
const isMe = playerId === myPlayerId;
|
||||||
|
const summary = seat.roundNet !== null
|
||||||
|
? formatSignedAu(seat.roundNet)
|
||||||
|
: seat.hands.length > 0
|
||||||
|
? `${seat.hands.length} hand${seat.hands.length === 1 ? "" : "s"}`
|
||||||
|
: seat.hasBet
|
||||||
|
? "Bet locked"
|
||||||
|
: betAmount > 0
|
||||||
|
? "Needs buy-in"
|
||||||
|
: "Waiting";
|
||||||
|
|
||||||
|
return { kind: "seat" as const, index, playerId, name, seat, isActive, isMe, summary };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="md:hidden">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.2em] text-white/45">Table Seats</div>
|
||||||
|
<div className="mt-1 text-sm text-white/65">Fast seat scan for mobile play and spectating.</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-full border border-white/10 bg-black/15 px-3 py-1.5 text-xs text-white/70">
|
||||||
|
{seatedPlayers.length}/6 filled
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="-mx-1 mt-4 flex snap-x gap-3 overflow-x-auto px-1 pb-1">
|
||||||
|
{seatCards.map(card => (
|
||||||
|
card.kind === "seat" ? (
|
||||||
|
<div
|
||||||
|
key={card.playerId}
|
||||||
|
className={`min-w-[152px] snap-start rounded-[22px] border p-3 ${
|
||||||
|
card.isActive
|
||||||
|
? "border-primary/45 bg-white/10"
|
||||||
|
: "border-white/10 bg-black/15"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className={`flex h-10 w-10 items-center justify-center rounded-2xl text-sm font-bold ${
|
||||||
|
card.isActive ? "bg-primary/20 text-primary" : "bg-white/10 text-white"
|
||||||
|
}`}>
|
||||||
|
{card.name[0]?.toUpperCase() ?? "?"}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
{card.isMe && (
|
||||||
|
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-primary">
|
||||||
|
You
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{card.isActive && (
|
||||||
|
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-emerald-200">
|
||||||
|
Acting
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 truncate text-sm font-semibold text-white">{card.name}</div>
|
||||||
|
<div className="mt-1 text-xs text-white/55">Seat {card.index + 1}</div>
|
||||||
|
<div className="mt-3 rounded-2xl border border-white/10 bg-black/15 px-3 py-2">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.16em] text-white/40">Status</div>
|
||||||
|
<div className={`mt-1 inline-flex rounded-full border px-2.5 py-1 text-xs ${toneClasses(card.seat.roundNet ?? card.seat.cumulativePnl)}`}>
|
||||||
|
{card.summary}
|
||||||
|
</div>
|
||||||
|
{betAmount > 0 && card.seat.totalWager > 0 && (
|
||||||
|
<div className="mt-2 text-xs text-white/60">Wager {formatAu(card.seat.totalWager)}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={`empty-${card.index}`}
|
||||||
|
className="min-w-[152px] snap-start rounded-[22px] border border-dashed border-white/10 bg-black/10 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-white/5 text-white/50">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm font-semibold text-white">Open Seat</div>
|
||||||
|
<div className="mt-1 text-xs text-white/50">Seat {card.index + 1}</div>
|
||||||
|
{canSitDown ? (
|
||||||
|
<button
|
||||||
|
onClick={onSit}
|
||||||
|
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-primary px-3 py-2 text-xs font-semibold text-on-primary transition hover:opacity-90"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Sit Down
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4 rounded-2xl border border-white/10 bg-black/15 px-3 py-2 text-xs text-white/55">
|
||||||
|
Available next betting round.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DealerPanel({ hand, visibleValue, fullValue }: {
|
function DealerPanel({ hand, visibleValue, fullValue }: {
|
||||||
hand: Card[];
|
hand: Card[];
|
||||||
visibleValue: number;
|
visibleValue: number;
|
||||||
@@ -622,6 +742,17 @@ export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, player
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<MobileSeatRail
|
||||||
|
seatedPlayers={seatedPlayers}
|
||||||
|
activePlayerId={view.activePlayerId}
|
||||||
|
myPlayerId={myPlayerId}
|
||||||
|
betAmount={betAmount}
|
||||||
|
canSitDown={canSitDown}
|
||||||
|
onSit={handleSit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
<div className="mt-5 grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||||
{seatedPlayers.map(({ playerId, seat, name }) => (
|
{seatedPlayers.map(({ playerId, seat, name }) => (
|
||||||
<SeatPanel
|
<SeatPanel
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Square } from "chess.js";
|
|||||||
import { Check, Clock3, Eye, Flag, Handshake, RotateCw, TimerReset, X } from "lucide-react";
|
import { Check, Clock3, Eye, Flag, Handshake, RotateCw, TimerReset, X } from "lucide-react";
|
||||||
import type { GameUIProps } from "../registry";
|
import type { GameUIProps } from "../registry";
|
||||||
import { chessPieces } from "./pieces";
|
import { chessPieces } from "./pieces";
|
||||||
|
import { CHESS_TIME_CONTROL_LABELS } from "./timeControls";
|
||||||
|
|
||||||
interface ChessClockView {
|
interface ChessClockView {
|
||||||
white: number;
|
white: number;
|
||||||
@@ -47,19 +48,6 @@ interface LocalNotice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"] as const;
|
const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"] as const;
|
||||||
const CHESS_TIME_CONTROL_LABELS: Record<string, string> = {
|
|
||||||
bullet_1_0: "Bullet 1+0",
|
|
||||||
bullet_2_1: "Bullet 2+1",
|
|
||||||
blitz_3_0: "Blitz 3+0",
|
|
||||||
blitz_3_2: "Blitz 3+2",
|
|
||||||
blitz_5_0: "Blitz 5+0",
|
|
||||||
blitz_5_3: "Blitz 5+3",
|
|
||||||
rapid_10_0: "Rapid 10+0",
|
|
||||||
rapid_15_10: "Rapid 15+10",
|
|
||||||
classical_30_0: "Classical 30+0",
|
|
||||||
none: "No Clock",
|
|
||||||
};
|
|
||||||
|
|
||||||
function isPlayerView(state: unknown): state is PlayerView {
|
function isPlayerView(state: unknown): state is PlayerView {
|
||||||
return typeof state === "object" && state !== null && "myColor" in state;
|
return typeof state === "object" && state !== null && "myColor" in state;
|
||||||
}
|
}
|
||||||
@@ -627,8 +615,58 @@ export function ChessGame({ state, myPlayerId, isSpectator, onAction, players, r
|
|||||||
metaFacts.push({ label: "Wager", value: `${roomOptions!.betAmount} AU` });
|
metaFacts.push({ label: "Wager", value: `${roomOptions!.betAmount} AU` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recapFacts = [
|
||||||
|
{ label: "Turn", value: colorLabel(view.turn) },
|
||||||
|
{ label: "Latest", value: latestMove?.san ?? "Opening" },
|
||||||
|
{ label: "Clock", value: timeControlLabel },
|
||||||
|
];
|
||||||
|
|
||||||
|
if ((roomOptions?.betAmount ?? 0) > 0) {
|
||||||
|
recapFacts.push({ label: "Stake", value: `${roomOptions!.betAmount} AU` });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[1440px]">
|
<div className="mx-auto w-full max-w-[1440px]">
|
||||||
|
<div className="mb-5 rounded-[28px] border border-border/60 bg-[radial-gradient(circle_at_top,rgba(233,195,73,0.12),transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.03),rgba(255,255,255,0.01))] p-4 shadow-[0_18px_50px_rgba(0,0,0,0.22)]">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-text-disabled">Quick Recap</div>
|
||||||
|
<div className="mt-2 text-xl font-display font-semibold text-foreground">{statusTitle}</div>
|
||||||
|
<div className="mt-1 text-sm text-text-secondary">{statusText}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
|
{view.isCheck && !isGameOver && (
|
||||||
|
<span className="rounded-full bg-destructive/15 px-2.5 py-1 text-destructive">Check</span>
|
||||||
|
)}
|
||||||
|
{!isSpectator && (
|
||||||
|
<span className="rounded-full bg-card/70 px-2.5 py-1 text-text-secondary">
|
||||||
|
Playing as {colorLabel(myColor)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isSpectator && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-card/70 px-2.5 py-1 text-text-secondary">
|
||||||
|
<Eye className="h-3.5 w-3.5" />
|
||||||
|
Spectating
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{queuedMove && !isGameOver && (
|
||||||
|
<span className="rounded-full bg-info/15 px-2.5 py-1 text-info">
|
||||||
|
Premove: {formatMoveIntent(queuedMove)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{recapFacts.map(fact => (
|
||||||
|
<div key={fact.label} className="rounded-2xl border border-border/60 bg-card/80 px-4 py-3">
|
||||||
|
<div className="text-[11px] uppercase tracking-[0.18em] text-text-disabled">{fact.label}</div>
|
||||||
|
<div className="mt-1 truncate text-sm font-semibold text-foreground">{fact.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start">
|
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start">
|
||||||
<section className="min-w-0">
|
<section className="min-w-0">
|
||||||
<div className="mx-auto flex max-w-[860px] flex-col items-center gap-4 lg:min-h-[calc(100vh-14rem)] lg:justify-center">
|
<div className="mx-auto flex max-w-[860px] flex-col items-center gap-4 lg:min-h-[calc(100vh-14rem)] lg:justify-center">
|
||||||
|
|||||||
25
panel/src/games/chess/timeControls.ts
Normal file
25
panel/src/games/chess/timeControls.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
export interface ChessTimeControlOption {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
category: "Bullet" | "Blitz" | "Rapid" | "Classical" | "No Clock";
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHESS_TIME_CONTROLS: ChessTimeControlOption[] = [
|
||||||
|
{ key: "bullet_1_0", label: "1+0", category: "Bullet", detail: "Instant games with no increment." },
|
||||||
|
{ key: "bullet_2_1", label: "2+1", category: "Bullet", detail: "Fast clock with a small recovery buffer." },
|
||||||
|
{ key: "blitz_3_0", label: "3+0", category: "Blitz", detail: "Classic blitz pace for sharp openings." },
|
||||||
|
{ key: "blitz_3_2", label: "3+2", category: "Blitz", detail: "Short games with enough time to convert." },
|
||||||
|
{ key: "blitz_5_0", label: "5+0", category: "Blitz", detail: "Simple blitz with no increment pressure." },
|
||||||
|
{ key: "blitz_5_3", label: "5+3", category: "Blitz", detail: "Balanced default for most quick matches." },
|
||||||
|
{ key: "rapid_10_0", label: "10+0", category: "Rapid", detail: "More room for calculated middlegames." },
|
||||||
|
{ key: "rapid_15_10", label: "15+10", category: "Rapid", detail: "Longer sessions with generous increment." },
|
||||||
|
{ key: "classical_30_0", label: "30+0", category: "Classical", detail: "Deliberate play for deep analysis." },
|
||||||
|
{ key: "none", label: "None", category: "No Clock", detail: "Untimed board for casual or teaching games." },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CHESS_TIME_CONTROL_LABELS = Object.fromEntries(
|
||||||
|
CHESS_TIME_CONTROLS.map(control => [control.key, `${control.category === "No Clock" ? "" : `${control.category} `}${control.label}`.trim()]),
|
||||||
|
) as Record<string, string>;
|
||||||
|
|
||||||
|
export const CHESS_TIME_CONTROL_CATEGORIES = ["Bullet", "Blitz", "Rapid", "Classical", "No Clock"] as const;
|
||||||
@@ -14,6 +14,8 @@ export interface GameUIPlugin {
|
|||||||
slug: string;
|
slug: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
tagline: string;
|
||||||
|
description: string;
|
||||||
minPlayers: number;
|
minPlayers: number;
|
||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
/** If true, the host must manually start the game. */
|
/** If true, the host must manually start the game. */
|
||||||
@@ -47,6 +49,8 @@ gameUIRegistry.register({
|
|||||||
slug: "chess",
|
slug: "chess",
|
||||||
name: "Chess",
|
name: "Chess",
|
||||||
icon: "\u265A",
|
icon: "\u265A",
|
||||||
|
tagline: "Head-to-head duels with clock and wager options.",
|
||||||
|
description: "Challenge another player, choose a time control, and launch a clean board built for focused play.",
|
||||||
minPlayers: 2,
|
minPlayers: 2,
|
||||||
maxPlayers: 2,
|
maxPlayers: 2,
|
||||||
component: ChessGame,
|
component: ChessGame,
|
||||||
@@ -56,6 +60,8 @@ gameUIRegistry.register({
|
|||||||
slug: "blackjack",
|
slug: "blackjack",
|
||||||
name: "Blackjack",
|
name: "Blackjack",
|
||||||
icon: "\uD83C\uDCA1",
|
icon: "\uD83C\uDCA1",
|
||||||
|
tagline: "Live table rounds with seating, betting, and spectator support.",
|
||||||
|
description: "Open a table, set the stake, and manage a shared room where players can buy in and queue for the next hand.",
|
||||||
minPlayers: 1,
|
minPlayers: 1,
|
||||||
maxPlayers: 6,
|
maxPlayers: 6,
|
||||||
manualStart: true,
|
manualStart: true,
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ interface GameRoomState {
|
|||||||
roomOptions: { betAmount?: number; timeControl?: string };
|
roomOptions: { betAmount?: number; timeControl?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createInitialRoomState(): GameRoomState {
|
||||||
|
return {
|
||||||
|
gameState: null,
|
||||||
|
players: [],
|
||||||
|
spectators: [],
|
||||||
|
roomStatus: "connecting",
|
||||||
|
isSpectator: false,
|
||||||
|
gameOver: null,
|
||||||
|
roundResult: null,
|
||||||
|
error: null,
|
||||||
|
sessionReplaced: false,
|
||||||
|
roomOptions: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function useGameRoom(roomId: string, userId: string, role?: string, preferAs: "player" | "spectator" = "player") {
|
export function useGameRoom(roomId: string, userId: string, role?: string, preferAs: "player" | "spectator" = "player") {
|
||||||
const { send, subscribe, connected } = useWebSocket();
|
const { send, subscribe, connected } = useWebSocket();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -38,22 +53,13 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [state, setState] = useState<GameRoomState>({
|
const [state, setState] = useState<GameRoomState>(() => createInitialRoomState());
|
||||||
gameState: null,
|
|
||||||
players: [],
|
|
||||||
spectators: [],
|
|
||||||
roomStatus: "connecting",
|
|
||||||
isSpectator: false,
|
|
||||||
gameOver: null,
|
|
||||||
roundResult: null,
|
|
||||||
error: null,
|
|
||||||
sessionReplaced: false,
|
|
||||||
roomOptions: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connected) return;
|
if (!connected) return;
|
||||||
|
|
||||||
|
setState(createInitialRoomState());
|
||||||
|
|
||||||
send({ type: "JOIN_ROOM", roomId, preferAs, role: role ?? "player" });
|
send({ type: "JOIN_ROOM", roomId, preferAs, role: role ?? "player" });
|
||||||
|
|
||||||
const unsubscribe = subscribe((msg: any) => {
|
const unsubscribe = subscribe((msg: any) => {
|
||||||
@@ -180,7 +186,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe
|
|||||||
send({ type: "LEAVE_ROOM", roomId });
|
send({ type: "LEAVE_ROOM", roomId });
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
};
|
};
|
||||||
}, [roomId, connected, userId, send, subscribe]);
|
}, [connected, preferAs, role, roomId, send, subscribe, userId]);
|
||||||
|
|
||||||
const sendAction = useCallback((action: unknown) => {
|
const sendAction = useCallback((action: unknown) => {
|
||||||
const sent = send({ type: "GAME_ACTION", roomId, action });
|
const sent = send({ type: "GAME_ACTION", roomId, action });
|
||||||
|
|||||||
Reference in New Issue
Block a user