feat: implement game UI components, WebSocket hook, routing (Phases 3-8)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
d1bed9ac20
commit
d489f84825
15 files changed
+611
-119
No files matched your search
Generated
+2
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "frontend-tmp",
|
||||
"name": "commander-ai-lab-frontend",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend-tmp",
|
||||
"name": "commander-ai-lab-frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
+13
-117
@@ -1,121 +1,17 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import LobbyPage from './pages/LobbyPage';
|
||||
import RoomPage from './pages/RoomPage';
|
||||
import GamePage from './pages/GamePage';
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
)
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<LobbyPage />} />
|
||||
<Route path="/room/:roomId" element={<RoomPage />} />
|
||||
<Route path="/game/:gameId" element={<GamePage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { GameState } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
import PlayerZone from './PlayerZone';
|
||||
import CardChip from './CardChip';
|
||||
|
||||
interface BoardProps {
|
||||
gameState: GameState;
|
||||
thinkingSeats: Set<number>;
|
||||
}
|
||||
|
||||
export default function Board({ gameState, thinkingSeats }: BoardProps) {
|
||||
const { players, active_seat, stack } = gameState;
|
||||
|
||||
const getPlayer = (seat: number) => players.find((p) => p.seat === seat);
|
||||
|
||||
const renderZone = (seat: number, className: string) => {
|
||||
const player = getPlayer(seat);
|
||||
if (!player) return <div className={className} />;
|
||||
return (
|
||||
<div className={className}>
|
||||
<PlayerZone
|
||||
player={player}
|
||||
isActive={active_seat === seat}
|
||||
isThinking={thinkingSeats.has(seat)}
|
||||
seatColour={SEAT_COLOURS[seat]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[180px_1fr_180px] grid-rows-3 gap-1 flex-1">
|
||||
{/* Row 1: P1 (top-left), stack center, P2 (top-right) */}
|
||||
{renderZone(1, 'row-start-1 col-start-1')}
|
||||
<div className="bg-bg-zone border border-accent-purple rounded-md min-h-[60px] row-span-3 col-start-2 p-2 overflow-y-auto">
|
||||
<span className="text-[10px] text-[#666] block mb-1">Stack</span>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{stack.map((card, i) => (
|
||||
<CardChip key={i} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{renderZone(2, 'row-start-1 col-start-3')}
|
||||
|
||||
{/* Row 2: empty side cells (stack spans all 3 rows in center) */}
|
||||
<div className="row-start-2 col-start-1" />
|
||||
<div className="row-start-2 col-start-3" />
|
||||
|
||||
{/* Row 3: P0 (bottom-left), P3 (bottom-right) */}
|
||||
{renderZone(0, 'row-start-3 col-start-1')}
|
||||
{renderZone(3, 'row-start-3 col-start-3')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
interface CardChipProps {
|
||||
card: Card;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export default function CardChip({ card, onClick }: CardChipProps) {
|
||||
return (
|
||||
<span
|
||||
onClick={onClick}
|
||||
className={`bg-card-bg border border-[#444] rounded text-[10px] max-w-[110px] overflow-hidden text-ellipsis whitespace-nowrap cursor-pointer px-1.5 py-0.5 hover:border-[#ffdc32] ${
|
||||
card.tapped ? 'opacity-55 italic' : ''
|
||||
}`}
|
||||
>
|
||||
{card.name}
|
||||
{card.is_commander && <span className="text-[#ffd200]"> ★</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Player } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface GameOverOverlayProps {
|
||||
show: boolean;
|
||||
winner: Player | null;
|
||||
reason: string;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export default function GameOverOverlay({ show, winner, reason, players }: GameOverOverlayProps) {
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/80 z-[100] flex items-center justify-center">
|
||||
<div className="bg-bg-zone border-2 border-[#4060c0] rounded-[10px] px-[60px] py-10 text-center">
|
||||
{winner && (
|
||||
<h2
|
||||
className="text-4xl font-bold"
|
||||
style={{ color: SEAT_COLOURS[winner.seat] }}
|
||||
>
|
||||
{winner.name} Wins!
|
||||
</h2>
|
||||
)}
|
||||
<p className="text-base text-[#aaa] mt-2">{reason}</p>
|
||||
<ul className="mt-6 space-y-1">
|
||||
{players.map((p) => (
|
||||
<li key={p.seat} className="text-sm">
|
||||
<span style={{ color: SEAT_COLOURS[p.seat] }}>P{p.seat}</span>{' '}
|
||||
{p.name} — {p.life} life
|
||||
{p.eliminated && (
|
||||
<span className="text-red-500 ml-1 font-bold">[ELIM]</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Card } from '../types/game';
|
||||
|
||||
interface HandCardProps {
|
||||
card: Card;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function HandCard({ card, isSelected, onClick }: HandCardProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`rounded text-[11px] px-2 py-1 cursor-pointer ${
|
||||
isSelected
|
||||
? 'border border-[#ffdc32] bg-card-selected'
|
||||
: 'bg-card-hand border border-[#5060a0]'
|
||||
}`}
|
||||
>
|
||||
{card.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import type { Card } from '../types/game';
|
||||
import HandCard from './HandCard';
|
||||
|
||||
interface MoveBarProps {
|
||||
hand: Card[];
|
||||
humanSeat: number;
|
||||
onPass: () => void;
|
||||
onConcede: () => void;
|
||||
onPlayCard: (index: number) => void;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export default function MoveBar({ hand, onPass, onConcede, onPlayCard, status }: MoveBarProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const handleCardClick = (index: number) => {
|
||||
if (selectedIndex === index) {
|
||||
onPlayCard(index);
|
||||
setSelectedIndex(null);
|
||||
} else {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-bg-bar border-t border-[#2a2e48] px-2.5 py-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={onPass}
|
||||
className="bg-[#2a5090] border border-[#4080d0] text-white rounded px-3 py-1 text-sm cursor-pointer hover:brightness-110"
|
||||
>
|
||||
Pass Priority
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Are you sure you want to concede?')) onConcede();
|
||||
}}
|
||||
className="bg-[#2a3868] border border-[#4060a0] text-white rounded px-3 py-1 text-sm cursor-pointer"
|
||||
>
|
||||
Concede
|
||||
</button>
|
||||
{hand.map((card, i) => (
|
||||
<HandCard
|
||||
key={i}
|
||||
card={card}
|
||||
index={i}
|
||||
isSelected={selectedIndex === i}
|
||||
onClick={() => handleCardClick(i)}
|
||||
/>
|
||||
))}
|
||||
<span className="text-[11px] text-[#778] ml-auto">{status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import type { NarrationLine } from '../types/game';
|
||||
import { SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface NarrationPanelProps {
|
||||
lines: NarrationLine[];
|
||||
thinkingSeats: Set<number>;
|
||||
}
|
||||
|
||||
export default function NarrationPanel({ lines, thinkingSeats }: NarrationPanelProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dots, setDots] = useState('.');
|
||||
|
||||
// Auto-scroll on new lines
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [lines.length]);
|
||||
|
||||
// Cycling dots for thinking animation
|
||||
useEffect(() => {
|
||||
if (thinkingSeats.size === 0) return;
|
||||
const interval = setInterval(() => {
|
||||
setDots((prev) => (prev.length >= 3 ? '.' : prev + '.'));
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [thinkingSeats.size]);
|
||||
|
||||
const displayed = lines.slice(-120);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-[260px] bg-bg-panel border-l border-[#2a2e48] overflow-y-auto p-2 flex flex-col"
|
||||
>
|
||||
{displayed.map((line, i) => {
|
||||
let colorClass = 'text-[#e8e8e8]';
|
||||
let style: React.CSSProperties | undefined;
|
||||
|
||||
if (line.isSystem) {
|
||||
colorClass = 'text-[#556]';
|
||||
} else if (line.seat !== undefined) {
|
||||
colorClass = '';
|
||||
style = { color: SEAT_COLOURS[line.seat] };
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`text-[11px] leading-[1.4] break-words ${colorClass}`}
|
||||
style={style}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{thinkingSeats.size > 0 &&
|
||||
Array.from(thinkingSeats).map((seat) => (
|
||||
<div
|
||||
key={`thinking-${seat}`}
|
||||
className="text-[11px] leading-[1.4] break-words text-accent-think italic"
|
||||
>
|
||||
P{seat} thinking{dots}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Phase, Player } from '../types/game';
|
||||
import { PHASES, SEAT_COLOURS } from '../constants';
|
||||
|
||||
interface PhaseBarProps {
|
||||
phase: Phase;
|
||||
activeSeat: number;
|
||||
players: Player[];
|
||||
}
|
||||
|
||||
export default function PhaseBar({ phase, activeSeat, players }: PhaseBarProps) {
|
||||
const activePlayer = players.find((p) => p.seat === activeSeat);
|
||||
|
||||
return (
|
||||
<div className="h-[34px] bg-[#1e2338] px-2 py-1 flex items-center gap-1">
|
||||
{PHASES.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`text-[11px] rounded px-2 py-0.5 ${
|
||||
p === phase
|
||||
? 'bg-accent-blue text-white'
|
||||
: 'bg-[#252a42] text-[#888]'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{activePlayer && (
|
||||
<span
|
||||
className="text-[11px] ml-auto font-bold"
|
||||
style={{ color: SEAT_COLOURS[activeSeat] }}
|
||||
>
|
||||
P{activeSeat}: {activePlayer.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Player } from '../types/game';
|
||||
import CardChip from './CardChip';
|
||||
|
||||
interface PlayerZoneProps {
|
||||
player: Player;
|
||||
isActive: boolean;
|
||||
isThinking: boolean;
|
||||
seatColour: string;
|
||||
}
|
||||
|
||||
function lifeColor(life: number): string {
|
||||
if (life >= 30) return 'text-life-high';
|
||||
if (life >= 15) return 'text-life-med';
|
||||
return 'text-life-low';
|
||||
}
|
||||
|
||||
export default function PlayerZone({ player, isActive, isThinking, seatColour }: PlayerZoneProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-bg-zone border border-[#3a4060] rounded-md p-1.5 ${
|
||||
isThinking ? 'animate-pulse' : ''
|
||||
} ${player.eliminated ? 'opacity-40' : ''}`}
|
||||
style={isActive ? { borderColor: seatColour } : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[11px] text-[#666]">
|
||||
P{player.seat}: {player.name}
|
||||
</span>
|
||||
<span className={`text-[28px] font-bold ${lifeColor(player.life)}`}>
|
||||
{player.life}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-0.5">
|
||||
{player.battlefield.map((card, i) => (
|
||||
<CardChip key={i} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useReducer, useRef, useEffect, useCallback } from 'react';
|
||||
import { gameReducer, initialState } from '../store/gameReducer';
|
||||
import type { WsIncoming, NarrationLine } from '../types/game';
|
||||
|
||||
export function useGameSocket(gameId: string) {
|
||||
const [state, dispatch] = useReducer(gameReducer, initialState);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const intentionalClose = useRef(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
const url = `ws://${window.location.host}/ws/game/${gameId}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Connected' });
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const msg: WsIncoming = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case 'state':
|
||||
dispatch({ type: 'SET_STATE', payload: msg.payload });
|
||||
break;
|
||||
|
||||
case 'hand':
|
||||
dispatch({ type: 'SET_HAND', cards: msg.cards });
|
||||
break;
|
||||
|
||||
case 'event': {
|
||||
const line: NarrationLine = {
|
||||
text: msg.text,
|
||||
seat: msg.seat,
|
||||
isSystem: false,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch({ type: 'ADD_NARRATION', line });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'phase': {
|
||||
const line: NarrationLine = {
|
||||
text: `Phase: ${msg.phase} (P${msg.active_seat})`,
|
||||
isSystem: true,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
dispatch({ type: 'ADD_NARRATION', line });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'thinking':
|
||||
dispatch({ type: 'SET_THINKING', seat: msg.seat, active: msg.active });
|
||||
break;
|
||||
|
||||
case 'game_over':
|
||||
dispatch({ type: 'GAME_OVER', winner: msg.winner, reason: msg.reason });
|
||||
break;
|
||||
|
||||
case 'pong':
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
dispatch({ type: 'SET_STATUS', status: `Error: ${msg.message}` });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!intentionalClose.current) {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Disconnected — reconnecting…' });
|
||||
reconnectTimeout.current = setTimeout(() => {
|
||||
connect();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
dispatch({ type: 'SET_STATUS', status: 'Connection error' });
|
||||
};
|
||||
}, [gameId]);
|
||||
|
||||
// Connect on mount
|
||||
useEffect(() => {
|
||||
intentionalClose.current = false;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
intentionalClose.current = true;
|
||||
if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
// Heartbeat ping every 15s
|
||||
useEffect(() => {
|
||||
heartbeatInterval.current = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
return () => {
|
||||
if (heartbeatInterval.current) clearInterval(heartbeatInterval.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendMove = useCallback((move: string, data?: Record<string, unknown>) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'move', move, ...data }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendConcede = useCallback(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'concede' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
gameState: state.gameState,
|
||||
narration: state.narration,
|
||||
thinking: state.thinkingSeats,
|
||||
hand: state.hand,
|
||||
gameOver: state.gameOver,
|
||||
status: state.status,
|
||||
sendMove,
|
||||
sendConcede,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGameSocket } from '../hooks/useGameSocket';
|
||||
import PhaseBar from '../components/PhaseBar';
|
||||
import Board from '../components/Board';
|
||||
import NarrationPanel from '../components/NarrationPanel';
|
||||
import MoveBar from '../components/MoveBar';
|
||||
import GameOverOverlay from '../components/GameOverOverlay';
|
||||
|
||||
export default function GamePage() {
|
||||
const { gameId } = useParams<{ gameId: string }>();
|
||||
const { gameState, narration, thinking, hand, gameOver, status, sendMove, sendConcede } =
|
||||
useGameSocket(gameId!);
|
||||
|
||||
if (!gameState) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
Connecting…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const winnerPlayer = gameOver
|
||||
? gameState.players.find((p) => p.seat === gameOver.winner) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen overflow-hidden bg-bg-base text-white">
|
||||
<PhaseBar
|
||||
phase={gameState.current_phase}
|
||||
activeSeat={gameState.active_seat}
|
||||
players={gameState.players}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Board gameState={gameState} thinkingSeats={thinking} />
|
||||
<NarrationPanel lines={narration} thinkingSeats={thinking} />
|
||||
</div>
|
||||
<MoveBar
|
||||
hand={hand}
|
||||
humanSeat={0}
|
||||
onPass={() => sendMove('pass')}
|
||||
onConcede={sendConcede}
|
||||
onPlayCard={(index) => sendMove('play_card', { index })}
|
||||
status={status}
|
||||
/>
|
||||
<GameOverOverlay
|
||||
show={gameOver !== null}
|
||||
winner={winnerPlayer}
|
||||
reason={gameOver?.reason ?? ''}
|
||||
players={gameState.players}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function LobbyPage() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
<h1 className="text-2xl">Lobby — coming soon</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function RoomPage() {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-bg-base text-white">
|
||||
<h1 className="text-2xl">Room — coming soon</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { GameState, Card, NarrationLine } from '../types/game';
|
||||
|
||||
export type GameAction =
|
||||
| { type: 'SET_STATE'; payload: GameState }
|
||||
| { type: 'SET_HAND'; cards: Card[] }
|
||||
| { type: 'ADD_NARRATION'; line: NarrationLine }
|
||||
| { type: 'SET_THINKING'; seat: number; active: boolean }
|
||||
| { type: 'GAME_OVER'; winner: number; reason: string }
|
||||
| { type: 'SET_STATUS'; status: string };
|
||||
|
||||
export interface ReducerState {
|
||||
gameState: GameState | null;
|
||||
hand: Card[];
|
||||
narration: NarrationLine[];
|
||||
thinkingSeats: Set<number>;
|
||||
gameOver: { winner: number; reason: string } | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const initialState: ReducerState = {
|
||||
gameState: null,
|
||||
hand: [],
|
||||
narration: [],
|
||||
thinkingSeats: new Set(),
|
||||
gameOver: null,
|
||||
status: '',
|
||||
};
|
||||
|
||||
export function gameReducer(state: ReducerState, action: GameAction): ReducerState {
|
||||
switch (action.type) {
|
||||
case 'SET_STATE':
|
||||
return { ...state, gameState: action.payload };
|
||||
|
||||
case 'SET_HAND':
|
||||
return { ...state, hand: action.cards };
|
||||
|
||||
case 'ADD_NARRATION': {
|
||||
const narration = [...state.narration, action.line];
|
||||
return { ...state, narration: narration.slice(-120) };
|
||||
}
|
||||
|
||||
case 'SET_THINKING': {
|
||||
const thinkingSeats = new Set(state.thinkingSeats);
|
||||
if (action.active) {
|
||||
thinkingSeats.add(action.seat);
|
||||
} else {
|
||||
thinkingSeats.delete(action.seat);
|
||||
}
|
||||
return { ...state, thinkingSeats };
|
||||
}
|
||||
|
||||
case 'GAME_OVER':
|
||||
return { ...state, gameOver: { winner: action.winner, reason: action.reason } };
|
||||
|
||||
case 'SET_STATUS':
|
||||
return { ...state, status: action.status };
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user