feat(client): capture-tally panel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude (blind_chess)
2026-05-18 20:16:18 -04:00
parent 3169995d7f
commit 783d85a40c
3 changed files with 91 additions and 0 deletions
@@ -0,0 +1,84 @@
<script lang="ts">
import type { CaptureTally, Color, PieceTally, PieceType } from '@blind-chess/shared';
import { pieceGlyph } from './pieces.js';
interface Props {
captures: CaptureTally;
you: Color;
}
let { captures, you }: Props = $props();
const ORDER: PieceType[] = ['q', 'r', 'b', 'n', 'p'];
const oppColor = $derived<Color>(you === 'w' ? 'b' : 'w');
function glyphs(tally: PieceTally, color: Color): { glyph: string; key: string }[] {
const out: { glyph: string; key: string }[] = [];
for (const t of ORDER) {
const n = tally[t] ?? 0;
for (let i = 0; i < n; i++) out.push({ glyph: pieceGlyph({ color, type: t }), key: `${t}${i}` });
}
return out;
}
function total(tally: PieceTally): number {
return Object.values(tally).reduce((a, b) => a + b, 0);
}
const youTook = $derived(glyphs(captures.byYou, oppColor));
const youLost = $derived(glyphs(captures.byOpponent, you));
</script>
<div class="panel">
<header>Captures</header>
<div class="row">
<span class="label">You took</span>
<span class="pieces">
{#each youTook as g (g.key)}<span class="g g-{oppColor}">{g.glyph}</span>{:else}<span class="muted"></span>{/each}
</span>
<span class="n">{total(captures.byYou)}</span>
</div>
<div class="row lost">
<span class="label">Lost</span>
<span class="pieces">
{#each youLost as g (g.key)}<span class="g g-{you}">{g.glyph}</span>{:else}<span class="muted"></span>{/each}
</span>
<span class="n">{total(captures.byOpponent)}</span>
</div>
</div>
<style>
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
}
header {
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid var(--border);
}
.row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
}
.row.lost { opacity: 0.7; }
.label {
font-size: 12px;
color: var(--text-dim);
width: 56px;
flex-shrink: 0;
}
.pieces { flex: 1; font-size: 20px; line-height: 1; }
.g-w { color: #fafafa; }
.g-b { color: #1a1a1a; }
.n {
font-family: ui-monospace, monospace;
font-size: 13px;
color: var(--text-dim);
}
</style>
+2
View File
@@ -3,6 +3,7 @@
import { game } from './stores/game.svelte.js'; import { game } from './stores/game.svelte.js';
import Board from './Board.svelte'; import Board from './Board.svelte';
import ModeratorPanel from './ModeratorPanel.svelte'; import ModeratorPanel from './ModeratorPanel.svelte';
import CaptureTally from './CaptureTally.svelte';
import PromotionDialog from './PromotionDialog.svelte'; import PromotionDialog from './PromotionDialog.svelte';
import type { PromotionType, Square } from '@blind-chess/shared'; import type { PromotionType, Square } from '@blind-chess/shared';
@@ -137,6 +138,7 @@
<aside class="side"> <aside class="side">
<ModeratorPanel announcements={game.state.announcements} you={game.state.you} /> <ModeratorPanel announcements={game.state.announcements} you={game.state.you} />
<CaptureTally captures={game.state.captures} you={game.state.you} />
<div class="actions"> <div class="actions">
{#if game.state.gameStatus === 'active'} {#if game.state.gameStatus === 'active'}
@@ -1,6 +1,7 @@
import type { import type {
Announcement, Announcement,
BoardView, BoardView,
CaptureTally,
ClientMessage, ClientMessage,
Color, Color,
ErrorCode, ErrorCode,
@@ -29,6 +30,7 @@ interface GameStateValue {
opponentConnected: boolean; opponentConnected: boolean;
lastError: { code: ErrorCode; message: string; at: number } | null; lastError: { code: ErrorCode; message: string; at: number } | null;
aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null; aiOpponent: { color: Color; brain: 'casual' | 'recon' } | null;
captures: CaptureTally;
} }
function makeStore() { function makeStore() {
@@ -49,6 +51,7 @@ function makeStore() {
opponentConnected: false, opponentConnected: false,
lastError: null, lastError: null,
aiOpponent: null, aiOpponent: null,
captures: { byYou: {}, byOpponent: {} },
}); });
function tokenKey(gameId: string) { return `bc:${gameId}`; } function tokenKey(gameId: string) { return `bc:${gameId}`; }
@@ -94,6 +97,7 @@ function makeStore() {
state.highlightingEnabled = m.highlightingEnabled; state.highlightingEnabled = m.highlightingEnabled;
state.opponentConnected = m.opponentConnected; state.opponentConnected = m.opponentConnected;
state.aiOpponent = m.aiOpponent ?? null; state.aiOpponent = m.aiOpponent ?? null;
state.captures = m.captures;
if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token); if (state.gameId) localStorage.setItem(tokenKey(state.gameId), m.token);
break; break;
case 'update': case 'update':
@@ -107,6 +111,7 @@ function makeStore() {
state.announcements = [...state.announcements, ...m.newAnnouncements]; state.announcements = [...state.announcements, ...m.newAnnouncements];
} }
if (m.aiOpponent !== undefined) state.aiOpponent = m.aiOpponent; if (m.aiOpponent !== undefined) state.aiOpponent = m.aiOpponent;
state.captures = m.captures;
break; break;
case 'peer-status': case 'peer-status':
if (state.you && m.color !== state.you) { if (state.you && m.color !== state.you) {