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.
at list 498 lines 15 kB view raw
1<script lang="ts"> 2 import { onMount } from 'svelte'; 3 import { browser } from '$app/environment'; 4 import Board from './Board.svelte'; 5 import { loadSgf, type SgfData } from '$lib/sgf-parser'; 6 import { cubicOut } from 'svelte/easing'; 7 import { fade } from 'svelte/transition'; 8 import type { TransitionConfig } from 'svelte/transition'; 9 10 interface Props { 11 isOpen?: boolean; 12 onClose?: () => void; 13 } 14 15 let { isOpen = $bindable(false), onClose = () => {} }: Props = $props(); 16 17 let currentStep = $state(0); 18 let singleCaptureData: SgfData | null = $state(null); 19 let groupCaptureData: SgfData | null = $state(null); 20 let scoringDemoData: SgfData | null = $state(null); 21 22 // Track current move index for each diagram 23 let singleCaptureMoveIndex = $state(0); 24 let groupCaptureMoveIndex = $state(0); 25 let scoringDemoMoveIndex = $state(0); 26 27 const STORAGE_KEY = 'cloudgo-tutorial-seen'; 28 29 const steps = [ 30 { 31 title: 'Welcome to Cloud Go!', 32 content: `<p>Go is an ancient game whose Chinese name "Weiqi" translates to "the Surrounding Game".</p> 33 <ul> 34 <li>Your goal is to surround as much territory as possible</li> 35 <li>You can capture your opponent's stones by surrounding them</li> 36 <li>The player who controls the most territory wins</li> 37 </ul>`, 38 sgfUrl: null 39 }, 40 { 41 title: 'Capturing a Single Stone', 42 content: `<ul> 43 <li>A stone is captured when it's surrounded on all sides</li> 44 <li>Stones connect along the lines (not diagonally)</li> 45 <li>Use the buttons below to step through the example</li> 46 </ul>`, 47 sgfUrl: '/simplecapture.sgf' 48 }, 49 { 50 title: 'Capturing Multiple Stones', 51 content: `<ul> 52 <li>Stones connected along lines form groups</li> 53 <li>You must surround the entire group to capture it</li> 54 <li>Step through to see a group capture in action</li> 55 </ul>`, 56 sgfUrl: '/3stonecapture.sgf' 57 }, 58 { 59 title: 'Counting Territory', 60 content: `<p>At the end of the game:</p> 61 <ul> 62 <li>Count the empty intersections you surround (your territory)</li> 63 <li>Add bonus points for captured opponent stones</li> 64 <li>The player with more points wins</li> 65 </ul> 66 <p>In this example, black controls more of the board and wins!</p>`, 67 sgfUrl: '/scoringdemo.sgf' 68 }, 69 { 70 title: 'How to Use Cloud Go', 71 content: `<p><strong>Game Flow:</strong></p> 72 <ul> 73 <li>The initiating player always goes first</li> 74 <li>You take turns playing - there are no time limits yet</li> 75 <li>If your opponent is taking too long or the game is lost, you can resign</li> 76 </ul> 77 <p><strong>Navigation:</strong></p> 78 <ul> 79 <li>Use the move list at the bottom or arrow keys to navigate move history</li> 80 <li>Click on a move to add comments with reaction emojis</li> 81 </ul> 82 <p><strong>Ending the Game:</strong></p> 83 <ul> 84 <li>Once both players pass, territories are auto-calculated</li> 85 <li>The black player can modify or commit the final scores</li> 86 </ul>`, 87 sgfUrl: null 88 } 89 ]; 90 91 async function loadStepDiagram() { 92 const step = steps[currentStep]; 93 if (!step.sgfUrl) return; 94 95 try { 96 const sgfData = await loadSgf(step.sgfUrl); 97 98 if (step.sgfUrl === '/simplecapture.sgf') { 99 singleCaptureData = sgfData; 100 singleCaptureMoveIndex = 0; // Start from beginning 101 } else if (step.sgfUrl === '/3stonecapture.sgf') { 102 groupCaptureData = sgfData; 103 groupCaptureMoveIndex = 0; // Start from beginning 104 } else if (step.sgfUrl === '/scoringdemo.sgf') { 105 scoringDemoData = sgfData; 106 scoringDemoMoveIndex = sgfData.moves.length; // Start at end to show final position 107 } 108 } catch (error) { 109 console.error('Failed to load SGF:', error); 110 } 111 } 112 113 function prevMove(diagram: 'single' | 'group' | 'scoring') { 114 if (diagram === 'single' && singleCaptureMoveIndex > 0) { 115 singleCaptureMoveIndex--; 116 } else if (diagram === 'group' && groupCaptureMoveIndex > 0) { 117 groupCaptureMoveIndex--; 118 } else if (diagram === 'scoring' && scoringDemoMoveIndex > 0) { 119 scoringDemoMoveIndex--; 120 } 121 } 122 123 function nextMove(diagram: 'single' | 'group' | 'scoring') { 124 if (diagram === 'single' && singleCaptureData && singleCaptureMoveIndex < singleCaptureData.moves.length) { 125 singleCaptureMoveIndex++; 126 } else if (diagram === 'group' && groupCaptureData && groupCaptureMoveIndex < groupCaptureData.moves.length) { 127 groupCaptureMoveIndex++; 128 } else if (diagram === 'scoring' && scoringDemoData && scoringDemoMoveIndex < scoringDemoData.moves.length) { 129 scoringDemoMoveIndex++; 130 } 131 } 132 133 function resetMoves(diagram: 'single' | 'group' | 'scoring') { 134 if (diagram === 'single') { 135 singleCaptureMoveIndex = 0; 136 } else if (diagram === 'group') { 137 groupCaptureMoveIndex = 0; 138 } else if (diagram === 'scoring') { 139 scoringDemoMoveIndex = 0; 140 } 141 } 142 143 onMount(() => { 144 if (!browser) return; 145 146 const seen = localStorage.getItem(STORAGE_KEY); 147 if (!seen) { 148 isOpen = true; 149 } 150 }); 151 152 $effect(() => { 153 if (isOpen && browser) { 154 loadStepDiagram(); 155 } 156 }); 157 158 function handleClose() { 159 if (browser) { 160 localStorage.setItem(STORAGE_KEY, 'true'); 161 } 162 isOpen = false; 163 onClose(); 164 } 165 166 function nextStep() { 167 if (currentStep < steps.length - 1) { 168 currentStep++; 169 } else { 170 handleClose(); 171 } 172 } 173 174 function prevStep() { 175 if (currentStep > 0) { 176 currentStep--; 177 } 178 } 179 180 function handleKeydown(e: KeyboardEvent) { 181 if (e.key === 'Escape') { 182 handleClose(); 183 } else if (e.key === 'ArrowRight') { 184 nextStep(); 185 } else if (e.key === 'ArrowLeft') { 186 prevStep(); 187 } 188 } 189 190 function cloudMaterialize(node: Element): TransitionConfig { 191 return { 192 duration: 800, 193 easing: cubicOut, 194 css: (t: number) => { 195 const opacity = t; 196 const blur = (1 - t) * 20; 197 const translateY = (1 - t) * -20; 198 const scale = 0.9 + (t * 0.1); 199 200 return ` 201 opacity: ${opacity}; 202 filter: blur(${blur}px); 203 transform: translateY(${translateY}px) scale(${scale}); 204 `; 205 } 206 }; 207 } 208 209 export function open() { 210 isOpen = true; 211 currentStep = 0; 212 } 213</script> 214 215{#if isOpen} 216 <div class="modal-overlay" onclick={handleClose} onkeydown={handleKeydown} role="button" tabindex="0" transition:fade={{ duration: 600 }}> 217 <div class="modal-content cloud-card" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" transition:cloudMaterialize> 218 <div class="modal-inner"> 219 <div class="modal-header"> 220 <h2>{steps[currentStep].title}</h2> 221 <button class="modal-close" onclick={handleClose} aria-label="Close tutorial">×</button> 222 </div> 223 224 <div class="modal-body"> 225 {@html steps[currentStep].content} 226 227 {#if steps[currentStep].sgfUrl === '/simplecapture.sgf' && singleCaptureData} 228 <div class="board-container"> 229 <Board 230 boardSize={singleCaptureData.boardSize} 231 gameState={{ moves: singleCaptureData.moves.slice(0, singleCaptureMoveIndex) }} 232 interactive={false} 233 /> 234 </div> 235 <div class="move-controls"> 236 <button 237 class="move-btn" 238 onclick={() => resetMoves('single')} 239 disabled={singleCaptureMoveIndex === 0} 240 title="Reset to start" 241 > 242243 </button> 244 <button 245 class="move-btn" 246 onclick={() => prevMove('single')} 247 disabled={singleCaptureMoveIndex === 0} 248 title="Previous move" 249 > 250251 </button> 252 <span class="move-counter"> 253 Move {singleCaptureMoveIndex} / {singleCaptureData.moves.length} 254 </span> 255 <button 256 class="move-btn" 257 onclick={() => nextMove('single')} 258 disabled={singleCaptureMoveIndex === singleCaptureData.moves.length} 259 title="Next move" 260 > 261262 </button> 263 <button 264 class="move-btn" 265 onclick={() => singleCaptureMoveIndex = singleCaptureData!.moves.length} 266 disabled={singleCaptureMoveIndex === singleCaptureData.moves.length} 267 title="Jump to end" 268 > 269270 </button> 271 </div> 272 {/if} 273 274 {#if steps[currentStep].sgfUrl === '/3stonecapture.sgf' && groupCaptureData} 275 <div class="board-container"> 276 <Board 277 boardSize={groupCaptureData.boardSize} 278 gameState={{ moves: groupCaptureData.moves.slice(0, groupCaptureMoveIndex) }} 279 interactive={false} 280 /> 281 </div> 282 <div class="move-controls"> 283 <button 284 class="move-btn" 285 onclick={() => resetMoves('group')} 286 disabled={groupCaptureMoveIndex === 0} 287 title="Reset to start" 288 > 289290 </button> 291 <button 292 class="move-btn" 293 onclick={() => prevMove('group')} 294 disabled={groupCaptureMoveIndex === 0} 295 title="Previous move" 296 > 297298 </button> 299 <span class="move-counter"> 300 Move {groupCaptureMoveIndex} / {groupCaptureData.moves.length} 301 </span> 302 <button 303 class="move-btn" 304 onclick={() => nextMove('group')} 305 disabled={groupCaptureMoveIndex === groupCaptureData.moves.length} 306 title="Next move" 307 > 308309 </button> 310 <button 311 class="move-btn" 312 onclick={() => groupCaptureMoveIndex = groupCaptureData!.moves.length} 313 disabled={groupCaptureMoveIndex === groupCaptureData.moves.length} 314 title="Jump to end" 315 > 316317 </button> 318 </div> 319 {/if} 320 321 {#if steps[currentStep].sgfUrl === '/scoringdemo.sgf' && scoringDemoData} 322 <div class="board-container"> 323 <Board 324 boardSize={scoringDemoData.boardSize} 325 gameState={{ moves: scoringDemoData.moves.slice(0, scoringDemoMoveIndex) }} 326 interactive={false} 327 /> 328 </div> 329 <div class="move-controls"> 330 <button 331 class="move-btn" 332 onclick={() => resetMoves('scoring')} 333 disabled={scoringDemoMoveIndex === 0} 334 title="Reset to start" 335 > 336337 </button> 338 <button 339 class="move-btn" 340 onclick={() => prevMove('scoring')} 341 disabled={scoringDemoMoveIndex === 0} 342 title="Previous move" 343 > 344345 </button> 346 <span class="move-counter"> 347 Move {scoringDemoMoveIndex} / {scoringDemoData.moves.length} 348 </span> 349 <button 350 class="move-btn" 351 onclick={() => nextMove('scoring')} 352 disabled={scoringDemoMoveIndex === scoringDemoData.moves.length} 353 title="Next move" 354 > 355356 </button> 357 <button 358 class="move-btn" 359 onclick={() => scoringDemoMoveIndex = scoringDemoData!.moves.length} 360 disabled={scoringDemoMoveIndex === scoringDemoData.moves.length} 361 title="Jump to end" 362 > 363364 </button> 365 </div> 366 {/if} 367 </div> 368 369 <div class="modal-footer"> 370 <button 371 class="btn-secondary" 372 onclick={prevStep} 373 disabled={currentStep === 0} 374 > 375 Previous 376 </button> 377 <span class="step-counter"> 378 Step {currentStep + 1} of {steps.length} 379 </span> 380 <button class="btn-primary" onclick={nextStep}> 381 {currentStep === steps.length - 1 ? "Get Started!" : "Next"} 382 </button> 383 </div> 384 </div> 385 </div> 386 </div> 387{/if} 388 389<style> 390 .modal-inner { 391 padding: 2rem; 392 max-height: 85vh; 393 overflow-y: auto; 394 } 395 396 .modal-body { 397 padding: 0 0.5rem; 398 } 399 400 .modal-body ul { 401 margin-left: 1.5rem; 402 margin-top: 1rem; 403 margin-bottom: 1.5rem; 404 line-height: 1.8; 405 } 406 407 .modal-body li { 408 margin-bottom: 0.75rem; 409 } 410 411 .modal-body p { 412 margin-bottom: 1rem; 413 line-height: 1.8; 414 } 415 416 .step-counter { 417 color: var(--color-text-muted); 418 font-size: 0.9rem; 419 padding: 0 1rem; 420 white-space: nowrap; 421 } 422 423 .board-container { 424 display: flex; 425 justify-content: center; 426 margin: 1.5rem 0 0.5rem 0; 427 max-width: 100%; 428 } 429 430 .board-container :global(> div) { 431 max-width: 400px; 432 width: 100%; 433 } 434 435 .move-controls { 436 display: flex; 437 justify-content: center; 438 align-items: center; 439 gap: 0.5rem; 440 margin-bottom: 1.5rem; 441 flex-wrap: wrap; 442 } 443 444 .move-btn { 445 background: linear-gradient(135deg, var(--color-bg-card) 0%, var(--color-border) 100%); 446 border: 1px solid var(--color-border); 447 border-radius: 0.5rem; 448 padding: 0.5rem 0.75rem; 449 cursor: pointer; 450 font-size: 1rem; 451 transition: all 0.2s; 452 color: var(--color-text); 453 } 454 455 .move-btn:hover:not(:disabled) { 456 background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%); 457 transform: translateY(-1px); 458 box-shadow: 0 2px 8px rgba(242, 197, 160, 0.3); 459 } 460 461 .move-btn:active:not(:disabled) { 462 transform: translateY(0); 463 } 464 465 .move-counter { 466 padding: 0.5rem 1rem; 467 color: var(--color-text-muted); 468 font-size: 0.9rem; 469 min-width: 120px; 470 text-align: center; 471 } 472 473 button:disabled { 474 opacity: 0.5; 475 cursor: not-allowed; 476 } 477 478 @media (max-width: 768px) { 479 .modal-inner { 480 padding: 1.5rem; 481 } 482 483 .board-container :global(> div) { 484 max-width: 300px; 485 } 486 487 .move-btn { 488 padding: 0.4rem 0.6rem; 489 font-size: 0.9rem; 490 } 491 492 .move-counter { 493 font-size: 0.85rem; 494 min-width: 100px; 495 padding: 0.4rem 0.8rem; 496 } 497 } 498</style>