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 { untrack } from 'svelte';
3 import JGO from 'jgoboard';
4 import { isMobileDevice } from '$lib/mobile-detection';
5 import { getSoundManager } from '$lib/sound-manager';
6
7 const soundManager = getSoundManager();
8
9 interface TerritoryData {
10 territory: Array<Array<'black' | 'white' | 'neutral' | null>>;
11 blackTerritory: number;
12 whiteTerritory: number;
13 }
14
15 interface ReactionOverlayItem {
16 x: number;
17 y: number;
18 emojis: string[];
19 }
20
21 interface Props {
22 boardSize?: number;
23 gameState?: any;
24 onMove?: (x: number, y: number, captures: number) => void;
25 onPass?: () => void;
26 interactive?: boolean;
27 currentTurn?: 'black' | 'white';
28 territoryData?: TerritoryData | null;
29 libertyData?: Array<Array<number>> | null;
30 deadStones?: string[];
31 markingDeadStones?: boolean;
32 onToggleDeadStone?: (x: number, y: number, color: 'black' | 'white') => void;
33 reactionOverlay?: ReactionOverlayItem[];
34 onReactionClick?: (x: number, y: number) => void;
35 }
36
37 let {
38 boardSize = 19,
39 gameState = null,
40 onMove = () => {},
41 onPass = () => {},
42 interactive = true,
43 currentTurn = 'black',
44 territoryData = null,
45 libertyData = null,
46 deadStones = [],
47 markingDeadStones = false,
48 onToggleDeadStone = () => {},
49 reactionOverlay = [],
50 onReactionClick = () => {}
51 }: Props = $props();
52
53 let boardElement: HTMLDivElement;
54 let board: any = $state(null);
55 let canvas: any = $state(null);
56 let isReady = $state(false);
57 let lastHover = $state(false);
58 let lastX = $state(-1);
59 let lastY = $state(-1);
60 let ko: any = $state(false);
61 let lastMarkedCoord: any = $state(null);
62 let pendingMove = $state<{ x: number, y: number, captures: number } | null>(null);
63 let showMobileConfirmation = $state(false);
64 let isMobile = $state(false);
65
66 // Responsive grid size - calculate based on viewport and board size
67 const calculateGridSize = () => {
68 if (typeof window === 'undefined') return 30;
69
70 const viewportWidth = window.innerWidth;
71 const maxBoardWidth = viewportWidth < 768 ? viewportWidth - 40 : Math.min(800, viewportWidth - 100);
72
73 // Calculate grid size that fits the board within viewport
74 // Account for padding/margins (approximately 2x gridSize on each side)
75 const gridSize = Math.floor((maxBoardWidth / boardSize) * 0.85);
76
77 // Clamp between reasonable values
78 return Math.max(20, Math.min(40, gridSize));
79 };
80
81 const gridSize = calculateGridSize();
82
83 // Use the turn from the parent component's prop
84 // The parent correctly calculates this based on moves + passes
85 const activeTurn = () => currentTurn;
86
87 // Load and scale texture images for realistic board and stones
88 function loadTextureImages(): Promise<{ black: HTMLCanvasElement, white: HTMLCanvasElement, shadow: HTMLCanvasElement, board: HTMLImageElement }> {
89 return new Promise((resolve, reject) => {
90 const sourceImages = {
91 black: new Image(),
92 white: new Image(),
93 shadow: new Image(),
94 board: new Image()
95 };
96
97 let loadedCount = 0;
98 const totalImages = 4;
99
100 const checkComplete = () => {
101 loadedCount++;
102 if (loadedCount === totalImages) {
103 // Scale stone images to match stone radius
104 // Original images are 48x48, scale proportionally to our stone radius
105 const stoneSize = Math.round(gridSize * 0.95);
106
107 const scaleImage = (img: HTMLImageElement): HTMLCanvasElement => {
108 const canvas = document.createElement('canvas');
109 canvas.width = stoneSize;
110 canvas.height = stoneSize;
111 const ctx = canvas.getContext('2d')!;
112 ctx.imageSmoothingEnabled = true;
113 ctx.imageSmoothingQuality = 'high';
114 ctx.drawImage(img, 0, 0, stoneSize, stoneSize);
115 return canvas;
116 };
117
118 console.log('Scaling stone textures to:', stoneSize);
119
120 resolve({
121 black: scaleImage(sourceImages.black),
122 white: scaleImage(sourceImages.white),
123 shadow: scaleImage(sourceImages.shadow),
124 board: sourceImages.board
125 });
126 }
127 };
128
129 const handleError = (e: Event) => {
130 console.error('Failed to load texture:', e);
131 reject(e);
132 };
133
134 sourceImages.black.onload = checkComplete;
135 sourceImages.black.onerror = handleError;
136 sourceImages.black.src = '/textures/black.png';
137
138 sourceImages.white.onload = checkComplete;
139 sourceImages.white.onerror = handleError;
140 sourceImages.white.src = '/textures/white.png';
141
142 sourceImages.shadow.onload = checkComplete;
143 sourceImages.shadow.onerror = handleError;
144 sourceImages.shadow.src = '/textures/shadow.png';
145
146 sourceImages.board.onload = checkComplete;
147 sourceImages.board.onerror = handleError;
148 sourceImages.board.src = '/textures/walnut.jpg';
149 });
150 }
151
152 // Initialize board once
153 // Initialize mobile detection
154 $effect(() => {
155 if (typeof window !== 'undefined') {
156 isMobile = isMobileDevice();
157 }
158 });
159
160 $effect(() => {
161 if (!boardElement) return;
162
163 untrack(() => {
164 (async () => {
165 try {
166 // Create jGoBoard board
167 board = new JGO.Board(boardSize);
168
169 // largeWalnut board configuration, scaled responsively
170 const boardOptions = {
171 padding: { normal: gridSize * 0.8, clipped: gridSize * 0.4 },
172 margin: { normal: gridSize * 0.8, clipped: gridSize * 0.2, color: '#f8dcc5' },
173 grid: {
174 x: gridSize,
175 y: gridSize,
176 color: '#101010',
177 lineWidth: Math.max(1, gridSize / 33),
178 borderWidth: Math.max(1.2, gridSize / 28),
179 smooth: 0.5
180 },
181 stone: {
182 radius: gridSize * 0.38,
183 dimAlpha: 0.35
184 },
185 shadow: {
186 xOff: gridSize * 0.04,
187 yOff: gridSize * 0.04,
188 blur: gridSize * 0.06
189 },
190 boardShadow: {
191 color: '#f2c5a0',
192 blur: gridSize * 0.6,
193 offX: Math.max(3, gridSize * 0.1),
194 offY: Math.max(3, gridSize * 0.1)
195 },
196 border: { color: 'rgba(0,0,0,0.3)', lineWidth: 1 },
197 coordinates: {
198 font: `bold ${Math.max(10, gridSize * 0.28)}px sans-serif`,
199 color: '#000000'
200 },
201 stars: {
202 radius: Math.max(2, gridSize * 0.06)
203 },
204 mark: {
205 lineWidth: 2,
206 blackColor: '#ffffff',
207 whiteColor: '#000000',
208 clearColor: '#000000',
209 font: 'bold 14px sans-serif'
210 },
211 textures: {
212 black: '/textures/black.png',
213 white: '/textures/white.png',
214 shadow: '/textures/shadow.png',
215 board: '/textures/walnut.jpg'
216 }
217 };
218
219 const setup = new JGO.Setup(board, boardOptions);
220
221 // Load texture images for realistic board and stones
222 const images = await loadTextureImages();
223
224 // Create the canvas using setup's processed options with textures
225 canvas = new JGO.Canvas(boardElement, setup.options, images);
226 canvas.draw(board, 0, 0, board.width - 1, board.height - 1);
227 setup.getNotifier().addCanvas(canvas);
228
229 // If there's existing game state, restore it
230 if (gameState && gameState.moves) {
231 gameState.moves.forEach((move: any, index: number) => {
232 try {
233 const coord = new JGO.Coordinate(move.x, move.y);
234 const type = move.color === 'black' ? JGO.BLACK : JGO.WHITE;
235
236 // Play the move through the game engine to handle captures
237 const play = board.playMove(coord, type, ko);
238 if (play.success) {
239 board.setType(coord, type);
240
241 // Remove captured stones
242 if (play.captures.length > 0) {
243 for (const capture of play.captures) {
244 board.setType(capture, JGO.CLEAR);
245 }
246 }
247
248 // Update ko point
249 ko = play.ko;
250 }
251
252 // Mark the last move with a circle
253 if (index === gameState.moves.length - 1) {
254 board.setMark(coord, JGO.MARK.CIRCLE);
255 lastMarkedCoord = coord;
256 }
257 } catch (err) {
258 console.error('Error restoring move:', err);
259 }
260 });
261 }
262
263 // Always set up click and hover handlers - the handlers themselves
264 // check the interactive prop at runtime, so they'll properly respond
265 // when interactivity changes (e.g. after opponent moves via Jetstream)
266 canvas.addListener('click', handleCanvasClick);
267 canvas.addListener('mousemove', handleMouseMove);
268 canvas.addListener('mouseout', handleMouseLeave);
269
270 isReady = true;
271 } catch (err) {
272 console.error('Failed to initialize board:', err);
273 }
274 })();
275 });
276
277 return () => {
278 isReady = false;
279 };
280 });
281
282 // React to gameState changes without recreating the entire board
283 $effect(() => {
284 // Only react to gameState.moves changes
285 const moves = gameState?.moves;
286
287 untrack(() => {
288 if (!board || !canvas || !isReady) return;
289
290 // Clear all marks first
291 if (lastMarkedCoord) {
292 board.setMark(lastMarkedCoord, JGO.MARK.NONE);
293 lastMarkedCoord = null;
294 }
295
296 // Clear the board
297 for (let y = 0; y < board.height; y++) {
298 for (let x = 0; x < board.width; x++) {
299 const coord = new JGO.Coordinate(x, y);
300 board.setType(coord, JGO.CLEAR);
301 board.setMark(coord, JGO.MARK.NONE);
302 }
303 }
304
305 // Reset ko
306 ko = false;
307
308 // Replay all moves from gameState
309 if (moves && moves.length > 0) {
310 moves.forEach((move: any, index: number) => {
311 try {
312 const coord = new JGO.Coordinate(move.x, move.y);
313 const type = move.color === 'black' ? JGO.BLACK : JGO.WHITE;
314
315 // Play the move through the game engine to handle captures
316 const play = board.playMove(coord, type, ko);
317 if (play.success) {
318 board.setType(coord, type);
319
320 // Remove captured stones
321 if (play.captures.length > 0) {
322 for (const capture of play.captures) {
323 board.setType(capture, JGO.CLEAR);
324 }
325 }
326
327 // Update ko point
328 ko = play.ko;
329 }
330
331 // Mark the last move with a circle
332 if (index === moves.length - 1) {
333 board.setMark(coord, JGO.MARK.CIRCLE);
334 lastMarkedCoord = coord;
335 }
336 } catch (err) {
337 console.error('Error restoring move:', err);
338 }
339 });
340 }
341 });
342 });
343
344 function handleCanvasClick(coord: any) {
345 if (!board || !isReady) return;
346
347 // coord.i and coord.j are -1 if outside board
348 if (coord.i < 0 || coord.j < 0) return;
349
350 // If in dead stone marking mode, toggle the stone
351 if (markingDeadStones) {
352 const type = board.getType(coord);
353 // Only allow marking actual stones (not empty intersections)
354 if (type === JGO.BLACK || type === JGO.WHITE) {
355 const color = type === JGO.BLACK ? 'black' : 'white';
356 onToggleDeadStone(coord.i, coord.j, color);
357 }
358 return;
359 }
360
361 // Always check the latest interactive prop value
362 if (!interactive) {
363 console.log('Click ignored - board not interactive. Interactive:', interactive, 'isReady:', isReady);
364 return;
365 }
366
367 // Clear hover preview before attempting move
368 if (lastHover) {
369 board.setType(new JGO.Coordinate(lastX, lastY), JGO.CLEAR);
370 lastHover = false;
371 }
372
373 // Determine current player stone type
374 const player = activeTurn() === 'black' ? JGO.BLACK : JGO.WHITE;
375
376 // Validate and play the move
377 const play = board.playMove(coord, player, ko);
378
379 if (play.success) {
380 // On mobile, show confirmation instead of immediate submission
381 if (isMobile) {
382 pendingMove = {
383 x: coord.i,
384 y: coord.j,
385 captures: play.captures.length
386 };
387 showMobileConfirmation = true;
388 } else {
389 // Desktop: immediate submission
390 // Place the stone
391 board.setType(coord, player);
392 soundManager.play('played_stone');
393
394 // Remove captured stones
395 if (play.captures.length > 0) {
396 for (const capture of play.captures) {
397 board.setType(capture, JGO.CLEAR);
398 }
399 soundManager.play('capture');
400 }
401
402 // Update ko point
403 ko = play.ko;
404
405 // Call the onMove callback with capture count
406 onMove(coord.i, coord.j, play.captures.length);
407 }
408 } else {
409 // Invalid move - show error or just ignore
410 console.log('Invalid move:', play.errorMsg);
411 }
412 }
413
414 function confirmMove() {
415 if (!pendingMove || !board) return;
416
417 const coord = new JGO.Coordinate(pendingMove.x, pendingMove.y);
418 const player = activeTurn() === 'black' ? JGO.BLACK : JGO.WHITE;
419 const play = board.playMove(coord, player, ko);
420
421 if (play.success) {
422 board.setType(coord, player);
423 soundManager.play('played_stone');
424
425 if (play.captures.length > 0) {
426 for (const capture of play.captures) {
427 board.setType(capture, JGO.CLEAR);
428 }
429 soundManager.play('capture');
430 }
431 ko = play.ko;
432 onMove(pendingMove.x, pendingMove.y, pendingMove.captures);
433 }
434
435 pendingMove = null;
436 showMobileConfirmation = false;
437 }
438
439 function cancelMove() {
440 pendingMove = null;
441 showMobileConfirmation = false;
442 }
443
444 function handleMouseMove(coord: any) {
445 if (!interactive || !isReady || !canvas || !board) return;
446
447 // Check if we've moved to a different intersection
448 if (coord.i < 0 || coord.j < 0 || (coord.i === lastX && coord.j === lastY)) {
449 return;
450 }
451
452 // Clear previous hover stone
453 if (lastHover) {
454 board.setType(new JGO.Coordinate(lastX, lastY), JGO.CLEAR);
455 }
456
457 lastX = coord.i;
458 lastY = coord.j;
459
460 // Show hover preview if intersection is empty
461 const intersectionType = board.getType(coord);
462 if (intersectionType === JGO.CLEAR) {
463 const turn = activeTurn();
464 const dimType = turn === 'white' ? JGO.DIM_WHITE : JGO.DIM_BLACK;
465 board.setType(coord, dimType);
466 lastHover = true;
467 } else {
468 lastHover = false;
469 }
470 }
471
472 function handleMouseLeave() {
473 if (!board) return;
474
475 // Clear hover preview when mouse leaves board
476 if (lastHover) {
477 board.setType(new JGO.Coordinate(lastX, lastY), JGO.CLEAR);
478 lastHover = false;
479 }
480 }
481
482 function handlePassClick() {
483 if (!interactive) return;
484 onPass();
485 }
486
487 export function addStone(x: number, y: number, color: 'black' | 'white') {
488 if (!board) return;
489 const coord = new JGO.Coordinate(x, y);
490 const type = color === 'black' ? JGO.BLACK : JGO.WHITE;
491 board.setType(coord, type);
492 }
493
494 export function replayToMove(moveIndex: number) {
495 if (!board || !canvas || !gameState || !gameState.moves) return;
496
497 // Clear the board
498 for (let i = 0; i < board.width; i++) {
499 for (let j = 0; j < board.height; j++) {
500 board.setType(new JGO.Coordinate(i, j), JGO.CLEAR);
501 board.setMark(new JGO.Coordinate(i, j), JGO.MARK.NONE);
502 }
503 }
504
505 // Reset ko
506 ko = false;
507 lastMarkedCoord = null;
508
509 // Replay moves up to moveIndex
510 const movesToReplay = gameState.moves.slice(0, moveIndex + 1);
511 movesToReplay.forEach((move: any, index: number) => {
512 const coord = new JGO.Coordinate(move.x, move.y);
513 const type = move.color === 'black' ? JGO.BLACK : JGO.WHITE;
514
515 const play = board.playMove(coord, type, ko);
516 if (play && play.success) {
517 board.setType(coord, type);
518
519 if (play.captures && play.captures.length > 0) {
520 for (const capture of play.captures) {
521 board.setType(capture, JGO.CLEAR);
522 }
523 }
524
525 ko = play.ko;
526
527 // Mark the last move
528 if (index === movesToReplay.length - 1) {
529 board.setMark(coord, JGO.MARK.CIRCLE);
530 lastMarkedCoord = coord;
531 }
532 }
533 });
534
535 // Redraw
536 canvas.draw(board, 0, 0, board.width - 1, board.height - 1);
537 }
538
539 export function playMove(x: number, y: number, color: 'black' | 'white') {
540 if (!board || !canvas) return;
541
542 const coord = new JGO.Coordinate(x, y);
543 const type = color === 'black' ? JGO.BLACK : JGO.WHITE;
544
545 // Check if stone already exists at this position (from Jetstream duplicate/delayed events)
546 const existingType = board.getType(coord);
547 if (existingType === type) {
548 // Duplicate move (likely from Jetstream latency), just update marker
549 if (lastMarkedCoord) {
550 board.setMark(lastMarkedCoord, JGO.MARK.NONE);
551 }
552 board.setMark(coord, JGO.MARK.CIRCLE);
553 lastMarkedCoord = coord;
554 canvas.draw(board, 0, 0, board.width - 1, board.height - 1);
555 return;
556 }
557
558 // Play the move through the game engine to handle captures
559 const play = board.playMove(coord, type, ko);
560
561 // Check if play result is valid
562 if (!play || !play.hasOwnProperty('success')) {
563 console.error('Invalid play result:', play);
564 return;
565 }
566
567 if (play.success) {
568 // Clear the previous move marker if it exists
569 if (lastMarkedCoord) {
570 board.setMark(lastMarkedCoord, JGO.MARK.NONE);
571 }
572
573 // Place the stone
574 board.setType(coord, type);
575
576 // Remove captured stones (opponent captured YOUR stones)
577 if (play.captures && play.captures.length > 0) {
578 for (const capture of play.captures) {
579 board.setType(capture, JGO.CLEAR);
580 }
581 soundManager.play('captured');
582 }
583
584 // Update ko point
585 ko = play.ko;
586
587 // Mark this move with a circle
588 board.setMark(coord, JGO.MARK.CIRCLE);
589 lastMarkedCoord = coord;
590
591 // Force canvas redraw to show the new move
592 canvas.draw(board, 0, 0, board.width - 1, board.height - 1);
593 } else {
594 console.warn('Move not applied (already exists or invalid):', play.errorMsg);
595 }
596 }
597</script>
598
599<div class="board-container">
600 {#if !isReady}
601 <div class="loading">...</div>
602 {/if}
603
604 <div class="board-wrapper" class:hidden={!isReady}>
605 <div class="board-with-overlay">
606 <div bind:this={boardElement} class="jgoboard"></div>
607 {#if territoryData && territoryData.territory}
608 {@const padding = gridSize * 0.8}
609 {@const margin = gridSize * 0.8}
610 {@const markerSize = gridSize * 0.5}
611 {@const boardOffset = padding + margin + markerSize}
612 <div class="territory-overlay">
613 {#each territoryData.territory as row, y}
614 {#each row as cell, x}
615 {#if cell === 'black' || cell === 'white'}
616 <div
617 class="territory-marker {cell}"
618 style="
619 left: {boardOffset + x * gridSize}px;
620 top: {boardOffset + y * gridSize}px;
621 width: {gridSize * 0.5}px;
622 height: {gridSize * 0.5}px;
623 transform: translate(-50%, -50%);
624 "
625 ></div>
626 {/if}
627 {/each}
628 {/each}
629 </div>
630 {/if}
631 {#if libertyData && board}
632 {@const padding = gridSize * 0.8}
633 {@const margin = gridSize * 0.8}
634 {@const stoneRadius = gridSize * 0.38}
635 {@const boardOffset = padding + margin + stoneRadius - 1}
636 <div class="liberty-overlay">
637 {#each libertyData as row, y}
638 {#each row as libertyCount, x}
639 {#if libertyCount > 0}
640 {@const coord = new JGO.Coordinate(x, y)}
641 {@const stoneType = board.getType(coord)}
642 {#if stoneType === JGO.BLACK || stoneType === JGO.WHITE}
643 {@const baseFontSize = Math.max(10, gridSize * 0.35)}
644 {@const sizeMultiplier = libertyCount === 1 ? 2.0 : libertyCount === 2 ? 1.5 : libertyCount === 3 ? 1.2 : 1.0}
645 {@const fontSize = baseFontSize * sizeMultiplier}
646 {@const isOnBlack = stoneType === JGO.BLACK}
647 {@const color = libertyCount === 1 ? '#ff0000' : libertyCount === 2 ? '#ff6600' : libertyCount === 3 ? '#ff9900' : (isOnBlack ? 'white' : 'black')}
648 {@const shadow = libertyCount === 1
649 ? '0 0 4px rgba(255, 0, 0, 1), 0 0 8px rgba(255, 0, 0, 0.8), 0 0 12px rgba(255, 0, 0, 0.6), 2px 2px 4px rgba(0, 0, 0, 0.9)'
650 : libertyCount <= 3
651 ? '0 0 3px rgba(0, 0, 0, 1), 0 0 6px rgba(0, 0, 0, 0.8), 1px 1px 3px rgba(0, 0, 0, 0.9)'
652 : isOnBlack
653 ? '0 0 3px rgba(0, 0, 0, 0.9), 0 0 6px rgba(0, 0, 0, 0.7), 1px 1px 3px rgba(0, 0, 0, 0.8)'
654 : '0 0 3px rgba(255, 255, 255, 0.9), 0 0 6px rgba(255, 255, 255, 0.7), 1px 1px 3px rgba(255, 255, 255, 0.8)'}
655 <div
656 class="liberty-number"
657 class:atari={libertyCount === 1}
658 style="
659 left: {boardOffset + x * gridSize + 5}px;
660 top: {boardOffset + y * gridSize+ 5}px;
661 font-size: {fontSize}px;
662 color: {color};
663 text-shadow: {shadow};
664 "
665 >
666 {libertyCount === 1 ? '1!' : libertyCount}
667 </div>
668 {/if}
669 {/if}
670 {/each}
671 {/each}
672 </div>
673 {/if}
674 {#if deadStones.length > 0 && board}
675 {@const padding = gridSize * 0.8}
676 {@const margin = gridSize * 0.8}
677 {@const stoneRadius = gridSize * 0.38}
678 {@const boardOffset = padding + margin + stoneRadius - 1}
679 <div class="dead-stone-overlay">
680 {#each deadStones as notation}
681 {@const col = notation.charCodeAt(1) - 65}
682 {@const row = parseInt(notation.slice(2)) - 1}
683 {#if row >= 0 && row < boardSize && col >= 0 && col < boardSize}
684 {@const coord = new JGO.Coordinate(col, row)}
685 {@const stoneType = board.getType(coord)}
686 {#if stoneType === JGO.BLACK || stoneType === JGO.WHITE}
687 {@const xSize = gridSize * 0.6}
688 <div
689 class="dead-stone-mark"
690 style="
691 left: {boardOffset + col * gridSize}px;
692 top: {boardOffset + row * gridSize}px;
693 width: {xSize}px;
694 height: {xSize}px;
695 font-size: {xSize * 0.8}px;
696 "
697 >
698 ✕
699 </div>
700 {/if}
701 {/if}
702 {/each}
703 </div>
704 {/if}
705 {#if reactionOverlay && reactionOverlay.length > 0}
706 {@const padding = gridSize * 0.8}
707 {@const margin = gridSize * 0.8}
708 {@const stoneRadius = gridSize * 0.38}
709 {@const boardOffset = padding + margin + stoneRadius - 1}
710 <div class="reaction-overlay">
711 {#each reactionOverlay as item}
712 {@const emojiSize = 28}
713 <button
714 class="reaction-emoji-badge"
715 style="
716 left: {boardOffset + item.x * gridSize}px;
717 top: {boardOffset + item.y * gridSize}px;
718 font-size: {emojiSize}px;
719 "
720 title={item.emojis.join(' ')}
721 onclick={() => onReactionClick(item.x, item.y)}
722 >
723 {#if item.emojis.length === 1}
724 {item.emojis[0]}
725 {:else if item.emojis.length === 2}
726 <span class="emoji-stack">
727 {#each item.emojis as emoji}
728 <span>{emoji}</span>
729 {/each}
730 </span>
731 {:else}
732 {item.emojis[0]}<span class="emoji-count">+{item.emojis.length - 1}</span>
733 {/if}
734 </button>
735 {/each}
736 </div>
737 {/if}
738 </div>
739 </div>
740
741 {#if interactive && isReady}
742 <div class="controls">
743 <button onclick={handlePassClick} class="pass-button">
744 Pass
745 </button>
746 <div class="turn-indicator">
747 Current turn: <span class="turn-{activeTurn()}">{activeTurn()}</span>
748 </div>
749 </div>
750 {/if}
751
752 {#if showMobileConfirmation && pendingMove}
753 <div class="mobile-confirmation-overlay">
754 <div class="mobile-confirmation-dialog">
755 <p class="confirmation-message">
756 Place {activeTurn()} stone at ({pendingMove.x}, {pendingMove.y})?
757 {#if pendingMove.captures > 0}
758 <span class="capture-info">
759 Will capture {pendingMove.captures} stone{pendingMove.captures > 1 ? 's' : ''}
760 </span>
761 {/if}
762 </p>
763 <div class="confirmation-buttons">
764 <button class="confirm-button" onclick={confirmMove}>Confirm Move</button>
765 <button class="cancel-button" onclick={cancelMove}>Cancel</button>
766 </div>
767 </div>
768 </div>
769 {/if}
770</div>
771
772<style>
773 .board-container {
774 display: flex;
775 flex-direction: column;
776 align-items: center;
777 gap: 1rem;
778 width: 100%;
779 max-width: 100%;
780 }
781
782 .board-wrapper {
783 position: relative;
784 line-height: 0;
785 max-width: 100%;
786 width: 100%;
787 display: flex;
788 justify-content: center;
789 }
790
791 .board-with-overlay {
792 position: relative;
793 display: inline-block;
794 }
795
796 .jgoboard {
797 box-shadow: 0 8px 24px rgba(90, 122, 144, 0.15);
798 display: block;
799 border-radius: 4px;
800 }
801
802 @media (max-width: 768px) {
803 .board-container {
804 padding: 0.5rem;
805 }
806 }
807
808 .controls {
809 display: flex;
810 gap: 2rem;
811 align-items: center;
812 padding: 0.75rem 1.5rem;
813 background: var(--sky-white, #f5f8fa);
814 border-radius: 0.75rem;
815 border: 1px solid var(--sky-blue-pale, #d4e5ef);
816 box-shadow: 0 2px 8px rgba(90, 122, 144, 0.08);
817 }
818
819 .pass-button {
820 padding: 0.625rem 2rem;
821 font-size: 1rem;
822 background: var(--sky-slate, #5a7a90);
823 color: white;
824 border: none;
825 border-radius: 0.5rem;
826 cursor: pointer;
827 transition: all 0.2s;
828 font-weight: 600;
829 }
830
831 .pass-button:hover {
832 background: var(--sky-slate-dark, #455d6e);
833 transform: translateY(-1px);
834 }
835
836 .turn-indicator {
837 font-size: 1.125rem;
838 font-weight: 600;
839 color: var(--sky-slate, #5a7a90);
840 }
841
842 .turn-black {
843 color: #1a1a1a;
844 font-weight: 700;
845 }
846
847 .turn-white {
848 color: var(--sky-gray, #94a8b8);
849 font-weight: 700;
850 }
851
852 .loading {
853 padding: 2rem;
854 text-align: center;
855 color: var(--sky-gray, #94a8b8);
856 }
857
858 .hidden {
859 visibility: hidden;
860 height: 0;
861 }
862
863 .territory-overlay {
864 position: absolute;
865 top: 0;
866 left: 0;
867 right: 0;
868 bottom: 0;
869 pointer-events: none;
870 }
871
872 .territory-marker {
873 position: absolute;
874 border-radius: 50%;
875 transform: translate(-50%, -50%);
876 }
877
878 .territory-marker.black {
879 background: rgba(20, 20, 20, 0.7);
880 box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
881 }
882
883 .territory-marker.white {
884 background: rgba(255, 255, 255, 0.85);
885 box-shadow: 0 0 2px rgba(0, 0, 0, 0.3);
886 }
887
888 .liberty-overlay {
889 position: absolute;
890 top: 0;
891 left: 0;
892 right: 0;
893 bottom: 0;
894 pointer-events: none;
895 }
896
897 .liberty-number {
898 position: absolute;
899 transform: translate(-50%, -50%);
900 font-weight: 700;
901 text-align: center;
902 line-height: 1;
903 pointer-events: none;
904 display: flex;
905 align-items: center;
906 justify-content: center;
907 transition: all 0.2s ease;
908 }
909
910 .liberty-number.atari {
911 font-weight: 900;
912 animation: pulse-atari 1s ease-in-out infinite;
913 }
914
915 @keyframes pulse-atari {
916 0%, 100% {
917 transform: translate(-50%, -50%) scale(1);
918 }
919 50% {
920 transform: translate(-50%, -50%) scale(1.1);
921 }
922 }
923
924 .dead-stone-overlay {
925 position: absolute;
926 top: 0;
927 left: 0;
928 right: 0;
929 bottom: 0;
930 pointer-events: none;
931 }
932
933 .dead-stone-mark {
934 position: absolute;
935 transform: translate(-50%, -50%);
936 color: #ff0000;
937 font-weight: 900;
938 text-align: center;
939 line-height: 1;
940 pointer-events: none;
941 display: flex;
942 align-items: center;
943 justify-content: center;
944 text-shadow:
945 0 0 4px rgba(255, 255, 255, 1),
946 0 0 8px rgba(255, 255, 255, 0.8),
947 2px 2px 4px rgba(0, 0, 0, 0.9);
948 opacity: 0.95;
949 }
950
951 .reaction-overlay {
952 position: absolute;
953 top: 0;
954 left: 0;
955 right: 0;
956 bottom: 0;
957 pointer-events: none;
958 }
959
960 .reaction-emoji-badge {
961 position: absolute;
962 transform: translate(-28%, -33%);
963 line-height: 1;
964 pointer-events: auto;
965 cursor: pointer;
966 filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
967 z-index: 10;
968 background: none;
969 border: none;
970 padding: 0;
971 transition: transform 0.3s ease;
972 }
973
974 .reaction-emoji-badge:hover {
975 transform: translate(-28%, -33%) scale(0.8);
976 }
977
978 .emoji-stack {
979 display: flex;
980 gap: 0;
981 }
982
983 .emoji-stack span:not(:first-child) {
984 margin-left: -0.3em;
985 }
986
987 .emoji-count {
988 font-size: 0.6em;
989 font-weight: 700;
990 color: white;
991 background: rgba(0, 0, 0, 0.6);
992 border-radius: 999px;
993 padding: 0 0.3em;
994 margin-left: 0.1em;
995 vertical-align: super;
996 }
997
998 .mobile-confirmation-overlay {
999 position: fixed;
1000 top: 0;
1001 left: 0;
1002 right: 0;
1003 bottom: 0;
1004 background: rgba(0, 0, 0, 0.5);
1005 display: flex;
1006 align-items: center;
1007 justify-content: center;
1008 z-index: 1000;
1009 backdrop-filter: blur(2px);
1010 }
1011
1012 .mobile-confirmation-dialog {
1013 background: white;
1014 border-radius: 1rem;
1015 padding: 1.5rem;
1016 max-width: 90%;
1017 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
1018 }
1019
1020 .confirmation-message {
1021 font-size: 1rem;
1022 color: var(--sky-slate-dark);
1023 margin: 0 0 1rem 0;
1024 text-align: center;
1025 }
1026
1027 .capture-info {
1028 display: block;
1029 color: var(--sky-apricot-dark);
1030 font-weight: 600;
1031 margin-top: 0.5rem;
1032 }
1033
1034 .confirmation-buttons {
1035 display: flex;
1036 gap: 0.75rem;
1037 }
1038
1039 .confirm-button {
1040 flex: 1;
1041 padding: 0.875rem 1.5rem;
1042 background: linear-gradient(135deg, var(--sky-apricot-dark) 0%, var(--sky-apricot) 100%);
1043 color: white;
1044 border: none;
1045 border-radius: 0.5rem;
1046 font-weight: 600;
1047 cursor: pointer;
1048 }
1049
1050 .cancel-button {
1051 flex: 1;
1052 padding: 0.875rem 1.5rem;
1053 background: var(--sky-cloud);
1054 color: var(--sky-slate);
1055 border: 1px solid var(--sky-blue-pale);
1056 border-radius: 0.5rem;
1057 cursor: pointer;
1058 }
1059</style>