your personal website on atproto - mirror
0
fork

Configure Feed

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

Merge pull request #9 from unbedenklich/new-game-card

New game card: Tetris

authored by

Florian and committed by
GitHub
e45e8007 f509f08a

+1016 -1
+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 + >
src/lib/cards/TetrisCard/Tetris8Bit.mp3

This is a binary file and will not be displayed.

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