your personal website on atproto - mirror
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

tetris card withouth background music

+252 -146
+252 -146
src/lib/cards/TetrisCard/TetrisCard.svelte
··· 23 23 // Line clear animation 24 24 let clearingLines: number[] = []; 25 25 let clearAnimationProgress = 0; 26 - let isClearingAnimation = false; 27 - const CLEAR_ANIMATION_DURATION = 200; // ms 26 + let isClearingAnimation = $state(false); 27 + const CLEAR_ANIMATION_DURATION = 120; // ms - fast and crisp 28 28 let clearAnimationStart = 0; 29 29 30 30 // Grid settings ··· 32 32 const ROWS = 20; 33 33 let cellSize = 20; 34 34 35 - // Color schemes for different modes 36 - const COLOR_SCHEMES = { 37 - light: { 38 - I: '#0891b2', // cyan-600 39 - O: '#ca8a04', // yellow-600 40 - T: '#9333ea', // purple-600 41 - S: '#16a34a', // green-600 42 - Z: '#dc2626', // red-600 43 - J: '#2563eb', // blue-600 44 - L: '#ea580c' // orange-600 45 - }, 46 - dark: { 47 - I: '#22d3ee', // cyan-400 48 - O: '#facc15', // yellow-400 49 - T: '#c084fc', // purple-400 50 - S: '#4ade80', // green-400 51 - Z: '#f87171', // red-400 52 - J: '#60a5fa', // blue-400 53 - L: '#fb923c' // orange-400 54 - }, 55 - accent: { 56 - I: '#164e63', // cyan-900 57 - O: '#713f12', // yellow-900 58 - T: '#581c87', // purple-900 59 - S: '#14532d', // green-900 60 - Z: '#7f1d1d', // red-900 61 - J: '#1e3a8a', // blue-900 62 - L: '#7c2d12' // orange-900 35 + // Vibrant color palette - tailwind 500 colors 36 + const VIBRANT_COLORS = { 37 + cyan: '#06b6d4', 38 + emerald: '#10b981', 39 + violet: '#8b5cf6', 40 + amber: '#f59e0b', 41 + rose: '#f43f5e', 42 + blue: '#3b82f6', 43 + lime: '#84cc16', 44 + fuchsia: '#d946ef', 45 + orange: '#f97316', 46 + teal: '#14b8a6', 47 + indigo: '#6366f1', 48 + pink: '#ec4899', 49 + red: '#ef4444', 50 + yellow: '#eab308', 51 + green: '#22c55e', 52 + purple: '#a855f7', 53 + sky: '#0ea5e9' 54 + }; 55 + 56 + // Color families that should not be used together (too similar) 57 + const COLOR_FAMILIES: Record<string, string[]> = { 58 + pink: ['pink', 'rose', 'red', 'fuchsia'], 59 + rose: ['rose', 'pink', 'red', 'fuchsia'], 60 + red: ['red', 'rose', 'pink', 'orange'], 61 + orange: ['orange', 'amber', 'red', 'yellow'], 62 + amber: ['amber', 'orange', 'yellow'], 63 + yellow: ['yellow', 'amber', 'lime', 'orange'], 64 + lime: ['lime', 'green', 'yellow', 'emerald'], 65 + green: ['green', 'emerald', 'lime', 'teal'], 66 + emerald: ['emerald', 'green', 'teal', 'cyan'], 67 + teal: ['teal', 'cyan', 'emerald', 'green'], 68 + cyan: ['cyan', 'teal', 'sky', 'blue'], 69 + sky: ['sky', 'cyan', 'blue'], 70 + blue: ['blue', 'sky', 'indigo', 'cyan'], 71 + indigo: ['indigo', 'blue', 'violet', 'purple'], 72 + violet: ['violet', 'purple', 'indigo', 'fuchsia'], 73 + purple: ['purple', 'violet', 'fuchsia', 'indigo'], 74 + fuchsia: ['fuchsia', 'purple', 'pink', 'violet'] 75 + }; 76 + 77 + let detectedAccentFamily = $state<string | null>(null); 78 + 79 + function detectAccentColor() { 80 + if (!container) return null; 81 + // Look for accent color class on parent card 82 + const card = container.closest('.card'); 83 + if (!card) return null; 84 + 85 + for (const colorName of Object.keys(COLOR_FAMILIES)) { 86 + if (card.classList.contains(colorName)) { 87 + return colorName; 88 + } 89 + } 90 + return null; 91 + } 92 + 93 + function getColorScheme(): Record<string, string> { 94 + // Get colors that contrast well with the current background 95 + const excludeColors = isAccentMode && detectedAccentFamily 96 + ? COLOR_FAMILIES[detectedAccentFamily] || [] 97 + : []; 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 + }; 63 139 } 64 - }; 65 140 66 - function getColorScheme() { 67 - if (isAccentMode) return COLOR_SCHEMES.accent; 68 - if (isDarkMode) return COLOR_SCHEMES.dark; 69 - return COLOR_SCHEMES.light; 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 + }; 70 151 } 71 152 72 153 // Tetromino definitions (each has rotations) ··· 101 182 isAccentMode = container.closest('.accent') !== null; 102 183 // Check dark mode 103 184 isDarkMode = container.closest('.dark') !== null && !container.closest('.light'); 185 + // Detect accent color family for smart contrast 186 + detectedAccentFamily = detectAccentColor(); 104 187 } 105 188 106 189 // Timing ··· 114 197 } 115 198 } 116 199 117 - function playTone(frequency: number, duration: number, type: OscillatorType = 'square') { 200 + function playTone(frequency: number, duration: number, type: OscillatorType = 'square', volume: number = 0.04) { 118 201 if (!audioCtx) return; 119 202 try { 120 203 const oscillator = audioCtx.createOscillator(); ··· 126 209 oscillator.frequency.value = frequency; 127 210 oscillator.type = type; 128 211 129 - gainNode.gain.setValueAtTime(0.1, audioCtx.currentTime); 130 - gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration); 212 + gainNode.gain.setValueAtTime(volume, audioCtx.currentTime); 213 + gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + duration); 131 214 132 215 oscillator.start(audioCtx.currentTime); 133 216 oscillator.stop(audioCtx.currentTime + duration); ··· 137 220 } 138 221 139 222 function playMove() { 140 - playTone(150, 0.05); 223 + // 8-bit tick 224 + playTone(200, 0.03, 'square', 0.025); 141 225 } 142 226 143 227 function playRotate() { 144 - playTone(300, 0.08); 228 + // 8-bit blip 229 + playTone(400, 0.04, 'square', 0.03); 145 230 } 146 231 147 232 function playDrop() { 148 - playTone(100, 0.15); 233 + // 8-bit thud 234 + playTone(80, 0.1, 'square', 0.04); 149 235 } 150 236 151 237 function playLineClear(count: number) { 238 + // Swoosh - original style 152 239 const baseFreq = 400; 153 240 for (let i = 0; i < count; i++) { 154 - setTimeout(() => playTone(baseFreq + i * 100, 0.15, 'sine'), i * 80); 241 + setTimeout(() => playTone(baseFreq + i * 100, 0.15, 'sine', 0.08), i * 80); 155 242 } 156 243 } 157 244 158 245 function playGameOver() { 159 - playTone(200, 0.3, 'sawtooth'); 160 - setTimeout(() => playTone(150, 0.3, 'sawtooth'), 200); 161 - setTimeout(() => playTone(100, 0.5, 'sawtooth'), 400); 246 + // 8-bit descending 247 + playTone(300, 0.15, 'square', 0.035); 248 + setTimeout(() => playTone(200, 0.15, 'square', 0.03), 150); 249 + setTimeout(() => playTone(120, 0.3, 'square', 0.025), 300); 162 250 } 163 251 164 252 // Initialize grid ··· 320 408 321 409 clearingLines = []; 322 410 isClearingAnimation = false; 323 - spawnPiece(); 411 + 412 + // Check if there are more complete lines (chains/cascades) 413 + checkAndClearLines(); 324 414 } 325 415 326 416 // Hard drop ··· 425 515 426 516 function calculateSize() { 427 517 if (!canvas) return; 428 - const container = canvas.parentElement; 429 - if (!container) return; 518 + const parent = canvas.parentElement; 519 + if (!parent) return; 430 520 431 - const maxWidth = container.clientWidth - 80; // Reserve space for next piece 432 - const maxHeight = container.clientHeight - 40; 521 + const padding = 8; 522 + const availableWidth = parent.clientWidth - padding * 2; 523 + const availableHeight = parent.clientHeight - padding * 2; 433 524 434 - cellSize = Math.floor(Math.min(maxWidth / COLS, maxHeight / ROWS)); 435 - cellSize = Math.max(10, Math.min(30, cellSize)); 525 + // Calculate cell size to fit the grid in available space 526 + // Use full width/height, UI will overlay on top 527 + cellSize = Math.floor(Math.min(availableWidth / COLS, availableHeight / ROWS)); 528 + cellSize = Math.max(8, cellSize); // minimum 8px cells for very small cards 436 529 437 - canvas.width = container.clientWidth; 438 - canvas.height = container.clientHeight; 530 + canvas.width = parent.clientWidth; 531 + canvas.height = parent.clientHeight; 439 532 } 440 533 441 534 function drawBlock(x: number, y: number, color: string, size: number = cellSize) { 442 535 if (!ctx) return; 443 536 537 + const gap = size >= 12 ? 1 : 0; 444 538 ctx.fillStyle = color; 445 - ctx.fillRect(x, y, size - 1, size - 1); 539 + ctx.fillRect(x, y, size - gap, size - gap); 540 + 541 + // Only draw highlights/shadows for larger cells 542 + if (size >= 10) { 543 + const edge = Math.max(2, Math.floor(size * 0.15)); 446 544 447 - // Highlight 448 - ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; 449 - ctx.fillRect(x, y, size - 1, 3); 450 - ctx.fillRect(x, y, 3, size - 1); 545 + // Highlight 546 + ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; 547 + ctx.fillRect(x, y, size - gap, edge); 548 + ctx.fillRect(x, y, edge, size - gap); 451 549 452 - // Shadow 453 - ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; 454 - ctx.fillRect(x + size - 4, y, 3, size - 1); 455 - ctx.fillRect(x, y + size - 4, size - 1, 3); 550 + // Shadow 551 + ctx.fillStyle = 'rgba(0, 0, 0, 0.3)'; 552 + ctx.fillRect(x + size - gap - edge, y, edge, size - gap); 553 + ctx.fillRect(x, y + size - gap - edge, size - gap, edge); 554 + } 456 555 } 457 556 458 557 function gameLoop(timestamp: number) { ··· 460 559 animationId = requestAnimationFrame(gameLoop); 461 560 return; 462 561 } 562 + 563 + // Detect theme on every frame for dynamic updates 564 + detectTheme(); 463 565 464 566 const colors = getColorScheme(); 465 - const textColor = isAccentMode ? '#1a1a1a' : (isDarkMode ? '#f5f5f5' : '#1a1a1a'); 567 + const textColor = isAccentMode ? '#000000' : (isDarkMode ? '#ffffff' : '#000000'); 466 568 const gridBgColor = isAccentMode ? 'rgba(0, 0, 0, 0.15)' : (isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'); 467 569 const gridLineColor = isAccentMode ? 'rgba(0, 0, 0, 0.1)' : (isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.08)'); 468 570 ··· 472 574 // Clear canvas 473 575 ctx.clearRect(0, 0, canvasWidth, canvasHeight); 474 576 475 - // Calculate grid position (centered with space for next piece on right) 577 + // Calculate grid position (centered, using full space) 476 578 const gridWidth = COLS * cellSize; 477 579 const gridHeight = ROWS * cellSize; 478 - const offsetX = Math.floor((canvasWidth - gridWidth - 60) / 2); 580 + const offsetX = Math.floor((canvasWidth - gridWidth) / 2); 479 581 const offsetY = Math.floor((canvasHeight - gridHeight) / 2); 480 582 481 583 // Draw grid background 482 584 ctx.fillStyle = gridBgColor; 483 585 ctx.fillRect(offsetX, offsetY, gridWidth, gridHeight); 484 586 485 - // Draw grid lines 486 - ctx.strokeStyle = gridLineColor; 487 - ctx.lineWidth = 1; 488 - for (let i = 0; i <= COLS; i++) { 489 - ctx.beginPath(); 490 - ctx.moveTo(offsetX + i * cellSize, offsetY); 491 - ctx.lineTo(offsetX + i * cellSize, offsetY + gridHeight); 492 - ctx.stroke(); 493 - } 494 - for (let i = 0; i <= ROWS; i++) { 495 - ctx.beginPath(); 496 - ctx.moveTo(offsetX, offsetY + i * cellSize); 497 - ctx.lineTo(offsetX + gridWidth, offsetY + i * cellSize); 498 - ctx.stroke(); 587 + // Only draw grid lines if cells are big enough 588 + if (cellSize >= 12) { 589 + ctx.strokeStyle = gridLineColor; 590 + ctx.lineWidth = 1; 591 + for (let i = 0; i <= COLS; i++) { 592 + ctx.beginPath(); 593 + ctx.moveTo(offsetX + i * cellSize, offsetY); 594 + ctx.lineTo(offsetX + i * cellSize, offsetY + gridHeight); 595 + ctx.stroke(); 596 + } 597 + for (let i = 0; i <= ROWS; i++) { 598 + ctx.beginPath(); 599 + ctx.moveTo(offsetX, offsetY + i * cellSize); 600 + ctx.lineTo(offsetX + gridWidth, offsetY + i * cellSize); 601 + ctx.stroke(); 602 + } 499 603 } 500 604 501 605 // Handle line clear animation ··· 517 621 518 622 // Check if this row is being cleared 519 623 if (clearingLines.includes(row)) { 520 - // Swoosh animation: reveal from left to right 521 - const swooshX = clearAnimationProgress * COLS; 522 - if (col < swooshX) { 523 - // Draw white flash that fades 524 - const flashProgress = Math.max(0, 1 - (swooshX - col) / 3); 525 - const flashColor = isAccentMode ? `rgba(255, 255, 255, ${flashProgress * 0.9})` : 526 - (isDarkMode ? `rgba(255, 255, 255, ${flashProgress * 0.9})` : `rgba(255, 255, 255, ${flashProgress * 0.95})`); 527 - ctx.fillStyle = flashColor; 624 + // Swoosh animation: white sweep from left to right 625 + const swooshCol = clearAnimationProgress * (COLS + 2); // +2 for overshoot 626 + if (col < swooshCol - 1) { 627 + // Already swept - show white fading out 628 + const fadeProgress = Math.min(1, (swooshCol - col - 1) / 2); 629 + ctx.fillStyle = `rgba(255, 255, 255, ${1 - fadeProgress})`; 630 + ctx.fillRect(offsetX + col * cellSize, offsetY + row * cellSize, cellSize - 1, cellSize - 1); 631 + } else if (col < swooshCol) { 632 + // Sweep edge - bright white 633 + ctx.fillStyle = '#ffffff'; 528 634 ctx.fillRect(offsetX + col * cellSize, offsetY + row * cellSize, cellSize - 1, cellSize - 1); 529 635 } else { 636 + // Not yet swept - show block 530 637 drawBlock(offsetX + col * cellSize, offsetY + row * cellSize, cellColor); 531 638 } 532 639 } else { ··· 536 643 } 537 644 } 538 645 539 - // Draw swoosh leading edge glow 540 - if (isClearingAnimation && clearingLines.length > 0) { 541 - const swooshX = clearAnimationProgress * COLS; 542 - const glowCol = Math.floor(swooshX); 543 - 544 - if (glowCol < COLS) { 545 - for (const row of clearingLines) { 546 - // Draw bright leading edge 547 - const glowWidth = cellSize * 0.5; 548 - const gradient = ctx.createLinearGradient( 549 - offsetX + glowCol * cellSize, 0, 550 - offsetX + glowCol * cellSize + glowWidth, 0 551 - ); 552 - const glowColor = isAccentMode ? 'rgba(255, 255, 255, 0.95)' : 553 - (isDarkMode ? 'rgba(255, 255, 255, 0.95)' : 'rgba(255, 255, 255, 1)'); 554 - gradient.addColorStop(0, glowColor); 555 - gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); 556 - 557 - ctx.fillStyle = gradient; 558 - ctx.fillRect(offsetX + glowCol * cellSize, offsetY + row * cellSize, glowWidth, cellSize - 1); 559 - } 560 - } 561 - } 562 - 563 646 // Game logic (pause during animation) 564 647 if (gameState === 'playing' && currentPiece && !isClearingAnimation) { 565 648 // Auto drop ··· 604 687 } 605 688 } 606 689 607 - // Draw next piece preview 690 + // Draw next piece preview (top-right corner overlay) 608 691 if (nextPiece && (gameState === 'playing' || isClearingAnimation)) { 609 692 const nextTetromino = TETROMINOES[nextPiece]; 610 - const previewX = offsetX + gridWidth + 10; 611 - const previewY = offsetY + 10; 612 - const previewSize = Math.floor(cellSize * 0.7); 693 + const previewSize = Math.max(8, Math.floor(cellSize * 0.6)); 694 + const previewPadding = 4; 695 + const previewWidth = 4 * previewSize + previewPadding * 2; 696 + const previewHeight = 2 * previewSize + previewPadding * 2 + 12; 697 + const previewX = offsetX + gridWidth - previewWidth; 698 + const previewY = offsetY; 613 699 614 - ctx.fillStyle = textColor; 615 - ctx.font = `bold ${Math.max(10, cellSize * 0.5)}px monospace`; 616 - ctx.textAlign = 'left'; 617 - ctx.fillText('NEXT', previewX, previewY); 700 + // Semi-transparent background 701 + ctx.fillStyle = isAccentMode ? 'rgba(255, 255, 255, 0.3)' : (isDarkMode ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.5)'); 702 + ctx.fillRect(previewX, previewY, previewWidth, previewHeight); 703 + 704 + // Only show "NEXT" label if there's enough space 705 + if (cellSize >= 12) { 706 + ctx.fillStyle = textColor; 707 + ctx.font = `bold ${Math.max(8, previewSize * 0.8)}px monospace`; 708 + ctx.textAlign = 'left'; 709 + ctx.fillText('NEXT', previewX + previewPadding, previewY + 10); 710 + } 618 711 619 712 const nextColor = colors[nextPiece]; 713 + const pieceOffsetY = cellSize >= 12 ? 14 : 4; 620 714 for (let row = 0; row < nextTetromino.shape.length; row++) { 621 715 for (let col = 0; col < nextTetromino.shape[row].length; col++) { 622 716 if (nextTetromino.shape[row][col]) { 623 717 drawBlock( 624 - previewX + col * previewSize, 625 - previewY + 10 + row * previewSize, 718 + previewX + previewPadding + col * previewSize, 719 + previewY + pieceOffsetY + row * previewSize, 626 720 nextColor, 627 721 previewSize 628 722 ); ··· 631 725 } 632 726 } 633 727 634 - // Draw score 635 - ctx.fillStyle = textColor; 636 - ctx.font = `bold ${Math.max(10, cellSize * 0.6)}px monospace`; 637 - ctx.textAlign = 'left'; 728 + // Draw score (top-left corner overlay) 729 + if (gameState === 'playing' || gameState === 'gameover' || isClearingAnimation) { 730 + const scoreSize = Math.max(10, cellSize * 0.6); 731 + const scorePadding = 4; 732 + 733 + // Semi-transparent background 734 + ctx.fillStyle = isAccentMode ? 'rgba(255, 255, 255, 0.3)' : (isDarkMode ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.5)'); 735 + const scoreBoxWidth = Math.max(40, scoreSize * 4); 736 + const scoreBoxHeight = cellSize >= 12 ? scoreSize * 2.5 : scoreSize * 1.5; 737 + ctx.fillRect(offsetX, offsetY, scoreBoxWidth, scoreBoxHeight); 638 738 639 - const infoX = offsetX + gridWidth + 10; 640 - const infoY = offsetY + 80; 739 + ctx.fillStyle = textColor; 740 + ctx.font = `bold ${scoreSize}px monospace`; 741 + ctx.textAlign = 'left'; 742 + ctx.fillText(`${score}`, offsetX + scorePadding, offsetY + scoreSize); 641 743 642 - if (gameState === 'playing' || gameState === 'gameover' || isClearingAnimation) { 643 - ctx.fillText(`${score}`, infoX, infoY); 644 - ctx.font = `${Math.max(8, cellSize * 0.4)}px monospace`; 645 - ctx.fillText(`LN ${lines}`, infoX, infoY + 15); 646 - ctx.fillText(`LV ${level}`, infoX, infoY + 28); 744 + if (cellSize >= 12) { 745 + ctx.font = `${Math.max(8, scoreSize * 0.6)}px monospace`; 746 + ctx.fillText(`L${level}`, offsetX + scorePadding, offsetY + scoreSize * 1.8); 747 + } 647 748 } 648 749 649 750 // Draw game over 650 751 if (gameState === 'gameover') { 651 752 ctx.fillStyle = textColor; 652 - ctx.font = `bold ${Math.max(12, cellSize * 0.8)}px monospace`; 753 + const gameOverSize = Math.max(12, Math.min(cellSize * 0.8, 24)); 754 + ctx.font = `bold ${gameOverSize}px monospace`; 653 755 ctx.textAlign = 'center'; 654 - ctx.fillText('GAME', offsetX + gridWidth / 2, offsetY + gridHeight / 2 - 10); 655 - ctx.fillText('OVER', offsetX + gridWidth / 2, offsetY + gridHeight / 2 + 15); 756 + ctx.fillText('GAME', offsetX + gridWidth / 2, offsetY + gridHeight / 2 - gameOverSize * 0.3); 757 + ctx.fillText('OVER', offsetX + gridWidth / 2, offsetY + gridHeight / 2 + gameOverSize * 0.9); 656 758 } 657 759 658 760 // Draw start screen with controls 659 761 if (gameState === 'idle') { 660 762 ctx.fillStyle = textColor; 661 - ctx.font = `bold ${Math.max(10, cellSize * 0.6)}px monospace`; 662 763 ctx.textAlign = 'center'; 663 764 664 - const centerX = canvasWidth / 2; 665 - const centerY = canvasHeight / 2; 765 + const centerX = offsetX + gridWidth / 2; 766 + const centerY = offsetY + gridHeight / 2; 767 + const titleSize = Math.max(12, Math.min(cellSize * 0.8, 20)); 666 768 667 - ctx.fillText('TETRIS', centerX, centerY - 40); 769 + ctx.font = `bold ${titleSize}px monospace`; 770 + ctx.fillText('TETRIS', centerX, centerY - titleSize); 668 771 669 - ctx.font = `${Math.max(8, cellSize * 0.4)}px monospace`; 670 - ctx.fillText('\u2190\u2192 or A/D: Move', centerX, centerY - 10); 671 - ctx.fillText('\u2191 or W: Rotate', centerX, centerY + 8); 672 - ctx.fillText('\u2193 or S: Soft drop', centerX, centerY + 26); 673 - ctx.fillText('SPACE: Hard drop', centerX, centerY + 44); 772 + // Only show controls on larger cards 773 + if (cellSize >= 15) { 774 + const controlSize = Math.max(8, cellSize * 0.35); 775 + ctx.font = `${controlSize}px monospace`; 776 + ctx.fillText('\u2190\u2192 Move', centerX, centerY + controlSize * 0.5); 777 + ctx.fillText('\u2191 Rotate \u2193 Down', centerX, centerY + controlSize * 2); 778 + ctx.fillText('SPACE Drop', centerX, centerY + controlSize * 3.5); 779 + } 674 780 } 675 781 676 782 animationId = requestAnimationFrame(gameLoop); ··· 721 827 {#if gameState === 'idle' || gameState === 'gameover'} 722 828 <button 723 829 onclick={startGame} 724 - class="absolute bottom-4 left-1/2 -translate-x-1/2 transform cursor-pointer rounded-lg border-2 border-base-800 bg-base-100/50 px-4 py-2 font-mono text-xs font-bold text-base-800 transition-colors hover:bg-base-800 hover:text-base-100 dark:border-base-200 dark:bg-base-800/50 dark:text-base-200 dark:hover:bg-base-200 dark:hover:text-base-800 accent:border-base-900 accent:bg-white/30 accent:text-base-900 accent:hover:bg-base-900 accent:hover:text-white" 830 + class="absolute bottom-4 left-1/2 -translate-x-1/2 transform cursor-pointer rounded-lg border-2 border-base-800 bg-base-100/80 px-4 py-2 font-mono text-xs font-bold text-base-800 transition-colors 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" 725 831 > 726 832 {gameState === 'gameover' ? 'PLAY AGAIN' : 'START'} 727 833 </button>