your personal website on atproto - mirror
0
fork

Configure Feed

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

Merge branch 'new-game-card' into main

authored by

David Terterian and committed by
GitHub
cc2a472b 2b956fde

+774
+25
src/lib/cards/TetrisCard/SidebarItemTetrisCard.svelte
··· 1 + <script lang="ts"> 2 + import { Button } from '@foxui/core'; 3 + 4 + let { onclick }: { onclick: () => void } = $props(); 5 + </script> 6 + 7 + <Button {onclick} variant="ghost" class="w-full justify-start"> 8 + <svg 9 + xmlns="http://www.w3.org/2000/svg" 10 + viewBox="0 0 24 24" 11 + fill="currentColor" 12 + class="text-accent-600 dark:text-accent-400" 13 + > 14 + <!-- Tetris blocks --> 15 + <rect x="4" y="4" width="5" height="5" /> 16 + <rect x="9" y="4" width="5" height="5" /> 17 + <rect x="9" y="9" width="5" height="5" /> 18 + <rect x="14" y="9" width="5" height="5" /> 19 + <rect x="4" y="14" width="5" height="5" /> 20 + <rect x="9" y="14" width="5" height="5" /> 21 + <rect x="14" y="14" width="5" height="5" /> 22 + </svg> 23 + 24 + Tetris</Button 25 + >
+729
src/lib/cards/TetrisCard/TetrisCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '../types'; 3 + import { onMount, onDestroy } from 'svelte'; 4 + 5 + let { item }: ContentComponentProps = $props(); 6 + 7 + let canvas: HTMLCanvasElement; 8 + let container: HTMLDivElement; 9 + let ctx: CanvasRenderingContext2D | null = null; 10 + let animationId: number; 11 + let audioCtx: AudioContext | null = null; 12 + 13 + // Theme detection 14 + let isAccentMode = $state(false); 15 + let isDarkMode = $state(false); 16 + 17 + // Game state 18 + let gameState = $state<'idle' | 'playing' | 'gameover'>('idle'); 19 + let score = $state(0); 20 + let lines = $state(0); 21 + let level = $state(1); 22 + 23 + // Line clear animation 24 + let clearingLines: number[] = []; 25 + let clearAnimationProgress = 0; 26 + let isClearingAnimation = false; 27 + const CLEAR_ANIMATION_DURATION = 200; // ms 28 + let clearAnimationStart = 0; 29 + 30 + // Grid settings 31 + const COLS = 10; 32 + const ROWS = 20; 33 + let cellSize = 20; 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 63 + } 64 + }; 65 + 66 + function getColorScheme() { 67 + if (isAccentMode) return COLOR_SCHEMES.accent; 68 + if (isDarkMode) return COLOR_SCHEMES.dark; 69 + return COLOR_SCHEMES.light; 70 + } 71 + 72 + // Tetromino definitions (each has rotations) 73 + const TETROMINOES = { 74 + I: { shape: [[1, 1, 1, 1]] }, 75 + O: { shape: [[1, 1], [1, 1]] }, 76 + T: { shape: [[0, 1, 0], [1, 1, 1]] }, 77 + S: { shape: [[0, 1, 1], [1, 1, 0]] }, 78 + Z: { shape: [[1, 1, 0], [0, 1, 1]] }, 79 + J: { shape: [[1, 0, 0], [1, 1, 1]] }, 80 + L: { shape: [[0, 0, 1], [1, 1, 1]] } 81 + }; 82 + 83 + type TetrominoType = keyof typeof TETROMINOES; 84 + 85 + // Game grid - stores tetromino type for color lookup 86 + let grid: (TetrominoType | null)[][] = []; 87 + 88 + // Current piece 89 + let currentPiece: { 90 + type: TetrominoType; 91 + shape: number[][]; 92 + x: number; 93 + y: number; 94 + } | null = null; 95 + 96 + let nextPiece: TetrominoType | null = null; 97 + 98 + function detectTheme() { 99 + if (!container) return; 100 + // Check if we're inside an accent card (has .accent class ancestor) 101 + isAccentMode = container.closest('.accent') !== null; 102 + // Check dark mode 103 + isDarkMode = container.closest('.dark') !== null && !container.closest('.light'); 104 + } 105 + 106 + // Timing 107 + let lastDrop = 0; 108 + let dropInterval = 1000; 109 + 110 + // Audio functions 111 + function initAudio() { 112 + if (!audioCtx) { 113 + audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)(); 114 + } 115 + } 116 + 117 + function playTone(frequency: number, duration: number, type: OscillatorType = 'square') { 118 + if (!audioCtx) return; 119 + try { 120 + const oscillator = audioCtx.createOscillator(); 121 + const gainNode = audioCtx.createGain(); 122 + 123 + oscillator.connect(gainNode); 124 + gainNode.connect(audioCtx.destination); 125 + 126 + oscillator.frequency.value = frequency; 127 + oscillator.type = type; 128 + 129 + gainNode.gain.setValueAtTime(0.1, audioCtx.currentTime); 130 + gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + duration); 131 + 132 + oscillator.start(audioCtx.currentTime); 133 + oscillator.stop(audioCtx.currentTime + duration); 134 + } catch (e) { 135 + // Audio not supported 136 + } 137 + } 138 + 139 + function playMove() { 140 + playTone(150, 0.05); 141 + } 142 + 143 + function playRotate() { 144 + playTone(300, 0.08); 145 + } 146 + 147 + function playDrop() { 148 + playTone(100, 0.15); 149 + } 150 + 151 + function playLineClear(count: number) { 152 + const baseFreq = 400; 153 + for (let i = 0; i < count; i++) { 154 + setTimeout(() => playTone(baseFreq + i * 100, 0.15, 'sine'), i * 80); 155 + } 156 + } 157 + 158 + function playGameOver() { 159 + playTone(200, 0.3, 'sawtooth'); 160 + setTimeout(() => playTone(150, 0.3, 'sawtooth'), 200); 161 + setTimeout(() => playTone(100, 0.5, 'sawtooth'), 400); 162 + } 163 + 164 + // Initialize grid 165 + function initGrid() { 166 + grid = Array(ROWS) 167 + .fill(null) 168 + .map(() => Array(COLS).fill(null)); 169 + } 170 + 171 + // Get random tetromino 172 + function randomTetromino(): TetrominoType { 173 + const types = Object.keys(TETROMINOES) as TetrominoType[]; 174 + return types[Math.floor(Math.random() * types.length)]; 175 + } 176 + 177 + // Spawn new piece 178 + function spawnPiece() { 179 + const type = nextPiece || randomTetromino(); 180 + nextPiece = randomTetromino(); 181 + 182 + const tetromino = TETROMINOES[type]; 183 + currentPiece = { 184 + type, 185 + shape: tetromino.shape.map((row) => [...row]), 186 + x: Math.floor((COLS - tetromino.shape[0].length) / 2), 187 + y: 0 188 + }; 189 + 190 + // Check if spawn position is blocked (game over) 191 + if (!isValidPosition(currentPiece.shape, currentPiece.x, currentPiece.y)) { 192 + gameState = 'gameover'; 193 + playGameOver(); 194 + } 195 + } 196 + 197 + // Check if position is valid 198 + function isValidPosition(shape: number[][], x: number, y: number): boolean { 199 + for (let row = 0; row < shape.length; row++) { 200 + for (let col = 0; col < shape[row].length; col++) { 201 + if (shape[row][col]) { 202 + const newX = x + col; 203 + const newY = y + row; 204 + 205 + if (newX < 0 || newX >= COLS || newY >= ROWS) { 206 + return false; 207 + } 208 + 209 + if (newY >= 0 && grid[newY][newX]) { 210 + return false; 211 + } 212 + } 213 + } 214 + } 215 + return true; 216 + } 217 + 218 + // Rotate piece 219 + function rotatePiece() { 220 + if (!currentPiece) return; 221 + 222 + const rotated: number[][] = []; 223 + const rows = currentPiece.shape.length; 224 + const cols = currentPiece.shape[0].length; 225 + 226 + for (let col = 0; col < cols; col++) { 227 + rotated[col] = []; 228 + for (let row = rows - 1; row >= 0; row--) { 229 + rotated[col][rows - 1 - row] = currentPiece.shape[row][col]; 230 + } 231 + } 232 + 233 + // Try rotation, with wall kicks 234 + const kicks = [0, -1, 1, -2, 2]; 235 + for (const kick of kicks) { 236 + if (isValidPosition(rotated, currentPiece.x + kick, currentPiece.y)) { 237 + currentPiece.shape = rotated; 238 + currentPiece.x += kick; 239 + playRotate(); 240 + return; 241 + } 242 + } 243 + } 244 + 245 + // Move piece 246 + function movePiece(dx: number, dy: number): boolean { 247 + if (!currentPiece) return false; 248 + 249 + if (isValidPosition(currentPiece.shape, currentPiece.x + dx, currentPiece.y + dy)) { 250 + currentPiece.x += dx; 251 + currentPiece.y += dy; 252 + if (dx !== 0) playMove(); 253 + return true; 254 + } 255 + return false; 256 + } 257 + 258 + // Lock piece to grid 259 + function lockPiece() { 260 + if (!currentPiece) return; 261 + 262 + for (let row = 0; row < currentPiece.shape.length; row++) { 263 + for (let col = 0; col < currentPiece.shape[row].length; col++) { 264 + if (currentPiece.shape[row][col]) { 265 + const gridY = currentPiece.y + row; 266 + const gridX = currentPiece.x + col; 267 + if (gridY >= 0) { 268 + grid[gridY][gridX] = currentPiece.type; 269 + } 270 + } 271 + } 272 + } 273 + 274 + playDrop(); 275 + checkAndClearLines(); 276 + } 277 + 278 + // Check for completed lines and start animation 279 + function checkAndClearLines() { 280 + // Find all completed lines 281 + clearingLines = []; 282 + for (let row = 0; row < ROWS; row++) { 283 + if (grid[row].every((cell) => cell !== null)) { 284 + clearingLines.push(row); 285 + } 286 + } 287 + 288 + if (clearingLines.length > 0) { 289 + // Start swoosh animation 290 + isClearingAnimation = true; 291 + clearAnimationStart = performance.now(); 292 + clearAnimationProgress = 0; 293 + playLineClear(clearingLines.length); 294 + } else { 295 + spawnPiece(); 296 + } 297 + } 298 + 299 + // Actually remove the cleared lines (called after animation) 300 + function finishLineClear() { 301 + const cleared = clearingLines.length; 302 + 303 + // Remove lines from bottom to top to maintain indices 304 + for (const row of [...clearingLines].sort((a, b) => b - a)) { 305 + grid.splice(row, 1); 306 + grid.unshift(Array(COLS).fill(null)); 307 + } 308 + 309 + lines += cleared; 310 + // Scoring: 100, 300, 500, 800 for 1, 2, 3, 4 lines 311 + const points = [0, 100, 300, 500, 800]; 312 + score += points[Math.min(cleared, 4)] * level; 313 + 314 + // Level up every 10 lines 315 + const newLevel = Math.floor(lines / 10) + 1; 316 + if (newLevel > level) { 317 + level = newLevel; 318 + dropInterval = Math.max(100, 1000 - (level - 1) * 100); 319 + } 320 + 321 + clearingLines = []; 322 + isClearingAnimation = false; 323 + spawnPiece(); 324 + } 325 + 326 + // Hard drop 327 + function hardDrop() { 328 + if (!currentPiece) return; 329 + 330 + while (movePiece(0, 1)) { 331 + score += 2; 332 + } 333 + lockPiece(); 334 + } 335 + 336 + // Handle keyboard input 337 + function handleKeyDown(e: KeyboardEvent) { 338 + if (gameState !== 'playing' || isClearingAnimation) { 339 + if (e.code === 'Space' || e.code === 'Enter') { 340 + e.preventDefault(); 341 + if (gameState !== 'playing') startGame(); 342 + } 343 + return; 344 + } 345 + 346 + switch (e.code) { 347 + case 'ArrowLeft': 348 + case 'KeyA': 349 + e.preventDefault(); 350 + movePiece(-1, 0); 351 + break; 352 + case 'ArrowRight': 353 + case 'KeyD': 354 + e.preventDefault(); 355 + movePiece(1, 0); 356 + break; 357 + case 'ArrowDown': 358 + case 'KeyS': 359 + e.preventDefault(); 360 + if (movePiece(0, 1)) score += 1; 361 + break; 362 + case 'ArrowUp': 363 + case 'KeyW': 364 + e.preventDefault(); 365 + rotatePiece(); 366 + break; 367 + case 'Space': 368 + e.preventDefault(); 369 + hardDrop(); 370 + break; 371 + } 372 + } 373 + 374 + // Touch controls 375 + let touchStartX = 0; 376 + let touchStartY = 0; 377 + 378 + function handleTouchStart(e: TouchEvent) { 379 + if (gameState !== 'playing' || isClearingAnimation) { 380 + if (gameState !== 'playing') startGame(); 381 + return; 382 + } 383 + touchStartX = e.touches[0].clientX; 384 + touchStartY = e.touches[0].clientY; 385 + } 386 + 387 + function handleTouchEnd(e: TouchEvent) { 388 + if (gameState !== 'playing' || isClearingAnimation) return; 389 + 390 + const touchEndX = e.changedTouches[0].clientX; 391 + const touchEndY = e.changedTouches[0].clientY; 392 + const dx = touchEndX - touchStartX; 393 + const dy = touchEndY - touchStartY; 394 + const threshold = 30; 395 + 396 + if (Math.abs(dx) < threshold && Math.abs(dy) < threshold) { 397 + // Tap - rotate 398 + rotatePiece(); 399 + } else if (Math.abs(dx) > Math.abs(dy)) { 400 + // Horizontal swipe 401 + if (dx > threshold) movePiece(1, 0); 402 + else if (dx < -threshold) movePiece(-1, 0); 403 + } else { 404 + // Vertical swipe 405 + if (dy > threshold * 2) hardDrop(); 406 + else if (dy > threshold) movePiece(0, 1); 407 + } 408 + } 409 + 410 + function startGame() { 411 + detectTheme(); 412 + initAudio(); 413 + initGrid(); 414 + score = 0; 415 + lines = 0; 416 + level = 1; 417 + dropInterval = 1000; 418 + clearingLines = []; 419 + isClearingAnimation = false; 420 + nextPiece = randomTetromino(); 421 + spawnPiece(); 422 + gameState = 'playing'; 423 + lastDrop = performance.now(); 424 + } 425 + 426 + function calculateSize() { 427 + if (!canvas) return; 428 + const container = canvas.parentElement; 429 + if (!container) return; 430 + 431 + const maxWidth = container.clientWidth - 80; // Reserve space for next piece 432 + const maxHeight = container.clientHeight - 40; 433 + 434 + cellSize = Math.floor(Math.min(maxWidth / COLS, maxHeight / ROWS)); 435 + cellSize = Math.max(10, Math.min(30, cellSize)); 436 + 437 + canvas.width = container.clientWidth; 438 + canvas.height = container.clientHeight; 439 + } 440 + 441 + function drawBlock(x: number, y: number, color: string, size: number = cellSize) { 442 + if (!ctx) return; 443 + 444 + ctx.fillStyle = color; 445 + ctx.fillRect(x, y, size - 1, size - 1); 446 + 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); 451 + 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); 456 + } 457 + 458 + function gameLoop(timestamp: number) { 459 + if (!ctx || !canvas) { 460 + animationId = requestAnimationFrame(gameLoop); 461 + return; 462 + } 463 + 464 + const colors = getColorScheme(); 465 + const textColor = isAccentMode ? '#1a1a1a' : (isDarkMode ? '#f5f5f5' : '#1a1a1a'); 466 + const gridBgColor = isAccentMode ? 'rgba(0, 0, 0, 0.15)' : (isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'); 467 + const gridLineColor = isAccentMode ? 'rgba(0, 0, 0, 0.1)' : (isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.08)'); 468 + 469 + const canvasWidth = canvas.width; 470 + const canvasHeight = canvas.height; 471 + 472 + // Clear canvas 473 + ctx.clearRect(0, 0, canvasWidth, canvasHeight); 474 + 475 + // Calculate grid position (centered with space for next piece on right) 476 + const gridWidth = COLS * cellSize; 477 + const gridHeight = ROWS * cellSize; 478 + const offsetX = Math.floor((canvasWidth - gridWidth - 60) / 2); 479 + const offsetY = Math.floor((canvasHeight - gridHeight) / 2); 480 + 481 + // Draw grid background 482 + ctx.fillStyle = gridBgColor; 483 + ctx.fillRect(offsetX, offsetY, gridWidth, gridHeight); 484 + 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(); 499 + } 500 + 501 + // Handle line clear animation 502 + if (isClearingAnimation) { 503 + const elapsed = timestamp - clearAnimationStart; 504 + clearAnimationProgress = Math.min(1, elapsed / CLEAR_ANIMATION_DURATION); 505 + 506 + if (clearAnimationProgress >= 1) { 507 + finishLineClear(); 508 + } 509 + } 510 + 511 + // Draw locked pieces 512 + for (let row = 0; row < ROWS; row++) { 513 + for (let col = 0; col < COLS; col++) { 514 + const cellType = grid[row][col]; 515 + if (cellType) { 516 + const cellColor = colors[cellType]; 517 + 518 + // Check if this row is being cleared 519 + 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; 528 + ctx.fillRect(offsetX + col * cellSize, offsetY + row * cellSize, cellSize - 1, cellSize - 1); 529 + } else { 530 + drawBlock(offsetX + col * cellSize, offsetY + row * cellSize, cellColor); 531 + } 532 + } else { 533 + drawBlock(offsetX + col * cellSize, offsetY + row * cellSize, cellColor); 534 + } 535 + } 536 + } 537 + } 538 + 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 + // Game logic (pause during animation) 564 + if (gameState === 'playing' && currentPiece && !isClearingAnimation) { 565 + // Auto drop 566 + if (timestamp - lastDrop > dropInterval) { 567 + if (!movePiece(0, 1)) { 568 + lockPiece(); 569 + } 570 + lastDrop = timestamp; 571 + } 572 + 573 + // Draw ghost piece 574 + const pieceColor = colors[currentPiece.type]; 575 + let ghostY = currentPiece.y; 576 + while (isValidPosition(currentPiece.shape, currentPiece.x, ghostY + 1)) { 577 + ghostY++; 578 + } 579 + ctx.globalAlpha = 0.3; 580 + for (let row = 0; row < currentPiece.shape.length; row++) { 581 + for (let col = 0; col < currentPiece.shape[row].length; col++) { 582 + if (currentPiece.shape[row][col]) { 583 + drawBlock( 584 + offsetX + (currentPiece.x + col) * cellSize, 585 + offsetY + (ghostY + row) * cellSize, 586 + pieceColor 587 + ); 588 + } 589 + } 590 + } 591 + ctx.globalAlpha = 1; 592 + 593 + // Draw current piece 594 + for (let row = 0; row < currentPiece.shape.length; row++) { 595 + for (let col = 0; col < currentPiece.shape[row].length; col++) { 596 + if (currentPiece.shape[row][col]) { 597 + drawBlock( 598 + offsetX + (currentPiece.x + col) * cellSize, 599 + offsetY + (currentPiece.y + row) * cellSize, 600 + pieceColor 601 + ); 602 + } 603 + } 604 + } 605 + } 606 + 607 + // Draw next piece preview 608 + if (nextPiece && (gameState === 'playing' || isClearingAnimation)) { 609 + const nextTetromino = TETROMINOES[nextPiece]; 610 + const previewX = offsetX + gridWidth + 10; 611 + const previewY = offsetY + 10; 612 + const previewSize = Math.floor(cellSize * 0.7); 613 + 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); 618 + 619 + const nextColor = colors[nextPiece]; 620 + for (let row = 0; row < nextTetromino.shape.length; row++) { 621 + for (let col = 0; col < nextTetromino.shape[row].length; col++) { 622 + if (nextTetromino.shape[row][col]) { 623 + drawBlock( 624 + previewX + col * previewSize, 625 + previewY + 10 + row * previewSize, 626 + nextColor, 627 + previewSize 628 + ); 629 + } 630 + } 631 + } 632 + } 633 + 634 + // Draw score 635 + ctx.fillStyle = textColor; 636 + ctx.font = `bold ${Math.max(10, cellSize * 0.6)}px monospace`; 637 + ctx.textAlign = 'left'; 638 + 639 + const infoX = offsetX + gridWidth + 10; 640 + const infoY = offsetY + 80; 641 + 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); 647 + } 648 + 649 + // Draw game over 650 + if (gameState === 'gameover') { 651 + ctx.fillStyle = textColor; 652 + ctx.font = `bold ${Math.max(12, cellSize * 0.8)}px monospace`; 653 + 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); 656 + } 657 + 658 + // Draw start screen with controls 659 + if (gameState === 'idle') { 660 + ctx.fillStyle = textColor; 661 + ctx.font = `bold ${Math.max(10, cellSize * 0.6)}px monospace`; 662 + ctx.textAlign = 'center'; 663 + 664 + const centerX = canvasWidth / 2; 665 + const centerY = canvasHeight / 2; 666 + 667 + ctx.fillText('TETRIS', centerX, centerY - 40); 668 + 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); 674 + } 675 + 676 + animationId = requestAnimationFrame(gameLoop); 677 + } 678 + 679 + function resizeCanvas() { 680 + calculateSize(); 681 + } 682 + 683 + onMount(() => { 684 + ctx = canvas.getContext('2d'); 685 + detectTheme(); 686 + calculateSize(); 687 + initGrid(); 688 + 689 + const resizeObserver = new ResizeObserver(() => { 690 + resizeCanvas(); 691 + }); 692 + resizeObserver.observe(canvas.parentElement!); 693 + 694 + animationId = requestAnimationFrame(gameLoop); 695 + 696 + return () => { 697 + resizeObserver.disconnect(); 698 + }; 699 + }); 700 + 701 + onDestroy(() => { 702 + if (animationId) { 703 + cancelAnimationFrame(animationId); 704 + } 705 + if (audioCtx) { 706 + audioCtx.close(); 707 + } 708 + }); 709 + </script> 710 + 711 + <svelte:window onkeydown={handleKeyDown} /> 712 + 713 + <div bind:this={container} class="relative h-full w-full overflow-hidden"> 714 + <canvas 715 + bind:this={canvas} 716 + class="h-full w-full" 717 + ontouchstart={handleTouchStart} 718 + ontouchend={handleTouchEnd} 719 + ></canvas> 720 + 721 + {#if gameState === 'idle' || gameState === 'gameover'} 722 + <button 723 + 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" 725 + > 726 + {gameState === 'gameover' ? 'PLAY AGAIN' : 'START'} 727 + </button> 728 + {/if} 729 + </div>
+18
src/lib/cards/TetrisCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import TetrisCard from './TetrisCard.svelte'; 3 + import SidebarItemTetrisCard from './SidebarItemTetrisCard.svelte'; 4 + 5 + export const TetrisCardDefinition = { 6 + type: 'tetris', 7 + contentComponent: TetrisCard, 8 + sidebarComponent: SidebarItemTetrisCard, 9 + allowSetColor: true, 10 + defaultColor: 'accent', 11 + createNew: (card) => { 12 + card.w = 4; 13 + card.h = 6; 14 + card.mobileW = 6; 15 + card.mobileH = 8; 16 + card.cardData = {}; 17 + } 18 + } as CardDefinition & { type: 'tetris' };
+2
src/lib/cards/index.ts
··· 5 5 import { BlueskyPostCardDefinition } from './BlueskyPostCard'; 6 6 import { DinoGameCardDefinition } from './GameCards/DinoGameCard'; 7 7 import { EmbedCardDefinition } from './EmbedCard'; 8 + import { TetrisCardDefinition } from './TetrisCard'; 8 9 import { ImageCardDefinition } from './ImageCard'; 9 10 import { LinkCardDefinition } from './LinkCard'; 10 11 import { LivestreamCardDefitition, LivestreamEmbedCardDefitition } from './LivestreamCard'; ··· 37 38 DinoGameCardDefinition, 38 39 BlueskyProfileCardDefinition, 39 40 GithubProfileCardDefitition 41 + TetrisCardDefinition 40 42 ] as const; 41 43 42 44 export const CardDefinitionsByType = AllCardDefinitions.reduce(