feat(client): render and drag phantom pieces on the board

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude (blind_chess)
2026-05-18 20:35:58 -04:00
parent 4b3e587f6c
commit 599dc17f44
+39
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ALL_SQUARES, FILES, RANKS, geometricMoves, type Color, type Piece, type Square } from '@blind-chess/shared'; import { ALL_SQUARES, FILES, RANKS, geometricMoves, type Color, type Piece, type Square } from '@blind-chess/shared';
import { pieceGlyph } from './pieces.js'; import { pieceGlyph } from './pieces.js';
import { phantomDrag } from './stores/phantom-drag.svelte.js';
interface Props { interface Props {
pieces: Partial<Record<Square, Piece>>; pieces: Partial<Record<Square, Piece>>;
@@ -8,6 +9,8 @@
toMove: Color; toMove: Color;
mode: 'blind' | 'vanilla'; mode: 'blind' | 'vanilla';
highlightingEnabled: boolean; highlightingEnabled: boolean;
phantoms?: Partial<Record<Square, Piece>>;
phantomsEnabled?: boolean;
armedSquare: Square | null; // local visual arm (pre-commit) armedSquare: Square | null; // local visual arm (pre-commit)
touchedSquare: Square | null; // server-authoritative touch touchedSquare: Square | null; // server-authoritative touch
onArm: (sq: Square | null) => void; onArm: (sq: Square | null) => void;
@@ -16,6 +19,7 @@
let { let {
pieces, you, toMove, mode, highlightingEnabled, pieces, you, toMove, mode, highlightingEnabled,
phantoms = {}, phantomsEnabled = false,
armedSquare, touchedSquare, onArm, onCommit, armedSquare, touchedSquare, onArm, onCommit,
}: Props = $props(); }: Props = $props();
@@ -43,6 +47,14 @@
return new Set(moves); return new Set(moves);
}); });
// The board square a phantom is currently being dragged out of (so it can
// be dimmed while the drag ghost is shown). Bind `active` to a local first
// so TypeScript narrows the discriminated union reliably.
const dragOrigin = $derived.by(() => {
const a = phantomDrag.state.active;
return a?.kind === 'board' && phantomDrag.state.moved ? a.from : null;
});
function squareColor(sq: Square): 'light' | 'dark' { function squareColor(sq: Square): 'light' | 'dark' {
const f = sq.charCodeAt(0) - 'a'.charCodeAt(0); const f = sq.charCodeAt(0) - 'a'.charCodeAt(0);
const r = parseInt(sq[1]!, 10) - 1; const r = parseInt(sq[1]!, 10) - 1;
@@ -50,6 +62,7 @@
} }
function onSquareClick(sq: Square) { function onSquareClick(sq: Square) {
if (phantomDrag.shouldSuppressClick(sq)) return;
const piece = pieces[sq]; const piece = pieces[sq];
// If a piece is touched (server-authoritative), only commits to that square's destinations are valid. // If a piece is touched (server-authoritative), only commits to that square's destinations are valid.
@@ -82,6 +95,7 @@
{#each filesDisplay as f (f)} {#each filesDisplay as f (f)}
{@const sq = `${f}${r}` as Square} {@const sq = `${f}${r}` as Square}
{@const piece = pieces[sq]} {@const piece = pieces[sq]}
{@const ph = phantomsEnabled ? phantoms[sq] : undefined}
{@const sc = squareColor(sq)} {@const sc = squareColor(sq)}
{@const isArmed = sq === armedSquare} {@const isArmed = sq === armedSquare}
{@const isTouched = sq === touchedSquare} {@const isTouched = sq === touchedSquare}
@@ -96,6 +110,7 @@
class:hl-cap={isHighlightCap} class:hl-cap={isHighlightCap}
onclick={() => onSquareClick(sq)} onclick={() => onSquareClick(sq)}
aria-label={sq} aria-label={sq}
data-square={sq}
> >
{#if r === (you === 'w' ? '1' : '8') && f === filesDisplay[0]} {#if r === (you === 'w' ? '1' : '8') && f === filesDisplay[0]}
<span class="coord coord-rank">{r}</span> <span class="coord coord-rank">{r}</span>
@@ -106,6 +121,13 @@
{#if piece} {#if piece}
<span class="piece piece-{piece.color}">{pieceGlyph(piece)}</span> <span class="piece piece-{piece.color}">{pieceGlyph(piece)}</span>
{/if} {/if}
{#if ph && !piece}
<span
class="phantom phantom-{ph.color}"
class:dragging={sq === dragOrigin}
onpointerdown={(e) => { e.preventDefault(); phantomDrag.start({ kind: 'board', from: sq, type: ph.type }, e); }}
>{pieceGlyph(ph)}</span>
{/if}
{#if isHighlight && !piece} {#if isHighlight && !piece}
<span class="dot"></span> <span class="dot"></span>
{/if} {/if}
@@ -154,6 +176,23 @@
.piece-w { color: #fafafa; } .piece-w { color: #fafafa; }
.piece-b { color: #1a1a1a; } .piece-b { color: #1a1a1a; }
.phantom {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
touch-action: none;
cursor: grab;
opacity: 0.4;
outline: 2px dashed var(--text-dim);
outline-offset: -5px;
}
.phantom-w { color: #fafafa; }
.phantom-b { color: #1a1a1a; }
.phantom.dragging { opacity: 0.12; }
.armed { box-shadow: inset 0 0 0 4px var(--armed); } .armed { box-shadow: inset 0 0 0 4px var(--armed); }
.touched { box-shadow: inset 0 0 0 4px var(--touched); } .touched { box-shadow: inset 0 0 0 4px var(--touched); }
.hl::before { .hl::before {