your personal website on atproto - mirror blento.app
at fix-cached-posts 1033 lines 28 kB view raw
1<script lang="ts"> 2 import { onMount, onDestroy } from 'svelte'; 3 import Tetris8BitMusic from './Tetris8Bit.mp3'; 4 5 let canvas: HTMLCanvasElement; 6 let container: HTMLDivElement; 7 let ctx: CanvasRenderingContext2D | null = null; 8 let animationId: number; 9 let audioCtx: AudioContext | null = null; 10 11 // Background music 12 let bgMusic: HTMLAudioElement | null = null; 13 14 // Theme detection 15 let isAccentMode = $state(false); 16 let isDarkMode = $state(false); 17 18 // Game state 19 let gameState = $state<'idle' | 'playing' | 'gameover'>('idle'); 20 let score = $state(0); 21 let lines = $state(0); 22 let level = $state(1); 23 24 // Line clear animation 25 let clearingLines: number[] = []; 26 let clearAnimationProgress = 0; 27 let isClearingAnimation = $state(false); 28 const CLEAR_ANIMATION_DURATION = 120; // ms - fast and crisp 29 let clearAnimationStart = 0; 30 31 // Grid settings 32 const COLS = 10; 33 const ROWS = 20; 34 let cellSize = 20; 35 36 // Vibrant color palette - tailwind 500 colors 37 const VIBRANT_COLORS = { 38 cyan: '#06b6d4', 39 emerald: '#10b981', 40 violet: '#8b5cf6', 41 amber: '#f59e0b', 42 rose: '#f43f5e', 43 blue: '#3b82f6', 44 lime: '#84cc16', 45 fuchsia: '#d946ef', 46 orange: '#f97316', 47 teal: '#14b8a6', 48 indigo: '#6366f1', 49 pink: '#ec4899', 50 red: '#ef4444', 51 yellow: '#eab308', 52 green: '#22c55e', 53 purple: '#a855f7', 54 sky: '#0ea5e9' 55 }; 56 57 // Color families that should not be used together (too similar) 58 const COLOR_FAMILIES: Record<string, string[]> = { 59 pink: ['pink', 'rose', 'red', 'fuchsia'], 60 rose: ['rose', 'pink', 'red', 'fuchsia'], 61 red: ['red', 'rose', 'pink', 'orange'], 62 orange: ['orange', 'amber', 'red', 'yellow'], 63 amber: ['amber', 'orange', 'yellow'], 64 yellow: ['yellow', 'amber', 'lime', 'orange'], 65 lime: ['lime', 'green', 'yellow', 'emerald'], 66 green: ['green', 'emerald', 'lime', 'teal'], 67 emerald: ['emerald', 'green', 'teal', 'cyan'], 68 teal: ['teal', 'cyan', 'emerald', 'green'], 69 cyan: ['cyan', 'teal', 'sky', 'blue'], 70 sky: ['sky', 'cyan', 'blue'], 71 blue: ['blue', 'sky', 'indigo', 'cyan'], 72 indigo: ['indigo', 'blue', 'violet', 'purple'], 73 violet: ['violet', 'purple', 'indigo', 'fuchsia'], 74 purple: ['purple', 'violet', 'fuchsia', 'indigo'], 75 fuchsia: ['fuchsia', 'purple', 'pink', 'violet'] 76 }; 77 78 let detectedAccentFamily = $state<string | null>(null); 79 80 function detectAccentColor() { 81 if (!container) return null; 82 // Look for accent color class on parent card 83 const card = container.closest('.card'); 84 if (!card) return null; 85 86 for (const colorName of Object.keys(COLOR_FAMILIES)) { 87 if (card.classList.contains(colorName)) { 88 return colorName; 89 } 90 } 91 return null; 92 } 93 94 function getColorScheme(): Record<string, string> { 95 // Get colors that contrast well with the current background 96 const excludeColors = 97 isAccentMode && detectedAccentFamily ? COLOR_FAMILIES[detectedAccentFamily] || [] : []; 98 99 // Pick 7 contrasting vibrant colors for the 7 tetrominos 100 const availableColors = Object.entries(VIBRANT_COLORS) 101 .filter(([name]) => !excludeColors.includes(name)) 102 .map(([, color]) => color); 103 104 // Always ensure we have enough colors 105 const allColors = Object.values(VIBRANT_COLORS); 106 while (availableColors.length < 7) { 107 const fallback = allColors[availableColors.length % allColors.length]; 108 if (!availableColors.includes(fallback)) { 109 availableColors.push(fallback); 110 } else { 111 availableColors.push(allColors[(availableColors.length * 3) % allColors.length]); 112 } 113 } 114 115 // For dark mode on base background, use slightly brighter versions 116 if (isDarkMode && !isAccentMode) { 117 return { 118 I: '#22d3ee', // cyan-400 119 O: '#fbbf24', // amber-400 120 T: '#a78bfa', // violet-400 121 S: '#34d399', // emerald-400 122 Z: '#fb7185', // rose-400 123 J: '#60a5fa', // blue-400 124 L: '#a3e635' // lime-400 125 }; 126 } 127 128 // For accent mode, use contrasting colors 129 if (isAccentMode) { 130 return { 131 I: availableColors[0], 132 O: availableColors[1], 133 T: availableColors[2], 134 S: availableColors[3], 135 Z: availableColors[4], 136 J: availableColors[5], 137 L: availableColors[6] 138 }; 139 } 140 141 // Light mode - vibrant standard colors 142 return { 143 I: '#06b6d4', // cyan 144 O: '#f59e0b', // amber 145 T: '#8b5cf6', // violet 146 S: '#10b981', // emerald 147 Z: '#f43f5e', // rose 148 J: '#3b82f6', // blue 149 L: '#84cc16' // lime 150 }; 151 } 152 153 // Tetromino definitions (each has rotations) 154 const TETROMINOES = { 155 I: { shape: [[1, 1, 1, 1]] }, 156 O: { 157 shape: [ 158 [1, 1], 159 [1, 1] 160 ] 161 }, 162 T: { 163 shape: [ 164 [0, 1, 0], 165 [1, 1, 1] 166 ] 167 }, 168 S: { 169 shape: [ 170 [0, 1, 1], 171 [1, 1, 0] 172 ] 173 }, 174 Z: { 175 shape: [ 176 [1, 1, 0], 177 [0, 1, 1] 178 ] 179 }, 180 J: { 181 shape: [ 182 [1, 0, 0], 183 [1, 1, 1] 184 ] 185 }, 186 L: { 187 shape: [ 188 [0, 0, 1], 189 [1, 1, 1] 190 ] 191 } 192 }; 193 194 type TetrominoType = keyof typeof TETROMINOES; 195 196 // Game grid - stores tetromino type for color lookup 197 let grid: (TetrominoType | null)[][] = []; 198 199 // Current piece 200 let currentPiece: { 201 type: TetrominoType; 202 shape: number[][]; 203 x: number; 204 y: number; 205 } | null = null; 206 207 let nextPiece: TetrominoType | null = null; 208 209 function detectTheme() { 210 if (!container) return; 211 // Check if we're inside an accent card (has .accent class ancestor) 212 isAccentMode = container.closest('.accent') !== null; 213 // Check dark mode 214 isDarkMode = container.closest('.dark') !== null && !container.closest('.light'); 215 // Detect accent color family for smart contrast 216 detectedAccentFamily = detectAccentColor(); 217 } 218 219 // Timing 220 let lastDrop = 0; 221 let dropInterval = 1000; 222 223 // Audio functions 224 function initAudio() { 225 if (!audioCtx) { 226 audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); 227 } 228 } 229 230 type OscillatorType = 'sine' | 'square' | 'sawtooth' | 'triangle'; 231 function playTone( 232 frequency: number, 233 duration: number, 234 type: OscillatorType = 'square', 235 volume: number = 0.04 236 ) { 237 if (!audioCtx) return; 238 try { 239 const oscillator = audioCtx.createOscillator(); 240 const gainNode = audioCtx.createGain(); 241 242 oscillator.connect(gainNode); 243 gainNode.connect(audioCtx.destination); 244 245 oscillator.frequency.value = frequency; 246 oscillator.type = type; 247 248 gainNode.gain.setValueAtTime(volume, audioCtx.currentTime); 249 gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration); 250 251 oscillator.start(audioCtx.currentTime); 252 oscillator.stop(audioCtx.currentTime + duration); 253 } catch { 254 // Audio not supported 255 } 256 } 257 258 function playMove() { 259 // 8-bit tick 260 playTone(200, 0.03, 'square', 0.08); 261 } 262 263 function playRotate() { 264 // 8-bit blip 265 playTone(400, 0.04, 'square', 0.1); 266 } 267 268 function playDrop() { 269 // 8-bit thud 270 playTone(80, 0.1, 'square', 0.12); 271 } 272 273 function playLineClear(count: number) { 274 // Swoosh - original style 275 const baseFreq = 400; 276 for (let i = 0; i < count; i++) { 277 setTimeout(() => playTone(baseFreq + i * 100, 0.15, 'sine', 0.15), i * 80); 278 } 279 } 280 281 function playGameOver() { 282 // 8-bit descending 283 playTone(300, 0.15, 'square', 0.1); 284 setTimeout(() => playTone(200, 0.15, 'square', 0.08), 150); 285 setTimeout(() => playTone(120, 0.3, 'square', 0.06), 300); 286 stopMusic(); 287 } 288 289 function startMusic() { 290 if (!bgMusic) { 291 bgMusic = new Audio(Tetris8BitMusic); 292 bgMusic.loop = true; 293 bgMusic.volume = 0.08; 294 } 295 bgMusic.currentTime = 0; 296 bgMusic.play().catch(() => { 297 // Autoplay blocked, ignore 298 }); 299 } 300 301 function stopMusic() { 302 if (bgMusic) { 303 bgMusic.pause(); 304 bgMusic.currentTime = 0; 305 } 306 } 307 308 // Initialize grid 309 function initGrid() { 310 grid = Array(ROWS) 311 .fill(null) 312 .map(() => Array(COLS).fill(null)); 313 } 314 315 // Get random tetromino 316 function randomTetromino(): TetrominoType { 317 const types = Object.keys(TETROMINOES) as TetrominoType[]; 318 return types[Math.floor(Math.random() * types.length)]; 319 } 320 321 // Spawn new piece 322 function spawnPiece() { 323 const type = nextPiece || randomTetromino(); 324 nextPiece = randomTetromino(); 325 326 const tetromino = TETROMINOES[type]; 327 currentPiece = { 328 type, 329 shape: tetromino.shape.map((row) => [...row]), 330 x: Math.floor((COLS - tetromino.shape[0].length) / 2), 331 y: 0 332 }; 333 334 // Check if spawn position is blocked (game over) 335 if (!isValidPosition(currentPiece.shape, currentPiece.x, currentPiece.y)) { 336 gameState = 'gameover'; 337 playGameOver(); 338 } 339 } 340 341 // Check if position is valid 342 function isValidPosition(shape: number[][], x: number, y: number): boolean { 343 for (let row = 0; row < shape.length; row++) { 344 for (let col = 0; col < shape[row].length; col++) { 345 if (shape[row][col]) { 346 const newX = x + col; 347 const newY = y + row; 348 349 if (newX < 0 || newX >= COLS || newY >= ROWS) { 350 return false; 351 } 352 353 if (newY >= 0 && grid[newY][newX]) { 354 return false; 355 } 356 } 357 } 358 } 359 return true; 360 } 361 362 // Rotate piece 363 function rotatePiece() { 364 if (!currentPiece) return; 365 366 const rotated: number[][] = []; 367 const rows = currentPiece.shape.length; 368 const cols = currentPiece.shape[0].length; 369 370 for (let col = 0; col < cols; col++) { 371 rotated[col] = []; 372 for (let row = rows - 1; row >= 0; row--) { 373 rotated[col][rows - 1 - row] = currentPiece.shape[row][col]; 374 } 375 } 376 377 // Try rotation, with wall kicks 378 const kicks = [0, -1, 1, -2, 2]; 379 for (const kick of kicks) { 380 if (isValidPosition(rotated, currentPiece.x + kick, currentPiece.y)) { 381 currentPiece.shape = rotated; 382 currentPiece.x += kick; 383 playRotate(); 384 return; 385 } 386 } 387 } 388 389 // Move piece 390 function movePiece(dx: number, dy: number): boolean { 391 if (!currentPiece) return false; 392 393 if (isValidPosition(currentPiece.shape, currentPiece.x + dx, currentPiece.y + dy)) { 394 currentPiece.x += dx; 395 currentPiece.y += dy; 396 if (dx !== 0) playMove(); 397 return true; 398 } 399 return false; 400 } 401 402 // Lock piece to grid 403 function lockPiece() { 404 if (!currentPiece) return; 405 406 for (let row = 0; row < currentPiece.shape.length; row++) { 407 for (let col = 0; col < currentPiece.shape[row].length; col++) { 408 if (currentPiece.shape[row][col]) { 409 const gridY = currentPiece.y + row; 410 const gridX = currentPiece.x + col; 411 if (gridY >= 0) { 412 grid[gridY][gridX] = currentPiece.type; 413 } 414 } 415 } 416 } 417 418 playDrop(); 419 checkAndClearLines(); 420 } 421 422 // Check for completed lines and start animation 423 function checkAndClearLines() { 424 // Find all completed lines 425 clearingLines = []; 426 for (let row = 0; row < ROWS; row++) { 427 if (grid[row].every((cell) => cell !== null)) { 428 clearingLines.push(row); 429 } 430 } 431 432 if (clearingLines.length > 0) { 433 // Start swoosh animation 434 isClearingAnimation = true; 435 clearAnimationStart = performance.now(); 436 clearAnimationProgress = 0; 437 playLineClear(clearingLines.length); 438 } else { 439 spawnPiece(); 440 } 441 } 442 443 // Actually remove the cleared lines (called after animation) 444 function finishLineClear() { 445 const cleared = clearingLines.length; 446 447 // Remove lines from bottom to top to maintain indices 448 // Do all splices first, then add empty rows, to avoid index shifting issues 449 for (const row of [...clearingLines].sort((a, b) => b - a)) { 450 grid.splice(row, 1); 451 } 452 for (let i = 0; i < cleared; i++) { 453 grid.unshift(Array(COLS).fill(null)); 454 } 455 456 lines += cleared; 457 // Scoring: 100, 300, 500, 800 for 1, 2, 3, 4 lines 458 const points = [0, 100, 300, 500, 800]; 459 score += points[Math.min(cleared, 4)] * level; 460 461 // Level up every 10 lines 462 const newLevel = Math.floor(lines / 10) + 1; 463 if (newLevel > level) { 464 level = newLevel; 465 dropInterval = Math.max(100, 1000 - (level - 1) * 100); 466 } 467 468 clearingLines = []; 469 isClearingAnimation = false; 470 471 // Check if there are more complete lines (chains/cascades) 472 checkAndClearLines(); 473 } 474 475 // Hard drop 476 function hardDrop() { 477 if (!currentPiece) return; 478 479 while (movePiece(0, 1)) { 480 score += 2; 481 } 482 lockPiece(); 483 } 484 485 // Handle keyboard input (only responds when this game container is focused) 486 function handleKeyDown(e: KeyboardEvent) { 487 if (gameState !== 'playing' || isClearingAnimation) { 488 if (e.code === 'Space' || e.code === 'Enter') { 489 e.preventDefault(); 490 if (gameState !== 'playing') startGame(); 491 } 492 return; 493 } 494 495 switch (e.code) { 496 case 'ArrowLeft': 497 case 'KeyA': 498 e.preventDefault(); 499 movePiece(-1, 0); 500 break; 501 case 'ArrowRight': 502 case 'KeyD': 503 e.preventDefault(); 504 movePiece(1, 0); 505 break; 506 case 'ArrowDown': 507 case 'KeyS': 508 e.preventDefault(); 509 if (movePiece(0, 1)) score += 1; 510 break; 511 case 'ArrowUp': 512 case 'KeyW': 513 e.preventDefault(); 514 rotatePiece(); 515 break; 516 case 'Space': 517 e.preventDefault(); 518 hardDrop(); 519 break; 520 } 521 } 522 523 // Touch controls 524 let touchStartX = 0; 525 let touchStartY = 0; 526 let touchStartTime = 0; 527 let longPressTimer: ReturnType<typeof setTimeout> | null = null; 528 let isLongPressing = false; 529 let longPressInterval: ReturnType<typeof setInterval> | null = null; 530 let lastMoveX = 0; 531 let hasMoved = false; 532 533 const LONG_PRESS_DELAY = 300; 534 const LONG_PRESS_MOVE_INTERVAL = 50; 535 const SWIPE_THRESHOLD = 30; 536 const MOVE_CELL_THRESHOLD = 25; 537 538 function handleTouchStart(e: TouchEvent) { 539 // Prevent page scrolling when game is active 540 if (gameState === 'playing') { 541 e.preventDefault(); 542 } 543 544 if (gameState !== 'playing' || isClearingAnimation) { 545 if (gameState !== 'playing') startGame(); 546 return; 547 } 548 549 const touch = e.touches[0]; 550 touchStartX = touch.clientX; 551 touchStartY = touch.clientY; 552 touchStartTime = performance.now(); 553 lastMoveX = touch.clientX; 554 hasMoved = false; 555 isLongPressing = false; 556 557 // Start long press detection 558 longPressTimer = setTimeout(() => { 559 isLongPressing = true; 560 // Start accelerated falling 561 longPressInterval = setInterval(() => { 562 if (gameState === 'playing' && !isClearingAnimation) { 563 if (movePiece(0, 1)) score += 1; 564 } 565 }, LONG_PRESS_MOVE_INTERVAL); 566 }, LONG_PRESS_DELAY); 567 } 568 569 function handleTouchMove(e: TouchEvent) { 570 if (gameState !== 'playing' || isClearingAnimation) return; 571 572 // Prevent page scrolling 573 e.preventDefault(); 574 575 const touch = e.touches[0]; 576 const dx = touch.clientX - touchStartX; 577 const dy = touch.clientY - touchStartY; 578 579 // If moved significantly, cancel long press 580 if (Math.abs(dx) > 10 || Math.abs(dy) > 10) { 581 if (longPressTimer) { 582 clearTimeout(longPressTimer); 583 longPressTimer = null; 584 } 585 if (longPressInterval) { 586 clearInterval(longPressInterval); 587 longPressInterval = null; 588 isLongPressing = false; 589 } 590 } 591 592 // Only allow horizontal movement if not swiping down 593 // (vertical movement is greater than horizontal) 594 if (Math.abs(dy) > Math.abs(dx) && dy > SWIPE_THRESHOLD) { 595 return; 596 } 597 598 // Handle horizontal movement in cells 599 const cellsMoved = Math.floor((touch.clientX - lastMoveX) / MOVE_CELL_THRESHOLD); 600 if (cellsMoved !== 0) { 601 hasMoved = true; 602 for (let i = 0; i < Math.abs(cellsMoved); i++) { 603 movePiece(cellsMoved > 0 ? 1 : -1, 0); 604 } 605 lastMoveX += cellsMoved * MOVE_CELL_THRESHOLD; 606 } 607 } 608 609 function handleTouchEnd(e: TouchEvent) { 610 // Clean up long press 611 if (longPressTimer) { 612 clearTimeout(longPressTimer); 613 longPressTimer = null; 614 } 615 if (longPressInterval) { 616 clearInterval(longPressInterval); 617 longPressInterval = null; 618 } 619 620 if (gameState !== 'playing' || isClearingAnimation) return; 621 622 // If was long pressing, don't do anything else 623 if (isLongPressing) { 624 isLongPressing = false; 625 return; 626 } 627 628 const touchEndX = e.changedTouches[0].clientX; 629 const touchEndY = e.changedTouches[0].clientY; 630 const dx = touchEndX - touchStartX; 631 const dy = touchEndY - touchStartY; 632 const elapsed = performance.now() - touchStartTime; 633 634 // Quick tap (no movement) - rotate 635 if ( 636 !hasMoved && 637 Math.abs(dx) < SWIPE_THRESHOLD && 638 Math.abs(dy) < SWIPE_THRESHOLD && 639 elapsed < 300 640 ) { 641 rotatePiece(); 642 return; 643 } 644 645 // Swipe down - hard drop 646 if (dy > SWIPE_THRESHOLD * 2 && Math.abs(dy) > Math.abs(dx)) { 647 hardDrop(); 648 } 649 } 650 651 function startGame() { 652 detectTheme(); 653 initAudio(); 654 initGrid(); 655 score = 0; 656 lines = 0; 657 level = 1; 658 dropInterval = 1000; 659 clearingLines = []; 660 isClearingAnimation = false; 661 nextPiece = randomTetromino(); 662 spawnPiece(); 663 gameState = 'playing'; 664 lastDrop = performance.now(); 665 startMusic(); 666 // Focus container so keyboard events work for this game 667 container?.focus(); 668 } 669 670 function calculateSize() { 671 if (!canvas) return; 672 const parent = canvas.parentElement; 673 if (!parent) return; 674 675 const padding = 8; 676 const availableWidth = parent.clientWidth - padding * 2; 677 const availableHeight = parent.clientHeight - padding * 2; 678 679 // Calculate cell size to fit the grid in available space 680 // Use full width/height, UI will overlay on top 681 cellSize = Math.floor(Math.min(availableWidth / COLS, availableHeight / ROWS)); 682 cellSize = Math.max(8, cellSize); // minimum 8px cells for very small cards 683 684 canvas.width = parent.clientWidth; 685 canvas.height = parent.clientHeight; 686 } 687 688 function drawBlock(x: number, y: number, color: string, size: number = cellSize) { 689 if (!ctx) return; 690 691 const gap = size >= 12 ? 1 : 0; 692 ctx.fillStyle = color; 693 ctx.fillRect(x, y, size - gap, size - gap); 694 695 // Only draw highlights/shadows for larger cells 696 if (size >= 10) { 697 const edge = Math.max(2, Math.floor(size * 0.15)); 698 699 // Highlight 700 ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; 701 ctx.fillRect(x, y, size - gap, edge); 702 ctx.fillRect(x, y, edge, size - gap); 703 704 // Shadow 705 ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; 706 ctx.fillRect(x + size - gap - edge, y, edge, size - gap); 707 ctx.fillRect(x, y + size - gap - edge, size - gap, edge); 708 } 709 } 710 711 function gameLoop(timestamp: number) { 712 if (!ctx || !canvas) { 713 animationId = requestAnimationFrame(gameLoop); 714 return; 715 } 716 717 // Detect theme on every frame for dynamic updates 718 detectTheme(); 719 720 const colors = getColorScheme(); 721 const textColor = isAccentMode ? '#000000' : isDarkMode ? '#ffffff' : '#000000'; 722 const gridBgColor = isAccentMode 723 ? 'rgba(0, 0, 0, 0.15)' 724 : isDarkMode 725 ? 'rgba(255, 255, 255, 0.05)' 726 : 'rgba(0, 0, 0, 0.05)'; 727 const gridLineColor = isAccentMode 728 ? 'rgba(0, 0, 0, 0.1)' 729 : isDarkMode 730 ? 'rgba(255, 255, 255, 0.05)' 731 : 'rgba(0, 0, 0, 0.08)'; 732 733 const canvasWidth = canvas.width; 734 const canvasHeight = canvas.height; 735 736 // Clear canvas 737 ctx.clearRect(0, 0, canvasWidth, canvasHeight); 738 739 // Calculate grid position (centered, using full space) 740 const gridWidth = COLS * cellSize; 741 const gridHeight = ROWS * cellSize; 742 const offsetX = Math.floor((canvasWidth - gridWidth) / 2); 743 const offsetY = Math.floor((canvasHeight - gridHeight) / 2); 744 745 // Draw grid background 746 ctx.fillStyle = gridBgColor; 747 ctx.fillRect(offsetX, offsetY, gridWidth, gridHeight); 748 749 // Only draw grid lines if cells are big enough 750 if (cellSize >= 12) { 751 ctx.strokeStyle = gridLineColor; 752 ctx.lineWidth = 1; 753 for (let i = 0; i <= COLS; i++) { 754 ctx.beginPath(); 755 ctx.moveTo(offsetX + i * cellSize, offsetY); 756 ctx.lineTo(offsetX + i * cellSize, offsetY + gridHeight); 757 ctx.stroke(); 758 } 759 for (let i = 0; i <= ROWS; i++) { 760 ctx.beginPath(); 761 ctx.moveTo(offsetX, offsetY + i * cellSize); 762 ctx.lineTo(offsetX + gridWidth, offsetY + i * cellSize); 763 ctx.stroke(); 764 } 765 } 766 767 // Handle line clear animation 768 if (isClearingAnimation) { 769 const elapsed = timestamp - clearAnimationStart; 770 clearAnimationProgress = Math.min(1, elapsed / CLEAR_ANIMATION_DURATION); 771 772 if (clearAnimationProgress >= 1) { 773 finishLineClear(); 774 } 775 } 776 777 // Draw locked pieces 778 for (let row = 0; row < ROWS; row++) { 779 for (let col = 0; col < COLS; col++) { 780 const cellType = grid[row][col]; 781 if (cellType) { 782 const cellColor = colors[cellType]; 783 784 // Check if this row is being cleared 785 if (clearingLines.includes(row)) { 786 // Swoosh animation: white sweep from left to right 787 const swooshCol = clearAnimationProgress * (COLS + 2); // +2 for overshoot 788 if (col < swooshCol - 1) { 789 // Already swept - show white fading out 790 const fadeProgress = Math.min(1, (swooshCol - col - 1) / 2); 791 ctx.fillStyle = `rgba(255, 255, 255, ${1 - fadeProgress})`; 792 ctx.fillRect( 793 offsetX + col * cellSize, 794 offsetY + row * cellSize, 795 cellSize - 1, 796 cellSize - 1 797 ); 798 } else if (col < swooshCol) { 799 // Sweep edge - bright white 800 ctx.fillStyle = '#ffffff'; 801 ctx.fillRect( 802 offsetX + col * cellSize, 803 offsetY + row * cellSize, 804 cellSize - 1, 805 cellSize - 1 806 ); 807 } else { 808 // Not yet swept - show block 809 drawBlock(offsetX + col * cellSize, offsetY + row * cellSize, cellColor); 810 } 811 } else { 812 drawBlock(offsetX + col * cellSize, offsetY + row * cellSize, cellColor); 813 } 814 } 815 } 816 } 817 818 // Game logic (pause during animation) 819 if (gameState === 'playing' && currentPiece && !isClearingAnimation) { 820 // Auto drop 821 if (timestamp - lastDrop > dropInterval) { 822 if (!movePiece(0, 1)) { 823 lockPiece(); 824 } 825 lastDrop = timestamp; 826 } 827 828 // Draw ghost piece 829 const pieceColor = colors[currentPiece.type]; 830 let ghostY = currentPiece.y; 831 while (isValidPosition(currentPiece.shape, currentPiece.x, ghostY + 1)) { 832 ghostY++; 833 } 834 ctx.globalAlpha = 0.3; 835 for (let row = 0; row < currentPiece.shape.length; row++) { 836 for (let col = 0; col < currentPiece.shape[row].length; col++) { 837 if (currentPiece.shape[row][col]) { 838 drawBlock( 839 offsetX + (currentPiece.x + col) * cellSize, 840 offsetY + (ghostY + row) * cellSize, 841 pieceColor 842 ); 843 } 844 } 845 } 846 ctx.globalAlpha = 1; 847 848 // Draw current piece 849 for (let row = 0; row < currentPiece.shape.length; row++) { 850 for (let col = 0; col < currentPiece.shape[row].length; col++) { 851 if (currentPiece.shape[row][col]) { 852 drawBlock( 853 offsetX + (currentPiece.x + col) * cellSize, 854 offsetY + (currentPiece.y + row) * cellSize, 855 pieceColor 856 ); 857 } 858 } 859 } 860 } 861 862 // Draw next piece preview (top-right corner overlay) 863 if (nextPiece && (gameState === 'playing' || isClearingAnimation)) { 864 const nextTetromino = TETROMINOES[nextPiece]; 865 const previewSize = Math.max(8, Math.floor(cellSize * 0.6)); 866 const previewPadding = 4; 867 const previewWidth = 4 * previewSize + previewPadding * 2; 868 const previewHeight = 2 * previewSize + previewPadding * 2 + 12; 869 const previewX = offsetX + gridWidth - previewWidth; 870 const previewY = offsetY; 871 872 // Semi-transparent background 873 ctx.fillStyle = isAccentMode 874 ? 'rgba(255, 255, 255, 0.3)' 875 : isDarkMode 876 ? 'rgba(0, 0, 0, 0.4)' 877 : 'rgba(255, 255, 255, 0.5)'; 878 ctx.fillRect(previewX, previewY, previewWidth, previewHeight); 879 880 // Only show "NEXT" label if there's enough space 881 if (cellSize >= 12) { 882 ctx.fillStyle = textColor; 883 ctx.font = `bold ${Math.max(8, previewSize * 0.8)}px monospace`; 884 ctx.textAlign = 'left'; 885 ctx.fillText('NEXT', previewX + previewPadding, previewY + 10); 886 } 887 888 const nextColor = colors[nextPiece]; 889 const pieceOffsetY = cellSize >= 12 ? 14 : 4; 890 for (let row = 0; row < nextTetromino.shape.length; row++) { 891 for (let col = 0; col < nextTetromino.shape[row].length; col++) { 892 if (nextTetromino.shape[row][col]) { 893 drawBlock( 894 previewX + previewPadding + col * previewSize, 895 previewY + pieceOffsetY + row * previewSize, 896 nextColor, 897 previewSize 898 ); 899 } 900 } 901 } 902 } 903 904 // Draw score (top-left corner overlay) 905 if (gameState === 'playing' || gameState === 'gameover' || isClearingAnimation) { 906 const scoreSize = Math.max(10, cellSize * 0.6); 907 const scorePadding = 4; 908 909 // Semi-transparent background 910 ctx.fillStyle = isAccentMode 911 ? 'rgba(255, 255, 255, 0.3)' 912 : isDarkMode 913 ? 'rgba(0, 0, 0, 0.4)' 914 : 'rgba(255, 255, 255, 0.5)'; 915 const scoreBoxWidth = Math.max(40, scoreSize * 4); 916 const scoreBoxHeight = cellSize >= 12 ? scoreSize * 2.5 : scoreSize * 1.5; 917 ctx.fillRect(offsetX, offsetY, scoreBoxWidth, scoreBoxHeight); 918 919 ctx.fillStyle = textColor; 920 ctx.font = `bold ${scoreSize}px monospace`; 921 ctx.textAlign = 'left'; 922 ctx.fillText(`${score}`, offsetX + scorePadding, offsetY + scoreSize); 923 924 if (cellSize >= 12) { 925 ctx.font = `${Math.max(8, scoreSize * 0.6)}px monospace`; 926 ctx.fillText(`L${level}`, offsetX + scorePadding, offsetY + scoreSize * 1.8); 927 } 928 } 929 930 // Draw game over 931 if (gameState === 'gameover') { 932 ctx.fillStyle = textColor; 933 const gameOverSize = Math.max(12, Math.min(cellSize * 0.8, 24)); 934 ctx.font = `bold ${gameOverSize}px monospace`; 935 ctx.textAlign = 'center'; 936 ctx.fillText('GAME', offsetX + gridWidth / 2, offsetY + gridHeight / 2 - gameOverSize * 0.3); 937 ctx.fillText('OVER', offsetX + gridWidth / 2, offsetY + gridHeight / 2 + gameOverSize * 0.9); 938 } 939 940 // Draw start screen with controls 941 if (gameState === 'idle') { 942 ctx.fillStyle = textColor; 943 ctx.textAlign = 'center'; 944 945 const centerX = offsetX + gridWidth / 2; 946 const centerY = offsetY + gridHeight / 2; 947 const titleSize = Math.max(12, Math.min(cellSize * 0.8, 20)); 948 949 ctx.font = `bold ${titleSize}px monospace`; 950 ctx.fillText('TETRIS', centerX, centerY - titleSize); 951 952 // Only show controls on larger cards 953 if (cellSize >= 15) { 954 const controlSize = Math.max(8, cellSize * 0.35); 955 ctx.font = `${controlSize}px monospace`; 956 ctx.fillText('\u2190\u2192 Move', centerX, centerY + controlSize * 0.5); 957 ctx.fillText('\u2191 Rotate \u2193 Down', centerX, centerY + controlSize * 2); 958 ctx.fillText('SPACE Drop', centerX, centerY + controlSize * 3.5); 959 } 960 } 961 962 animationId = requestAnimationFrame(gameLoop); 963 } 964 965 function resizeCanvas() { 966 calculateSize(); 967 } 968 969 onMount(() => { 970 ctx = canvas.getContext('2d'); 971 detectTheme(); 972 calculateSize(); 973 initGrid(); 974 975 const resizeObserver = new ResizeObserver(() => { 976 resizeCanvas(); 977 }); 978 resizeObserver.observe(canvas.parentElement!); 979 980 animationId = requestAnimationFrame(gameLoop); 981 982 return () => { 983 resizeObserver.disconnect(); 984 }; 985 }); 986 987 onDestroy(() => { 988 if (animationId) { 989 cancelAnimationFrame(animationId); 990 } 991 if (audioCtx) { 992 audioCtx.close(); 993 } 994 if (bgMusic) { 995 bgMusic.pause(); 996 bgMusic = null; 997 } 998 if (longPressTimer) { 999 clearTimeout(longPressTimer); 1000 } 1001 if (longPressInterval) { 1002 clearInterval(longPressInterval); 1003 } 1004 }); 1005</script> 1006 1007<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 1008<!-- svelte-ignore a11y_no_noninteractive_tabindex --> 1009<div 1010 bind:this={container} 1011 class="relative h-full w-full overflow-hidden outline-none" 1012 tabindex="0" 1013 role="application" 1014 aria-label="Tetris game" 1015 onkeydown={handleKeyDown} 1016> 1017 <canvas 1018 bind:this={canvas} 1019 class="h-full w-full touch-none select-none" 1020 ontouchstart={handleTouchStart} 1021 ontouchmove={handleTouchMove} 1022 ontouchend={handleTouchEnd} 1023 ></canvas> 1024 1025 {#if gameState === 'idle' || gameState === 'gameover'} 1026 <button 1027 onclick={startGame} 1028 class="border-base-800 bg-base-100/80 text-base-800 hover:bg-base-800 hover:text-base-100 dark:border-base-200 dark:bg-base-800/80 dark:text-base-200 dark:hover:bg-base-200 dark:hover:text-base-800 accent:border-base-900 accent:bg-white/80 accent:text-base-900 accent:hover:bg-base-900 accent:hover:text-white absolute bottom-4 left-1/2 -translate-x-1/2 transform cursor-pointer rounded-lg border-2 px-4 py-2 font-mono text-xs font-bold transition-colors" 1029 > 1030 {gameState === 'gameover' ? 'PLAY AGAIN' : 'START'} 1031 </button> 1032 {/if} 1033</div>