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 master 1059 lines 31 kB view raw
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>