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.
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 >
252 ⏮
253 </button>
254 <button
255 class="move-btn"
256 onclick={() => prevMove('single')}
257 disabled={singleCaptureMoveIndex === 0}
258 title="Previous move"
259 >
260 ◀
261 </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 >
271 ▶
272 </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 >
279 ⏭
280 </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 >
299 ⏮
300 </button>
301 <button
302 class="move-btn"
303 onclick={() => prevMove('group')}
304 disabled={groupCaptureMoveIndex === 0}
305 title="Previous move"
306 >
307 ◀
308 </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 >
318 ▶
319 </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 >
326 ⏭
327 </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 >
346 ⏮
347 </button>
348 <button
349 class="move-btn"
350 onclick={() => prevMove('scoring')}
351 disabled={scoringDemoMoveIndex === 0}
352 title="Previous move"
353 >
354 ◀
355 </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 >
365 ▶
366 </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 >
373 ⏭
374 </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>