your personal website on atproto - mirror
blento.app
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>