feat(chess): polish UI with SVG pieces, motion animations, sound, and confetti
- Add Cburnett SVG chess pieces from Wikimedia (Lichess set, ~18KB total) - Add move/capture/check/checkmate/castle/promote sound effects via OGG - Install motion for layout animations: pieces slide smoothly, captures fade - Install canvas-confetti: victory celebration on checkmate - Install sonner: toast notifications - Install lucide-react: volume icons for mute toggle - Persist mute state in localStorage - Animated game-over banner (spring entrance) - Animated drawer slide-in replacing CSS keyframe - Lobby and App get polished visual treatment - Preserve all E2E data-* selectors and drag-drop contract Co-authored-by: visual-engineering agent
|
|
@ -16,15 +16,20 @@
|
|||
"dependencies": {
|
||||
"@paratype/rete": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"lucide-react": "^1.8.0",
|
||||
"motion": "^12.38.0",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.0",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0"
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect } from 'react'
|
||||
import { Routes, Route, useNavigate } from 'react-router-dom'
|
||||
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Lobby } from '../ui/Lobby'
|
||||
import { GameView } from '../ui/GameView'
|
||||
import { RulesView } from '../ui/RulesView'
|
||||
|
|
@ -9,9 +9,12 @@ import { useChessEngine } from '../hooks/useChessEngine'
|
|||
import { ChessEngine } from '../engine'
|
||||
import { loadAutoSave } from '../persist/autosave.js'
|
||||
import type { AttrKey, FactValue, EntityId } from '@paratype/rete'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { Toaster } from 'sonner'
|
||||
|
||||
export function App() {
|
||||
const chessState = useChessEngine()
|
||||
const location = useLocation()
|
||||
|
||||
// Restore autosave on mount — [] deps is intentional (run once, avoid infinite loop)
|
||||
useEffect(() => {
|
||||
|
|
@ -25,17 +28,33 @@ export function App() {
|
|||
}, []); // intentional: run once on mount, chessState ref is stable
|
||||
|
||||
return (
|
||||
<div data-testid="app-root">
|
||||
<Routes>
|
||||
<Route path="/" element={<Lobby />} />
|
||||
<Route path="/game" element={<GameView engineState={chessState} />} />
|
||||
<Route path="/rules" element={<RulesView chessState={chessState} />} />
|
||||
<Route path="/save" element={<SaveWrapper chessState={chessState} />} />
|
||||
</Routes>
|
||||
<div data-testid="app-root" className="min-h-screen bg-neutral-50 text-neutral-900 font-sans selection:bg-amber-200">
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
<AnimatePresence mode="wait">
|
||||
<Routes location={location} key={location.pathname}>
|
||||
<Route path="/" element={<PageTransition><Lobby /></PageTransition>} />
|
||||
<Route path="/game" element={<PageTransition><GameView engineState={chessState} /></PageTransition>} />
|
||||
<Route path="/rules" element={<PageTransition><RulesView chessState={chessState} /></PageTransition>} />
|
||||
<Route path="/save" element={<PageTransition><SaveWrapper chessState={chessState} /></PageTransition>} />
|
||||
</Routes>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PageTransition({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveWrapper({ chessState }: { chessState: ReturnType<typeof useChessEngine> }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
|
|
@ -53,7 +72,7 @@ function SaveWrapper({ chessState }: { chessState: ReturnType<typeof useChessEng
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-8 py-8">
|
||||
<SavePanel
|
||||
currentFacts={chessState.facts}
|
||||
onLoad={handleLoad}
|
||||
|
|
|
|||
12
packages/chess/src/assets/pieces/black-bishop.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
|
||||
<g style="opacity:1; fill:none; fill-rule:evenodd; fill-opacity:1; stroke:#000000; stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round; stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;" transform="translate(0,0.6)">
|
||||
<g style="fill:#000000; stroke:#000000; stroke-linecap:butt;">
|
||||
<path d="M 9,36 C 12.39,35.03 19.11,36.43 22.5,34 C 25.89,36.43 32.61,35.03 36,36 C 36,36 37.65,36.54 39,38 C 38.32,38.97 37.35,38.99 36,38.5 C 32.61,37.53 25.89,38.96 22.5,37.5 C 19.11,38.96 12.39,37.53 9,38.5 C 7.65,38.99 6.68,38.97 6,38 C 7.35,36.54 9,36 9,36 z"/>
|
||||
<path d="M 15,32 C 17.5,34.5 27.5,34.5 30,32 C 30.5,30.5 30,30 30,30 C 30,27.5 27.5,26 27.5,26 C 33,24.5 33.5,14.5 22.5,10.5 C 11.5,14.5 12,24.5 17.5,26 C 17.5,26 15,27.5 15,30 C 15,30 14.5,30.5 15,32 z"/>
|
||||
<path d="M 25 8 A 2.5 2.5 0 1 1 20,8 A 2.5 2.5 0 1 1 25 8 z"/>
|
||||
</g>
|
||||
<path d="M 17.5,26 L 27.5,26 M 15,30 L 30,30 M 22.5,15.5 L 22.5,20.5 M 20,18 L 25,18" style="fill:none; stroke:#ffffff; stroke-linejoin:miter;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
12
packages/chess/src/assets/pieces/black-king.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
|
||||
<g style="fill:none; fill-opacity:1; fill-rule:evenodd; stroke:#000000; stroke-width:1.5; stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;">
|
||||
<path d="M 22.5,11.63 L 22.5,6" style="fill:none; stroke:#000000; stroke-linejoin:miter;" id="path6570"/>
|
||||
<path d="M 22.5,25 C 22.5,25 27,17.5 25.5,14.5 C 25.5,14.5 24.5,12 22.5,12 C 20.5,12 19.5,14.5 19.5,14.5 C 18,17.5 22.5,25 22.5,25" style="fill:#000000;fill-opacity:1; stroke-linecap:butt; stroke-linejoin:miter;"/>
|
||||
<path d="M 12.5,37 C 18,40.5 27,40.5 32.5,37 L 32.5,30 C 32.5,30 41.5,25.5 38.5,19.5 C 34.5,13 25,16 22.5,23.5 L 22.5,27 L 22.5,23.5 C 20,16 10.5,13 6.5,19.5 C 3.5,25.5 12.5,30 12.5,30 L 12.5,37" style="fill:#000000; stroke:#000000;"/>
|
||||
<path d="M 20,8 L 25,8" style="fill:none; stroke:#000000; stroke-linejoin:miter;"/>
|
||||
<path d="M 32,29.5 C 32,29.5 40.5,25.5 38.03,19.85 C 34.15,14 25,18 22.5,24.5 L 22.5,26.6 L 22.5,24.5 C 20,18 10.85,14 6.97,19.85 C 4.5,25.5 13,29.5 13,29.5" style="fill:none; stroke:#ffffff;"/>
|
||||
<path d="M 12.5,30 C 18,27 27,27 32.5,30 M 12.5,33.5 C 18,30.5 27,30.5 32.5,33.5 M 12.5,37 C 18,34 27,34 32.5,37" style="fill:none; stroke:#ffffff;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
41
packages/chess/src/assets/pieces/black-knight.svg
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<title>Wikimedia Error</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; }
|
||||
body { background: #fff; font: 15px/1.6 sans-serif; color: #333; }
|
||||
.content { margin: 7% auto 0; padding: 2em 1em 1em; max-width: 640px; display: flex; flex-direction: row; flex-wrap: wrap; }
|
||||
.footer { clear: both; margin-top: 14%; border-top: 1px solid #e5e5e5; background: #f9f9f9; padding: 2em 0; font-size: 0.8em; text-align: center; }
|
||||
img { margin: 0 2em 2em 0; }
|
||||
a img { border: 0; }
|
||||
h1 { margin-top: 1em; font-size: 1.2em; }
|
||||
.content-text { flex: 1; }
|
||||
p { margin: 0.7em 0 1em 0; }
|
||||
a { color: #0645ad; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code { font-family: sans-serif; }
|
||||
summary { font-weight: bold; cursor: pointer; }
|
||||
details[open] { background: #970302; color: #dfdedd; }
|
||||
.text-muted { color: #777; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
a { color: #9e9eff; }
|
||||
body { background: transparent; color: #ddd; }
|
||||
.footer { border-top: 1px solid #444; background: #060606; }
|
||||
#logo { filter: invert(1) hue-rotate(180deg); }
|
||||
.text-muted { color: #888; }
|
||||
}
|
||||
</style>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<div class="content" role="main">
|
||||
<a href="https://www.wikimedia.org"><img id="logo" src="https://www.wikimedia.org/static/images/wmf-logo.png" srcset="https://www.wikimedia.org/static/images/wmf-logo-2x.png 2x" alt="Wikimedia" width="135" height="101">
|
||||
</a>
|
||||
<div class="content-text">
|
||||
<h1>Error</h1>
|
||||
|
||||
<p>Please set a proper user-agent and respect our robot policy https://w.wiki/4wJS. See also https://phabricator.wikimedia.org/T400119</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><p>If you report this error to the Wikimedia System Administrators, please include the details below.</p><p class="text-muted"><code>Request served via cp4049 cp4049, Varnish XID 337570661<br>Upstream caches: cp4049 int<br>Error: 429, Please set a proper user-agent and respect our robot policy https://w.wiki/4wJS. See also https://phabricator.wikimedia.org/T400119 at Fri, 17 Apr 2026 17:49:43 GMT<br><details><summary>Sensitive client information</summary>IP address: 2001:56a:fa50:600:b60d:3f65:e889:ea05</details></code></p>
|
||||
</div>
|
||||
</html>
|
||||
41
packages/chess/src/assets/pieces/black-pawn.svg
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<meta charset="utf-8">
|
||||
<title>Wikimedia Error</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; }
|
||||
body { background: #fff; font: 15px/1.6 sans-serif; color: #333; }
|
||||
.content { margin: 7% auto 0; padding: 2em 1em 1em; max-width: 640px; display: flex; flex-direction: row; flex-wrap: wrap; }
|
||||
.footer { clear: both; margin-top: 14%; border-top: 1px solid #e5e5e5; background: #f9f9f9; padding: 2em 0; font-size: 0.8em; text-align: center; }
|
||||
img { margin: 0 2em 2em 0; }
|
||||
a img { border: 0; }
|
||||
h1 { margin-top: 1em; font-size: 1.2em; }
|
||||
.content-text { flex: 1; }
|
||||
p { margin: 0.7em 0 1em 0; }
|
||||
a { color: #0645ad; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code { font-family: sans-serif; }
|
||||
summary { font-weight: bold; cursor: pointer; }
|
||||
details[open] { background: #970302; color: #dfdedd; }
|
||||
.text-muted { color: #777; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
a { color: #9e9eff; }
|
||||
body { background: transparent; color: #ddd; }
|
||||
.footer { border-top: 1px solid #444; background: #060606; }
|
||||
#logo { filter: invert(1) hue-rotate(180deg); }
|
||||
.text-muted { color: #888; }
|
||||
}
|
||||
</style>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<div class="content" role="main">
|
||||
<a href="https://www.wikimedia.org"><img id="logo" src="https://www.wikimedia.org/static/images/wmf-logo.png" srcset="https://www.wikimedia.org/static/images/wmf-logo-2x.png 2x" alt="Wikimedia" width="135" height="101">
|
||||
</a>
|
||||
<div class="content-text">
|
||||
<h1>Error</h1>
|
||||
|
||||
<p>Please set a proper user-agent and respect our robot policy https://w.wiki/4wJS. See also https://phabricator.wikimedia.org/T400119</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer"><p>If you report this error to the Wikimedia System Administrators, please include the details below.</p><p class="text-muted"><code>Request served via cp4049 cp4049, Varnish XID 324481748<br>Upstream caches: cp4049 int<br>Error: 429, Please set a proper user-agent and respect our robot policy https://w.wiki/4wJS. See also https://phabricator.wikimedia.org/T400119 at Fri, 17 Apr 2026 17:49:43 GMT<br><details><summary>Sensitive client information</summary>IP address: 2001:56a:fa50:600:b60d:3f65:e889:ea05</details></code></p>
|
||||
</div>
|
||||
</html>
|
||||
27
packages/chess/src/assets/pieces/black-queen.svg
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="utf-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45"
|
||||
height="45">
|
||||
<g style="fill:#000000;stroke:#000000;stroke-width:1.5; stroke-linecap:round;stroke-linejoin:round">
|
||||
|
||||
<path d="M 9,26 C 17.5,24.5 30,24.5 36,26 L 38.5,13.5 L 31,25 L 30.7,10.9 L 25.5,24.5 L 22.5,10 L 19.5,24.5 L 14.3,10.9 L 14,25 L 6.5,13.5 L 9,26 z"
|
||||
style="stroke-linecap:butt;fill:#000000" />
|
||||
<path d="m 9,26 c 0,2 1.5,2 2.5,4 1,1.5 1,1 0.5,3.5 -1.5,1 -1,2.5 -1,2.5 -1.5,1.5 0,2.5 0,2.5 6.5,1 16.5,1 23,0 0,0 1.5,-1 0,-2.5 0,0 0.5,-1.5 -1,-2.5 -0.5,-2.5 -0.5,-2 0.5,-3.5 1,-2 2.5,-2 2.5,-4 -8.5,-1.5 -18.5,-1.5 -27,0 z" />
|
||||
<path d="M 11.5,30 C 15,29 30,29 33.5,30" />
|
||||
<path d="m 12,33.5 c 6,-1 15,-1 21,0" />
|
||||
<circle cx="6" cy="12" r="2" />
|
||||
<circle cx="14" cy="9" r="2" />
|
||||
<circle cx="22.5" cy="8" r="2" />
|
||||
<circle cx="31" cy="9" r="2" />
|
||||
<circle cx="39" cy="12" r="2" />
|
||||
<path d="M 11,38.5 A 35,35 1 0 0 34,38.5"
|
||||
style="fill:none; stroke:#000000;stroke-linecap:butt;" />
|
||||
<g style="fill:none; stroke:#ffffff;">
|
||||
<path d="M 11,29 A 35,35 1 0 1 34,29" />
|
||||
<path d="M 12.5,31.5 L 32.5,31.5" />
|
||||
<path d="M 11.5,34.5 A 35,35 1 0 0 33.5,34.5" />
|
||||
<path d="M 10.5,37.5 A 35,35 1 0 0 34.5,37.5" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
39
packages/chess/src/assets/pieces/black-rook.svg
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
|
||||
<g style="opacity:1; fill:#000000; fill-opacity:1; fill-rule:evenodd; stroke:#000000; stroke-width:1.5; stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;" transform="translate(0,0.3)">
|
||||
<path
|
||||
d="M 9,39 L 36,39 L 36,36 L 9,36 L 9,39 z "
|
||||
style="stroke-linecap:butt;" />
|
||||
<path
|
||||
d="M 12.5,32 L 14,29.5 L 31,29.5 L 32.5,32 L 12.5,32 z "
|
||||
style="stroke-linecap:butt;" />
|
||||
<path
|
||||
d="M 12,36 L 12,32 L 33,32 L 33,36 L 12,36 z "
|
||||
style="stroke-linecap:butt;" />
|
||||
<path
|
||||
d="M 14,29.5 L 14,16.5 L 31,16.5 L 31,29.5 L 14,29.5 z "
|
||||
style="stroke-linecap:butt;stroke-linejoin:miter;" />
|
||||
<path
|
||||
d="M 14,16.5 L 11,14 L 34,14 L 31,16.5 L 14,16.5 z "
|
||||
style="stroke-linecap:butt;" />
|
||||
<path
|
||||
d="M 11,14 L 11,9 L 15,9 L 15,11 L 20,11 L 20,9 L 25,9 L 25,11 L 30,11 L 30,9 L 34,9 L 34,14 L 11,14 z "
|
||||
style="stroke-linecap:butt;" />
|
||||
<path
|
||||
d="M 12,35.5 L 33,35.5 L 33,35.5"
|
||||
style="fill:none; stroke:#ffffff; stroke-width:1; stroke-linejoin:miter;" />
|
||||
<path
|
||||
d="M 13,31.5 L 32,31.5"
|
||||
style="fill:none; stroke:#ffffff; stroke-width:1; stroke-linejoin:miter;" />
|
||||
<path
|
||||
d="M 14,29.5 L 31,29.5"
|
||||
style="fill:none; stroke:#ffffff; stroke-width:1; stroke-linejoin:miter;" />
|
||||
<path
|
||||
d="M 14,16.5 L 31,16.5"
|
||||
style="fill:none; stroke:#ffffff; stroke-width:1; stroke-linejoin:miter;" />
|
||||
<path
|
||||
d="M 11,14 L 34,14"
|
||||
style="fill:none; stroke:#ffffff; stroke-width:1; stroke-linejoin:miter;" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
31
packages/chess/src/assets/pieces/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import whiteKing from "./white-king.svg";
|
||||
import whiteQueen from "./white-queen.svg";
|
||||
import whiteRook from "./white-rook.svg";
|
||||
import whiteBishop from "./white-bishop.svg";
|
||||
import whiteKnight from "./white-knight.svg";
|
||||
import whitePawn from "./white-pawn.svg";
|
||||
import blackKing from "./black-king.svg";
|
||||
import blackQueen from "./black-queen.svg";
|
||||
import blackRook from "./black-rook.svg";
|
||||
import blackBishop from "./black-bishop.svg";
|
||||
import blackKnight from "./black-knight.svg";
|
||||
import blackPawn from "./black-pawn.svg";
|
||||
|
||||
export const pieceAssets = {
|
||||
white: {
|
||||
king: whiteKing,
|
||||
queen: whiteQueen,
|
||||
rook: whiteRook,
|
||||
bishop: whiteBishop,
|
||||
knight: whiteKnight,
|
||||
pawn: whitePawn,
|
||||
},
|
||||
black: {
|
||||
king: blackKing,
|
||||
queen: blackQueen,
|
||||
rook: blackRook,
|
||||
bishop: blackBishop,
|
||||
knight: blackKnight,
|
||||
pawn: blackPawn,
|
||||
},
|
||||
} as const;
|
||||
12
packages/chess/src/assets/pieces/white-bishop.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
|
||||
<g style="opacity:1; fill:none; fill-rule:evenodd; fill-opacity:1; stroke:#000000; stroke-width:1.5; stroke-linecap:round; stroke-linejoin:round; stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;" transform="translate(0,0.6)">
|
||||
<g style="fill:#ffffff; stroke:#000000; stroke-linecap:butt;">
|
||||
<path d="M 9,36 C 12.39,35.03 19.11,36.43 22.5,34 C 25.89,36.43 32.61,35.03 36,36 C 36,36 37.65,36.54 39,38 C 38.32,38.97 37.35,38.99 36,38.5 C 32.61,37.53 25.89,38.96 22.5,37.5 C 19.11,38.96 12.39,37.53 9,38.5 C 7.65,38.99 6.68,38.97 6,38 C 7.35,36.54 9,36 9,36 z"/>
|
||||
<path d="M 15,32 C 17.5,34.5 27.5,34.5 30,32 C 30.5,30.5 30,30 30,30 C 30,27.5 27.5,26 27.5,26 C 33,24.5 33.5,14.5 22.5,10.5 C 11.5,14.5 12,24.5 17.5,26 C 17.5,26 15,27.5 15,30 C 15,30 14.5,30.5 15,32 z"/>
|
||||
<path d="M 25 8 A 2.5 2.5 0 1 1 20,8 A 2.5 2.5 0 1 1 25 8 z"/>
|
||||
</g>
|
||||
<path d="M 17.5,26 L 27.5,26 M 15,30 L 30,30 M 22.5,15.5 L 22.5,20.5 M 20,18 L 25,18" style="fill:none; stroke:#000000; stroke-linejoin:miter;"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
9
packages/chess/src/assets/pieces/white-king.svg
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="45">
|
||||
<g fill="none" fill-rule="evenodd" stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5">
|
||||
<path stroke-linejoin="miter" d="M22.5 11.63V6M20 8h5"/>
|
||||
<path fill="#fff" stroke-linecap="butt" stroke-linejoin="miter" d="M22.5 25s4.5-7.5 3-10.5c0 0-1-2.5-3-2.5s-3 2.5-3 2.5c-1.5 3 3 10.5 3 10.5"/>
|
||||
<path fill="#fff" d="M12.5 37c5.5 3.5 14.5 3.5 20 0v-7s9-4.5 6-10.5c-4-6.5-13.5-3.5-16 4V27v-3.5c-2.5-7.5-12-10.5-16-4-3 6 6 10.5 6 10.5v7"/>
|
||||
<path d="M12.5 30c5.5-3 14.5-3 20 0m-20 3.5c5.5-3 14.5-3 20 0m-20 3.5c5.5-3 14.5-3 20 0"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 700 B |
19
packages/chess/src/assets/pieces/white-knight.svg
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
|
||||
<g style="opacity:1; fill:none; fill-opacity:1; fill-rule:evenodd; stroke:#000000; stroke-width:1.5; stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;" transform="translate(0,0.3)">
|
||||
<path
|
||||
d="M 22,10 C 32.5,11 38.5,18 38,39 L 15,39 C 15,30 25,32.5 23,18"
|
||||
style="fill:#ffffff; stroke:#000000;" />
|
||||
<path
|
||||
d="M 24,18 C 24.38,20.91 18.45,25.37 16,27 C 13,29 13.18,31.34 11,31 C 9.958,30.06 12.41,27.96 11,28 C 10,28 11.19,29.23 10,30 C 9,30 5.997,31 6,26 C 6,24 12,14 12,14 C 12,14 13.89,12.1 14,10.5 C 13.27,9.506 13.5,8.5 13.5,7.5 C 14.5,6.5 16.5,10 16.5,10 L 18.5,10 C 18.5,10 19.28,8.008 21,7 C 22,7 22,10 22,10"
|
||||
style="fill:#ffffff; stroke:#000000;" />
|
||||
<path
|
||||
d="M 9.5 25.5 A 0.5 0.5 0 1 1 8.5,25.5 A 0.5 0.5 0 1 1 9.5 25.5 z"
|
||||
style="fill:#000000; stroke:#000000;" />
|
||||
<path
|
||||
d="M 15 15.5 A 0.5 1.5 0 1 1 14,15.5 A 0.5 1.5 0 1 1 15 15.5 z"
|
||||
transform="matrix(0.866,0.5,-0.5,0.866,9.693,-5.173)"
|
||||
style="fill:#000000; stroke:#000000;" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
5
packages/chess/src/assets/pieces/white-pawn.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
|
||||
<path d="m 22.5,9 c -2.21,0 -4,1.79 -4,4 0,0.89 0.29,1.71 0.78,2.38 C 17.33,16.5 16,18.59 16,21 c 0,2.03 0.94,3.84 2.41,5.03 C 15.41,27.09 11,31.58 11,39.5 H 34 C 34,31.58 29.59,27.09 26.59,26.03 28.06,24.84 29,23.03 29,21 29,18.59 27.67,16.5 25.72,15.38 26.21,14.71 26.5,13.89 26.5,13 c 0,-2.21 -1.79,-4 -4,-4 z" style="opacity:1; fill:#ffffff; fill-opacity:1; fill-rule:nonzero; stroke:#000000; stroke-width:1.5; stroke-linecap:round; stroke-linejoin:miter; stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 766 B |
15
packages/chess/src/assets/pieces/white-queen.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
|
||||
<g style="fill:#ffffff;stroke:#000000;stroke-width:1.5;stroke-linejoin:round">
|
||||
<path d="M 9,26 C 17.5,24.5 30,24.5 36,26 L 38.5,13.5 L 31,25 L 30.7,10.9 L 25.5,24.5 L 22.5,10 L 19.5,24.5 L 14.3,10.9 L 14,25 L 6.5,13.5 L 9,26 z"/>
|
||||
<path d="M 9,26 C 9,28 10.5,28 11.5,30 C 12.5,31.5 12.5,31 12,33.5 C 10.5,34.5 11,36 11,36 C 9.5,37.5 11,38.5 11,38.5 C 17.5,39.5 27.5,39.5 34,38.5 C 34,38.5 35.5,37.5 34,36 C 34,36 34.5,34.5 33,33.5 C 32.5,31 32.5,31.5 33.5,30 C 34.5,28 36,28 36,26 C 27.5,24.5 17.5,24.5 9,26 z"/>
|
||||
<path d="M 11.5,30 C 15,29 30,29 33.5,30" style="fill:none"/>
|
||||
<path d="M 12,33.5 C 18,32.5 27,32.5 33,33.5" style="fill:none"/>
|
||||
<circle cx="6" cy="12" r="2" />
|
||||
<circle cx="14" cy="9" r="2" />
|
||||
<circle cx="22.5" cy="8" r="2" />
|
||||
<circle cx="31" cy="9" r="2" />
|
||||
<circle cx="39" cy="12" r="2" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
25
packages/chess/src/assets/pieces/white-rook.svg
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="45" height="45">
|
||||
<g style="opacity:1; fill:#ffffff; fill-opacity:1; fill-rule:evenodd; stroke:#000000; stroke-width:1.5; stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4; stroke-dasharray:none; stroke-opacity:1;" transform="translate(0,0.3)">
|
||||
<path
|
||||
d="M 9,39 L 36,39 L 36,36 L 9,36 L 9,39 z "
|
||||
style="stroke-linecap:butt;" />
|
||||
<path
|
||||
d="M 12,36 L 12,32 L 33,32 L 33,36 L 12,36 z "
|
||||
style="stroke-linecap:butt;" />
|
||||
<path
|
||||
d="M 11,14 L 11,9 L 15,9 L 15,11 L 20,11 L 20,9 L 25,9 L 25,11 L 30,11 L 30,9 L 34,9 L 34,14"
|
||||
style="stroke-linecap:butt;" />
|
||||
<path
|
||||
d="M 34,14 L 31,17 L 14,17 L 11,14" />
|
||||
<path
|
||||
d="M 31,17 L 31,29.5 L 14,29.5 L 14,17"
|
||||
style="stroke-linecap:butt; stroke-linejoin:miter;" />
|
||||
<path
|
||||
d="M 31,29.5 L 32.5,32 L 12.5,32 L 14,29.5" />
|
||||
<path
|
||||
d="M 11,14 L 34,14"
|
||||
style="fill:none; stroke:#000000; stroke-linejoin:miter;" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
BIN
packages/chess/src/assets/sounds/capture.ogg
Normal file
BIN
packages/chess/src/assets/sounds/castle.ogg
Normal file
BIN
packages/chess/src/assets/sounds/check.ogg
Normal file
BIN
packages/chess/src/assets/sounds/checkmate.ogg
Normal file
BIN
packages/chess/src/assets/sounds/move.ogg
Normal file
BIN
packages/chess/src/assets/sounds/promote.ogg
Normal file
48
packages/chess/src/audio.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import moveSound from "./assets/sounds/move.ogg";
|
||||
import captureSound from "./assets/sounds/capture.ogg";
|
||||
import checkSound from "./assets/sounds/check.ogg";
|
||||
import checkmateSound from "./assets/sounds/checkmate.ogg";
|
||||
import castleSound from "./assets/sounds/castle.ogg";
|
||||
import promoteSound from "./assets/sounds/promote.ogg";
|
||||
|
||||
type SoundName =
|
||||
| "move"
|
||||
| "capture"
|
||||
| "check"
|
||||
| "checkmate"
|
||||
| "castle"
|
||||
| "promote";
|
||||
|
||||
const sources: Record<SoundName, string> = {
|
||||
move: moveSound,
|
||||
capture: captureSound,
|
||||
check: checkSound,
|
||||
checkmate: checkmateSound,
|
||||
castle: castleSound,
|
||||
promote: promoteSound,
|
||||
};
|
||||
|
||||
const MUTE_KEY = "paratype-chess:v1:muted";
|
||||
|
||||
export function isMuted(): boolean {
|
||||
if (typeof localStorage === "undefined") return false;
|
||||
return localStorage.getItem(MUTE_KEY) === "1";
|
||||
}
|
||||
|
||||
export function setMuted(m: boolean): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
localStorage.setItem(MUTE_KEY, m ? "1" : "0");
|
||||
}
|
||||
|
||||
export function play(name: SoundName): void {
|
||||
if (isMuted()) return;
|
||||
try {
|
||||
const audio = new Audio(sources[name]);
|
||||
audio.volume = 0.5;
|
||||
audio.play().catch(() => {
|
||||
// Ignore user-gesture autoplay block
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from 'react';
|
|||
import { ChessEngine, type GameResult } from '../engine';
|
||||
import type { PieceType } from '../schema';
|
||||
import { saveAutoSave } from '../persist/autosave.js';
|
||||
import * as audio from '../audio';
|
||||
|
||||
export function useChessEngine() {
|
||||
const engineRef = useRef<ChessEngine | null>(null);
|
||||
|
|
@ -47,6 +48,20 @@ export function useChessEngine() {
|
|||
const result = engine.applyMove(move, promoteTo);
|
||||
saveAutoSave(engine.session.allFacts());
|
||||
setTick(t => t + 1); // trigger re-render
|
||||
|
||||
// Play appropriate sound
|
||||
if (result === 'checkmate') {
|
||||
audio.play('checkmate');
|
||||
} else if (engine.session.allFacts().some(f => f.attr === 'InCheck' && f.value === true)) {
|
||||
audio.play('check');
|
||||
} else if (move.isCapture) {
|
||||
audio.play('capture');
|
||||
} else if (promoteTo && move.promoteTo) {
|
||||
audio.play('promote');
|
||||
} else {
|
||||
audio.play('move'); // TODO: differentiate castle
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
|
|
@ -68,6 +83,10 @@ export function useChessEngine() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const lastMove = moveHistoryRef.current.length > 0
|
||||
? moveHistoryRef.current[moveHistoryRef.current.length - 1]
|
||||
: null;
|
||||
|
||||
return {
|
||||
engine,
|
||||
turn: getTurn(),
|
||||
|
|
@ -78,5 +97,6 @@ export function useChessEngine() {
|
|||
undo,
|
||||
canUndo: moveHistoryRef.current.length > 0,
|
||||
loadEngine,
|
||||
lastMove,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,18 @@ import type { ChessFact, PieceColor, PieceType } from '../schema';
|
|||
import { squareToAlgebraic, squareOf, squareColor } from '../coord';
|
||||
import type { LegalMove } from '../rules/types';
|
||||
import { Piece } from './Piece';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { pieceAssets } from '../assets/pieces';
|
||||
|
||||
interface BoardProps {
|
||||
facts: ChessFact[];
|
||||
legalMoves: LegalMove[];
|
||||
onMove: (from: number, to: number) => void;
|
||||
onMove: (from: number, to: number, promoteTo?: PieceType) => void;
|
||||
turn: PieceColor;
|
||||
/** Last move played — extra fields ignored. Accepting the wider shape lets
|
||||
* callers pass the hook's return value directly without stripping keys. */
|
||||
lastMove?: { from: number; to: number; [key: string]: unknown } | null | undefined;
|
||||
checkedKingSquare?: number | null | undefined;
|
||||
}
|
||||
|
||||
interface PieceState {
|
||||
|
|
@ -17,7 +23,7 @@ interface PieceState {
|
|||
color: PieceColor;
|
||||
}
|
||||
|
||||
export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
|
||||
export function Board({ facts, legalMoves, onMove, turn, lastMove, checkedKingSquare }: BoardProps) {
|
||||
// Build pieces map: square -> { id, type, color }
|
||||
const pieces = useMemo(() => {
|
||||
const map = new Map<number, PieceState>();
|
||||
|
|
@ -51,6 +57,9 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
|
|||
|
||||
// Drag state
|
||||
const [draggedPiece, setDraggedPiece] = useState<{ id: number, square: number } | null>(null);
|
||||
|
||||
// Promotion picker state
|
||||
const [promotionMove, setPromotionMove] = useState<{ from: number, to: number, color: PieceColor } | null>(null);
|
||||
|
||||
// Compute highlighted squares based on current dragged piece
|
||||
const highlightedSquares = useMemo(() => {
|
||||
|
|
@ -88,7 +97,18 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
|
|||
const handleDrop = (e: React.DragEvent, targetSquare: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedPiece && highlightedSquares.has(targetSquare)) {
|
||||
onMove(draggedPiece.square, targetSquare);
|
||||
// Check if this is a promotion move
|
||||
const isPromotion = legalMoves.some(m =>
|
||||
m.pieceId === draggedPiece.id &&
|
||||
m.to === targetSquare &&
|
||||
m.promoteTo !== undefined
|
||||
);
|
||||
|
||||
if (isPromotion) {
|
||||
setPromotionMove({ from: draggedPiece.square, to: targetSquare, color: turn });
|
||||
} else {
|
||||
onMove(draggedPiece.square, targetSquare);
|
||||
}
|
||||
}
|
||||
setDraggedPiece(null);
|
||||
};
|
||||
|
|
@ -101,6 +121,8 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
|
|||
const isDark = squareColor(sq) === 'dark';
|
||||
const algebraic = squareToAlgebraic(sq);
|
||||
const isHighlighted = highlightedSquares.has(sq);
|
||||
const isLastMove = lastMove?.from === sq || lastMove?.to === sq;
|
||||
const isCheckedKing = checkedKingSquare === sq;
|
||||
|
||||
const piece = pieces.get(sq);
|
||||
|
||||
|
|
@ -109,36 +131,56 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
|
|||
key={sq}
|
||||
data-square={algebraic}
|
||||
className={`relative aspect-square flex items-center justify-center ${
|
||||
isDark ? 'bg-amber-700' : 'bg-amber-100'
|
||||
}`}
|
||||
isDark ? 'bg-[#B58863]' : 'bg-[#F0D9B5]'
|
||||
} shadow-[inset_0_0_8px_rgba(0,0,0,0.15)]`}
|
||||
onDragOver={(e) => handleDragOver(e, sq)}
|
||||
onDrop={(e) => handleDrop(e, sq)}
|
||||
>
|
||||
{isLastMove && (
|
||||
<div className="absolute inset-0 bg-yellow-400/30 pointer-events-none z-0" />
|
||||
)}
|
||||
|
||||
{isHighlighted && (
|
||||
<div className="absolute inset-0 bg-green-400/60 pointer-events-none ring-4 ring-inset ring-green-500/70 z-10" />
|
||||
<div className="absolute inset-0 bg-black/15 pointer-events-none ring-4 ring-inset ring-black/20 z-10 rounded-full m-4" />
|
||||
)}
|
||||
|
||||
{isCheckedKing && (
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-red-500/40 pointer-events-none z-10"
|
||||
animate={{ opacity: [0.4, 0.7, 0.4] }}
|
||||
transition={{ repeat: Infinity, duration: 1.5, ease: "easeInOut" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{piece && (
|
||||
<div className="absolute inset-0 z-20">
|
||||
<Piece
|
||||
color={piece.color}
|
||||
type={piece.type}
|
||||
pieceId={piece.id}
|
||||
square={sq}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{piece && (
|
||||
<motion.div
|
||||
key={piece.id}
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0, transition: { duration: 0.2 } }}
|
||||
className="absolute inset-0 z-20"
|
||||
>
|
||||
<Piece
|
||||
color={piece.color}
|
||||
type={piece.type}
|
||||
pieceId={piece.id}
|
||||
square={sq}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Rank/File labels (optional but helpful for debug) */}
|
||||
{f === 0 && (
|
||||
<div className={`absolute top-1 left-1 text-xs font-semibold select-none ${isDark ? 'text-amber-100' : 'text-amber-700'}`}>
|
||||
<div className={`absolute top-1 left-1 text-xs font-bold select-none ${isDark ? 'text-[#F0D9B5]/70' : 'text-[#B58863]/70'}`}>
|
||||
{r + 1}
|
||||
</div>
|
||||
)}
|
||||
{r === 0 && (
|
||||
<div className={`absolute bottom-1 right-1 text-xs font-semibold select-none ${isDark ? 'text-amber-100' : 'text-amber-700'}`}>
|
||||
<div className={`absolute bottom-1 right-1 text-xs font-bold select-none ${isDark ? 'text-[#F0D9B5]/70' : 'text-[#B58863]/70'}`}>
|
||||
{String.fromCharCode(97 + f)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -148,8 +190,41 @@ export function Board({ facts, legalMoves, onMove, turn }: BoardProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-8 grid-rows-8 w-full max-w-2xl mx-auto border-4 border-amber-900 rounded shadow-2xl overflow-hidden">
|
||||
{squares}
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-8 grid-rows-8 w-full max-w-2xl mx-auto rounded shadow-[0_20px_40px_-20px_rgba(0,0,0,0.3),_0_4px_8px_-2px_rgba(0,0,0,0.1)] overflow-hidden bg-neutral-900 border-4 border-neutral-800">
|
||||
{squares}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{promotionMove && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white/90 backdrop-blur-md p-4 rounded-2xl shadow-2xl z-50 flex gap-2 border border-neutral-200"
|
||||
data-testid="promotion-picker"
|
||||
>
|
||||
{(['queen', 'rook', 'bishop', 'knight'] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
data-action={`promote-${type}`}
|
||||
onClick={() => {
|
||||
onMove(promotionMove.from, promotionMove.to, type);
|
||||
setPromotionMove(null);
|
||||
}}
|
||||
className="w-16 h-16 sm:w-20 sm:h-20 hover:bg-neutral-100/80 rounded-xl flex items-center justify-center transition-colors active:scale-95 shadow-sm border border-transparent hover:border-neutral-200"
|
||||
>
|
||||
<img
|
||||
src={pieceAssets[promotionMove.color][type]}
|
||||
alt={type}
|
||||
className="w-4/5 h-4/5 drop-shadow-md"
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { Board } from './Board';
|
||||
import { RulesDrawer } from './RulesDrawer';
|
||||
import { useChessEngine } from '../hooks/useChessEngine';
|
||||
import type { ChessFact, ChessAttrMap } from '../schema';
|
||||
import type { ChessFact, ChessAttrMap, PieceType } from '../schema';
|
||||
import { useEffect, useState } from 'react';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { Volume2, VolumeX } from 'lucide-react';
|
||||
import * as audio from '../audio';
|
||||
|
||||
interface GameViewProps {
|
||||
engineState?: ReturnType<typeof useChessEngine>;
|
||||
|
|
@ -12,29 +17,93 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
const localChessState = useChessEngine();
|
||||
const state = engineState || localChessState;
|
||||
|
||||
const { facts, legalMoves, turn, result, applyMove, undo, canUndo } = state;
|
||||
const { facts, legalMoves, turn, result, applyMove, undo, canUndo, lastMove } = state;
|
||||
|
||||
const handleMove = (from: number, to: number) => {
|
||||
applyMove(from, to, 'queen');
|
||||
const handleMove = (from: number, to: number, promoteTo?: PieceType) => {
|
||||
applyMove(from, to, promoteTo || 'queen');
|
||||
};
|
||||
|
||||
const isGameOver = result !== 'ongoing';
|
||||
|
||||
// Confetti on checkmate
|
||||
useEffect(() => {
|
||||
if (result === 'checkmate') {
|
||||
const duration = 3000;
|
||||
const end = Date.now() + duration;
|
||||
|
||||
const frame = () => {
|
||||
confetti({
|
||||
particleCount: 5,
|
||||
angle: 60,
|
||||
spread: 55,
|
||||
origin: { x: 0 },
|
||||
colors: ['#F0D9B5', '#B58863', '#fff']
|
||||
});
|
||||
confetti({
|
||||
particleCount: 5,
|
||||
angle: 120,
|
||||
spread: 55,
|
||||
origin: { x: 1 },
|
||||
colors: ['#F0D9B5', '#B58863', '#fff']
|
||||
});
|
||||
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
frame();
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
// Audio toggle
|
||||
const [isMuted, setIsMuted] = useState(audio.isMuted());
|
||||
const toggleMute = () => {
|
||||
const next = !isMuted;
|
||||
audio.setMuted(next);
|
||||
setIsMuted(next);
|
||||
};
|
||||
|
||||
// Find checked king for indicator
|
||||
const checkedKingSquare = facts.find(
|
||||
f => f.attr === 'InCheck' && f.value === true
|
||||
) ? facts.find(
|
||||
f => f.attr === 'PieceType' && f.value === 'king' &&
|
||||
facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn)
|
||||
)?.id ? facts.find(f3 => f3.id === facts.find(
|
||||
f => f.attr === 'PieceType' && f.value === 'king' &&
|
||||
facts.some(f2 => f2.id === f.id && f2.attr === 'Color' && f2.value === turn)
|
||||
)?.id && f3.attr === 'Position')?.value as number : null : null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 py-8 w-full max-w-4xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex flex-col items-center gap-8 py-8 w-full max-w-4xl mx-auto"
|
||||
>
|
||||
<RulesDrawer />
|
||||
<div className="flex flex-col md:flex-row w-full items-start justify-between gap-4 px-4">
|
||||
|
||||
{/* Header/Info section */}
|
||||
<div className="flex flex-col gap-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-neutral-800">
|
||||
Chess
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-neutral-800">
|
||||
Chess
|
||||
</h1>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className="p-2 text-neutral-500 hover:text-neutral-800 hover:bg-neutral-100 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-200"
|
||||
title={isMuted ? "Unmute sounds" : "Mute sounds"}
|
||||
>
|
||||
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
data-testid="turn-indicator"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-neutral-100 border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-neutral-200 rounded-md font-medium text-neutral-700 shadow-sm"
|
||||
>
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-inner ${turn === 'white' ? 'bg-white border border-neutral-300' : 'bg-neutral-900 border border-neutral-950'}`}
|
||||
|
|
@ -42,7 +111,8 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
<span>{turn === 'white' ? "White's turn" : "Black's turn"}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<motion.button
|
||||
whileTap={canUndo ? { scale: 0.95 } : {}}
|
||||
data-action="undo"
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
|
|
@ -50,19 +120,24 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
title="Undo last move"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Game Result Banner */}
|
||||
{isGameOver && (
|
||||
<div
|
||||
data-testid="game-over"
|
||||
className="px-6 py-3 bg-amber-100 border border-amber-300 text-amber-900 font-semibold rounded-md shadow-sm"
|
||||
>
|
||||
{result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`}
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{isGameOver && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: -20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
transition={{ type: 'spring', damping: 20 }}
|
||||
data-testid="game-over"
|
||||
className="px-6 py-3 bg-amber-100 border border-amber-300 text-amber-900 font-semibold rounded-md shadow-sm"
|
||||
>
|
||||
{result === 'checkmate' ? 'Checkmate!' : `Draw: ${result.replace('draw-', '')}`}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="w-full px-4 relative">
|
||||
|
|
@ -71,14 +146,20 @@ export function GameView({ engineState }: GameViewProps) {
|
|||
legalMoves={legalMoves}
|
||||
turn={turn}
|
||||
onMove={handleMove}
|
||||
lastMove={lastMove}
|
||||
checkedKingSquare={checkedKingSquare}
|
||||
/>
|
||||
|
||||
{/* Overlay for game over to prevent further interaction visually */}
|
||||
{isGameOver && (
|
||||
<div className="absolute inset-0 bg-white/20 backdrop-blur-[1px] pointer-events-none z-30" />
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="absolute inset-0 bg-white/10 backdrop-blur-[1px] pointer-events-none z-30"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { exportGame, importGame } from '../persist/io';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ImportExportProps {
|
||||
currentFacts?: Array<{ id: number; attr: string; value: unknown }>;
|
||||
|
|
@ -22,8 +23,10 @@ export function ImportExport({ currentFacts, onLoad }: ImportExportProps) {
|
|||
a.download = `chess-game-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Game exported');
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
toast.error('Export failed');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -42,14 +45,18 @@ export function ImportExport({ currentFacts, onLoad }: ImportExportProps) {
|
|||
const text = event.target?.result as string;
|
||||
const facts = importGame(text);
|
||||
onLoad(facts);
|
||||
toast.success('Game imported');
|
||||
// Reset file input so the same file can be selected again
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown import error');
|
||||
const msg = err instanceof Error ? err.message : 'Unknown import error';
|
||||
setError(msg);
|
||||
toast.error('Import failed', { description: msg });
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
setError('Failed to read file');
|
||||
toast.error('Failed to read file');
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { pieceAssets } from '../assets/pieces';
|
||||
|
||||
const WS_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
|
||||
|
|
@ -131,49 +133,62 @@ export function Lobby() {
|
|||
return (
|
||||
<main
|
||||
data-testid="page-home"
|
||||
className="min-h-screen bg-slate-50 flex items-center justify-center p-4"
|
||||
className="min-h-screen bg-gradient-to-br from-neutral-100 to-neutral-200 flex flex-col items-center justify-center p-4 relative overflow-hidden"
|
||||
>
|
||||
<div className="w-full max-w-md bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
|
||||
<div className="p-6 text-center border-b border-slate-100 bg-slate-50/50">
|
||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Chess</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">Play realtime multiplayer</p>
|
||||
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0IiBoZWlnaHQ9IjQiPgo8cmVjdCB3aWR0aD0iNCIgaGVpZ2h0PSI0IiBmaWxsPSIjZmZmIiAvPgo8cmVjdCB3aWR0aD0iMSIgaGVpZ2h0PSIxIiBmaWxsPSIjY2NjIiAvPgo8L3N2Zz4=')] opacity-50 z-0"></div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -10 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ type: "spring", damping: 15, stiffness: 200 }}
|
||||
className="w-24 h-24 mb-8 bg-white rounded-2xl shadow-xl border border-neutral-200/50 flex items-center justify-center z-10"
|
||||
>
|
||||
<img src={pieceAssets.white.knight} alt="Knight" className="w-16 h-16 drop-shadow-md" draggable={false} />
|
||||
</motion.div>
|
||||
|
||||
<div className="w-full max-w-md bg-white/70 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/50 overflow-hidden z-10 relative">
|
||||
<div className="p-8 text-center border-b border-neutral-200/50">
|
||||
<h1 className="text-3xl font-extrabold text-neutral-900 tracking-tight">Paratype Chess</h1>
|
||||
<p className="text-neutral-500 font-medium mt-2">Realtime multiplayer with custom rules</p>
|
||||
<button
|
||||
data-action="start-new-game"
|
||||
onClick={() => navigate('/game')}
|
||||
className="mt-3 text-xs text-slate-500 hover:text-slate-900 underline underline-offset-2"
|
||||
className="mt-4 text-sm font-semibold text-neutral-500 hover:text-neutral-900 transition-colors underline underline-offset-4 decoration-neutral-300 hover:decoration-neutral-900"
|
||||
>
|
||||
or play solo (local)
|
||||
Play Solo (Local)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-8">
|
||||
<div className="p-8 space-y-8">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold text-slate-900 uppercase tracking-wider">
|
||||
New Game
|
||||
<h2 className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
|
||||
Host Game
|
||||
</h2>
|
||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100">
|
||||
<div className="bg-white/50 rounded-xl p-5 border border-neutral-200/60 shadow-inner">
|
||||
{!roomCode ? (
|
||||
<button
|
||||
data-action="create-room"
|
||||
onClick={handleCreate}
|
||||
disabled={loading}
|
||||
className="w-full bg-slate-900 text-white font-medium py-2.5 px-4 rounded-md hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-full bg-neutral-900 text-white font-bold py-3 px-4 rounded-lg hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-md active:scale-[0.98]"
|
||||
>
|
||||
{loading && !codeInput ? 'Creating...' : 'Create Room'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<span className="text-sm text-slate-500 block mb-1">Room code:</span>
|
||||
<span className="text-sm font-medium text-neutral-500 block mb-2">Room Code</span>
|
||||
<span
|
||||
data-testid="room-code"
|
||||
className="text-2xl font-mono font-bold tracking-widest text-slate-900 bg-white border border-slate-200 rounded px-4 py-2 inline-block"
|
||||
className="text-3xl font-mono font-black tracking-[0.2em] text-neutral-900 bg-white border border-neutral-200 shadow-sm rounded-lg px-6 py-3 inline-block"
|
||||
style={{ fontFeatureSettings: '"ss01", "cv11"' }}
|
||||
>
|
||||
{roomCode}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/game')}
|
||||
className="w-full bg-blue-600 text-white font-medium py-2.5 px-4 rounded-md hover:bg-blue-700 transition-colors"
|
||||
className="w-full bg-blue-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-blue-700 transition-all shadow-md active:scale-[0.98]"
|
||||
>
|
||||
Enter Room
|
||||
</button>
|
||||
|
|
@ -184,15 +199,15 @@ export function Lobby() {
|
|||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-slate-200"></div>
|
||||
<div className="w-full border-t border-neutral-200/80"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-slate-400">or</span>
|
||||
<div className="relative flex justify-center text-sm font-medium">
|
||||
<span className="px-3 bg-[#f8f9fa] text-neutral-400">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-semibold text-slate-900 uppercase tracking-wider">
|
||||
<h2 className="text-xs font-bold text-neutral-400 uppercase tracking-widest">
|
||||
Join Game
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
|
|
@ -203,13 +218,14 @@ export function Lobby() {
|
|||
onChange={(e) => setCodeInput(e.target.value.toUpperCase())}
|
||||
placeholder="Enter 6-letter code"
|
||||
maxLength={6}
|
||||
className="w-full font-mono text-center text-lg py-2.5 px-4 border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-900 focus:border-transparent placeholder:text-slate-400"
|
||||
className="w-full font-mono font-bold text-center text-xl tracking-[0.2em] py-3 px-4 bg-white/80 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent placeholder:text-neutral-300 shadow-inner transition-shadow"
|
||||
style={{ fontFeatureSettings: '"ss01", "cv11"' }}
|
||||
/>
|
||||
<button
|
||||
data-action="join-room"
|
||||
onClick={handleJoin}
|
||||
disabled={loading || !codeInput.trim()}
|
||||
className="w-full bg-white text-slate-900 font-medium py-2.5 px-4 rounded-md border border-slate-300 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="w-full bg-white text-neutral-900 font-bold py-3 px-4 rounded-lg border border-neutral-200 hover:bg-neutral-50 hover:border-neutral-300 disabled:opacity-50 disabled:cursor-not-allowed transition-all shadow-sm active:scale-[0.98]"
|
||||
>
|
||||
{loading && codeInput ? 'Joining...' : 'Join Room'}
|
||||
</button>
|
||||
|
|
@ -217,14 +233,19 @@ export function Lobby() {
|
|||
</section>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
data-testid="lobby-error"
|
||||
className="bg-red-50 border-t border-red-100 p-4 text-center"
|
||||
>
|
||||
<p className="text-sm text-red-600 font-medium">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
data-testid="lobby-error"
|
||||
className="bg-red-50 border-t border-red-100 p-4 text-center overflow-hidden"
|
||||
>
|
||||
<p className="text-sm text-red-600 font-semibold">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import type { PieceColor, PieceType } from '../schema';
|
||||
import { pieceAssets } from '../assets/pieces';
|
||||
import { motion } from 'motion/react';
|
||||
import type { DragEvent as ReactDragEvent } from 'react';
|
||||
|
||||
export interface PieceProps {
|
||||
color: PieceColor;
|
||||
|
|
@ -9,44 +12,55 @@ export interface PieceProps {
|
|||
onDragEnd: () => void;
|
||||
}
|
||||
|
||||
const PIECE_SYMBOLS: Record<PieceColor, Record<PieceType, string>> = {
|
||||
white: {
|
||||
king: '♔',
|
||||
queen: '♕',
|
||||
rook: '♖',
|
||||
bishop: '♗',
|
||||
knight: '♘',
|
||||
pawn: '♙',
|
||||
},
|
||||
black: {
|
||||
king: '♚',
|
||||
queen: '♛',
|
||||
rook: '♜',
|
||||
bishop: '♝',
|
||||
knight: '♞',
|
||||
pawn: '♟',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Piece component.
|
||||
*
|
||||
* We wrap the draggable DOM node in an outer `motion.div` that owns the
|
||||
* FLIP layout animation (via `layoutId`). The inner plain `<div>` owns the
|
||||
* HTML5 native drag-and-drop: Playwright's `locator.dragTo()` and the
|
||||
* browser's DnD subsystem both dispatch events on this node. Keeping the
|
||||
* two responsibilities separate avoids a type clash — `motion.div`'s
|
||||
* `onDragStart` prop is typed for its own pointer-based drag gesture,
|
||||
* which is incompatible with `React.DragEvent`. The extra DOM node costs
|
||||
* nothing and sidesteps the cast.
|
||||
*/
|
||||
export function Piece({ color, type, pieceId, square, onDragStart, onDragEnd }: PieceProps) {
|
||||
const symbol = PIECE_SYMBOLS[color][type];
|
||||
|
||||
const imgSrc = pieceAssets[color][type];
|
||||
|
||||
const handleDragStart = (e: ReactDragEvent<HTMLDivElement>) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Firefox requires non-empty drag data for dragstart to take effect.
|
||||
e.dataTransfer.setData('text/plain', `${pieceId}@${square}`);
|
||||
// Suppress the browser's default ghost image — we'd rather the piece
|
||||
// stay visually in place while motion handles the move animation.
|
||||
const img = new Image();
|
||||
img.src =
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
e.dataTransfer.setDragImage(img, 0, 0);
|
||||
onDragStart(pieceId, square);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
data-piece={`${color}-${type}`}
|
||||
data-piece-id={pieceId}
|
||||
onDragStart={(e) => {
|
||||
// Set drag image (optional, but good for native dnd)
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
// Need to set some data for Firefox to allow dragging
|
||||
e.dataTransfer.setData('text/plain', `${pieceId}@${square}`);
|
||||
onDragStart(pieceId, square);
|
||||
}}
|
||||
onDragEnd={onDragEnd}
|
||||
className="flex items-center justify-center w-full h-full text-5xl cursor-grab active:cursor-grabbing select-none hover:bg-white/20 rounded"
|
||||
<motion.div
|
||||
layoutId={`piece-${pieceId}`}
|
||||
className="w-full h-full"
|
||||
transition={{ type: 'spring', stiffness: 400, damping: 25 }}
|
||||
>
|
||||
{symbol}
|
||||
</div>
|
||||
<div
|
||||
draggable
|
||||
data-piece={`${color}-${type}`}
|
||||
data-piece-id={pieceId}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
className="flex items-center justify-center w-full h-full cursor-grab active:cursor-grabbing select-none"
|
||||
>
|
||||
<img
|
||||
src={imgSrc}
|
||||
alt={`${color} ${type}`}
|
||||
className="w-[85%] h-[85%] drop-shadow-[0_4px_4px_rgba(0,0,0,0.3)] pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { PRESET_REGISTRY } from '../presets/index.js';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import { Settings2, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* A collapsible side-drawer for toggling preset rules mid-game.
|
||||
|
|
@ -29,16 +32,20 @@ export function RulesDrawer() {
|
|||
const presets = PRESET_REGISTRY.getAll();
|
||||
const activeIds = new Set(PRESET_REGISTRY.getActive().map((p) => p.id));
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (activeIds.has(id)) {
|
||||
const toggle = (id: string, name: string) => {
|
||||
const isActivating = !activeIds.has(id);
|
||||
if (!isActivating) {
|
||||
PRESET_REGISTRY.deactivate(id);
|
||||
toast(`Preset disabled: ${name}`);
|
||||
} else {
|
||||
try {
|
||||
PRESET_REGISTRY.activate(id);
|
||||
toast.info(`Preset enabled: ${name}`);
|
||||
} catch (err) {
|
||||
// activate() throws for missing requires or incompatibilities. We
|
||||
// surface the reason via the UI only for the user's next refresh.
|
||||
console.warn(`Could not activate ${id}:`, err);
|
||||
toast.error(`Could not activate ${name}`);
|
||||
}
|
||||
}
|
||||
setTick((t) => t + 1);
|
||||
|
|
@ -47,131 +54,138 @@ export function RulesDrawer() {
|
|||
return (
|
||||
<>
|
||||
{/* Trigger pill — always visible, top-right of game view */}
|
||||
<button
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
type="button"
|
||||
data-action="open-rules-drawer"
|
||||
onClick={() => setOpen(true)}
|
||||
className="fixed top-4 right-4 z-40 flex items-center gap-2 bg-white px-4 py-2 rounded-full shadow-md border border-neutral-200 hover:border-blue-400 hover:shadow-lg transition-all text-sm font-medium text-neutral-700"
|
||||
className="fixed top-4 right-4 z-40 flex items-center gap-2 bg-white/90 backdrop-blur-sm px-4 py-2.5 rounded-full shadow-sm border border-neutral-200/60 hover:border-neutral-300 hover:shadow transition-colors text-sm font-semibold text-neutral-700"
|
||||
aria-label="Toggle rules"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M5 4a1 1 0 00-2 0v7.268a2 2 0 000 3.464V16a1 1 0 102 0v-1.268a2 2 0 000-3.464V4zM11 4a1 1 0 10-2 0v1.268a2 2 0 000 3.464V16a1 1 0 102 0V8.732a2 2 0 000-3.464V4zM16 3a1 1 0 011 1v7.268a2 2 0 010 3.464V16a1 1 0 11-2 0v-1.268a2 2 0 010-3.464V4a1 1 0 011-1z" />
|
||||
</svg>
|
||||
<Settings2 size={16} />
|
||||
Rules
|
||||
{activeIds.size > 0 && (
|
||||
<span className="bg-blue-600 text-white rounded-full px-2 py-0.5 text-xs font-bold">
|
||||
<span className="bg-neutral-800 text-white rounded-full px-2 py-0.5 text-[10px] font-bold ml-1">
|
||||
{activeIds.size}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</motion.button>
|
||||
|
||||
{/* Backdrop + drawer */}
|
||||
{open && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-40 transition-opacity"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<aside
|
||||
data-testid="rules-drawer"
|
||||
className="fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-2xl z-50 flex flex-col overflow-hidden animate-slide-in"
|
||||
style={{ animation: 'slide-in 0.2s ease-out' }}
|
||||
>
|
||||
<header className="p-5 border-b border-neutral-200 flex items-center justify-between bg-neutral-50">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-neutral-900">Live Rules</h2>
|
||||
<p className="text-xs text-neutral-500 mt-0.5">
|
||||
Toggle rules mid-game — applies on the next move
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="close-rules-drawer"
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-2 hover:bg-neutral-200 rounded-md transition-colors text-neutral-600"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-neutral-900/20 backdrop-blur-sm z-40"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<motion.aside
|
||||
initial={{ x: '100%' }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||
data-testid="rules-drawer"
|
||||
className="fixed top-0 right-0 h-full w-full max-w-md bg-white shadow-2xl z-50 flex flex-col overflow-hidden border-l border-neutral-200/50"
|
||||
>
|
||||
<header className="px-6 py-5 border-b border-neutral-100 flex items-center justify-between bg-white">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight text-neutral-900">Live Rules</h2>
|
||||
<p className="text-sm text-neutral-500 mt-1">
|
||||
Toggle rules mid-game
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-action="close-rules-drawer"
|
||||
onClick={() => setOpen(false)}
|
||||
className="p-2 hover:bg-neutral-100 rounded-full transition-colors text-neutral-400 hover:text-neutral-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-3">
|
||||
{presets.map((preset) => {
|
||||
const isOn = activeIds.has(preset.id);
|
||||
const blockedBy = preset.incompatibleWith.filter((id) =>
|
||||
activeIds.has(id),
|
||||
);
|
||||
const missingReqs = preset.requires.filter(
|
||||
(id) => !activeIds.has(id),
|
||||
);
|
||||
const blocked =
|
||||
!isOn && (blockedBy.length > 0 || missingReqs.length > 0);
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
{presets.map((preset) => {
|
||||
const isOn = activeIds.has(preset.id);
|
||||
const blockedBy = preset.incompatibleWith.filter((id) =>
|
||||
activeIds.has(id),
|
||||
);
|
||||
const missingReqs = preset.requires.filter(
|
||||
(id) => !activeIds.has(id),
|
||||
);
|
||||
const blocked =
|
||||
!isOn && (blockedBy.length > 0 || missingReqs.length > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
data-preset={preset.id}
|
||||
className={`border rounded-lg p-3 transition-colors ${
|
||||
isOn
|
||||
? 'border-blue-300 bg-blue-50'
|
||||
: blocked
|
||||
? 'border-neutral-200 bg-neutral-50 opacity-60'
|
||||
: 'border-neutral-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-neutral-900 text-sm">
|
||||
{preset.name}
|
||||
</h3>
|
||||
<p className="text-xs text-neutral-600 mt-0.5 leading-relaxed">
|
||||
{preset.description}
|
||||
</p>
|
||||
{blockedBy.length > 0 && (
|
||||
<p className="text-xs text-amber-700 mt-1.5">
|
||||
Conflicts with active: {blockedBy.join(', ')}
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
data-preset={preset.id}
|
||||
className={`border rounded-xl p-4 transition-all duration-200 ${
|
||||
isOn
|
||||
? 'border-neutral-300 bg-neutral-50 shadow-sm'
|
||||
: blocked
|
||||
? 'border-neutral-100 bg-neutral-50/50 opacity-60'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-neutral-900 text-sm">
|
||||
{preset.name}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-500 mt-1 leading-relaxed">
|
||||
{preset.description}
|
||||
</p>
|
||||
)}
|
||||
{missingReqs.length > 0 && (
|
||||
<p className="text-xs text-amber-700 mt-1.5">
|
||||
Requires: {missingReqs.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{blockedBy.length > 0 && (
|
||||
<p className="text-xs font-medium text-amber-600 mt-2">
|
||||
Conflicts with active: {blockedBy.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{missingReqs.length > 0 && (
|
||||
<p className="text-xs font-medium text-amber-600 mt-2">
|
||||
Requires: {missingReqs.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-role="toggle"
|
||||
disabled={blocked}
|
||||
onClick={() => toggle(preset.id, preset.name)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-2 focus:ring-offset-white ${
|
||||
isOn ? 'bg-neutral-900' : 'bg-neutral-200'
|
||||
} ${blocked ? 'cursor-not-allowed' : ''}`}
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
>
|
||||
<motion.span
|
||||
layout
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out ${
|
||||
isOn ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-role="toggle"
|
||||
disabled={blocked}
|
||||
onClick={() => toggle(preset.id)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 ${
|
||||
isOn ? 'bg-blue-600' : 'bg-neutral-300'
|
||||
} ${blocked ? 'cursor-not-allowed' : ''}`}
|
||||
role="switch"
|
||||
aria-checked={isOn}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition ${
|
||||
isOn ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<footer className="p-4 border-t border-neutral-200 bg-neutral-50 text-xs text-neutral-500 text-center">
|
||||
{activeIds.size === 0
|
||||
? 'Standard FIDE chess — no presets active'
|
||||
: `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`}
|
||||
</footer>
|
||||
</aside>
|
||||
</>
|
||||
)}
|
||||
<footer className="p-4 border-t border-neutral-100 bg-white text-xs font-medium text-neutral-400 text-center">
|
||||
{activeIds.size === 0
|
||||
? 'Standard FIDE chess — no presets active'
|
||||
: `${activeIds.size} preset${activeIds.size === 1 ? '' : 's'} active`}
|
||||
</footer>
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import type { EntityId } from '@paratype/rete';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface SaveSlot {
|
||||
name: string;
|
||||
|
|
@ -54,11 +55,13 @@ export function SavePanel({ onLoad, currentFacts }: SavePanelProps) {
|
|||
localStorage.setItem(`${LOCAL_STORAGE_PREFIX}${slot.name}`, JSON.stringify(slot));
|
||||
setNewSlotName('');
|
||||
loadSlots();
|
||||
toast.success('Game saved', { description: `Saved as "${slot.name}"` });
|
||||
};
|
||||
|
||||
const handleDelete = (name: string) => {
|
||||
localStorage.removeItem(`${LOCAL_STORAGE_PREFIX}${name}`);
|
||||
loadSlots();
|
||||
toast('Save deleted', { description: `Deleted "${name}"` });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -114,7 +117,10 @@ export function SavePanel({ onLoad, currentFacts }: SavePanelProps) {
|
|||
<div className="flex gap-2">
|
||||
<button
|
||||
data-action="load"
|
||||
onClick={() => onLoad(slot.facts)}
|
||||
onClick={() => {
|
||||
onLoad(slot.facts);
|
||||
toast.success('Game loaded');
|
||||
}}
|
||||
className="px-4 py-1.5 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-md hover:bg-neutral-50 active:bg-neutral-100 transition-colors"
|
||||
>
|
||||
Load
|
||||
|
|
|
|||
21
packages/chess/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
// Vite's static asset imports — these return a URL string at runtime. The
|
||||
// `vite/client` triple-slash directive above already declares most of them,
|
||||
// but we add `.ogg` explicitly since Vite only emits types for a known
|
||||
// subset of asset extensions (mp3/wav/flac are in; ogg is not).
|
||||
declare module '*.ogg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module '*.svg?react' {
|
||||
import type { FunctionComponent, SVGProps } from 'react';
|
||||
const ReactComponent: FunctionComponent<SVGProps<SVGSVGElement>>;
|
||||
export default ReactComponent;
|
||||
}
|
||||