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 feature/study-tab 508 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 // If loading fails, allow continuing without the diagram 111 // The tutorial can still be read without the visual examples 112 } 113 } 114 115 function prevMove(diagram: 'single' | 'group' | 'scoring') { 116 if (diagram === 'single' && singleCaptureMoveIndex > 0) { 117 singleCaptureMoveIndex--; 118 } else if (diagram === 'group' && groupCaptureMoveIndex > 0) { 119 groupCaptureMoveIndex--; 120 } else if (diagram === 'scoring' && scoringDemoMoveIndex > 0) { 121 scoringDemoMoveIndex--; 122 } 123 } 124 125 function nextMove(diagram: 'single' | 'group' | 'scoring') { 126 if (diagram === 'single' && singleCaptureData && singleCaptureMoveIndex < singleCaptureData.moves.length) { 127 singleCaptureMoveIndex++; 128 } else if (diagram === 'group' && groupCaptureData && groupCaptureMoveIndex < groupCaptureData.moves.length) { 129 groupCaptureMoveIndex++; 130 } else if (diagram === 'scoring' && scoringDemoData && scoringDemoMoveIndex < scoringDemoData.moves.length) { 131 scoringDemoMoveIndex++; 132 } 133 } 134 135 function resetMoves(diagram: 'single' | 'group' | 'scoring') { 136 if (diagram === 'single') { 137 singleCaptureMoveIndex = 0; 138 } else if (diagram === 'group') { 139 groupCaptureMoveIndex = 0; 140 } else if (diagram === 'scoring') { 141 scoringDemoMoveIndex = 0; 142 } 143 } 144 145 onMount(() => { 146 if (!browser) return; 147 148 try { 149 const seen = localStorage.getItem(STORAGE_KEY); 150 // Temporarily disabled auto-open to fix mobile tap issues 151 // Users can still access tutorial via Footer link 152 // TODO: Re-enable with better mobile error handling 153 // if (!seen) { 154 // isOpen = true; 155 // } 156 } catch (error) { 157 console.error('Failed to check tutorial status:', error); 158 isOpen = false; 159 } 160 }); 161 162 $effect(() => { 163 if (isOpen && browser) { 164 loadStepDiagram(); 165 } 166 }); 167 168 function handleClose() { 169 if (browser) { 170 localStorage.setItem(STORAGE_KEY, 'true'); 171 } 172 isOpen = false; 173 onClose(); 174 } 175 176 function nextStep() { 177 if (currentStep < steps.length - 1) { 178 currentStep++; 179 } else { 180 handleClose(); 181 } 182 } 183 184 function prevStep() { 185 if (currentStep > 0) { 186 currentStep--; 187 } 188 } 189 190 function handleKeydown(e: KeyboardEvent) { 191 if (e.key === 'Escape') { 192 handleClose(); 193 } else if (e.key === 'ArrowRight') { 194 nextStep(); 195 } else if (e.key === 'ArrowLeft') { 196 prevStep(); 197 } 198 } 199 200 function cloudMaterialize(node: Element): TransitionConfig { 201 return { 202 duration: 800, 203 easing: cubicOut, 204 css: (t: number) => { 205 const opacity = t; 206 const blur = (1 - t) * 20; 207 const translateY = (1 - t) * -20; 208 const scale = 0.9 + (t * 0.1); 209 210 return ` 211 opacity: ${opacity}; 212 filter: blur(${blur}px); 213 transform: translateY(${translateY}px) scale(${scale}); 214 `; 215 } 216 }; 217 } 218 219 export function open() { 220 isOpen = true; 221 currentStep = 0; 222 } 223</script> 224 225{#if isOpen} 226 <div class="modal-overlay" onclick={handleClose} onkeydown={handleKeydown} role="button" tabindex="0" transition:fade={{ duration: 600 }}> 227 <div class="modal-content cloud-card" onclick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" transition:cloudMaterialize> 228 <div class="modal-inner"> 229 <div class="modal-header"> 230 <h2>{steps[currentStep].title}</h2> 231 <button class="modal-close" onclick={handleClose} aria-label="Close tutorial">×</button> 232 </div> 233 234 <div class="modal-body"> 235 {@html steps[currentStep].content} 236 237 {#if steps[currentStep].sgfUrl === '/simplecapture.sgf' && singleCaptureData} 238 <div class="board-container"> 239 <Board 240 boardSize={singleCaptureData.boardSize} 241 gameState={{ moves: singleCaptureData.moves.slice(0, singleCaptureMoveIndex) }} 242 interactive={false} 243 /> 244 </div> 245 <div class="move-controls"> 246 <button 247 class="move-btn" 248 onclick={() => resetMoves('single')} 249 disabled={singleCaptureMoveIndex === 0} 250 title="Reset to start" 251 > 252253 </button> 254 <button 255 class="move-btn" 256 onclick={() => prevMove('single')} 257 disabled={singleCaptureMoveIndex === 0} 258 title="Previous move" 259 > 260261 </button> 262 <span class="move-counter"> 263 Move {singleCaptureMoveIndex} / {singleCaptureData.moves.length} 264 </span> 265 <button 266 class="move-btn" 267 onclick={() => nextMove('single')} 268 disabled={singleCaptureMoveIndex === singleCaptureData.moves.length} 269 title="Next move" 270 > 271272 </button> 273 <button 274 class="move-btn" 275 onclick={() => singleCaptureMoveIndex = singleCaptureData!.moves.length} 276 disabled={singleCaptureMoveIndex === singleCaptureData.moves.length} 277 title="Jump to end" 278 > 279280 </button> 281 </div> 282 {/if} 283 284 {#if steps[currentStep].sgfUrl === '/3stonecapture.sgf' && groupCaptureData} 285 <div class="board-container"> 286 <Board 287 boardSize={groupCaptureData.boardSize} 288 gameState={{ moves: groupCaptureData.moves.slice(0, groupCaptureMoveIndex) }} 289 interactive={false} 290 /> 291 </div> 292 <div class="move-controls"> 293 <button 294 class="move-btn" 295 onclick={() => resetMoves('group')} 296 disabled={groupCaptureMoveIndex === 0} 297 title="Reset to start" 298 > 299300 </button> 301 <button 302 class="move-btn" 303 onclick={() => prevMove('group')} 304 disabled={groupCaptureMoveIndex === 0} 305 title="Previous move" 306 > 307308 </button> 309 <span class="move-counter"> 310 Move {groupCaptureMoveIndex} / {groupCaptureData.moves.length} 311 </span> 312 <button 313 class="move-btn" 314 onclick={() => nextMove('group')} 315 disabled={groupCaptureMoveIndex === groupCaptureData.moves.length} 316 title="Next move" 317 > 318319 </button> 320 <button 321 class="move-btn" 322 onclick={() => groupCaptureMoveIndex = groupCaptureData!.moves.length} 323 disabled={groupCaptureMoveIndex === groupCaptureData.moves.length} 324 title="Jump to end" 325 > 326327 </button> 328 </div> 329 {/if} 330 331 {#if steps[currentStep].sgfUrl === '/scoringdemo.sgf' && scoringDemoData} 332 <div class="board-container"> 333 <Board 334 boardSize={scoringDemoData.boardSize} 335 gameState={{ moves: scoringDemoData.moves.slice(0, scoringDemoMoveIndex) }} 336 interactive={false} 337 /> 338 </div> 339 <div class="move-controls"> 340 <button 341 class="move-btn" 342 onclick={() => resetMoves('scoring')} 343 disabled={scoringDemoMoveIndex === 0} 344 title="Reset to start" 345 > 346347 </button> 348 <button 349 class="move-btn" 350 onclick={() => prevMove('scoring')} 351 disabled={scoringDemoMoveIndex === 0} 352 title="Previous move" 353 > 354355 </button> 356 <span class="move-counter"> 357 Move {scoringDemoMoveIndex} / {scoringDemoData.moves.length} 358 </span> 359 <button 360 class="move-btn" 361 onclick={() => nextMove('scoring')} 362 disabled={scoringDemoMoveIndex === scoringDemoData.moves.length} 363 title="Next move" 364 > 365366 </button> 367 <button 368 class="move-btn" 369 onclick={() => scoringDemoMoveIndex = scoringDemoData!.moves.length} 370 disabled={scoringDemoMoveIndex === scoringDemoData.moves.length} 371 title="Jump to end" 372 > 373374 </button> 375 </div> 376 {/if} 377 </div> 378 379 <div class="modal-footer"> 380 <button 381 class="btn-secondary" 382 onclick={prevStep} 383 disabled={currentStep === 0} 384 > 385 Previous 386 </button> 387 <span class="step-counter"> 388 Step {currentStep + 1} of {steps.length} 389 </span> 390 <button class="btn-primary" onclick={nextStep}> 391 {currentStep === steps.length - 1 ? "Get Started!" : "Next"} 392 </button> 393 </div> 394 </div> 395 </div> 396 </div> 397{/if} 398 399<style> 400 .modal-inner { 401 padding: 2rem; 402 max-height: 85vh; 403 overflow-y: auto; 404 } 405 406 .modal-body { 407 padding: 0 0.5rem; 408 } 409 410 .modal-body ul { 411 margin-left: 1.5rem; 412 margin-top: 1rem; 413 margin-bottom: 1.5rem; 414 line-height: 1.8; 415 } 416 417 .modal-body li { 418 margin-bottom: 0.75rem; 419 } 420 421 .modal-body p { 422 margin-bottom: 1rem; 423 line-height: 1.8; 424 } 425 426 .step-counter { 427 color: var(--color-text-muted); 428 font-size: 0.9rem; 429 padding: 0 1rem; 430 white-space: nowrap; 431 } 432 433 .board-container { 434 display: flex; 435 justify-content: center; 436 margin: 1.5rem 0 0.5rem 0; 437 max-width: 100%; 438 } 439 440 .board-container :global(> div) { 441 max-width: 400px; 442 width: 100%; 443 } 444 445 .move-controls { 446 display: flex; 447 justify-content: center; 448 align-items: center; 449 gap: 0.5rem; 450 margin-bottom: 1.5rem; 451 flex-wrap: wrap; 452 } 453 454 .move-btn { 455 background: linear-gradient(135deg, var(--color-bg-card) 0%, var(--color-border) 100%); 456 border: 1px solid var(--color-border); 457 border-radius: 0.5rem; 458 padding: 0.5rem 0.75rem; 459 cursor: pointer; 460 font-size: 1rem; 461 transition: all 0.2s; 462 color: var(--color-text); 463 } 464 465 .move-btn:hover:not(:disabled) { 466 background: linear-gradient(135deg, var(--color-primary-light) 0%, var(--color-primary) 100%); 467 transform: translateY(-1px); 468 box-shadow: 0 2px 8px rgba(242, 197, 160, 0.3); 469 } 470 471 .move-btn:active:not(:disabled) { 472 transform: translateY(0); 473 } 474 475 .move-counter { 476 padding: 0.5rem 1rem; 477 color: var(--color-text-muted); 478 font-size: 0.9rem; 479 min-width: 120px; 480 text-align: center; 481 } 482 483 button:disabled { 484 opacity: 0.5; 485 cursor: not-allowed; 486 } 487 488 @media (max-width: 768px) { 489 .modal-inner { 490 padding: 1.5rem; 491 } 492 493 .board-container :global(> div) { 494 max-width: 300px; 495 } 496 497 .move-btn { 498 padding: 0.4rem 0.6rem; 499 font-size: 0.9rem; 500 } 501 502 .move-counter { 503 font-size: 0.85rem; 504 min-width: 100px; 505 padding: 0.4rem 0.8rem; 506 } 507 } 508</style>