diff --git a/panel/src/games/GameLobby.tsx b/panel/src/games/GameLobby.tsx index a4ea7a3..91e4fe7 100644 --- a/panel/src/games/GameLobby.tsx +++ b/panel/src/games/GameLobby.tsx @@ -1,10 +1,26 @@ -import { useState, useEffect } from "react"; +import type { ReactNode } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { + ArrowRight, + ChevronLeft, + Clock3, + Coins, + Eye, + Gamepad2, + Sparkles, + Swords, + Users, +} from "lucide-react"; +import { cn } from "../lib/utils"; import { useWebSocket } from "../lib/useWebSocket"; import { gameUIRegistry } from "./registry"; -import { ChevronLeft } from "lucide-react"; +import { + CHESS_TIME_CONTROLS, + CHESS_TIME_CONTROL_CATEGORIES, + CHESS_TIME_CONTROL_LABELS, +} from "./chess/timeControls"; -// Mirrors RoomSummary in api/src/games/types.ts โ€” keep in sync interface RoomSummary { id: string; gameSlug: string; @@ -17,18 +33,409 @@ interface RoomSummary { betAmount: number; } -const CHESS_TIME_CONTROLS = [ - { key: "bullet_1_0", label: "1+0", category: "Bullet" }, - { key: "bullet_2_1", label: "2+1", category: "Bullet" }, - { key: "blitz_3_0", label: "3+0", category: "Blitz" }, - { key: "blitz_3_2", label: "3+2", category: "Blitz" }, - { key: "blitz_5_0", label: "5+0", category: "Blitz" }, - { key: "blitz_5_3", label: "5+3", category: "Blitz" }, - { key: "rapid_10_0", label: "10+0", category: "Rapid" }, - { key: "rapid_15_10", label: "15+10", category: "Rapid" }, - { key: "classical_30_0", label: "30+0", category: "Classical" }, - { key: "none", label: "None", category: "No Clock" }, -] as const; +type ConfiguredGame = "chess" | "blackjack" | null; + +const BET_OPTIONS = [0, 10, 25, 50, 100, 250, 500] as const; + +function roomStatusMeta(status: RoomSummary["status"]) { + if (status === "waiting") { + return { + label: "Waiting", + chipClassName: "border-warning/25 bg-warning/12 text-warning", + accentClassName: "from-warning/18 via-warning/8 to-transparent", + }; + } + if (status === "playing") { + return { + label: "In Progress", + chipClassName: "border-success/25 bg-success/12 text-success", + accentClassName: "from-success/18 via-success/8 to-transparent", + }; + } + return { + label: "Finished", + chipClassName: "border-border/70 bg-card/80 text-text-tertiary", + accentClassName: "from-white/8 via-transparent to-transparent", + }; +} + +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_40%),linear-gradient(180deg,rgba(6,95,70,0.45),rgba(6,78,59,0.12))]" + : "border-primary/20 bg-[radial-gradient(circle_at_top,rgba(233,195,73,0.14),transparent_40%),linear-gradient(180deg,rgba(61,46,0,0.35),rgba(61,46,0,0.08))]"; +} + +function roomActionLabel(room: RoomSummary): string { + return room.status === "waiting" ? "Join Room" : "Spectate"; +} + +function orderRooms(a: RoomSummary, b: RoomSummary): number { + const statusWeight = { waiting: 0, playing: 1, finished: 2 } as const; + return statusWeight[a.status] - statusWeight[b.status] + || b.playerCount - a.playerCount + || b.spectatorCount - a.spectatorCount; +} + +function MetricCard({ label, value, hint }: { label: string; value: string; hint: string }) { + return ( +
+
{label}
+
{value}
+
{hint}
+
+ ); +} + +function FilterChip({ + active, + children, + onClick, +}: { + active: boolean; + children: ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +function CreateRoomDialog({ + configGame, + show, + onBack, + onClose, + onChooseGame, + onCreateChess, + onCreateBlackjack, + chessTimeControl, + setChessTimeControl, + soloMode, + setSoloMode, + betAmount, + setBetAmount, +}: { + configGame: ConfiguredGame; + show: boolean; + onBack: () => void; + onClose: () => void; + onChooseGame: (slug: string) => void; + onCreateChess: () => void; + onCreateBlackjack: () => void; + chessTimeControl: string; + setChessTimeControl: (value: string) => void; + soloMode: boolean; + setSoloMode: (value: boolean | ((previous: boolean) => boolean)) => void; + betAmount: number; + setBetAmount: (value: number) => void; +}) { + if (!show) return null; + + const selectedGame = configGame ? gameUIRegistry.get(configGame) : null; + const selectedChessControl = CHESS_TIME_CONTROLS.find(control => control.key === chessTimeControl) ?? CHESS_TIME_CONTROLS[0]; + + return ( +
+
event.stopPropagation()} + > + {selectedGame ? ( +
+
+
+ +
+ {selectedGame.icon} +
+

{selectedGame.name}

+

{selectedGame.description}

+
+
+
+ +
+ + {configGame === "chess" ? ( +
+
+ {CHESS_TIME_CONTROL_CATEGORIES.map(category => { + const controls = CHESS_TIME_CONTROLS.filter(control => control.category === category); + return ( +
+
{category}
+
+ {controls.map(control => { + const active = chessTimeControl === control.key; + return ( + + ); + })} +
+
+ ); + })} + +
+
+
+
Solo Play
+
+ Fill both sides with your own session for testing or practice. +
+
+ +
+
+ +
+
Wager
+
+ Stake the match only when both seats are played by different users. +
+
+ {BET_OPTIONS.map(amount => ( + + ))} +
+
+
+ + +
+ ) : ( +
+
+
+
Table Format
+
+
+
Manual Start
+
+ The host opens the deal when the table is ready. +
+
+
+
Up To 6 Seats
+
+ Spectators can watch and claim an empty seat during betting. +
+
+
+
+ +
+
Table Stake
+
+ This value becomes the base bet for each new hand at the table. +
+
+ {BET_OPTIONS.map(amount => ( + + ))} +
+
+
+ + +
+ )} +
+ ) : ( +
+
+
+
+ + Create Room +
+

Choose a game room

+

+ Pick the table or duel you want to host, then tune the settings before you publish the invite link. +

+
+ +
+ +
+ {gameUIRegistry.list().map(game => ( + + ))} +
+
+ )} +
+
+ ); +} export function GameLobby() { const { send, subscribe, connected } = useWebSocket(); @@ -36,7 +443,7 @@ export function GameLobby() { const [rooms, setRooms] = useState([]); const [filter, setFilter] = useState(null); const [showCreate, setShowCreate] = useState(false); - const [configGame, setConfigGame] = useState(null); + const [configGame, setConfigGame] = useState(null); const [chessTimeControl, setChessTimeControl] = useState("blitz_5_3"); const [soloMode, setSoloMode] = useState(false); const [betAmount, setBetAmount] = useState(0); @@ -56,25 +463,36 @@ export function GameLobby() { }); return unsubscribe; - }, [connected, subscribe, navigate]); + }, [connected, navigate, subscribe]); - const filteredRooms = filter ? rooms.filter(r => r.gameSlug === filter) : rooms; - const activeRooms = filteredRooms.filter(r => r.status !== "finished"); + const activeRooms = useMemo(() => { + const nextRooms = filter ? rooms.filter(room => room.gameSlug === filter) : rooms; + return nextRooms.filter(room => room.status !== "finished").sort(orderRooms); + }, [filter, rooms]); + + const waitingRoomCount = rooms.filter(room => room.status === "waiting").length; + const totalPlayers = rooms.reduce((sum, room) => sum + room.playerCount, 0); + const totalSpectators = rooms.reduce((sum, room) => sum + room.spectatorCount, 0); + const wagerTables = rooms.filter(room => room.betAmount > 0 && room.status !== "finished").length; + + function resetCreateState() { + setShowCreate(false); + setConfigGame(null); + setChessTimeControl("blitz_5_3"); + setSoloMode(false); + setBetAmount(0); + } function handleGameSelect(gameSlug: string) { - if (gameSlug === "chess") { - setConfigGame("chess"); - setSoloMode(false); - setBetAmount(0); - return; - } - if (gameSlug === "blackjack") { - setConfigGame("blackjack"); + if (gameSlug === "chess" || gameSlug === "blackjack") { + setConfigGame(gameSlug); setBetAmount(0); + if (gameSlug === "chess") setSoloMode(false); return; } + send({ type: "CREATE_ROOM", gameType: gameSlug }); - setShowCreate(false); + resetCreateState(); } function createChessRoom() { @@ -87,10 +505,7 @@ export function GameLobby() { ...(betAmount > 0 && !soloMode && { betAmount }), }, }); - setShowCreate(false); - setConfigGame(null); - setSoloMode(false); - setBetAmount(0); + resetCreateState(); } function createBlackjackRoom() { @@ -101,259 +516,290 @@ export function GameLobby() { ...(betAmount > 0 && { betAmount }), }, }); - setShowCreate(false); - setConfigGame(null); - setBetAmount(0); + resetCreateState(); } return ( -
-
-
-

Games

-

Browse and create game rooms

-
- -
+
+
+
+
+
+ + Game Rooms +
+

+ Host faster. Join clearer. Spectate without guessing. +

+

+ The lobby now separates waiting rooms from active tables, exposes launch rules up front, and makes room creation feel like one guided flow instead of a stack of toggles. +

-
- - {gameTypes.map(g => ( - - ))} -
+
+ +
+ + {connected ? "Live connection" : "Reconnecting"} +
+
-
-
- Active Rooms - ({activeRooms.length}) -
- {activeRooms.length === 0 ? ( -
- No active rooms. Create one to get started! +
+ + + +
- ) : ( -
- {activeRooms.map(room => { - const plugin = gameUIRegistry.get(room.gameSlug); - return ( -
-
- {plugin?.icon ?? "๐ŸŽฎ"} -
-
{room.gameName}
-
- - {room.status === "waiting" ? "Waiting" : "Playing"} + +
+ {gameTypes.map(game => ( + + ))} + +
+
Lobby Snapshot
+
+
+ + {waitingRoomCount} room{waitingRoomCount !== 1 ? "s" : ""} waiting to start +
+
+ + {wagerTables} wager table{wagerTables !== 1 ? "s" : ""} live now +
+
+ + {totalSpectators} spectator{totalSpectators !== 1 ? "s" : ""} across rooms +
+
+
+
+
+
+ +
+
+
+ setFilter(null)}>All Games + {gameTypes.map(game => ( + setFilter(game.slug)}> + + {game.icon} + {game.name} + + + ))} +
+ +
+ {activeRooms.length === 0 ? ( +
+
+ +
+
No active rooms yet
+

+ Create a new room to seed the lobby, or switch filters if you expected another game type. +

+ +
+ ) : ( + activeRooms.map(room => { + const plugin = gameUIRegistry.get(room.gameSlug); + const status = roomStatusMeta(room.status); + const openSeats = Math.max(room.maxPlayers - room.playerCount, 0); + const roomFacts = [ + `${room.playerCount}/${room.maxPlayers} seats filled`, + openSeats > 0 ? `${openSeats} open` : "Table full", + ]; + + if (room.betAmount > 0) { + roomFacts.push(`${room.betAmount} AU stake`); + } + + return ( +
+
+
+
+
+
+ {plugin?.icon ?? "๐ŸŽฎ"} +
+
{room.gameName}
+
+ {plugin?.description ?? "Game room"} +
+
+
+
+ + {status.label} - {room.playerCount}/{room.maxPlayers} players - {room.betAmount > 0 && ( - - {room.betAmount} AU +
+ +
+ {roomFacts.map(fact => ( + + {fact} - )} - {room.spectatorCount > 0 && ยท ๐Ÿ‘ {room.spectatorCount}} + ))} +
+ +
+
+
+ + Players +
+
{room.playerCount}/{room.maxPlayers}
+
+
+
+ + Spectators +
+
{room.spectatorCount}
+
+
+
+ + Launch +
+
+ {plugin?.manualStart ? "Host starts" : "Auto starts"} +
+
+
+ +
+
+ Room ID {room.id.slice(0, 8).toUpperCase()} +
+
-
- -
- ); - })} -
- )} -
- - {showCreate && ( -
{ setShowCreate(false); setConfigGame(null); }}> -
e.stopPropagation()}> - {configGame === "blackjack" ? ( - <> - -

{"\uD83C\uDCA1"} Blackjack

-

Beat the dealer โ€” closest to 21 wins

- - {/* Bet Amount */} -
-
- Wager (AU) -
-
- {[0, 10, 25, 50, 100, 250, 500].map(amt => ( - - ))} -
-
- - - - ) : configGame === "chess" ? ( - <> - -

{"\u265A"} Chess

-

Choose your time control

- -
- {["Bullet", "Blitz", "Rapid", "Classical", "No Clock"].map(category => { - const controls = CHESS_TIME_CONTROLS.filter(tc => tc.category === category); - return ( -
-
- {category} -
-
- {controls.map(tc => ( - - ))} -
-
- ); - })} -
- - {/* Solo Mode Toggle */} -
-
-
Solo Play
-
Play both sides yourself
-
- -
- - {/* Bet Amount */} -
-
- Wager (AU) -
-
- {[0, 10, 25, 50, 100, 250, 500].map(amt => ( - - ))} -
-
- - - - ) : ( - <> -

Create a Room

-
- {gameTypes.map(g => ( - - ))} -
- + + ); + }) )} -
- )} + + + + + setConfigGame(null)} + onClose={resetCreateState} + onChooseGame={handleGameSelect} + onCreateChess={createChessRoom} + onCreateBlackjack={createBlackjackRoom} + chessTimeControl={chessTimeControl} + setChessTimeControl={setChessTimeControl} + soloMode={soloMode} + setSoloMode={setSoloMode} + betAmount={betAmount} + setBetAmount={setBetAmount} + />
); } diff --git a/panel/src/games/GameRoom.tsx b/panel/src/games/GameRoom.tsx index 80ed0dc..d9f7dea 100644 --- a/panel/src/games/GameRoom.tsx +++ b/panel/src/games/GameRoom.tsx @@ -1,34 +1,168 @@ -import { useState } from "react"; -import { useParams, useNavigate, useLocation } from "react-router-dom"; +import { useMemo, useState } from "react"; +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 { CHESS_TIME_CONTROL_LABELS } from "./chess/timeControls"; import { gameUIRegistry } from "./registry"; -import { Loader2 } from "lucide-react"; -function CopyInviteLink({ url }: { url: string }) { - const [copied, setCopied] = useState(false); - function copy() { - navigator.clipboard.writeText(url).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }); - } +function stateChip(status: "waiting" | "playing" | "finished") { + if (status === "waiting") return "border-warning/25 bg-warning/12 text-warning"; + if (status === "playing") return "border-success/25 bg-success/12 text-success"; + return "border-white/10 bg-card text-text-tertiary"; +} + +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 ( -
-
Share this link to invite:
-
- - {url} - - +
+
+ {loading ? : } +
+

{title}

+

{body}

+ +
+ ); +} + +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 ( +
+
Invite Link
+
+ Share this room URL to bring another player directly into the waiting room. +
+
+
{url}
+
+ +
+ ); +} + +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 ( +
+
+
+
+ + {stateLabel(state)} + + + Room {roomCode} + + {isSpectator && ( + + Spectating + + )} +
+ +
+ +
+ + + {playerCount}/{maxPlayers} players + + + + {spectatorCount} spectators + + {facts.map(fact => ( + + {fact.label}: {fact.value} + + ))} +
); @@ -41,177 +175,365 @@ export function GameRoom({ userId, role }: { userId: string; role?: string }) { const preferAs = (location.state as { preferAs?: "player" | "spectator" } | null)?.preferAs ?? "player"; const { - gameState, players, spectators, roomStatus, - isSpectator, gameOver, roundResult, error, sendAction, leaveRoom, sessionReplaced, rejoin, fillRoom, startGame, roomOptions, + gameState, + players, + spectators, + roomStatus, + isSpectator, + gameOver, + roundResult, + error, + sendAction, + leaveRoom, + sessionReplaced, + rejoin, + fillRoom, + startGame, + roomOptions, } = useGameRoom(roomId!, userId, role, preferAs); - const betAmount = roomOptions.betAmount ?? 0; - const plugin = gameSlug ? gameUIRegistry.get(gameSlug) : undefined; + function exitRoom() { + leaveRoom(); + navigate("/games"); + } + if (!plugin) { return ( -
-
Unknown Game
-

The game type "{gameSlug}" doesn't exist.

- -
+ navigate("/games")} + /> ); } if (roomStatus === "not_found") { return ( -
-
Room Not Found
-

This room no longer exists or has expired.

- -
+ navigate("/games")} + /> ); } if (roomStatus === "connecting") { return ( -
- -

- {preferAs === "spectator" ? "Joining as spectator..." : "Joining room..."} -

-
+ navigate("/games")} + loading + /> ); } 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 ( -
-
-
- {plugin.icon} -
-

{plugin.name}

-
- - {roomStatus === "waiting" ? "Waiting" : roomStatus === "playing" ? "Playing" : "Finished"} - - {isSpectator && Spectating} - {betAmount > 0 && ( - - {betAmount} AU{players.length > 1 ? `/player` : ""} - - )} - ๐Ÿ‘ {spectators.length} -
-
-
- -
- - {sessionReplaced && ( -
-

- You opened this game in another tab. Actions from this tab are disabled. -

+
+
+
+
- )} - {error && ( -
- {error} -
- )} +
+
+
+ {plugin.icon} +
+
+

{plugin.name}

+ + {stateLabel(roomStatus)} + + {isSpectator && ( + + Spectating + + )} +
+

{plugin.description}

+
+ + Room ID {roomCode} + + {roomFacts.map(fact => ( + + {fact.label}: {fact.value} + + ))} +
+
+
- {gameOver && ( -
-
- {gameOver.winner - ? `Winner: ${players.find(p => p.discordId === gameOver.winner)?.username ?? gameOver.winner}` - : "Draw!"} +
+
+
+ + Players +
+
{players.length}/{plugin.maxPlayers}
+
+
+
+ + Spectators +
+
{spectators.length}
+
+
+
+ {betAmount > 0 ? : } + {betAmount > 0 ? "Stake" : "Launch"} +
+
+ {betAmount > 0 ? `${betAmount} AU${plugin.slug === "blackjack" ? " / hand" : ""}` : plugin.manualStart ? "Host starts" : "Auto starts"} +
+
+
-
{gameOver.reason}
- {gameOver.payout && ( -
- {gameOver.payout.refunded - ? `Wager refunded: ${gameOver.payout.amount} AU` - : `Payout: ${gameOver.payout.amount} AU`} + +
+
Room Notes
+
{startHint}
+ {hostPlayer && ( +
+ Host + {hostPlayer.username} +
+ )} +
+
+ +
+ {sessionReplaced && ( +
+
+ Another tab claimed this room session. Actions from this tab are currently disabled. +
+ +
+ )} + + {error && ( +
+ {error} +
+ )} + + {gameOver && ( +
+
+ {gameOver.winner + ? `Winner: ${players.find(player => player.discordId === gameOver.winner)?.username ?? gameOver.winner}` + : "Draw"} +
+
{gameOver.reason}
+ {gameOver.payout && ( +
+ {gameOver.payout.refunded + ? `Wager refunded: ${gameOver.payout.amount} AU` + : `Payout: ${gameOver.payout.amount} AU`} +
+ )}
)}
- )} - - {roomStatus === "finished" && ( -
- -
- )} +
{roomStatus === "waiting" && ( -
-
- Waiting for players ({players.length}/{plugin.maxPlayers}) -
-
- {Array.from({ length: Math.max(players.length + 1, plugin.minPlayers ?? 1, Math.min(plugin.maxPlayers, 4)) }).map((_, i) => { - if (i >= plugin.maxPlayers) return null; - const player = players[i]; - return ( -
-
- {player ? player.username[0]?.toUpperCase() : "?"} +
+
+
+
+
Waiting Room
+

+ {plugin.manualStart ? "Seat players and launch when ready" : "Filling seats before the game begins"} +

+

+ {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."} +

+
+
+
Seats Filled
+
{players.length}/{plugin.maxPlayers}
+
+
+ +
+ {Array.from({ length: plugin.maxPlayers }).map((_, index) => { + const player = players[index]; + const isSeatHost = index === 0 && !!player; + const isMe = player?.discordId === userId; + return ( +
+
+
+ {player ? player.username[0]?.toUpperCase() : "?"} +
+
+ {isSeatHost && ( + + Host + + )} + {isMe && ( + + You + + )} +
+
+
+ {player ? player.username : "Open Seat"} +
+
+ {player ? `Player ${index + 1}` : "Share the invite to fill this seat."} +
-
- {player ? player.username : Waiting...} -
-
- Player {i + 1} + ); + })} +
+
+ +
+
)} + {(roomStatus === "playing" || roomStatus === "finished") && ( + + )} + {(roomStatus === "playing" || roomStatus === "finished") && gameState != null && ( )} + + {roomStatus === "finished" && ( +
+ +
+ )}
); } diff --git a/panel/src/games/blackjack/BlackjackGame.tsx b/panel/src/games/blackjack/BlackjackGame.tsx index ec6b452..d7ddf4e 100644 --- a/panel/src/games/blackjack/BlackjackGame.tsx +++ b/panel/src/games/blackjack/BlackjackGame.tsx @@ -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 ( +
+
+
+
Table Seats
+
Fast seat scan for mobile play and spectating.
+
+
+ {seatedPlayers.length}/6 filled +
+
+ +
+ {seatCards.map(card => ( + card.kind === "seat" ? ( +
+
+
+ {card.name[0]?.toUpperCase() ?? "?"} +
+
+ {card.isMe && ( + + You + + )} + {card.isActive && ( + + Acting + + )} +
+
+
{card.name}
+
Seat {card.index + 1}
+
+
Status
+
+ {card.summary} +
+ {betAmount > 0 && card.seat.totalWager > 0 && ( +
Wager {formatAu(card.seat.totalWager)}
+ )} +
+
+ ) : ( +
+
+ +
+
Open Seat
+
Seat {card.index + 1}
+ {canSitDown ? ( + + ) : ( +
+ Available next betting round. +
+ )} +
+ ) + ))} +
+
+ ); +} + function DealerPanel({ hand, visibleValue, fullValue }: { hand: Card[]; visibleValue: number; @@ -622,6 +742,17 @@ export function BlackjackGame({ state, myPlayerId, isSpectator, onAction, player
+
+ +
+
{seatedPlayers.map(({ playerId, seat, name }) => ( = { - 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 { 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` }); } + 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 (
+
+
+
+
Quick Recap
+
{statusTitle}
+
{statusText}
+
+
+ {view.isCheck && !isGameOver && ( + Check + )} + {!isSpectator && ( + + Playing as {colorLabel(myColor)} + + )} + {isSpectator && ( + + + Spectating + + )} + {queuedMove && !isGameOver && ( + + Premove: {formatMoveIntent(queuedMove)} + + )} +
+
+ +
+ {recapFacts.map(fact => ( +
+
{fact.label}
+
{fact.value}
+
+ ))} +
+
+
diff --git a/panel/src/games/chess/timeControls.ts b/panel/src/games/chess/timeControls.ts new file mode 100644 index 0000000..d7bd91a --- /dev/null +++ b/panel/src/games/chess/timeControls.ts @@ -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; + +export const CHESS_TIME_CONTROL_CATEGORIES = ["Bullet", "Blitz", "Rapid", "Classical", "No Clock"] as const; diff --git a/panel/src/games/registry.ts b/panel/src/games/registry.ts index 6651855..45fc43f 100644 --- a/panel/src/games/registry.ts +++ b/panel/src/games/registry.ts @@ -14,6 +14,8 @@ export interface GameUIPlugin { slug: string; name: string; icon: string; + tagline: string; + description: string; minPlayers: number; maxPlayers: number; /** If true, the host must manually start the game. */ @@ -47,6 +49,8 @@ gameUIRegistry.register({ slug: "chess", name: "Chess", 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, maxPlayers: 2, component: ChessGame, @@ -56,6 +60,8 @@ gameUIRegistry.register({ slug: "blackjack", name: "Blackjack", 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, maxPlayers: 6, manualStart: true, diff --git a/panel/src/lib/useGameRoom.ts b/panel/src/lib/useGameRoom.ts index 02b02ca..c658342 100644 --- a/panel/src/lib/useGameRoom.ts +++ b/panel/src/lib/useGameRoom.ts @@ -24,6 +24,21 @@ interface GameRoomState { 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") { const { send, subscribe, connected } = useWebSocket(); const navigate = useNavigate(); @@ -38,22 +53,13 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe } }, []); - const [state, setState] = useState({ - gameState: null, - players: [], - spectators: [], - roomStatus: "connecting", - isSpectator: false, - gameOver: null, - roundResult: null, - error: null, - sessionReplaced: false, - roomOptions: {}, - }); + const [state, setState] = useState(() => createInitialRoomState()); useEffect(() => { if (!connected) return; + setState(createInitialRoomState()); + send({ type: "JOIN_ROOM", roomId, preferAs, role: role ?? "player" }); const unsubscribe = subscribe((msg: any) => { @@ -180,7 +186,7 @@ export function useGameRoom(roomId: string, userId: string, role?: string, prefe send({ type: "LEAVE_ROOM", roomId }); unsubscribe(); }; - }, [roomId, connected, userId, send, subscribe]); + }, [connected, preferAs, role, roomId, send, subscribe, userId]); const sendAction = useCallback((action: unknown) => { const sent = send({ type: "GAME_ACTION", roomId, action });