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 }
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 >
242 ⏮
243 </button>
244 <button
245 class="move-btn"
246 onclick={() => prevMove('single')}
247 disabled={singleCaptureMoveIndex === 0}
248 title="Previous move"
249 >
250 ◀
251 </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 >
261 ▶
262 </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 >
269 ⏭
270 </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 >
289 ⏮
290 </button>
291 <button
292 class="move-btn"
293 onclick={() => prevMove('group')}
294 disabled={groupCaptureMoveIndex === 0}
295 title="Previous move"
296 >
297 ◀
298 </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 >
308 ▶
309 </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 >
316 ⏭
317 </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 >
336 ⏮
337 </button>
338 <button
339 class="move-btn"
340 onclick={() => prevMove('scoring')}
341 disabled={scoringDemoMoveIndex === 0}
342 title="Previous move"
343 >
344 ◀
345 </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 >
355 ▶
356 </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 >
363 ⏭
364 </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>