feat(panel): add game UI registry and chess board component
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
133
panel/src/games/chess/ChessBoard.tsx
Normal file
133
panel/src/games/chess/ChessBoard.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { GameUIProps } from "../registry";
|
||||||
|
|
||||||
|
interface Piece {
|
||||||
|
type: string;
|
||||||
|
color: "white" | "black";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChessState {
|
||||||
|
board: (Piece | null)[][];
|
||||||
|
currentTurn: "white" | "black";
|
||||||
|
players: { white: string; black: string };
|
||||||
|
moveHistory: string[];
|
||||||
|
status: string;
|
||||||
|
winner: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PIECE_SYMBOLS: Record<string, Record<string, string>> = {
|
||||||
|
white: { king: "♔", queen: "♕", rook: "♖", bishop: "♗", knight: "♘", pawn: "♙" },
|
||||||
|
black: { king: "♚", queen: "♛", rook: "♜", bishop: "♝", knight: "♞", pawn: "♟" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChessBoard({ state, myPlayerId, isSpectator, onAction, players }: GameUIProps) {
|
||||||
|
const chess = state as ChessState;
|
||||||
|
const [selected, setSelected] = useState<[number, number] | null>(null);
|
||||||
|
|
||||||
|
if (!chess?.board) {
|
||||||
|
return <div className="text-text-tertiary text-sm">Waiting for game to start...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const myColor = chess.players.white === myPlayerId ? "white" : chess.players.black === myPlayerId ? "black" : null;
|
||||||
|
const isMyTurn = myColor === chess.currentTurn && !isSpectator;
|
||||||
|
|
||||||
|
function handleSquareClick(row: number, col: number) {
|
||||||
|
if (isSpectator || !isMyTurn) return;
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
onAction({ type: "move", from: selected, to: [row, col] });
|
||||||
|
setSelected(null);
|
||||||
|
} else {
|
||||||
|
const piece = chess.board[row][col];
|
||||||
|
if (piece && piece.color === myColor) {
|
||||||
|
setSelected([row, col]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleForfeit() {
|
||||||
|
onAction({ type: "forfeit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const opponentId = myColor === "white" ? chess.players.black : chess.players.white;
|
||||||
|
const opponent = players.find(p => p.discordId === opponentId);
|
||||||
|
const me = players.find(p => p.discordId === myPlayerId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium">
|
||||||
|
{opponent?.username?.[0]?.toUpperCase() ?? "?"}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{opponent?.username ?? "Opponent"}</span>
|
||||||
|
<span className="text-xs text-text-tertiary">· {myColor === "white" ? "Black" : "White"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inline-grid grid-cols-8 border-2 border-border rounded overflow-hidden">
|
||||||
|
{chess.board.map((row, r) =>
|
||||||
|
row.map((piece, c) => {
|
||||||
|
const isLight = (r + c) % 2 === 0;
|
||||||
|
const isSelected = selected?.[0] === r && selected?.[1] === c;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${r}-${c}`}
|
||||||
|
onClick={() => handleSquareClick(r, c)}
|
||||||
|
className={`w-12 h-12 flex items-center justify-center text-2xl transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/40"
|
||||||
|
: isLight
|
||||||
|
? "bg-raised"
|
||||||
|
: "bg-surface"
|
||||||
|
} ${isMyTurn ? "cursor-pointer hover:bg-primary/20" : "cursor-default"}`}
|
||||||
|
>
|
||||||
|
{piece ? PIECE_SYMBOLS[piece.color][piece.type] : ""}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<div className="w-6 h-6 rounded-full bg-surface flex items-center justify-center text-xs font-medium border-2 border-primary">
|
||||||
|
{me?.username?.[0]?.toUpperCase() ?? "?"}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">{me?.username ?? "You"}</span>
|
||||||
|
<span className="text-xs text-text-tertiary">· {myColor ?? "Spectator"}</span>
|
||||||
|
{isMyTurn && (
|
||||||
|
<span className="text-xs font-medium bg-primary/15 text-primary px-2 py-0.5 rounded">Your turn</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 min-w-[180px]">
|
||||||
|
<div className="bg-card rounded-lg border border-border">
|
||||||
|
<div className="px-4 py-2.5 border-b border-border text-xs font-semibold">Move History</div>
|
||||||
|
<div className="px-4 py-2 max-h-64 overflow-y-auto">
|
||||||
|
{chess.moveHistory.length === 0 ? (
|
||||||
|
<div className="text-xs text-text-disabled">No moves yet</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-text-tertiary font-mono leading-6">
|
||||||
|
{chess.moveHistory.map((move, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i % 2 === 0 && <span className="text-text-disabled">{Math.floor(i / 2) + 1}. </span>}
|
||||||
|
{move}{" "}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSpectator && chess.status === "playing" && (
|
||||||
|
<button
|
||||||
|
onClick={handleForfeit}
|
||||||
|
className="rounded-md px-3 py-2 text-sm font-medium bg-destructive/15 text-destructive hover:bg-destructive/25 transition-colors"
|
||||||
|
>
|
||||||
|
Forfeit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
panel/src/games/chess/index.ts
Normal file
9
panel/src/games/chess/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { gameUIRegistry } from "../registry";
|
||||||
|
import { ChessBoard } from "./ChessBoard";
|
||||||
|
|
||||||
|
gameUIRegistry.register({
|
||||||
|
slug: "chess",
|
||||||
|
name: "Chess",
|
||||||
|
icon: "♟",
|
||||||
|
component: ChessBoard,
|
||||||
|
});
|
||||||
30
panel/src/games/registry.ts
Normal file
30
panel/src/games/registry.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
|
||||||
|
export interface GameUIProps {
|
||||||
|
state: any;
|
||||||
|
myPlayerId: string;
|
||||||
|
isSpectator: boolean;
|
||||||
|
onAction: (action: unknown) => void;
|
||||||
|
players: { discordId: string; username: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameUIPlugin {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
component: ComponentType<GameUIProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = new Map<string, GameUIPlugin>();
|
||||||
|
|
||||||
|
export const gameUIRegistry = {
|
||||||
|
register(plugin: GameUIPlugin) {
|
||||||
|
plugins.set(plugin.slug, plugin);
|
||||||
|
},
|
||||||
|
get(slug: string): GameUIPlugin | undefined {
|
||||||
|
return plugins.get(slug);
|
||||||
|
},
|
||||||
|
list(): GameUIPlugin[] {
|
||||||
|
return Array.from(plugins.values());
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user