extremely claude-assisted go game based on atproto! working on cleaning up and giving a more unique design, still has a bit of a slop vibe to it.
Something went wrong. Try again.
15 kB · 498 lines
Svelte
at list
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499<script lang="ts"> import { onMount } from 'svelte'; import { browser } from '$app/environment'; import Board from './Board.svelte'; import { loadSgf, type SgfData } from '$lib/sgf-parser'; import { cubicOut } from 'svelte/easing'; import { fade } from 'svelte/transition'; import type { TransitionConfig } from 'svelte/transition';
interface Props { isOpen?: boolean; onClose?: () => void; }
let { isOpen = $bindable(false), onClose = () => {} }: Props = $props();
let currentStep = $state(0); let singleCaptureData: SgfData | null = $state(null); let groupCaptureData: SgfData | null = $state(null); let scoringDemoData: SgfData | null = $state(null);
// Track current move index for each diagram let singleCaptureMoveIndex = $state(0); let groupCaptureMoveIndex = $state(0); let scoringDemoMoveIndex = $state(0);
const STORAGE_KEY = 'cloudgo-tutorial-seen';
const steps = [ { title: 'Welcome to Cloud Go!', content: `<p>Go is an ancient game whose Chinese name "Weiqi" translates to "the Surrounding Game".</p> <ul> <li>Your goal is to surround as much territory as possible</li> <li>You can capture your opponent's stones by surrounding them</li> <li>The player who controls the most territory wins</li> </ul>`, sgfUrl: null }, { title: 'Capturing a Single Stone', content: `<ul> <li>A stone is captured when it's surrounded on all sides</li> <li>Stones connect along the lines (not diagonally)</li> <li>Use the buttons below to step through the example</li> </ul>`, sgfUrl: '/simplecapture.sgf' }, { title: 'Capturing Multiple Stones', content: `<ul> <li>Stones connected along lines form groups</li> <li>You must surround the entire group to capture it</li> <li>Step through to see a group capture in action</li> </ul>`, sgfUrl: '/3stonecapture.sgf' }, { title: 'Counting Territory', content: `<p>At the end of the game:</p> <ul> <li>Count the empty intersections you surround (your territory)</li> <li>Add bonus points for captured opponent stones</li> <li>The player with more points wins</li> </ul> <p>In this example, black controls more of the board and wins!</p>`, sgfUrl: '/scoringdemo.sgf' }, { title: 'How to Use Cloud Go', content: `<p><strong>Game Flow:</strong></p> <ul> <li>The initiating player always goes first</li> <li>You take turns playing - there are no time limits yet</li> <li>If your opponent is taking too long or the game is lost, you can resign</li> </ul> <p><strong>Navigation:</strong></p> <ul> <li>Use the move list at the bottom or arrow keys to navigate move history</li> <li>Click on a move to add comments with reaction emojis</li> </ul> <p><strong>Ending the Game:</strong></p> <ul> <li>Once both players pass, territories are auto-calculated</li> <li>The black player can modify or commit the final scores</li> </ul>`, sgfUrl: null } ];
async function loadStepDiagram() { const step = steps[currentStep]; if (!step.sgfUrl) return;
try { const sgfData = await loadSgf(step.sgfUrl);
if (step.sgfUrl === '/simplecapture.sgf') { singleCaptureData = sgfData; singleCaptureMoveIndex = 0; // Start from beginning } else if (step.sgfUrl === '/3stonecapture.sgf') { groupCaptureData = sgfData; groupCaptureMoveIndex = 0; // Start from beginning } else if (step.sgfUrl === '/scoringdemo.sgf') { scoringDemoData = sgfData; scoringDemoMoveIndex = sgfData.moves.length; // Start at end to show final position } } catch (error) { console.error('Failed to load SGF:', error); } }
function prevMove(diagram: 'single' | 'group' | 'scoring') { if (diagram === 'single' && singleCaptureMoveIndex > 0) { singleCaptureMoveIndex--; } else if (diagram === 'group' && groupCaptureMoveIndex > 0) { groupCaptureMoveIndex--; } else if (diagram === 'scoring' && scoringDemoMoveIndex > 0) { scoringDemoMoveIndex--; } }
function nextMove(diagram: 'single' | 'group' | 'scoring') { if (diagram === 'single' && singleCaptureData && singleCaptureMoveIndex < singleCaptureData.moves.length) { singleCaptureMoveIndex++; } else if (diagram === 'group' && groupCaptureData && groupCaptureMoveIndex < groupCaptureData.moves.length) { groupCaptureMoveIndex++; } else if (diagram === 'scoring' && scoringDemoData && scoringDemoMoveIndex < scoringDemoData.moves.length) { scoringDemoMoveIndex++; } }
function resetMoves(diagram: 'single' | 'group' | 'scoring') { if (diagram === 'single') { singleCaptureMoveIndex = 0; } else if (diagram === 'group') { groupCaptureMoveIndex = 0; } else if (diagram === 'scoring') { scoringDemoMoveIndex = 0; } }
onMount(() => { if (!browser) return;
const seen = localStorage.getItem(STORAGE_KEY); if (!seen) { isOpen = true; } });
$effect(() => { if (isOpen && browser) { loadStepDiagram(); } });
function handleClose() { if (browser) { localStorage.setItem(STORAGE_KEY, 'true'); } isOpen = false; onClose(); }
function nextStep() { if (currentStep < steps.length - 1) { currentStep++; } else { handleClose(); } }
function prevStep() { if (currentStep > 0) { currentStep--; } }
function handleKeydown(e: KeyboardEvent) { if (e.key === 'Escape') { handleClose(); } else if (e.key === 'ArrowRight') { nextStep(); } else if (e.key === 'ArrowLeft') { prevStep(); } }
function cloudMaterialize(node: Element): TransitionConfig { return { duration: 800, easing: cubicOut, css: (t: number) => { const opacity = t; const blur = (1 - t) * 20; const translateY = (1 - t) * -20; const scale = 0.9 + (t * 0.1);
return ` opacity: ${opacity}; filter: blur(${blur}px); transform: translateY(${translateY}px) scale(${scale}); `; } }; }
export function open() { isOpen = true; currentStep = 0; }</script>
{#if isOpen} <div class="modal-overlay" onclick={handleClose} onkeydown={handleKeydown} role="button" tabindex="0" transition:fade={{ duration: 600 }}> <div class="modal-content cloud-card" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" transition:cloudMaterialize> <div class="modal-inner"> <div class="modal-header"> <h2>{steps[currentStep].title}</h2> <button class="modal-close" onclick={handleClose} aria-label="Close tutorial">×</button> </div>
<div class="modal-body"> {@html steps[currentStep].content}
{#if steps[currentStep].sgfUrl === '/simplecapture.sgf' && singleCaptureData} <div class="board-container"> <Board boardSize={singleCaptureData.boardSize} gameState={{ moves: singleCaptureData.moves.slice(0, singleCaptureMoveIndex) }} interactive={false} /> </div> <div class="move-controls"> <button class="move-btn" onclick={() => resetMoves('single')} disabled={singleCaptureMoveIndex === 0} title="Reset to start" > ⏮ </button> <button class="move-btn" onclick={() => prevMove('single')} disabled={singleCaptureMoveIndex === 0} title="Previous move" > ◀ </button> <span class="move-counter"> Move {singleCaptureMoveIndex} / {singleCaptureData.moves.length} </span> <button class="move-btn" onclick={() => nextMove('single')} disabled={singleCaptureMoveIndex === singleCaptureData.moves.length} title="Next move" > ▶ </button> <button class="move-btn" onclick={() => singleCaptureMoveIndex = singleCaptureData!.moves.length} disabled={singleCaptureMoveIndex === singleCaptureData.moves.length} title="Jump to end" > ⏭ </button> </div> {/if}
{#if steps[currentStep].sgfUrl === '/3stonecapture.sgf' && groupCaptureData} <div class="board-container"> <Board boardSize={groupCaptureData.boardSize} gameState={{ moves: groupCaptureData.moves.slice(0, groupCaptureMoveIndex) }} interactive={false} /> </div> <div class="move-controls"> <button class="move-btn" onclick={() => resetMoves('group')} disabled={groupCaptureMoveIndex === 0} title="Reset to start" > ⏮ </button> <button class="move-btn" onclick={() => prevMove('group')} disabled={groupCaptureMoveIndex === 0} title="Previous move" > ◀ </button> <span class="move-counter"> Move {groupCaptureMoveIndex} / {groupCaptureData.moves.length} </span> <button class="move-btn" onclick={() => nextMove('group')} disabled={groupCaptureMoveIndex === groupCaptureData.moves.length} title="Next move" > ▶ </button> <button class="move-btn" onclick={() => groupCaptureMoveIndex = groupCaptureData!.moves.length} disabled={groupCaptureMoveIndex === groupCaptureData.moves.length} title="Jump to end" > ⏭ </button> </div> {/if}
{#if steps[currentStep].sgfUrl === '/scoringdemo.sgf' && scoringDemoData} <div class="board-container"> <Board boardSize={scoringDemoData.boardSize} gameState={{ moves: scoringDemoData.moves.slice(0, scoringDemoMoveIndex) }} interactive={false} /> </div> <div class="move-controls"> <button class="move-btn" onclick={() => resetMoves('scoring')} disabled={scoringDemoMoveIndex === 0} title="Reset to start" > ⏮ </button> <button class="move-btn" onclick={() => prevMove('scoring')} disabled={scoringDemoMoveIndex === 0} title="Previous move" > ◀ </button> <span class="move-counter"> Move {scoringDemoMoveIndex} / {scoringDemoData.moves.length} </span> <button class="move-btn" onclick={() => nextMove('scoring')} disabled={scoringDemoMoveIndex === scoringDemoData.moves.length} title="Next move" > ▶ </button> <button class="move-btn" onclick={() => scoringDemoMoveIndex = scoringDemoData!.moves.length} disabled={scoringDemoMoveIndex === scoringDemoData.moves.length} title="Jump to end" > ⏭ </button> </div> {/if} </div>
<div class="modal-footer"> <button class="btn-secondary" onclick={prevStep} disabled={currentStep === 0} > Previous </button> <span class="step-counter"> Step {currentStep + 1} of {steps.length} </span> <button class="btn-primary" onclick={nextStep}> {currentStep === steps.length - 1 ? "Get Started!" : "Next"} </button> </div> </div> </div> </div>{/if}
<style> .modal-inner { padding: 2rem; max-height: 85vh; overflow-y: auto; }
.modal-body { padding: 0 0.5rem; }
.modal-body ul { margin-left: 1.5rem; margin-top: 1rem; margin-bottom: 1.5rem; line-height: 1.8; }
.modal-body li { margin-bottom: 0.75rem; }
.modal-body p { margin-bottom: 1rem; line-height: 1.8; }
.step-counter { color: var(--color-text-muted); font-size: 0.9rem; padding: 0 1rem; white-space: nowrap; }
.board-container { display: flex; justify-content: center; margin: 1.5rem 0 0.5rem 0; max-width: 100%; }
.board-container :global(> div) { max-width: 400px; width: 100%; }
.move-controls { display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.move-btn { background: linear-gradient(135deg, var(--color-bg-card) 0%, var(--color-border) 100%); border: 1px solid var(--color-border); border-radius: 0.5rem; padding: 0.5rem 0.75rem; cursor: pointer; font-size: 1rem; transition: all 0.2s; color: var(--color-text); }
.move-btn:hover:not(:disabled) { background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%); transform: translateY(-1px); box-shadow: 0 2px 8px rgba(242, 197, 160, 0.3); }
.move-btn:active:not(:disabled) { transform: translateY(0); }
.move-counter { padding: 0.5rem 1rem; color: var(--color-text-muted); font-size: 0.9rem; min-width: 120px; text-align: center; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
@media (max-width: 768px) { .modal-inner { padding: 1.5rem; }
.board-container :global(> div) { max-width: 300px; }
.move-btn { padding: 0.4rem 0.6rem; font-size: 0.9rem; }
.move-counter { font-size: 0.85rem; min-width: 100px; padding: 0.4rem 0.8rem; } }</style>