diff --git a/.gitattributes b/.gitattributes
index a6344aac8c09253b3b630fb776ae94478aa0275b..e8b893ca2dee53e6e947a838a3eeb3fda4a6dc61 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -33,3 +33,14 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
+public/assets/music/level-1/track.mp3 filter=lfs diff=lfs merge=lfs -text
+public/assets/music/level-10/track.mp3 filter=lfs diff=lfs merge=lfs -text
+public/assets/music/level-2/track.mp3 filter=lfs diff=lfs merge=lfs -text
+public/assets/music/level-3/track.mp3 filter=lfs diff=lfs merge=lfs -text
+public/assets/music/level-4/track.mp3 filter=lfs diff=lfs merge=lfs -text
+public/assets/music/level-5/track.mp3 filter=lfs diff=lfs merge=lfs -text
+public/assets/music/level-6/track.mp3 filter=lfs diff=lfs merge=lfs -text
+public/assets/music/level-7/track.mp3 filter=lfs diff=lfs merge=lfs -text
+public/assets/music/level-8/track.mp3 filter=lfs diff=lfs merge=lfs -text
+public/assets/music/level-9/track.mp3 filter=lfs diff=lfs merge=lfs -text
+public/assets/tv.png filter=lfs diff=lfs merge=lfs -text
diff --git a/public/assets/backdrops/level-1/backdrop.png b/public/assets/backdrops/level-1/backdrop.png
new file mode 100644
index 0000000000000000000000000000000000000000..4c38694200ce724d0b77d425face71d2b8489faf
Binary files /dev/null and b/public/assets/backdrops/level-1/backdrop.png differ
diff --git a/public/assets/backdrops/level-10/backdrop.png b/public/assets/backdrops/level-10/backdrop.png
new file mode 100644
index 0000000000000000000000000000000000000000..4eedae7ba3fd52c22d861b1301a7b88b5d7ac4bc
Binary files /dev/null and b/public/assets/backdrops/level-10/backdrop.png differ
diff --git a/public/assets/backdrops/level-10/title.png b/public/assets/backdrops/level-10/title.png
new file mode 100644
index 0000000000000000000000000000000000000000..e690b149d04b05ffffe32e21c2cf85f58b200b80
Binary files /dev/null and b/public/assets/backdrops/level-10/title.png differ
diff --git a/public/assets/backdrops/level-2/backdrop.png b/public/assets/backdrops/level-2/backdrop.png
new file mode 100644
index 0000000000000000000000000000000000000000..71462d9f9e9cfd738f6cea6ed3e70d3d29770b06
Binary files /dev/null and b/public/assets/backdrops/level-2/backdrop.png differ
diff --git a/public/assets/backdrops/level-3/backdrop.png b/public/assets/backdrops/level-3/backdrop.png
new file mode 100644
index 0000000000000000000000000000000000000000..12ea6760f70dd9825e2fc3b0562c89b530209cdf
Binary files /dev/null and b/public/assets/backdrops/level-3/backdrop.png differ
diff --git a/public/assets/backdrops/level-4/backdrop.png b/public/assets/backdrops/level-4/backdrop.png
new file mode 100644
index 0000000000000000000000000000000000000000..b01afab81c3eb08b71c6121a218303400f743500
Binary files /dev/null and b/public/assets/backdrops/level-4/backdrop.png differ
diff --git a/public/assets/backdrops/level-5/backdrop.png b/public/assets/backdrops/level-5/backdrop.png
new file mode 100644
index 0000000000000000000000000000000000000000..3aadf4641cf24d62e499008633982f671150e9d7
Binary files /dev/null and b/public/assets/backdrops/level-5/backdrop.png differ
diff --git a/public/assets/backdrops/level-6/backdrop.png b/public/assets/backdrops/level-6/backdrop.png
new file mode 100644
index 0000000000000000000000000000000000000000..6cbc8f0a27591564c741127187295a97df0e23c8
Binary files /dev/null and b/public/assets/backdrops/level-6/backdrop.png differ
diff --git a/public/assets/backdrops/level-7/backdrop.png b/public/assets/backdrops/level-7/backdrop.png
new file mode 100644
index 0000000000000000000000000000000000000000..998a8869445ceb06a6f42624241ef7b2e77d54f1
Binary files /dev/null and b/public/assets/backdrops/level-7/backdrop.png differ
diff --git a/public/assets/backdrops/level-8/backdrop.png b/public/assets/backdrops/level-8/backdrop.png
new file mode 100644
index 0000000000000000000000000000000000000000..9929170f203dfbe3f9efc43a36df34a125f2a204
Binary files /dev/null and b/public/assets/backdrops/level-8/backdrop.png differ
diff --git a/public/assets/backdrops/level-9/backdrop.png b/public/assets/backdrops/level-9/backdrop.png
new file mode 100644
index 0000000000000000000000000000000000000000..59dec60009ccf42797f8e0dd3c7026e91bb61281
Binary files /dev/null and b/public/assets/backdrops/level-9/backdrop.png differ
diff --git a/public/assets/blocks-sprite.png b/public/assets/blocks-sprite.png
new file mode 100644
index 0000000000000000000000000000000000000000..99919d8277bda72a1b88b22a841acb24f861ea8e
Binary files /dev/null and b/public/assets/blocks-sprite.png differ
diff --git a/public/assets/blocks.png b/public/assets/blocks.png
new file mode 100644
index 0000000000000000000000000000000000000000..62207017bb80f6d9cd748149c600f7da7ecf5787
Binary files /dev/null and b/public/assets/blocks.png differ
diff --git a/public/assets/crush-alt.png b/public/assets/crush-alt.png
new file mode 100644
index 0000000000000000000000000000000000000000..e6477d84ea1424b1142056f4367d16a5a77c71a9
Binary files /dev/null and b/public/assets/crush-alt.png differ
diff --git a/public/assets/crush.png b/public/assets/crush.png
new file mode 100644
index 0000000000000000000000000000000000000000..e37b86cbaa23936344ed03c8fa22f7bfa10194e8
Binary files /dev/null and b/public/assets/crush.png differ
diff --git a/public/assets/fonts/RetroArcade.ttf b/public/assets/fonts/RetroArcade.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..ddeb6a049484b55c94835118d580930984717246
Binary files /dev/null and b/public/assets/fonts/RetroArcade.ttf differ
diff --git a/public/assets/fonts/font.otf b/public/assets/fonts/font.otf
new file mode 100644
index 0000000000000000000000000000000000000000..833196e0c786229e9791e02ce1f15e5b4074ffb6
Binary files /dev/null and b/public/assets/fonts/font.otf differ
diff --git a/public/assets/fonts/font.png b/public/assets/fonts/font.png
new file mode 100644
index 0000000000000000000000000000000000000000..e8050fea38dc609b549eb85ce1dc3b33fe4f4fd5
Binary files /dev/null and b/public/assets/fonts/font.png differ
diff --git a/public/assets/fonts/thick_8x8.png b/public/assets/fonts/thick_8x8.png
new file mode 100644
index 0000000000000000000000000000000000000000..8f5fd09f131aa03d1a151e8908f4553ea7a9d6b1
Binary files /dev/null and b/public/assets/fonts/thick_8x8.png differ
diff --git a/public/assets/fonts/thick_8x8.xml b/public/assets/fonts/thick_8x8.xml
new file mode 100644
index 0000000000000000000000000000000000000000..78f80c84572ae17bd8252c55eae881d2c1ca6c9b
--- /dev/null
+++ b/public/assets/fonts/thick_8x8.xml
@@ -0,0 +1,276 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/assets/game-over.png b/public/assets/game-over.png
new file mode 100644
index 0000000000000000000000000000000000000000..00a37ff2c9c1a049582e44f66ddcc4cfb1f6b1e8
Binary files /dev/null and b/public/assets/game-over.png differ
diff --git a/public/assets/music/level-1/track.mp3 b/public/assets/music/level-1/track.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..2d1f3667c23c38b1afe931c3f2d1cfda8a1b8561
--- /dev/null
+++ b/public/assets/music/level-1/track.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2d7c6678c69eb655135dead8900255f92addcd43829573c8a5b51250c8362a66
+size 3095196
diff --git a/public/assets/music/level-10/track.mp3 b/public/assets/music/level-10/track.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..5048ab1db37944350674269579677f1a0c3ab1e9
--- /dev/null
+++ b/public/assets/music/level-10/track.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bfe26f1a1561409094c22f299d56c0bc669081397a62e6b33b5ee8f0942ed2cf
+size 3379882
diff --git a/public/assets/music/level-2/track.mp3 b/public/assets/music/level-2/track.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..967f52db3df7065c95300cb5b146e324e4bf10a3
--- /dev/null
+++ b/public/assets/music/level-2/track.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0a963babd307c9dc6f4130cdf1a46a325a016e20c855f26b02147584fbd6db00
+size 3552437
diff --git a/public/assets/music/level-3/track.mp3 b/public/assets/music/level-3/track.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..7f4aa3d02dec54055bf7617897bcc03c17e692fa
--- /dev/null
+++ b/public/assets/music/level-3/track.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:da2f3c48ba7204dbe4c8204e7ab975f0790096a02c44e8c9c251be2196043377
+size 2951867
diff --git a/public/assets/music/level-4/track.mp3 b/public/assets/music/level-4/track.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..1a4566850e29544308a1c7e6dc98bef1385e2ad6
--- /dev/null
+++ b/public/assets/music/level-4/track.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c5fe5a6007c042b9173e1b1a4c8cc34b642ad18ba7f0332d1c7ec7ee48717bc3
+size 2826888
diff --git a/public/assets/music/level-5/track.mp3 b/public/assets/music/level-5/track.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..15138e074228d724b054febf49965941787f16d4
--- /dev/null
+++ b/public/assets/music/level-5/track.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6d80b9049d1ec638813ae6332427ddda79843255b1fbc78fd8281791d8c1940f
+size 2922595
diff --git a/public/assets/music/level-6/track.mp3 b/public/assets/music/level-6/track.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..a2a06da905fc298dcf8590d7a0745396fa6d4c39
--- /dev/null
+++ b/public/assets/music/level-6/track.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dc4a2932dc8dcee1d2d69f122bf35bd3fe1a10fe3f271e30053f4ff0a36f4141
+size 2504409
diff --git a/public/assets/music/level-7/track.mp3 b/public/assets/music/level-7/track.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..2bd1fb2d322eb34258f0bdc78c580da6866a9560
--- /dev/null
+++ b/public/assets/music/level-7/track.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2ec0c8c525cfd451c194eded97ca17ded86be87a457e016732739c50376ebccd
+size 2747379
diff --git a/public/assets/music/level-8/track.mp3 b/public/assets/music/level-8/track.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..d108d01053d4a927332f911d245c07f55f39ed98
--- /dev/null
+++ b/public/assets/music/level-8/track.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4a7fdf42c917607f67c0ecc24869c59d7c19f3d87c33232b01b639b306593c11
+size 3645189
diff --git a/public/assets/music/level-9/track.mp3 b/public/assets/music/level-9/track.mp3
new file mode 100644
index 0000000000000000000000000000000000000000..7b9ca30b3a51d60fe617e2b6672fb3e5bd7df3c6
--- /dev/null
+++ b/public/assets/music/level-9/track.mp3
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e35ae825ee92e94e986e8e1cb63f96b42b3a9e58a87fa2b02e9e430cae54d30a
+size 3038032
diff --git a/public/assets/title.png b/public/assets/title.png
new file mode 100644
index 0000000000000000000000000000000000000000..b3124251d38cd16e28da5895cb577dd5a10dc097
Binary files /dev/null and b/public/assets/title.png differ
diff --git a/public/assets/tv.png b/public/assets/tv.png
new file mode 100644
index 0000000000000000000000000000000000000000..9efcc14f2099f0d6aebea0d3d19b213bd8e69083
--- /dev/null
+++ b/public/assets/tv.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3708b47de0b5b463d8817900ea7d406f316aaedf29f4ab26d3451f02ae27d6e2
+size 170622
diff --git a/public/template-generator.html b/public/template-generator.html
new file mode 100644
index 0000000000000000000000000000000000000000..40402d6c086f8386dc656c4949d9a7e73dbba2aa
--- /dev/null
+++ b/public/template-generator.html
@@ -0,0 +1,184 @@
+
+
+
+
+
+ Backdrop Template Generator
+
+
+
+ Tetris Backdrop Template Generator
+
+
+
Instructions:
+
+ - Click "Download Template" to save the template image
+ - Open the template in your image editor (Photoshop, GIMP, etc.)
+ - The BLACK area with MAGENTA border is the play area (80×160 pixels)
+ - The play area will be BLACK in-game, so design around it
+ - Create your artwork on layers below the template
+ - Delete the template layer when done
+ - Export as 256×224 PNG
+ - Save to
public/assets/backdrops/level-X/backdrop.png
+
+
+
+
+
+
+
+
+
+
Specifications:
+
+ - Total Size: 256 × 224 pixels
+ - Play Area Position: X: 88, Y: 32
+ - Play Area Size: 80 × 160 pixels
+ - Grid: 10 blocks wide × 20 blocks tall
+ - Block Size: 8 × 8 pixels
+
+
+
+
+
+
+
diff --git a/scripts/generate-placeholders.js b/scripts/generate-placeholders.js
new file mode 100644
index 0000000000000000000000000000000000000000..b17f0bfab18cbf62ea81d667c604c28fa4bb56bb
--- /dev/null
+++ b/scripts/generate-placeholders.js
@@ -0,0 +1,188 @@
+/**
+ * Generate placeholder assets for all 10 levels
+ * Run with: node scripts/generate-placeholders.js
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const projectRoot = path.join(__dirname, '..');
+
+// Create directories
+const assetsDir = path.join(projectRoot, 'public', 'assets');
+const backdropsDir = path.join(assetsDir, 'backdrops');
+const musicDir = path.join(assetsDir, 'music');
+
+// Ensure directories exist
+fs.mkdirSync(backdropsDir, { recursive: true });
+fs.mkdirSync(musicDir, { recursive: true });
+
+// Color palettes for each level
+const levelPalettes = [
+ ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8', '#F7DC6F', '#BB8FCE'], // Level 1 - Warm
+ ['#3498DB', '#E74C3C', '#2ECC71', '#F39C12', '#9B59B6', '#1ABC9C', '#E67E22'], // Level 2 - Primary
+ ['#FF1744', '#00E676', '#2979FF', '#FFEA00', '#D500F9', '#00E5FF', '#FF9100'], // Level 3 - Neon
+ ['#8E44AD', '#16A085', '#C0392B', '#F39C12', '#2980B9', '#27AE60', '#D35400'], // Level 4 - Deep
+ ['#FF6F61', '#6B5B95', '#88B04B', '#F7CAC9', '#92A8D1', '#955251', '#B565A7'], // Level 5 - Pastel
+ ['#34495E', '#E74C3C', '#ECF0F1', '#3498DB', '#2ECC71', '#F39C12', '#9B59B6'], // Level 6 - Modern
+ ['#FF4500', '#FFD700', '#00CED1', '#FF1493', '#00FF00', '#1E90FF', '#FF69B4'], // Level 7 - Vibrant
+ ['#8B4513', '#DAA520', '#CD853F', '#D2691E', '#B8860B', '#A0522D', '#DEB887'], // Level 8 - Earth
+ ['#000080', '#4B0082', '#8B008B', '#9400D3', '#9932CC', '#BA55D3', '#DA70D6'], // Level 9 - Purple
+ ['#FF0000', '#FF4500', '#FF6347', '#FF7F50', '#FFA500', '#FFD700', '#FFFF00'] // Level 10 - Fire
+];
+
+// Generate backdrop images using Canvas API (Node.js)
+async function generateBackdrop(level, palette) {
+ const { createCanvas } = await import('canvas');
+ const canvas = createCanvas(256, 224);
+ const ctx = canvas.getContext('2d');
+
+ // Create gradient background
+ const gradient = ctx.createLinearGradient(0, 0, 256, 224);
+ gradient.addColorStop(0, palette[0]);
+ gradient.addColorStop(0.5, palette[1]);
+ gradient.addColorStop(1, palette[2]);
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, 256, 224);
+
+ // Add some retro patterns
+ ctx.fillStyle = palette[3];
+ ctx.globalAlpha = 0.1;
+ for (let i = 0; i < 50; i++) {
+ const x = Math.random() * 256;
+ const y = Math.random() * 224;
+ const size = Math.random() * 20 + 5;
+ ctx.fillRect(x, y, size, size);
+ }
+
+ // Add level indicator in corner
+ ctx.globalAlpha = 0.3;
+ ctx.fillStyle = palette[4];
+ ctx.font = 'bold 48px monospace';
+ ctx.fillText(`L${level}`, 10, 50);
+
+ // PLAY AREA INDICATOR - Make it very clear
+ // Play area is at x:88, y:32, width:80, height:160
+
+ // Dark background for play area
+ ctx.globalAlpha = 0.4;
+ ctx.fillStyle = '#000000';
+ ctx.fillRect(88, 32, 80, 160);
+
+ // Bright border around play area
+ ctx.globalAlpha = 1.0;
+ ctx.strokeStyle = '#FFFF00'; // Bright yellow
+ ctx.lineWidth = 3;
+ ctx.strokeRect(88, 32, 80, 160);
+
+ // Add corner markers for extra visibility
+ ctx.fillStyle = '#FFFF00';
+ const markerSize = 8;
+ // Top-left corner
+ ctx.fillRect(88 - markerSize, 32 - markerSize, markerSize, markerSize);
+ // Top-right corner
+ ctx.fillRect(88 + 80, 32 - markerSize, markerSize, markerSize);
+ // Bottom-left corner
+ ctx.fillRect(88 - markerSize, 32 + 160, markerSize, markerSize);
+ // Bottom-right corner
+ ctx.fillRect(88 + 80, 32 + 160, markerSize, markerSize);
+
+ // Add text labels
+ ctx.globalAlpha = 0.8;
+ ctx.fillStyle = '#FFFFFF';
+ ctx.font = 'bold 10px monospace';
+ ctx.fillText('PLAY AREA', 92, 28);
+ ctx.fillText('80x160px', 95, 200);
+ ctx.fillText(`(${88},${32})`, 92, 44);
+
+ // Add grid lines inside play area to show it clearly
+ ctx.globalAlpha = 0.15;
+ ctx.strokeStyle = '#FFFFFF';
+ ctx.lineWidth = 1;
+ // Vertical lines every 8 pixels (block size)
+ for (let x = 88; x <= 168; x += 8) {
+ ctx.beginPath();
+ ctx.moveTo(x, 32);
+ ctx.lineTo(x, 192);
+ ctx.stroke();
+ }
+ // Horizontal lines every 8 pixels
+ for (let y = 32; y <= 192; y += 8) {
+ ctx.beginPath();
+ ctx.moveTo(88, y);
+ ctx.lineTo(168, y);
+ ctx.stroke();
+ }
+
+ return canvas.toBuffer('image/png');
+}
+
+// Generate silent MP3 placeholder (we'll create a minimal valid MP3)
+function generateSilentMP3() {
+ // Minimal valid MP3 header for 1 second of silence
+ // This is a simplified approach - in production you'd use a proper audio library
+ const mp3Header = Buffer.from([
+ 0xFF, 0xFB, 0x90, 0x00, // MP3 sync word and header
+ ]);
+
+ // Create a small buffer with MP3 frame headers
+ const frames = 38; // Approximately 1 second at 44.1kHz
+ const frameSize = 417;
+ const buffer = Buffer.alloc(frames * frameSize);
+
+ for (let i = 0; i < frames; i++) {
+ mp3Header.copy(buffer, i * frameSize);
+ }
+
+ return buffer;
+}
+
+// Main generation function
+async function generateAllAssets() {
+ console.log('Generating placeholder assets...\n');
+
+ // Check if canvas is available
+ let canvasAvailable = false;
+ try {
+ await import('canvas');
+ canvasAvailable = true;
+ } catch (e) {
+ console.log('⚠️ Canvas module not available. Install with: npm install canvas');
+ console.log(' Skipping backdrop generation. You can add your own PNG files.\n');
+ }
+
+ for (let level = 1; level <= 10; level++) {
+ const levelBackdropDir = path.join(backdropsDir, `level-${level}`);
+ const levelMusicDir = path.join(musicDir, `level-${level}`);
+
+ fs.mkdirSync(levelBackdropDir, { recursive: true });
+ fs.mkdirSync(levelMusicDir, { recursive: true });
+
+ // Generate backdrop
+ if (canvasAvailable) {
+ const backdropPath = path.join(levelBackdropDir, 'backdrop.png');
+ const backdropBuffer = await generateBackdrop(level, levelPalettes[level - 1]);
+ fs.writeFileSync(backdropPath, backdropBuffer);
+ console.log(`✓ Generated backdrop for level ${level}`);
+ } else {
+ console.log(`⊘ Skipped backdrop for level ${level} (canvas not available)`);
+ }
+
+ // Generate silent MP3
+ const musicPath = path.join(levelMusicDir, 'track.mp3');
+ const mp3Buffer = generateSilentMP3();
+ fs.writeFileSync(musicPath, mp3Buffer);
+ console.log(`✓ Generated music placeholder for level ${level}`);
+ }
+
+ console.log('\n✅ All placeholder assets generated!');
+ console.log('\nYou can now replace these files with your own:');
+ console.log(' - Backdrops: public/assets/backdrops/level-X/backdrop.png (256x224 pixels)');
+ console.log(' - Music: public/assets/music/level-X/track.mp3');
+}
+
+generateAllAssets().catch(console.error);
+
diff --git a/scripts/generate-template.js b/scripts/generate-template.js
new file mode 100644
index 0000000000000000000000000000000000000000..dd03ce71d8e2a2cf41079f216cc425e9987e6dbf
--- /dev/null
+++ b/scripts/generate-template.js
@@ -0,0 +1,170 @@
+/**
+ * Generate a simple template image showing the play area
+ * Run with: node scripts/generate-template.js
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const projectRoot = path.join(__dirname, '..');
+
+async function generateTemplate() {
+ try {
+ const { createCanvas } = await import('canvas');
+ const canvas = createCanvas(256, 224);
+ const ctx = canvas.getContext('2d');
+
+ console.log('Canvas created:', canvas.width, 'x', canvas.height);
+
+ // Set global alpha to fully opaque
+ ctx.globalAlpha = 1.0;
+
+ // Background - light gray (with explicit RGB)
+ ctx.fillStyle = 'rgb(204, 204, 204)';
+ ctx.fillRect(0, 0, 256, 224);
+
+ // Play area - bright magenta/pink (easy to see and select in image editors)
+ ctx.fillStyle = 'rgb(255, 0, 255)';
+ ctx.fillRect(88, 32, 80, 160);
+
+ // Add grid lines in play area
+ ctx.strokeStyle = '#CC00CC';
+ ctx.lineWidth = 1;
+ // Vertical lines every 8 pixels
+ for (let x = 88; x <= 168; x += 8) {
+ ctx.beginPath();
+ ctx.moveTo(x, 32);
+ ctx.lineTo(x, 192);
+ ctx.stroke();
+ }
+ // Horizontal lines every 8 pixels
+ for (let y = 32; y <= 192; y += 8) {
+ ctx.beginPath();
+ ctx.moveTo(88, y);
+ ctx.lineTo(168, y);
+ ctx.stroke();
+ }
+
+ // Border around play area - black
+ ctx.strokeStyle = '#000000';
+ ctx.lineWidth = 2;
+ ctx.strokeRect(88, 32, 80, 160);
+
+ // Add labels
+ ctx.fillStyle = '#000000';
+ ctx.font = 'bold 12px sans-serif';
+
+ // Title
+ ctx.fillText('TETRIS BACKDROP TEMPLATE', 40, 15);
+ ctx.font = '10px sans-serif';
+ ctx.fillText('256 x 224 pixels', 85, 215);
+
+ // Play area label
+ ctx.fillStyle = '#FFFFFF';
+ ctx.font = 'bold 10px sans-serif';
+ ctx.fillText('PLAY AREA', 100, 110);
+ ctx.fillText('80 x 160', 105, 122);
+
+ // Coordinates
+ ctx.fillStyle = '#000000';
+ ctx.font = '8px sans-serif';
+ ctx.fillText('(88,32)', 90, 28);
+ ctx.fillText('(168,192)', 130, 200);
+
+ // UI area labels
+ ctx.fillStyle = '#666666';
+ ctx.font = '8px sans-serif';
+ ctx.fillText('SCORE/LEVEL', 10, 20);
+ ctx.fillText('AREA', 10, 30);
+
+ ctx.fillText('NEXT PIECE', 185, 20);
+ ctx.fillText('AREA', 185, 30);
+
+ // Corner markers
+ ctx.fillStyle = '#FF0000';
+ const markerSize = 6;
+ // Top-left
+ ctx.fillRect(88 - markerSize/2, 32 - markerSize/2, markerSize, markerSize);
+ // Top-right
+ ctx.fillRect(168 - markerSize/2, 32 - markerSize/2, markerSize, markerSize);
+ // Bottom-left
+ ctx.fillRect(88 - markerSize/2, 192 - markerSize/2, markerSize, markerSize);
+ // Bottom-right
+ ctx.fillRect(168 - markerSize/2, 192 - markerSize/2, markerSize, markerSize);
+
+ // Add dimension arrows
+ ctx.strokeStyle = '#000000';
+ ctx.lineWidth = 1;
+ ctx.fillStyle = '#000000';
+
+ // Width arrow (top)
+ ctx.beginPath();
+ ctx.moveTo(88, 25);
+ ctx.lineTo(168, 25);
+ ctx.stroke();
+ // Arrow heads
+ ctx.beginPath();
+ ctx.moveTo(88, 25);
+ ctx.lineTo(92, 23);
+ ctx.lineTo(92, 27);
+ ctx.closePath();
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(168, 25);
+ ctx.lineTo(164, 23);
+ ctx.lineTo(164, 27);
+ ctx.closePath();
+ ctx.fill();
+ ctx.fillText('80px', 120, 23);
+
+ // Height arrow (left)
+ ctx.beginPath();
+ ctx.moveTo(82, 32);
+ ctx.lineTo(82, 192);
+ ctx.stroke();
+ // Arrow heads
+ ctx.beginPath();
+ ctx.moveTo(82, 32);
+ ctx.lineTo(80, 36);
+ ctx.lineTo(84, 36);
+ ctx.closePath();
+ ctx.fill();
+ ctx.beginPath();
+ ctx.moveTo(82, 192);
+ ctx.lineTo(80, 188);
+ ctx.lineTo(84, 188);
+ ctx.closePath();
+ ctx.fill();
+
+ ctx.save();
+ ctx.translate(75, 112);
+ ctx.rotate(-Math.PI / 2);
+ ctx.fillText('160px', -15, 0);
+ ctx.restore();
+
+ // Save the template
+ const templatePath = path.join(projectRoot, 'BACKDROP-TEMPLATE.png');
+ const buffer = canvas.toBuffer('image/png');
+ console.log('Buffer size:', buffer.length, 'bytes');
+ fs.writeFileSync(templatePath, buffer);
+
+ console.log('✅ Template created: BACKDROP-TEMPLATE.png');
+ console.log('');
+ console.log('The MAGENTA/PINK area (#FF00FF) is the play area.');
+ console.log('Use this template in your image editor:');
+ console.log(' 1. Open BACKDROP-TEMPLATE.png');
+ console.log(' 2. Create your artwork on layers below the template');
+ console.log(' 3. Delete or hide the template layer');
+ console.log(' 4. Export as 256x224 PNG');
+ console.log(' 5. Save to public/assets/backdrops/level-X/backdrop.png');
+ } catch (error) {
+ console.error('Error generating template:', error);
+ throw error;
+ }
+}
+
+generateTemplate().catch(console.error);
+
diff --git a/scripts/test-canvas.js b/scripts/test-canvas.js
new file mode 100644
index 0000000000000000000000000000000000000000..771b988692375f7121377c42c85e94baca6c98c5
--- /dev/null
+++ b/scripts/test-canvas.js
@@ -0,0 +1,24 @@
+import { createCanvas } from 'canvas';
+import fs from 'fs';
+
+const canvas = createCanvas(256, 224);
+const ctx = canvas.getContext('2d');
+
+// Fill with white
+ctx.fillStyle = '#FFFFFF';
+ctx.fillRect(0, 0, 256, 224);
+
+// Draw a big red rectangle
+ctx.fillStyle = '#FF0000';
+ctx.fillRect(50, 50, 100, 100);
+
+// Draw a blue circle
+ctx.fillStyle = '#0000FF';
+ctx.beginPath();
+ctx.arc(200, 100, 30, 0, Math.PI * 2);
+ctx.fill();
+
+const buffer = canvas.toBuffer('image/png');
+fs.writeFileSync('test-canvas.png', buffer);
+console.log('Test image created:', buffer.length, 'bytes');
+
diff --git a/src/config.js b/src/config.js
new file mode 100644
index 0000000000000000000000000000000000000000..a7196045fe57ec3e554f579b125eb2e41abca331
--- /dev/null
+++ b/src/config.js
@@ -0,0 +1,13 @@
+/**
+ * Game configuration
+ * Set different values for development vs production
+ */
+
+// Check if we're in production (deployed) or development (local)
+const isProduction = import.meta.env.PROD;
+
+export const CONFIG = {
+ // Number of lines needed to advance to the next level
+ LINES_PER_LEVEL: isProduction ? 15 : 2,
+};
+
diff --git a/src/constants.js b/src/constants.js
new file mode 100644
index 0000000000000000000000000000000000000000..76886db9073bf49f4d6c12506d03138d184a4bad
--- /dev/null
+++ b/src/constants.js
@@ -0,0 +1,188 @@
+// Game dimensions
+export const GAME_WIDTH = 256;
+export const GAME_HEIGHT = 224;
+export const BORDER_OFFSET = 21; // Offset for 21px borders on each side
+
+// Bitmap font character set
+export const BITMAP_FONT_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+
+// Grid configuration
+export const BLOCK_SIZE = 8; // 8x8 pixel blocks for retro feel
+export const GRID_WIDTH = 10;
+export const GRID_HEIGHT = 20;
+export const PLAY_AREA_WIDTH = GRID_WIDTH * BLOCK_SIZE; // 80 pixels
+export const PLAY_AREA_HEIGHT = GRID_HEIGHT * BLOCK_SIZE; // 160 pixels
+export const PLAY_AREA_X = 80 + BORDER_OFFSET; // Centered with room for UI + border offset
+export const PLAY_AREA_Y = 28; // Room for header (moved up 20 pixels from original 48)
+
+// Level progression
+export const LINES_PER_LEVEL = 2; // Temporary for testing
+export const MAX_LEVEL = 10;
+
+// Tetromino shapes (NES Tetris style)
+export const TETROMINOES = {
+ I: {
+ shape: [[1, 1, 1, 1]],
+ color: 0, // Will be replaced with palette color
+ name: 'I'
+ },
+ O: {
+ shape: [
+ [1, 1],
+ [1, 1]
+ ],
+ color: 1,
+ name: 'O'
+ },
+ T: {
+ shape: [
+ [0, 1, 0],
+ [1, 1, 1]
+ ],
+ color: 2,
+ name: 'T'
+ },
+ S: {
+ shape: [
+ [0, 1, 1],
+ [1, 1, 0]
+ ],
+ color: 3,
+ name: 'S'
+ },
+ Z: {
+ shape: [
+ [1, 1, 0],
+ [0, 1, 1]
+ ],
+ color: 4,
+ name: 'Z'
+ },
+ J: {
+ shape: [
+ [1, 0, 0],
+ [1, 1, 1]
+ ],
+ color: 5,
+ name: 'J'
+ },
+ L: {
+ shape: [
+ [0, 0, 1],
+ [1, 1, 1]
+ ],
+ color: 6,
+ name: 'L'
+ }
+};
+
+// Advanced mode tetrominoes (includes all classic + new shapes)
+export const ADVANCED_TETROMINOES = {
+ ...TETROMINOES,
+ // Small L (3 blocks) - top row
+ SMALL_L: {
+ shape: [
+ [1, 1],
+ [1, 0]
+ ],
+ color: 0,
+ name: 'SMALL_L'
+ },
+ // Small L mirrored (3 blocks) - top row
+ SMALL_L_MIRROR: {
+ shape: [
+ [1, 1],
+ [0, 1]
+ ],
+ color: 1,
+ name: 'SMALL_L_MIRROR'
+ },
+ // U shape (5 blocks) - middle left
+ U: {
+ shape: [
+ [1, 0, 1],
+ [1, 1, 1]
+ ],
+ color: 2,
+ name: 'U'
+ },
+ // S shape (4 blocks) - middle right
+ S_ADVANCED: {
+ shape: [
+ [0, 1, 1],
+ [1, 1, 0]
+ ],
+ color: 3,
+ name: 'S_ADVANCED'
+ },
+ // 2x2 with extra piece (5 blocks) - bottom left
+ BLOCK_PLUS: {
+ shape: [
+ [1, 1, 1],
+ [1, 1, 0]
+ ],
+ color: 4,
+ name: 'BLOCK_PLUS'
+ },
+ // T with top extended (5 blocks) - bottom right
+ T_EXTENDED: {
+ shape: [
+ [0, 1, 0],
+ [0, 1, 1],
+ [0, 1, 0]
+ ],
+ color: 5,
+ name: 'T_EXTENDED'
+ }
+};
+
+// NES Tetris scoring
+export const SCORES = {
+ SINGLE: 40,
+ DOUBLE: 100,
+ TRIPLE: 300,
+ TETRIS: 1200,
+ SOFT_DROP: 1,
+ PERFECT_CLEAR: 10000 // Bonus for clearing entire field
+};
+
+// Game speeds (frames per drop) - Higher = slower
+// Smooth progression, gets hard at level 6+
+export const LEVEL_SPEEDS = [
+ 90, // Level 1 - relaxed start
+ 80, // Level 2
+ 70, // Level 3
+ 60, // Level 4
+ 50, // Level 5
+ 35, // Level 6 - starts getting hard
+ 25, // Level 7
+ 18, // Level 8
+ 12, // Level 9
+ 6 // Level 10 - very challenging
+];
+
+// Level titles for intro screens
+export const LEVEL_TITLES = {
+ 1: 'Low Earth Orbit',
+ 2: 'Moon Surface',
+ 3: 'Mars Horizon',
+ 4: 'Asteroid Belt',
+ 5: 'Jupiter Storms',
+ 6: 'Deep Space Station',
+ 7: 'Nebula Expanse',
+ 8: 'Binary Star System',
+ 9: 'Alien Megastructure',
+ 10: 'Edge of the Universe'
+};
+
+// UI Layout - single panel to the right of play area
+export const UI = {
+ // Panel positioned right of play area with 8px gap between frames
+ PANEL_X: PLAY_AREA_X + PLAY_AREA_WIDTH + 16,
+ PANEL_Y: PLAY_AREA_Y,
+ PANEL_WIDTH: 64,
+ PANEL_HEIGHT: PLAY_AREA_HEIGHT,
+ PADDING: 6,
+ LINE_HEIGHT: 20
+};
+
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000000000000000000000000000000000000..6810734680f09e3bf036a992fc8b5040bd777f80
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,40 @@
+import Phaser from 'phaser';
+import GameScene from './scenes/GameScene.js';
+import PreloadScene from './scenes/PreloadScene.js';
+import ModeSelectScene from './scenes/ModeSelectScene.js';
+import { createShaderOverlay } from './shaderOverlay.js';
+
+const config = {
+ type: Phaser.WEBGL,
+ width: 298, // 256 + 21px borders on each side (42px total)
+ height: 224,
+ parent: 'game-container',
+ backgroundColor: '#0a0a0a', // Dark grey for the borders
+ pixelArt: true,
+ roundPixels: true,
+ antialias: false,
+ fps: {
+ target: 60,
+ forceSetTimeOut: false
+ },
+ render: {
+ antialias: false,
+ pixelArt: true,
+ roundPixels: true,
+ antialiasGL: false
+ },
+ scale: {
+ mode: Phaser.Scale.NONE,
+ width: 298,
+ height: 224
+ },
+ scene: [PreloadScene, ModeSelectScene, GameScene]
+};
+
+const game = new Phaser.Game(config);
+
+// Apply shader overlay to the scaled canvas
+setTimeout(() => {
+ createShaderOverlay(game.canvas);
+}, 100);
+
diff --git a/src/pipelines/TrinitronPipeline.js b/src/pipelines/TrinitronPipeline.js
new file mode 100644
index 0000000000000000000000000000000000000000..81564d8f8314bf3fab175365369a4130c5efaba7
--- /dev/null
+++ b/src/pipelines/TrinitronPipeline.js
@@ -0,0 +1,190 @@
+import Phaser from 'phaser';
+
+const fragShader = `
+precision mediump float;
+
+uniform sampler2D uMainSampler;
+uniform vec2 resolution;
+uniform float time;
+
+varying vec2 outTexCoord;
+
+#define PI 3.14159265359
+
+vec4 permute(vec4 t) {
+ return mod(((t * 34.0) + 1.0) * t, 289.0);
+}
+
+float noise3d(vec3 p) {
+ vec3 a = floor(p);
+ vec3 d = p - a;
+ d = d * d * (3.0 - 2.0 * d);
+
+ vec4 b = a.xxyy + vec4(0.0, 1.0, 0.0, 1.0);
+ vec4 k1 = permute(b.xyxy);
+ vec4 k2 = permute(k1.xyxy + b.zzww);
+
+ vec4 c = k2 + a.zzzz;
+ vec4 k3 = permute(c);
+ vec4 k4 = permute(c + 1.0);
+
+ vec4 o1 = fract(k3 * (1.0 / 41.0));
+ vec4 o2 = fract(k4 * (1.0 / 41.0));
+
+ vec4 o3_interp_z = o2 * d.z + o1 * (1.0 - d.z);
+ vec2 o4_interp_xy = o3_interp_z.yw * d.x + o3_interp_z.xz * (1.0 - d.x);
+
+ return o4_interp_xy.y * d.y + o4_interp_xy.x * (1.0 - d.y);
+}
+
+void main() {
+ float brightness = 2.5;
+ float red_balance = 1.0;
+ float green_balance = 0.85;
+ float blue_balance = 1.0;
+
+ float phosphorWidth = 2.50;
+ float phosphorHeight = 4.50;
+ float internalHorizontalGap = 1.0;
+ float columnGap = 0.2;
+ float verticalCellGap = 0.2;
+ float phosphorPower = 0.9;
+
+ float cell_noise_variation_amount = 0.025;
+ float cell_noise_scale_xy = 240.0;
+ float cell_noise_speed = 24.0;
+ float curvature_amount = 0.0;
+
+ vec2 fragCoord = gl_FragCoord.xy;
+ vec2 uv = outTexCoord;
+ vec2 centered_uv_output = uv - 0.5;
+ float r = length(centered_uv_output);
+ float distort_factor = 1.0 + curvature_amount * r * r;
+ vec2 centered_uv_source = centered_uv_output * distort_factor;
+ vec2 source_uv = centered_uv_source + 0.5;
+ vec2 fragCoord_warped = source_uv * resolution;
+
+ bool is_on_original_flat_screen = source_uv.x >= 0.0 && source_uv.x <= 1.0 &&
+ source_uv.y >= 0.0 && source_uv.y <= 1.0;
+
+ if (!is_on_original_flat_screen) {
+ gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
+ return;
+ }
+
+ float fullCellWidth = 3.0 * phosphorWidth + 3.0 * internalHorizontalGap + columnGap;
+ float fullRowHeight = phosphorHeight + verticalCellGap;
+
+ float logical_cell_index_x = floor(fragCoord_warped.x / fullCellWidth);
+ float shift_y_offset = 0.0;
+
+ if (mod(logical_cell_index_x, 2.0) != 0.0) {
+ shift_y_offset = fullRowHeight / 2.0;
+ }
+
+ float effective_y_warped = fragCoord_warped.y + shift_y_offset;
+ float logical_row_index = floor(effective_y_warped / fullRowHeight);
+
+ float uv_cell_x = mod(fragCoord_warped.x, fullCellWidth);
+ if (uv_cell_x < 0.0) {
+ uv_cell_x += fullCellWidth;
+ }
+
+ float uv_row_y = mod(effective_y_warped, fullRowHeight);
+ if (uv_row_y < 0.0) {
+ uv_row_y += fullRowHeight;
+ }
+
+ vec3 video_color = texture2D(uMainSampler, source_uv).rgb;
+ video_color.r *= red_balance;
+ video_color.g *= green_balance;
+ video_color.b *= blue_balance;
+
+ vec3 final_color = vec3(0.0);
+ bool in_column_gap = uv_cell_x >= (3.0 * phosphorWidth + 3.0 * internalHorizontalGap);
+ bool in_vertical_gap = uv_row_y >= phosphorHeight;
+
+ if (!in_column_gap && !in_vertical_gap) {
+ float uv_cell_x_within_block = uv_cell_x;
+ vec3 phosphor_base_color = vec3(0.0);
+ float video_component_intensity = 0.0;
+ float current_phosphor_startX_in_block = -1.0;
+ float current_x_tracker = 0.0;
+
+ if (uv_cell_x_within_block >= current_x_tracker && uv_cell_x_within_block < current_x_tracker + phosphorWidth) {
+ phosphor_base_color = vec3(1.0, 0.0, 0.0);
+ video_component_intensity = video_color.r;
+ current_phosphor_startX_in_block = current_x_tracker;
+ }
+ current_x_tracker += phosphorWidth + internalHorizontalGap;
+
+ if (uv_cell_x_within_block >= current_x_tracker && uv_cell_x_within_block < current_x_tracker + phosphorWidth) {
+ phosphor_base_color = vec3(0.0, 1.0, 0.0);
+ video_component_intensity = video_color.g;
+ current_phosphor_startX_in_block = current_x_tracker;
+ }
+ current_x_tracker += phosphorWidth + internalHorizontalGap;
+
+ if (uv_cell_x_within_block >= current_x_tracker && uv_cell_x_within_block < current_x_tracker + phosphorWidth) {
+ phosphor_base_color = vec3(0.0, 0.0, 1.0);
+ video_component_intensity = video_color.b;
+ current_phosphor_startX_in_block = current_x_tracker;
+ }
+
+ if (current_phosphor_startX_in_block >= 0.0) {
+ float x_in_phosphor = (uv_cell_x_within_block - current_phosphor_startX_in_block) / phosphorWidth;
+ float horizontal_intensity_factor = pow(sin(x_in_phosphor * PI), phosphorPower);
+ float y_in_phosphor_band = uv_row_y / phosphorHeight;
+ float vertical_intensity_factor = (phosphorHeight > 0.0) ? pow(sin(y_in_phosphor_band * PI), phosphorPower) : 1.0;
+ float total_intensity_factor = horizontal_intensity_factor * vertical_intensity_factor;
+ final_color = phosphor_base_color * video_component_intensity * total_intensity_factor;
+ }
+ }
+
+ vec3 noise_pos = vec3(logical_cell_index_x * cell_noise_scale_xy,
+ logical_row_index * cell_noise_scale_xy,
+ time * cell_noise_speed);
+
+ vec3 cell_noise_rgb;
+ cell_noise_rgb.r = noise3d(noise_pos);
+ cell_noise_rgb.g = noise3d(noise_pos + vec3(19.0, 0.0, 0.0));
+ cell_noise_rgb.b = noise3d(noise_pos + vec3(0.0, 13.0, 0.0));
+ cell_noise_rgb = cell_noise_rgb * 2.0 - 1.0;
+ final_color += cell_noise_rgb * cell_noise_variation_amount;
+
+ final_color *= brightness;
+ float edge_darken_strength = 0.1;
+ float vignette_factor = 1.0 - dot(centered_uv_output, centered_uv_output) * edge_darken_strength * 2.0;
+ vignette_factor = clamp(vignette_factor, 0.0, 1.0);
+ final_color *= vignette_factor;
+
+ final_color = clamp(final_color, 0.0, 1.0);
+ gl_FragColor = vec4(final_color, 1.0);
+}
+`;
+
+export default class TrinitronPipeline extends Phaser.Renderer.WebGL.Pipelines.PostFXPipeline {
+ constructor(game) {
+ super({
+ name: 'TrinitronPipeline',
+ game: game,
+ renderTarget: true,
+ fragShader: fragShader,
+ uniforms: [
+ 'uMainSampler',
+ 'resolution',
+ 'time'
+ ]
+ });
+ }
+
+ onPreRender() {
+ // Use the actual canvas display size (after scaling), not the game resolution
+ const canvas = this.game.canvas;
+ const displayWidth = canvas.width;
+ const displayHeight = canvas.height;
+ this.set2f('resolution', displayWidth, displayHeight);
+ this.set1f('time', this.game.loop.time / 1000);
+ }
+}
+
diff --git a/src/scenes/GameScene.js b/src/scenes/GameScene.js
new file mode 100644
index 0000000000000000000000000000000000000000..b2e580dd07e666f897795410f73bc6f8cbb2d23c
--- /dev/null
+++ b/src/scenes/GameScene.js
@@ -0,0 +1,983 @@
+import Phaser from 'phaser';
+import {
+ GAME_WIDTH, GAME_HEIGHT, BLOCK_SIZE, GRID_WIDTH, GRID_HEIGHT,
+ PLAY_AREA_X, PLAY_AREA_Y, PLAY_AREA_WIDTH, PLAY_AREA_HEIGHT,
+ TETROMINOES, ADVANCED_TETROMINOES, SCORES, LEVEL_SPEEDS, MAX_LEVEL, UI, BORDER_OFFSET, LEVEL_TITLES
+} from '../constants.js';
+import ColorExtractor from '../utils/ColorExtractor.js';
+import SpriteBlockRenderer from '../utils/SpriteBlockRenderer.js';
+import SoundGenerator from '../utils/SoundGenerator.js';
+import { CONFIG } from '../config.js';
+
+export default class GameScene extends Phaser.Scene {
+ constructor() { super({ key: 'GameScene' }); }
+
+ create() {
+ // Get game mode from registry (set by ModeSelectScene)
+ this.gameMode = this.registry.get('gameMode') || 'classic';
+ this.tetrominoes = this.gameMode === 'advanced' ? ADVANCED_TETROMINOES : TETROMINOES;
+
+ // CRITICAL: Ensure canvas has focus and can receive keyboard events
+ this.game.canvas.setAttribute('tabindex', '1');
+ this.game.canvas.focus();
+ this.game.canvas.style.outline = 'none';
+
+ // Visual indicator for focus loss
+ this.focusWarning = null;
+
+ // Re-focus on any click
+ this.game.canvas.addEventListener('click', () => {
+ this.game.canvas.focus();
+ if (this.focusWarning) {
+ this.focusWarning.destroy();
+ this.focusWarning = null;
+ }
+ });
+
+ // Monitor focus state
+ this.game.canvas.addEventListener('blur', () => {
+ console.log('Canvas lost focus!');
+ if (!this.focusWarning) {
+ this.focusWarning = this.add.text(GAME_WIDTH / 2 + BORDER_OFFSET, 10, 'CLICK TO FOCUS', {
+ fontSize: '8px',
+ color: '#ff0000',
+ backgroundColor: '#000000'
+ }).setOrigin(0.5).setDepth(300);
+ }
+ });
+
+ this.game.canvas.addEventListener('focus', () => {
+ console.log('Canvas gained focus');
+ if (this.focusWarning) {
+ this.focusWarning.destroy();
+ this.focusWarning = null;
+ }
+ });
+
+ // Re-focus if window regains focus
+ window.addEventListener('focus', () => {
+ this.game.canvas.focus();
+ });
+
+ this.grid = this.createEmptyGrid();
+ this.score = 0; this.level = 1; this.lines = 0; this.gameOver = false;
+ this.clearing = false;
+ this.dropCounter = 0; this.dropInterval = LEVEL_SPEEDS[0];
+ this.softDropping = false; this.softDropCounter = 0;
+ this.inputEnabled = true;
+ this.currentPiece = null; this.nextPiece = null;
+ this.currentX = 0; this.currentY = 0;
+ this.blockSprites = []; this.ghostSprites = [];
+ this.setupInput();
+ this.loadLevel(this.level, false); // Load level first without intro
+ this.createUI(); // Create UI after level is loaded
+ this.spawnPiece(); this.nextPiece = this.getRandomPiece();
+ this.updateNextPieceDisplay();
+
+ // Show intro animation after everything is set up
+ this.showLevelIntro();
+ }
+
+ createEmptyGrid() {
+ const grid = [];
+ for (let y = 0; y < GRID_HEIGHT; y++) { grid[y] = []; for (let x = 0; x < GRID_WIDTH; x++) grid[y][x] = 0; }
+ return grid;
+ }
+
+ loadLevel(level, showIntro = false) {
+ if (this.currentMusic) this.currentMusic.stop();
+ const backdropKey = `backdrop-${level}`;
+ if (this.backdrop) this.backdrop.destroy();
+ this.backdrop = this.add.image(BORDER_OFFSET, 0, backdropKey).setOrigin(0, 0);
+ this.backdrop.setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
+ this.backdrop.setDepth(-1);
+ this.colorPalette = ColorExtractor.extractPalette(this, backdropKey);
+ this.createBlockTextures();
+ const musicKey = `music-${level}`;
+ this.currentMusic = this.sound.add(musicKey, { loop: true, volume: 0.5 });
+ this.currentMusic.play();
+ this.redrawGrid();
+
+ if (showIntro) {
+ this.showLevelIntro();
+ }
+ }
+
+ showLevelIntro() {
+ // Immediately move containers off-screen (before any delay)
+ if (this.playAreaContainer) {
+ this.playAreaContainer.y = -GAME_HEIGHT;
+ }
+ if (this.uiPanelContainer) {
+ this.uiPanelContainer.y = -GAME_HEIGHT;
+ }
+
+ // Hide all block sprites (current piece and grid)
+ this.blockSprites.forEach(sprite => sprite.setVisible(false));
+ this.ghostSprites.forEach(sprite => sprite.setVisible(false));
+
+ // Disable input temporarily
+ this.inputEnabled = false;
+
+ // Show level title text
+ const levelTitle = LEVEL_TITLES[this.level] || 'Unknown';
+ const levelText = this.createBitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT / 2 - 10, `LEVEL ${this.level}`, 16);
+ levelText.setOrigin(0.5);
+ levelText.setDepth(201);
+
+ const titleText = this.createBitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT / 2 + 10, levelTitle, 10);
+ titleText.setOrigin(0.5);
+ titleText.setDepth(201);
+
+ // Wait 1 second showing backdrop and title
+ this.time.delayedCall(1500, () => {
+ // Fade out title texts
+ this.tweens.add({
+ targets: [levelText, titleText],
+ alpha: 0,
+ duration: 300,
+ onComplete: () => {
+ levelText.destroy();
+ titleText.destroy();
+ }
+ });
+
+ // Play woosh sound
+ SoundGenerator.playWoosh();
+
+ // Animate play area falling in
+ if (this.playAreaContainer) {
+ this.tweens.add({
+ targets: this.playAreaContainer,
+ y: 0,
+ duration: 600,
+ ease: 'Bounce.easeOut'
+ });
+ }
+
+ // Animate UI panel falling in (slightly delayed)
+ if (this.uiPanelContainer) {
+ this.tweens.add({
+ targets: this.uiPanelContainer,
+ y: 0,
+ duration: 600,
+ delay: 100,
+ ease: 'Bounce.easeOut',
+ onComplete: () => {
+ // Show blocks and re-enable input after animations complete
+ this.blockSprites.forEach(sprite => sprite.setVisible(true));
+ this.ghostSprites.forEach(sprite => sprite.setVisible(true));
+ this.inputEnabled = true;
+ }
+ });
+ }
+ });
+ }
+
+ createBlockTextures() {
+ const enhanced = SpriteBlockRenderer.enhancePalette(this.colorPalette);
+ this.colorPalette = enhanced;
+ Object.keys(this.tetrominoes).forEach((key, i) => {
+ // Remove old textures if they exist
+ if (this.textures.exists(`block-${key}`)) {
+ this.textures.remove(`block-${key}`);
+ }
+ if (this.textures.exists(`ghost-${key}`)) {
+ this.textures.remove(`ghost-${key}`);
+ }
+ SpriteBlockRenderer.createBlockTexture(this, this.colorPalette, this.level, `block-${key}`, i);
+ SpriteBlockRenderer.createBlockTexture(this, this.colorPalette, this.level, `ghost-${key}`, i);
+ });
+ }
+
+ setupInput() {
+ // Simple polling - use Phaser's built-in JustDown
+ this.cursors = this.input.keyboard.createCursorKeys();
+ this.spaceKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
+ this.pKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.P);
+
+ // DAS settings for left/right auto-repeat when HOLDING
+ this.dasDelay = 16; // Frames before repeat starts (longer delay)
+ this.dasSpeed = 4; // Frames between repeats (slower repeat)
+ this.leftHoldCounter = 0;
+ this.rightHoldCounter = 0;
+
+ // Grace period to prevent double-taps
+ this.moveGracePeriod = 2; // Minimum frames between moves
+ this.leftGraceCounter = 0;
+ this.rightGraceCounter = 0;
+
+ this.paused = false;
+ }
+
+ createBitmapText(x, y, text, size = 10) {
+ const t = this.add.bitmapText(x, y, 'pixel-font', text.toUpperCase(), size);
+ t.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+ return t;
+ }
+
+ createUI() {
+ // Create container for play area (so it can be animated as a unit)
+ this.playAreaContainer = this.add.container(0, 0);
+ const playAreaGraphics = this.add.graphics();
+ this.drawNESFrame(playAreaGraphics, PLAY_AREA_X - 2, PLAY_AREA_Y - 2, PLAY_AREA_WIDTH + 5, PLAY_AREA_HEIGHT + 4);
+ this.playAreaContainer.add(playAreaGraphics);
+
+ // Create container for right-side UI panels
+ this.uiPanelContainer = this.add.container(0, 0);
+ const panelGraphics = this.add.graphics();
+
+ // UI text positions - align first frame with play area top
+ const frameWidth = UI.PANEL_WIDTH - 3;
+ const x = UI.PANEL_X + UI.PADDING;
+ let y = PLAY_AREA_Y; // Align with play area top
+
+ // SCORE frame
+ this.drawNESFrame(panelGraphics, UI.PANEL_X, y - 2, frameWidth, 26);
+ const scoreLabel = this.createBitmapText(x, y + 2, 'SCORE');
+ y += 12;
+ this.scoreText = this.createBitmapText(x, y + 2, '000000');
+ y += 12 + 12; // 12px vertical space
+
+ // LEVEL frame
+ this.drawNESFrame(panelGraphics, UI.PANEL_X, y - 2, frameWidth, 26);
+ const levelLabel = this.createBitmapText(x, y + 2, 'LEVEL');
+ y += 12;
+ this.levelText = this.createBitmapText(x, y + 2, '1');
+ y += 12 + 12; // 12px vertical space
+
+ // LINES frame
+ this.drawNESFrame(panelGraphics, UI.PANEL_X, y - 2, frameWidth, 26);
+ const linesLabel = this.createBitmapText(x, y + 2, 'LINES');
+ y += 12;
+ this.linesText = this.createBitmapText(x, y + 2, '0');
+ y += 12 + 12; // 12px vertical space
+
+ // NEXT frame
+ const nextFrameHeight = 42; // Enough for piece preview + 2px top padding
+ this.drawNESFrame(panelGraphics, UI.PANEL_X, y - 2, frameWidth, nextFrameHeight);
+ this.nextPieceText = this.createBitmapText(x, y + 2, 'NEXT');
+ this.nextPieceY = y + 16;
+ this.nextPieceX = x;
+
+ // Add all UI elements to the container
+ this.uiPanelContainer.add([
+ panelGraphics,
+ scoreLabel,
+ this.scoreText,
+ levelLabel,
+ this.levelText,
+ linesLabel,
+ this.linesText,
+ this.nextPieceText
+ ]);
+ }
+
+ drawNESFrame(g, x, y, w, h) {
+ g.fillStyle(0x000000, 1); g.fillRect(x, y, w, h);
+ g.lineStyle(2, 0xAAAAAA, 1); g.strokeRect(x, y, w, h);
+ g.lineStyle(1, 0x555555, 1); g.strokeRect(x + 2, y + 2, w - 4, h - 4);
+ g.lineStyle(1, 0xFFFFFF, 1); g.beginPath(); g.moveTo(x + 1, y + h - 1); g.lineTo(x + 1, y + 1); g.lineTo(x + w - 1, y + 1); g.strokePath();
+ g.lineStyle(1, 0x333333, 1); g.beginPath(); g.moveTo(x + w - 1, y + 1); g.lineTo(x + w - 1, y + h - 1); g.lineTo(x + 1, y + h - 1); g.strokePath();
+ }
+
+ getRandomPiece() {
+ const keys = Object.keys(this.tetrominoes);
+ return JSON.parse(JSON.stringify(this.tetrominoes[keys[Math.floor(Math.random() * keys.length)]]));
+ }
+
+ spawnPiece() {
+ this.currentPiece = this.nextPiece ? this.nextPiece : this.getRandomPiece();
+ this.nextPiece = this.getRandomPiece();
+ this.currentX = Math.floor(GRID_WIDTH / 2) - Math.floor(this.currentPiece.shape[0].length / 2);
+ this.currentY = 0;
+ if (this.checkCollision(this.currentPiece, this.currentX, this.currentY)) { this.gameOver = true; this.handleGameOver(); }
+ this.updateNextPieceDisplay();
+ }
+
+ update(time, delta) {
+ if (this.gameOver || !this.inputEnabled) return;
+
+ // Pause check - always available
+ if (Phaser.Input.Keyboard.JustDown(this.pKey)) {
+ this.togglePause();
+ }
+
+ if (this.clearing || this.paused) return;
+
+ this.handleInput();
+ this.dropCounter++;
+ if (this.dropCounter >= this.dropInterval) { this.dropCounter = 0; this.moveDown(); }
+ this.renderPiece();
+ }
+
+ togglePause() {
+ this.paused = !this.paused;
+ if (this.paused) {
+ this.pauseOverlay = this.add.rectangle(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.8);
+ this.pauseOverlay.setDepth(100);
+ this.pauseText = this.createBitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT / 2, 'PAUSED');
+ this.pauseText.setOrigin(0.5).setDepth(101);
+ this.pauseHintText = this.createBitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT / 2 + 12, 'PRESS P');
+ this.pauseHintText.setOrigin(0.5).setDepth(101);
+ if (this.currentMusic) this.currentMusic.pause();
+ } else {
+ if (this.pauseOverlay) { this.pauseOverlay.destroy(); this.pauseOverlay = null; }
+ if (this.pauseText) { this.pauseText.destroy(); this.pauseText = null; }
+ if (this.pauseHintText) { this.pauseHintText.destroy(); this.pauseHintText = null; }
+ if (this.currentMusic) this.currentMusic.resume();
+ }
+ }
+
+ handleInput() {
+ // Rotation - JustDown ensures one action per press
+ if (Phaser.Input.Keyboard.JustDown(this.cursors.up)) {
+ this.rotatePiece();
+ }
+
+ // Hard drop - JustDown ensures one action per press
+ if (Phaser.Input.Keyboard.JustDown(this.spaceKey)) {
+ this.hardDrop();
+ }
+
+ // Soft drop (down key held) - continuous action
+ if (this.cursors.down.isDown) {
+ if (!this.softDropping) { this.softDropping = true; this.softDropCounter = 0; }
+ this.softDropCounter++;
+ if (this.softDropCounter >= 2) {
+ this.softDropCounter = 0;
+ if (this.moveDown()) {
+ this.score += SCORES.SOFT_DROP;
+ this.updateUI();
+ SoundGenerator.playSoftDrop();
+ }
+ }
+ } else {
+ this.softDropping = false;
+ this.softDropCounter = 0;
+ }
+
+ // Decrement grace counters
+ if (this.leftGraceCounter > 0) this.leftGraceCounter--;
+ if (this.rightGraceCounter > 0) this.rightGraceCounter--;
+
+ // LEFT - JustDown for first press, then auto-repeat when held
+ if (Phaser.Input.Keyboard.JustDown(this.cursors.left) && this.leftGraceCounter === 0) {
+ this.moveLeft();
+ this.leftHoldCounter = 0;
+ this.leftGraceCounter = this.moveGracePeriod;
+ } else if (this.cursors.left.isDown && this.leftGraceCounter === 0) {
+ this.leftHoldCounter++;
+ if (this.leftHoldCounter >= this.dasDelay && (this.leftHoldCounter - this.dasDelay) % this.dasSpeed === 0) {
+ this.moveLeft();
+ this.leftGraceCounter = this.moveGracePeriod;
+ }
+ } else if (!this.cursors.left.isDown) {
+ this.leftHoldCounter = 0;
+ }
+
+ // RIGHT - JustDown for first press, then auto-repeat when held
+ if (Phaser.Input.Keyboard.JustDown(this.cursors.right) && this.rightGraceCounter === 0) {
+ this.moveRight();
+ this.rightHoldCounter = 0;
+ this.rightGraceCounter = this.moveGracePeriod;
+ } else if (this.cursors.right.isDown && this.rightGraceCounter === 0) {
+ this.rightHoldCounter++;
+ if (this.rightHoldCounter >= this.dasDelay && (this.rightHoldCounter - this.dasDelay) % this.dasSpeed === 0) {
+ this.moveRight();
+ this.rightGraceCounter = this.moveGracePeriod;
+ }
+ } else if (!this.cursors.right.isDown) {
+ this.rightHoldCounter = 0;
+ }
+ }
+
+ moveLeft() { if (!this.checkCollision(this.currentPiece, this.currentX - 1, this.currentY)) { this.currentX--; SoundGenerator.playMove(); } }
+ moveRight() { if (!this.checkCollision(this.currentPiece, this.currentX + 1, this.currentY)) { this.currentX++; SoundGenerator.playMove(); } }
+ moveDown() { if (!this.checkCollision(this.currentPiece, this.currentX, this.currentY + 1)) { this.currentY++; return true; } else { this.lockPiece(); return false; } }
+ hardDrop() { while (!this.checkCollision(this.currentPiece, this.currentX, this.currentY + 1)) this.currentY++; SoundGenerator.playDrop(); this.lockPiece(); }
+
+ rotatePiece() {
+ const rotated = this.getRotatedPiece(this.currentPiece);
+
+ // Try rotation at current position
+ if (!this.checkCollision(rotated, this.currentX, this.currentY)) {
+ this.currentPiece = rotated;
+ SoundGenerator.playRotate();
+ return;
+ }
+
+ // Wall kick: try shifting right
+ if (!this.checkCollision(rotated, this.currentX + 1, this.currentY)) {
+ this.currentPiece = rotated;
+ this.currentX++;
+ SoundGenerator.playRotate();
+ return;
+ }
+
+ // Wall kick: try shifting left
+ if (!this.checkCollision(rotated, this.currentX - 1, this.currentY)) {
+ this.currentPiece = rotated;
+ this.currentX--;
+ SoundGenerator.playRotate();
+ return;
+ }
+
+ // Wall kick: try shifting right 2 spaces (for I-piece)
+ if (!this.checkCollision(rotated, this.currentX + 2, this.currentY)) {
+ this.currentPiece = rotated;
+ this.currentX += 2;
+ SoundGenerator.playRotate();
+ return;
+ }
+
+ // Wall kick: try shifting left 2 spaces (for I-piece)
+ if (!this.checkCollision(rotated, this.currentX - 2, this.currentY)) {
+ this.currentPiece = rotated;
+ this.currentX -= 2;
+ SoundGenerator.playRotate();
+ return;
+ }
+
+ // Rotation failed - no valid position found
+ }
+
+ getRotatedPiece(piece) {
+ const rotated = JSON.parse(JSON.stringify(piece));
+ const shape = piece.shape;
+ const rows = shape.length;
+ const cols = shape[0].length;
+ const newShape = [];
+ for (let x = 0; x < cols; x++) { newShape[x] = []; for (let y = rows - 1; y >= 0; y--) newShape[x][rows - 1 - y] = shape[y][x]; }
+ rotated.shape = newShape;
+ return rotated;
+ }
+
+ checkCollision(piece, x, y) {
+ const shape = piece.shape;
+ for (let row = 0; row < shape.length; row++) {
+ for (let col = 0; col < shape[row].length; col++) {
+ if (shape[row][col]) {
+ const gridX = x + col;
+ const gridY = y + row;
+ if (gridX < 0 || gridX >= GRID_WIDTH || gridY >= GRID_HEIGHT) return true;
+ if (gridY >= 0 && this.grid[gridY][gridX]) return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ lockPiece() {
+ const shape = this.currentPiece.shape;
+ for (let row = 0; row < shape.length; row++) {
+ for (let col = 0; col < shape[row].length; col++) {
+ if (shape[row][col]) {
+ const gridX = this.currentX + col;
+ const gridY = this.currentY + row;
+ if (gridY >= 0) this.grid[gridY][gridX] = this.currentPiece.name;
+ }
+ }
+ }
+ this.checkAndClearLines();
+ }
+
+ checkAndClearLines() {
+ // Find complete lines - a line is complete ONLY if every cell is filled
+ const completeLines = [];
+ for (let y = 0; y < GRID_HEIGHT; y++) {
+ let isComplete = true;
+ for (let x = 0; x < GRID_WIDTH; x++) {
+ if (!this.grid[y][x]) {
+ isComplete = false;
+ break;
+ }
+ }
+ if (isComplete) {
+ console.log(`Line ${y} is complete:`, JSON.stringify(this.grid[y]));
+ completeLines.push(y);
+ }
+ }
+
+ if (completeLines.length > 0) {
+ console.log('Complete lines found:', completeLines);
+ console.log('Grid state:', JSON.stringify(this.grid));
+ }
+
+ if (completeLines.length === 0) {
+ this.spawnPiece();
+ this.redrawGrid();
+ return;
+ }
+
+ // Block game updates during line clear
+ this.clearing = true;
+
+ // Play sound based on number of lines cleared
+ SoundGenerator.playLineClear(completeLines.length);
+
+ // Show the locked piece first
+ this.redrawGrid();
+
+ // Run the line clear animation, then apply changes
+ this.animateLineClear(completeLines);
+ }
+
+ animateLineClear(completeLines) {
+ // Create crush animation for each block
+ const crushSprites = [];
+ const texturesToCleanup = [];
+
+ completeLines.forEach(y => {
+ for (let x = 0; x < GRID_WIDTH; x++) {
+ const blockType = this.grid[y][x];
+ if (!blockType) continue;
+
+ const px = PLAY_AREA_X + x * BLOCK_SIZE;
+ const py = PLAY_AREA_Y + y * BLOCK_SIZE;
+
+ // Get the block's color from the palette
+ const colorIndex = blockType - 1;
+ const color = this.colorPalette[colorIndex % this.colorPalette.length];
+
+ // Create unique crush animation frames for this specific block instance
+ const uniqueId = `${Date.now()}-${x}-${y}-${Math.random().toString(36).substr(2, 9)}`;
+ const frames = [];
+ for (let f = 0; f < 5; f++) {
+ const frameKey = `crush-${uniqueId}-${f}`;
+ SpriteBlockRenderer.createCrushTexture(this, color, f, frameKey);
+ frames.push(frameKey);
+ texturesToCleanup.push(frameKey);
+ }
+
+ // Create sprite starting with frame 4 (most intact)
+ const sprite = this.add.sprite(px, py, frames[4]).setOrigin(0, 0);
+ sprite.setDepth(50);
+ crushSprites.push({ sprite, frames });
+ }
+ });
+
+ // Cycle through the 5 crush frames in REVERSE: 4 -> 3 -> 2 -> 1 -> 0
+ let frameCounter = 4;
+
+ this.time.addEvent({
+ delay: 75, // 75ms per frame (twice as fast)
+ repeat: 4, // repeat 4 times = 5 total callbacks (frames 4,3,2,1,0)
+ callback: () => {
+ if (frameCounter > 0) {
+ frameCounter--;
+ crushSprites.forEach(crushData => {
+ crushData.sprite.setTexture(crushData.frames[frameCounter]);
+ });
+ }
+ }
+ });
+
+ // After all 5 frames (4 shows immediately, then 3,2,1,0 at 75ms each = 300ms total), clean up
+ this.time.delayedCall(350, () => {
+ crushSprites.forEach(crushData => {
+ crushData.sprite.destroy();
+ });
+
+ // Clean up all textures
+ texturesToCleanup.forEach(frameKey => {
+ if (this.textures.exists(frameKey)) {
+ this.textures.remove(frameKey);
+ }
+ });
+
+ this.finishLineClear(completeLines);
+ });
+ }
+
+ finishLineClear(completeLines) {
+ // Apply the grid changes first
+ const validLines = completeLines.filter(y => {
+ if (y < 0 || y >= GRID_HEIGHT) return false;
+ for (let x = 0; x < GRID_WIDTH; x++) {
+ if (!this.grid[y][x]) return false;
+ }
+ return true;
+ });
+
+ if (validLines.length === 0) {
+ console.warn('No valid lines to clear after validation');
+ this.clearing = false;
+ this.spawnPiece();
+ this.redrawGrid();
+ return;
+ }
+
+ // Build new grid
+ const newGrid = [];
+ const linesToRemove = new Set(validLines);
+
+ for (let i = 0; i < validLines.length; i++) {
+ newGrid.push(new Array(GRID_WIDTH).fill(0));
+ }
+
+ for (let y = 0; y < GRID_HEIGHT; y++) {
+ if (!linesToRemove.has(y)) {
+ newGrid.push([...this.grid[y]]);
+ }
+ }
+
+ this.grid = newGrid;
+
+ // Now animate the falling blocks
+ // Rebuild sprites from new grid state
+ this.redrawGrid();
+
+ // Animate all sprites falling into place
+ const sortedLines = [...validLines].sort((a, b) => a - b);
+
+ this.blockSprites.forEach(sprite => {
+ const spriteGridY = Math.floor((sprite.y - PLAY_AREA_Y) / BLOCK_SIZE);
+
+ // Count how many cleared lines were below this sprite's ORIGINAL position
+ let linesBelowCount = 0;
+ sortedLines.forEach(clearedY => {
+ if (clearedY > spriteGridY - validLines.length) {
+ linesBelowCount++;
+ }
+ });
+
+ if (linesBelowCount > 0) {
+ // Start sprite higher, then animate down to current position
+ const startY = sprite.y - (linesBelowCount * BLOCK_SIZE);
+ sprite.y = startY;
+
+ this.tweens.add({
+ targets: sprite,
+ y: sprite.y + (linesBelowCount * BLOCK_SIZE),
+ duration: 150,
+ ease: 'Bounce.easeOut'
+ });
+ }
+ });
+
+ // Wait for fall animation, then finish
+ this.time.delayedCall(160, () => {
+ this.finishScoring(validLines);
+ });
+ }
+
+ finishScoring(validLines) {
+ // Update score
+ this.lines += validLines.length;
+ const levelMultiplier = this.level;
+ switch (validLines.length) {
+ case 1: this.score += SCORES.SINGLE * levelMultiplier; break;
+ case 2: this.score += SCORES.DOUBLE * levelMultiplier; break;
+ case 3: this.score += SCORES.TRIPLE * levelMultiplier; break;
+ case 4: this.score += SCORES.TETRIS * levelMultiplier; break;
+ }
+
+ // Check for perfect clear (entire grid is empty)
+ const isPerfectClear = this.grid.every(row => row.every(cell => cell === 0));
+ if (isPerfectClear) {
+ this.score += SCORES.PERFECT_CLEAR * levelMultiplier;
+ // Show perfect clear message
+ const perfectText = this.createBitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT / 2, 'PERFECT CLEAR!', 12);
+ perfectText.setOrigin(0.5);
+ perfectText.setDepth(150);
+ perfectText.setTint(0xFFD700); // Gold color
+
+ // Animate the text
+ this.tweens.add({
+ targets: perfectText,
+ scale: 1.5,
+ alpha: 0,
+ duration: 2000,
+ ease: 'Power2',
+ onComplete: () => perfectText.destroy()
+ });
+
+ // Play special sound
+ SoundGenerator.playLevelUp();
+ }
+
+ // Check for level up
+ const newLevel = Math.min(MAX_LEVEL, Math.floor(this.lines / CONFIG.LINES_PER_LEVEL) + 1);
+ if (newLevel > this.level) {
+ this.level = newLevel;
+ this.dropInterval = LEVEL_SPEEDS[this.level - 1];
+ SoundGenerator.playLevelUp();
+
+ // Exciting level transition!
+ this.showLevelTransition(newLevel);
+ } else {
+ this.updateUI();
+ this.clearing = false;
+ this.spawnPiece();
+ }
+ }
+
+
+
+ showLevelTransition(newLevel) {
+ // Keep game paused during transition
+ this.clearing = true;
+
+ // Black screen overlay
+ const blackScreen = this.add.rectangle(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000);
+ blackScreen.setDepth(200);
+ blackScreen.setAlpha(0);
+
+ // Fade to black
+ this.tweens.add({
+ targets: blackScreen,
+ alpha: 1,
+ duration: 300,
+ ease: 'Power2',
+ onComplete: () => {
+ // Pre-load the new level's palette and create preview blocks
+ const backdropKey = `backdrop-${newLevel}`;
+ const rawPalette = ColorExtractor.extractPalette(this, backdropKey);
+ const newPalette = SpriteBlockRenderer.enhancePalette(rawPalette);
+
+ // Level up text
+ const levelText = this.createBitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, 60, `LEVEL ${newLevel}`, 20);
+ levelText.setOrigin(0.5);
+ levelText.setDepth(201);
+ levelText.setAlpha(0);
+
+ // Subtitle - show level title
+ const levelTitle = LEVEL_TITLES[newLevel] || 'Unknown';
+ const subtitle = this.createBitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, 85, levelTitle, 10);
+ subtitle.setOrigin(0.5);
+ subtitle.setDepth(201);
+ subtitle.setAlpha(0);
+
+ // Create preview blocks showing new level's style
+ const previewBlocks = [];
+ const startX = GAME_WIDTH / 2 + BORDER_OFFSET - 32; // Center 8 blocks (8*8 = 64px wide)
+ const startY = 120;
+
+ for (let i = 0; i < 7; i++) {
+ const x = startX + i * 10;
+ const y = startY;
+ const blockKey = `preview-block-${i}`;
+
+ // Create block texture with new level's palette
+ SpriteBlockRenderer.createBlockTexture(this, newPalette, newLevel, blockKey, i);
+
+ const block = this.add.sprite(x, y, blockKey).setOrigin(0, 0);
+ block.setDepth(201);
+ block.setAlpha(0);
+ block.setScale(0.5);
+ previewBlocks.push({ sprite: block, key: blockKey });
+ }
+
+ // Animate text and blocks in
+ this.tweens.add({
+ targets: [levelText, subtitle],
+ alpha: 1,
+ duration: 400,
+ ease: 'Power2'
+ });
+
+ this.tweens.add({
+ targets: previewBlocks.map(b => b.sprite),
+ alpha: 1,
+ scale: 1,
+ duration: 500,
+ delay: 200,
+ ease: 'Back.easeOut',
+ onComplete: () => {
+ // Hold for a moment
+ this.time.delayedCall(1200, () => {
+ // Fade out text and preview blocks only (keep black screen)
+ this.tweens.add({
+ targets: [levelText, subtitle, ...previewBlocks.map(b => b.sprite)],
+ alpha: 0,
+ duration: 300,
+ onComplete: () => {
+ // Clean up text and preview blocks
+ levelText.destroy();
+ subtitle.destroy();
+ previewBlocks.forEach(b => {
+ b.sprite.destroy();
+ if (this.textures.exists(b.key)) {
+ this.textures.remove(b.key);
+ }
+ });
+
+ // Black screen stays for a moment
+ this.time.delayedCall(300, () => {
+ // Destroy old level elements while screen is black
+ this.blockSprites.forEach(sprite => sprite.destroy());
+ this.blockSprites = [];
+ this.ghostSprites.forEach(sprite => sprite.destroy());
+ this.ghostSprites = [];
+
+ // Load new level (no intro yet)
+ this.loadLevel(newLevel, false);
+ this.updateUI();
+ this.clearing = false;
+ this.spawnPiece();
+
+ // IMMEDIATELY hide UI containers before fading out black screen
+ if (this.playAreaContainer) {
+ this.playAreaContainer.y = -GAME_HEIGHT;
+ }
+ if (this.uiPanelContainer) {
+ this.uiPanelContainer.y = -GAME_HEIGHT;
+ }
+ this.blockSprites.forEach(sprite => sprite.setVisible(false));
+ this.ghostSprites.forEach(sprite => sprite.setVisible(false));
+ this.inputEnabled = false;
+
+ // Fade out black screen to reveal ONLY the backdrop
+ this.tweens.add({
+ targets: blackScreen,
+ alpha: 0,
+ duration: 500,
+ ease: 'Power2',
+ onComplete: () => {
+ blackScreen.destroy();
+ // Now show the intro animation (UI falls in)
+ // Wait 1 second showing just the backdrop
+ this.time.delayedCall(1000, () => {
+ // Play woosh sound
+ SoundGenerator.playWoosh();
+
+ // Animate play area falling in
+ if (this.playAreaContainer) {
+ this.tweens.add({
+ targets: this.playAreaContainer,
+ y: 0,
+ duration: 600,
+ ease: 'Bounce.easeOut'
+ });
+ }
+
+ // Animate UI panel falling in (slightly delayed)
+ if (this.uiPanelContainer) {
+ this.tweens.add({
+ targets: this.uiPanelContainer,
+ y: 0,
+ duration: 600,
+ delay: 100,
+ ease: 'Bounce.easeOut',
+ onComplete: () => {
+ // Show blocks and re-enable input after animations complete
+ this.blockSprites.forEach(sprite => sprite.setVisible(true));
+ this.ghostSprites.forEach(sprite => sprite.setVisible(true));
+ this.inputEnabled = true;
+ }
+ });
+ }
+ });
+ }
+ });
+ });
+ }
+ });
+ });
+ }
+ });
+ }
+ });
+ }
+
+ redrawGrid() {
+ this.blockSprites.forEach(sprite => sprite.destroy());
+ this.blockSprites = [];
+ for (let y = 0; y < GRID_HEIGHT; y++) {
+ for (let x = 0; x < GRID_WIDTH; x++) {
+ if (this.grid[y][x]) {
+ const blockType = this.grid[y][x];
+ const sprite = this.add.sprite(PLAY_AREA_X + x * BLOCK_SIZE, PLAY_AREA_Y + y * BLOCK_SIZE, `block-${blockType}`).setOrigin(0, 0);
+ sprite.setDepth(2);
+ this.blockSprites.push(sprite);
+ }
+ }
+ }
+ }
+
+ renderPiece() {
+ this.blockSprites.forEach(sprite => { if (sprite.getData('current')) sprite.destroy(); });
+ this.blockSprites = this.blockSprites.filter(s => !s.getData('current'));
+ this.ghostSprites.forEach(sprite => sprite.destroy());
+ this.ghostSprites = [];
+ if (!this.currentPiece) return;
+ if (this.level === 1) {
+ let ghostY = this.currentY;
+ while (!this.checkCollision(this.currentPiece, this.currentX, ghostY + 1)) ghostY++;
+ const shape = this.currentPiece.shape;
+ for (let row = 0; row < shape.length; row++) {
+ for (let col = 0; col < shape[row].length; col++) {
+ if (shape[row][col]) {
+ const x = PLAY_AREA_X + (this.currentX + col) * BLOCK_SIZE;
+ const y = PLAY_AREA_Y + (ghostY + row) * BLOCK_SIZE;
+ const sprite = this.add.sprite(x, y, `block-${this.currentPiece.name}`).setOrigin(0, 0);
+ sprite.setAlpha(0.3);
+ sprite.setDepth(1);
+ this.ghostSprites.push(sprite);
+ }
+ }
+ }
+ }
+ const shape = this.currentPiece.shape;
+ for (let row = 0; row < shape.length; row++) {
+ for (let col = 0; col < shape[row].length; col++) {
+ if (shape[row][col]) {
+ const x = PLAY_AREA_X + (this.currentX + col) * BLOCK_SIZE;
+ const y = PLAY_AREA_Y + (this.currentY + row) * BLOCK_SIZE;
+ const sprite = this.add.sprite(x, y, `block-${this.currentPiece.name}`).setOrigin(0, 0);
+ sprite.setData('current', true);
+ sprite.setDepth(2);
+ this.blockSprites.push(sprite);
+ }
+ }
+ }
+ }
+
+ updateNextPieceDisplay() {
+ if (this.nextPieceSprites) this.nextPieceSprites.forEach(sprite => sprite.destroy());
+ this.nextPieceSprites = [];
+ if (!this.nextPiece) return;
+ const shape = this.nextPiece.shape;
+ const startX = this.nextPieceX;
+ const startY = this.nextPieceY;
+ for (let row = 0; row < shape.length; row++) {
+ for (let col = 0; col < shape[row].length; col++) {
+ if (shape[row][col]) {
+ const x = startX + col * BLOCK_SIZE;
+ const y = startY + row * BLOCK_SIZE;
+ const sprite = this.add.sprite(x, y, `block-${this.nextPiece.name}`).setOrigin(0, 0);
+ sprite.setDepth(20);
+ this.nextPieceSprites.push(sprite);
+ // Add to UI container so it animates with the panel
+ if (this.uiPanelContainer) {
+ this.uiPanelContainer.add(sprite);
+ }
+ }
+ }
+ }
+ }
+
+ updateUI() {
+ const scoreStr = this.score.toString().padStart(6, '0');
+ this.scoreText.setText(scoreStr);
+ this.levelText.setText(this.level.toString());
+ this.linesText.setText(this.lines.toString());
+ }
+
+ handleGameOver() {
+ if (this.currentMusic) this.currentMusic.stop();
+ SoundGenerator.playGameOver();
+
+ // Display game over image (256x224, fills the game area)
+ const gameOverImage = this.add.image(BORDER_OFFSET, 0, 'game-over');
+ gameOverImage.setOrigin(0, 0);
+ gameOverImage.setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
+ gameOverImage.setDepth(100);
+ gameOverImage.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+
+ this.input.keyboard.once('keydown-SPACE', () => {
+ this.scene.start('PreloadScene');
+ });
+ }
+}
diff --git a/src/scenes/ModeSelectScene.js b/src/scenes/ModeSelectScene.js
new file mode 100644
index 0000000000000000000000000000000000000000..c5783c26805d5238c94bdd1e411240c950309286
--- /dev/null
+++ b/src/scenes/ModeSelectScene.js
@@ -0,0 +1,125 @@
+import Phaser from 'phaser';
+import { GAME_WIDTH, GAME_HEIGHT, BORDER_OFFSET } from '../constants.js';
+import SoundGenerator from '../utils/SoundGenerator.js';
+
+export default class ModeSelectScene extends Phaser.Scene {
+ constructor() {
+ super({ key: 'ModeSelectScene' });
+ }
+
+ create() {
+ // Use the title backdrop
+ const titleImage = this.add.image(BORDER_OFFSET, 0, 'title');
+ titleImage.setOrigin(0, 0);
+ titleImage.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+
+ // Dim the background by 50%
+ const dimOverlay = this.add.rectangle(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.5);
+ dimOverlay.setDepth(5);
+
+ // Title
+ const titleText = this.add.bitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, 60, 'pixel-font', 'MODE SELECT', 10).setOrigin(0.5);
+ titleText.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+ titleText.setDepth(10);
+
+ // Classic mode option
+ this.classicText = this.add.bitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, 100, 'pixel-font', '> CLASSIC', 10).setOrigin(0.5);
+ this.classicText.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+ this.classicText.setDepth(10);
+ this.classicText.setInteractive({ useHandCursor: true });
+
+ const classicDesc = this.add.bitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, 115, 'pixel-font', '7 STANDARD PIECES', 10).setOrigin(0.5);
+ classicDesc.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+ classicDesc.setDepth(10);
+
+ // Advanced mode option
+ this.advancedText = this.add.bitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, 145, 'pixel-font', ' ADVANCED', 10).setOrigin(0.5);
+ this.advancedText.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+ this.advancedText.setDepth(10);
+ this.advancedText.setInteractive({ useHandCursor: true });
+
+ const advancedDesc = this.add.bitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, 160, 'pixel-font', 'EXTRA UNIQUE PIECES', 10).setOrigin(0.5);
+ advancedDesc.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+ advancedDesc.setDepth(10);
+
+ // Track selected mode
+ this.selectedMode = 'classic';
+
+ // Hover effects
+ this.classicText.on('pointerover', () => {
+ if (this.selectedMode !== 'classic') {
+ SoundGenerator.playMove();
+ this.selectedMode = 'classic';
+ this.updateSelection();
+ }
+ });
+
+ this.advancedText.on('pointerover', () => {
+ if (this.selectedMode !== 'advanced') {
+ SoundGenerator.playMove();
+ this.selectedMode = 'advanced';
+ this.updateSelection();
+ }
+ });
+
+ // Click handlers
+ this.classicText.on('pointerdown', () => {
+ SoundGenerator.playRotate();
+ this.startGame('classic');
+ });
+
+ this.advancedText.on('pointerdown', () => {
+ SoundGenerator.playRotate();
+ this.startGame('advanced');
+ });
+
+ // Keyboard controls
+ const upKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.UP);
+ const downKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.DOWN);
+ const spaceKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.SPACE);
+ const enterKey = this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.ENTER);
+
+ upKey.on('down', () => {
+ if (this.selectedMode !== 'classic') {
+ SoundGenerator.playMove();
+ this.selectedMode = 'classic';
+ this.updateSelection();
+ }
+ });
+
+ downKey.on('down', () => {
+ if (this.selectedMode !== 'advanced') {
+ SoundGenerator.playMove();
+ this.selectedMode = 'advanced';
+ this.updateSelection();
+ }
+ });
+
+ spaceKey.on('down', () => {
+ SoundGenerator.playRotate();
+ this.startGame(this.selectedMode);
+ });
+
+ enterKey.on('down', () => {
+ SoundGenerator.playRotate();
+ this.startGame(this.selectedMode);
+ });
+ }
+
+ updateSelection() {
+ if (this.selectedMode === 'classic') {
+ this.classicText.setText('> CLASSIC');
+ this.advancedText.setText(' ADVANCED');
+ } else {
+ this.classicText.setText(' CLASSIC');
+ this.advancedText.setText('> ADVANCED');
+ }
+ }
+
+ startGame(mode) {
+ // Store the selected mode in the registry so GameScene can access it
+ this.registry.set('gameMode', mode);
+ this.scene.start('GameScene');
+ }
+}
+
diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js
new file mode 100644
index 0000000000000000000000000000000000000000..ff2ad9b4a6f090a97fd3a115ee887f4be3495dc2
--- /dev/null
+++ b/src/scenes/PreloadScene.js
@@ -0,0 +1,106 @@
+import Phaser from 'phaser';
+import { MAX_LEVEL, GAME_WIDTH, GAME_HEIGHT, BORDER_OFFSET } from '../constants.js';
+
+export default class PreloadScene extends Phaser.Scene {
+ constructor() {
+ super({ key: 'PreloadScene' });
+ }
+
+ preload() {
+ // Create loading screen
+ const loadingText = this.add.text(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT / 2 - 20, 'LOADING...', {
+ fontSize: '16px',
+ color: '#ffffff',
+ fontFamily: 'monospace'
+ }).setOrigin(0.5);
+
+ const progressText = this.add.text(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT / 2 + 10, '0%', {
+ fontSize: '14px',
+ color: '#ffffff',
+ fontFamily: 'monospace'
+ }).setOrigin(0.5);
+
+ // Progress bar
+ const progressBar = this.add.graphics();
+ const progressBox = this.add.graphics();
+ progressBox.fillStyle(0x222222, 0.8);
+ progressBox.fillRect(GAME_WIDTH / 2 + BORDER_OFFSET - 80, GAME_HEIGHT / 2 + 30, 160, 20);
+
+ // Update progress
+ this.load.on('progress', (value) => {
+ progressText.setText(Math.floor(value * 100) + '%');
+ progressBar.clear();
+ progressBar.fillStyle(0xffffff, 1);
+ progressBar.fillRect(GAME_WIDTH / 2 + BORDER_OFFSET - 78, GAME_HEIGHT / 2 + 32, 156 * value, 16);
+ });
+
+ this.load.on('complete', () => {
+ progressBar.destroy();
+ progressBox.destroy();
+ loadingText.destroy();
+ progressText.destroy();
+ });
+
+ // Load title screen
+ this.load.image('title', 'assets/title.png');
+
+ // Load game over screen
+ this.load.image('game-over', 'assets/game-over.png');
+
+ // Load block sprite sheet (grayscale with depth)
+ this.load.image('blocks-spritesheet', 'assets/blocks-sprite.png');
+
+ // Load crush animation sprite (40x8px = 5 frames of 8x8px)
+ this.load.image('crush-spritesheet', 'assets/crush.png');
+
+ // Load bitmap font (Thick 8x8 from frostyfreeze)
+ this.load.bitmapFont('pixel-font', 'assets/fonts/thick_8x8.png', 'assets/fonts/thick_8x8.xml');
+
+ // Load backdrops for all levels
+ for (let i = 1; i <= MAX_LEVEL; i++) {
+ this.load.image(`backdrop-${i}`, `assets/backdrops/level-${i}/backdrop.png`);
+ }
+
+ // Load music for all levels
+ for (let i = 1; i <= MAX_LEVEL; i++) {
+ this.load.audio(`music-${i}`, `assets/music/level-${i}/track.mp3`);
+ }
+ }
+
+ create() {
+ // Title image fills entire screen (256x224), offset by border
+ const titleImage = this.add.image(BORDER_OFFSET, 0, 'title');
+ titleImage.setOrigin(0, 0);
+ titleImage.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+
+ // "Press space to start" text - positioned in bottom third
+ const startText = this.add.bitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT * 0.7 + 20, 'pixel-font', 'PRESS SPACE TO START', 10).setOrigin(0.5);
+ startText.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+ startText.setDepth(10);
+
+ // Demo mode timer - start demo after 10 seconds of inactivity
+ this.demoTimer = this.time.delayedCall(10000, () => {
+ this.startDemoMode();
+ });
+
+ // Blinking effect for start text
+ this.tweens.add({
+ targets: startText,
+ alpha: 0.3,
+ duration: 600,
+ yoyo: true,
+ repeat: -1
+ });
+
+ // Credits text - positioned below start text in bottom third
+ const creditsText = this.add.bitmapText(GAME_WIDTH / 2 + BORDER_OFFSET, GAME_HEIGHT * 0.8 + 20, 'pixel-font', 'BY MARCO VAN HYLCKAMA VLIEG', 10).setOrigin(0.5);
+ creditsText.texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+ creditsText.setDepth(10);
+
+ // Wait for space key to start
+ this.input.keyboard.once('keydown-SPACE', () => {
+ this.scene.start('ModeSelectScene');
+ });
+ }
+}
+
diff --git a/src/scenes/PreloadScene_new.js b/src/scenes/PreloadScene_new.js
new file mode 100644
index 0000000000000000000000000000000000000000..7f2ad1d58a7dd1eaab9f7158e3743b8084626aee
--- /dev/null
+++ b/src/scenes/PreloadScene_new.js
@@ -0,0 +1,52 @@
+import Phaser from 'phaser';
+import { MAX_LEVEL } from '../constants.js';
+
+export default class PreloadScene extends Phaser.Scene {
+ constructor() {
+ super({ key: 'PreloadScene' });
+ }
+
+ preload() {
+ // Load pixel font first
+ this.load.font('retro', 'assets/fonts/font.otf', 'opentype');
+
+ // Create loading text
+ const loadingText = this.add.text(128, 112, 'LOADING...', {
+ fontFamily: 'monospace',
+ fontSize: '8px',
+ color: '#ffffff'
+ }).setOrigin(0.5);
+
+ // Load block sprite sheet (64x8px, 10 sprites of 8x8 each)
+ this.load.image('blocks-spritesheet', 'assets/blocks.png');
+
+ // Load backdrops for all levels
+ for (let i = 1; i <= MAX_LEVEL; i++) {
+ this.load.image(`backdrop-${i}`, `assets/backdrops/level-${i}/backdrop.png`);
+ }
+
+ // Load music for all levels
+ for (let i = 1; i <= MAX_LEVEL; i++) {
+ this.load.audio(`music-${i}`, `assets/music/level-${i}/track.mp3`);
+ }
+
+ // Update loading progress
+ this.load.on('progress', (value) => {
+ loadingText.setText(`LOADING... ${Math.floor(value * 100)}%`);
+ });
+
+ this.load.on('complete', () => {
+ // Update to use pixel font once loaded
+ loadingText.setFontFamily('retro');
+ loadingText.setText('PRESS SPACE TO START');
+ });
+ }
+
+ create() {
+ // Wait for space key to start
+ this.input.keyboard.once('keydown-SPACE', () => {
+ this.scene.start('GameScene');
+ });
+ }
+}
+
diff --git a/src/shaderOverlay.js b/src/shaderOverlay.js
new file mode 100644
index 0000000000000000000000000000000000000000..c01378dd963210337204759ca3e65807e241c851
--- /dev/null
+++ b/src/shaderOverlay.js
@@ -0,0 +1,212 @@
+// This creates a WebGL canvas overlay that applies the Trinitron shader to the final scaled output
+
+const vertexShaderSource = `
+attribute vec2 a_position;
+attribute vec2 a_texCoord;
+varying vec2 v_texCoord;
+
+void main() {
+ gl_Position = vec4(a_position, 0.0, 1.0);
+ v_texCoord = a_texCoord;
+}
+`;
+
+const fragmentShaderSource = `
+precision mediump float;
+
+uniform sampler2D u_texture;
+uniform vec2 u_resolution;
+uniform float u_time;
+
+varying vec2 v_texCoord;
+
+#define PI 3.14159265359
+
+// Random noise function for static
+float random(vec2 co) {
+ return fract(sin(dot(co.xy, vec2(12.9898, 78.233))) * 43758.5453);
+}
+
+void main() {
+ vec2 uv = v_texCoord;
+
+ // CRT curvature - subtle but noticeable
+ vec2 centered = uv - 0.5;
+ float curvature = 0.06; // Curvature amount (reduced by half from 0.12)
+
+ // Apply barrel distortion
+ float r2 = centered.x * centered.x + centered.y * centered.y;
+ float distortion = 1.0 + curvature * r2;
+ vec2 curvedUV = centered * distortion + 0.5;
+
+ // Check if we're outside the original screen bounds (black borders)
+ if (curvedUV.x < 0.0 || curvedUV.x > 1.0 || curvedUV.y < 0.0 || curvedUV.y > 1.0) {
+ gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
+ return;
+ }
+
+ vec3 color = texture2D(u_texture, curvedUV).rgb;
+
+ // Add static noise - using larger blocks for grainier effect
+ // Divide by 4.0 to make static "pixels" 4x4 screen pixels
+ vec2 staticCoord = floor(gl_FragCoord.xy / 4.0);
+ float staticNoise = random(staticCoord + vec2(u_time * 100.0)) * 0.06; // Slightly lower intensity
+ color += vec3(staticNoise);
+
+ // Add flicker (brightness variation over time) - multiple frequencies for realism
+ float flicker = sin(u_time * 12.0) * 0.015 + sin(u_time * 5.7) * 0.0125 + sin(u_time * 23.3) * 0.0075;
+ color *= (1.0 + flicker);
+
+ // 480i scanline effect - simulating classic CRT TV
+ float scanline = gl_FragCoord.y;
+
+ // Calculate scanline width to achieve ~480 scanlines for current resolution
+ // For 556px height, we want 480 scanlines: 556/480 ≈ 1.16 pixels per scanline
+ float scanlineWidth = 2.0;
+ float scanlineIntensity = 0.7; // How dark the scanlines are
+ float scanlineMod = mod(scanline, scanlineWidth);
+
+ // Make scanline darker for half the pixels
+ float scanlineFactor = 1.0;
+ if (scanlineMod < 1.0) {
+ scanlineFactor = 1.0 - scanlineIntensity;
+ }
+
+ // Apply scanlines
+ color *= scanlineFactor;
+
+ // Slight bloom/glow on bright areas (CRT phosphor persistence)
+ float brightness = (color.r + color.g + color.b) / 3.0;
+ color *= 1.0 + (brightness * 0.15);
+
+ // Vignette (darker edges like a CRT tube)
+ float vignette = 1.0 - dot(centered, centered) * 0.4;
+ color *= vignette;
+
+ // Slight color shift for CRT feel
+ color.r *= 1.02;
+ color.b *= 0.98;
+
+ gl_FragColor = vec4(color, 1.0);
+}
+`;
+
+export function createShaderOverlay(gameCanvas) {
+ console.log('Creating shader overlay for canvas:', gameCanvas);
+
+ // Create overlay canvas
+ const overlay = document.createElement('canvas');
+ overlay.style.position = 'absolute';
+ overlay.style.pointerEvents = 'none';
+ overlay.style.zIndex = '1000';
+
+ // Position it over the game canvas
+ const updateOverlayPosition = () => {
+ const rect = gameCanvas.getBoundingClientRect();
+ overlay.style.left = rect.left + 'px';
+ overlay.style.top = rect.top + 'px';
+ overlay.width = rect.width;
+ overlay.height = rect.height;
+ overlay.style.width = rect.width + 'px';
+ overlay.style.height = rect.height + 'px';
+ };
+
+ document.body.appendChild(overlay);
+ updateOverlayPosition();
+
+ // Update on resize
+ window.addEventListener('resize', updateOverlayPosition);
+
+ const gl = overlay.getContext('webgl') || overlay.getContext('experimental-webgl');
+ if (!gl) {
+ console.error('WebGL not supported');
+ return null;
+ }
+
+ console.log('WebGL context created, overlay size:', overlay.width, 'x', overlay.height);
+
+ // Compile shaders
+ function compileShader(source, type) {
+ const shader = gl.createShader(type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
+ console.error('Shader compile error:', gl.getShaderInfoLog(shader));
+ gl.deleteShader(shader);
+ return null;
+ }
+ return shader;
+ }
+
+ const vertexShader = compileShader(vertexShaderSource, gl.VERTEX_SHADER);
+ const fragmentShader = compileShader(fragmentShaderSource, gl.FRAGMENT_SHADER);
+
+ const program = gl.createProgram();
+ gl.attachShader(program, vertexShader);
+ gl.attachShader(program, fragmentShader);
+ gl.linkProgram(program);
+
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
+ console.error('Program link error:', gl.getProgramInfoLog(program));
+ return null;
+ }
+
+ gl.useProgram(program);
+
+ // Set up geometry (flip Y coordinate for texture)
+ const positions = new Float32Array([
+ -1, -1, 0, 1,
+ 1, -1, 1, 1,
+ -1, 1, 0, 0,
+ 1, 1, 1, 0
+ ]);
+
+ const buffer = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
+ gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
+
+ const positionLoc = gl.getAttribLocation(program, 'a_position');
+ const texCoordLoc = gl.getAttribLocation(program, 'a_texCoord');
+
+ gl.enableVertexAttribArray(positionLoc);
+ gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 16, 0);
+
+ gl.enableVertexAttribArray(texCoordLoc);
+ gl.vertexAttribPointer(texCoordLoc, 2, gl.FLOAT, false, 16, 8);
+
+ // Create texture from game canvas
+ const texture = gl.createTexture();
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+
+ const resolutionLoc = gl.getUniformLocation(program, 'u_resolution');
+ const timeLoc = gl.getUniformLocation(program, 'u_time');
+ const borderWidthLoc = gl.getUniformLocation(program, 'u_borderWidth');
+
+ // Render loop
+ function render() {
+ updateOverlayPosition();
+
+ // Copy game canvas to texture
+ gl.bindTexture(gl.TEXTURE_2D, texture);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, gameCanvas);
+
+ // Set uniforms
+ gl.uniform2f(resolutionLoc, overlay.width, overlay.height);
+ gl.uniform1f(timeLoc, performance.now() / 1000);
+
+ // Draw
+ gl.viewport(0, 0, overlay.width, overlay.height);
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
+
+ requestAnimationFrame(render);
+ }
+
+ render();
+
+ return overlay;
+}
+
diff --git a/src/shaders/trinitron-fragment.glsl b/src/shaders/trinitron-fragment.glsl
new file mode 100644
index 0000000000000000000000000000000000000000..e6db1b6bddefca0ea229d1fdfb5f8a45b7ba100e
--- /dev/null
+++ b/src/shaders/trinitron-fragment.glsl
@@ -0,0 +1,179 @@
+precision mediump float;
+
+// Texture and coordinates
+uniform sampler2D uMainSampler;
+varying vec2 outTexCoord;
+
+// Uniforms for shader parameters
+uniform vec2 resolution;
+uniform float time;
+
+#define PI 3.14159265359
+
+// --- Noise Helper Function (Permutation) ---
+vec4 permute(vec4 t) {
+ return mod(((t * 34.0) + 1.0) * t, 289.0);
+}
+
+// --- 3D Noise Function ---
+float noise3d(vec3 p) {
+ vec3 a = floor(p);
+ vec3 d = p - a;
+ d = d * d * (3.0 - 2.0 * d);
+
+ vec4 b = a.xxyy + vec4(0.0, 1.0, 0.0, 1.0);
+ vec4 k1 = permute(b.xyxy);
+ vec4 k2 = permute(k1.xyxy + b.zzww);
+
+ vec4 c = k2 + a.zzzz;
+ vec4 k3 = permute(c);
+ vec4 k4 = permute(c + 1.0);
+
+ vec4 o1 = fract(k3 * (1.0 / 41.0));
+ vec4 o2 = fract(k4 * (1.0 / 41.0));
+
+ vec4 o3_interp_z = o2 * d.z + o1 * (1.0 - d.z);
+ vec2 o4_interp_xy = o3_interp_z.yw * d.x + o3_interp_z.xz * (1.0 - d.x);
+
+ return o4_interp_xy.y * d.y + o4_interp_xy.x * (1.0 - d.y);
+}
+
+void main() {
+ // --- Configuration Parameters ---
+ float brightness = 2.5;
+ float red_balance = 1.0;
+ float green_balance = 0.85;
+ float blue_balance = 1.0;
+
+ // Custom settings as requested
+ float phosphorWidth = 2.50;
+ float phosphorHeight = 4.50;
+ float internalHorizontalGap = 1.0;
+ float columnGap = 0.2;
+ float verticalCellGap = 0.2;
+ float phosphorPower = 0.9;
+
+ float cell_noise_variation_amount = 0.025;
+ float cell_noise_scale_xy = 240.0;
+ float cell_noise_speed = 24.0;
+ float curvature_amount = 0.0; // Set to 0 as requested
+
+ // --- Apply Curvature Distortion ---
+ vec2 fragCoord = gl_FragCoord.xy;
+ vec2 uv = outTexCoord;
+ vec2 centered_uv_output = uv - 0.5;
+ float r = length(centered_uv_output);
+ float distort_factor = 1.0 + curvature_amount * r * r;
+ vec2 centered_uv_source = centered_uv_output * distort_factor;
+ vec2 source_uv = centered_uv_source + 0.5;
+ vec2 fragCoord_warped = source_uv * resolution;
+
+ // --- Check if Warped Coordinate is on the "Flat Screen" ---
+ bool is_on_original_flat_screen = source_uv.x >= 0.0 && source_uv.x <= 1.0 &&
+ source_uv.y >= 0.0 && source_uv.y <= 1.0;
+
+ if (!is_on_original_flat_screen) {
+ gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0);
+ return;
+ }
+
+ // --- Calculated Grid Dimensions ---
+ float fullCellWidth = 3.0 * phosphorWidth + 3.0 * internalHorizontalGap + columnGap;
+ float fullRowHeight = phosphorHeight + verticalCellGap;
+
+ // --- Calculate Logical Grid Positions ---
+ float logical_cell_index_x = floor(fragCoord_warped.x / fullCellWidth);
+ float shift_y_offset = 0.0;
+
+ if (mod(logical_cell_index_x, 2.0) != 0.0) {
+ shift_y_offset = fullRowHeight / 2.0;
+ }
+
+ float effective_y_warped = fragCoord_warped.y + shift_y_offset;
+ float logical_row_index = floor(effective_y_warped / fullRowHeight);
+
+ float uv_cell_x = mod(fragCoord_warped.x, fullCellWidth);
+ if (uv_cell_x < 0.0) {
+ uv_cell_x += fullCellWidth;
+ }
+
+ float uv_row_y = mod(effective_y_warped, fullRowHeight);
+ if (uv_row_y < 0.0) {
+ uv_row_y += fullRowHeight;
+ }
+
+ // --- Video Sampling and Color Balancing ---
+ vec3 video_color = texture2D(uMainSampler, source_uv).rgb;
+ video_color.r *= red_balance;
+ video_color.g *= green_balance;
+ video_color.b *= blue_balance;
+
+ // --- Determine if inside a Phosphor Area ---
+ vec3 final_color = vec3(0.0);
+ bool in_column_gap = uv_cell_x >= (3.0 * phosphorWidth + 3.0 * internalHorizontalGap);
+ bool in_vertical_gap = uv_row_y >= phosphorHeight;
+
+ if (!in_column_gap && !in_vertical_gap) {
+ float uv_cell_x_within_block = uv_cell_x;
+ vec3 phosphor_base_color = vec3(0.0);
+ float video_component_intensity = 0.0;
+ float current_phosphor_startX_in_block = -1.0;
+ float current_x_tracker = 0.0;
+
+ // Red phosphor area
+ if (uv_cell_x_within_block >= current_x_tracker && uv_cell_x_within_block < current_x_tracker + phosphorWidth) {
+ phosphor_base_color = vec3(1.0, 0.0, 0.0);
+ video_component_intensity = video_color.r;
+ current_phosphor_startX_in_block = current_x_tracker;
+ }
+ current_x_tracker += phosphorWidth + internalHorizontalGap;
+
+ // Green phosphor area
+ if (uv_cell_x_within_block >= current_x_tracker && uv_cell_x_within_block < current_x_tracker + phosphorWidth) {
+ phosphor_base_color = vec3(0.0, 1.0, 0.0);
+ video_component_intensity = video_color.g;
+ current_phosphor_startX_in_block = current_x_tracker;
+ }
+ current_x_tracker += phosphorWidth + internalHorizontalGap;
+
+ // Blue phosphor area
+ if (uv_cell_x_within_block >= current_x_tracker && uv_cell_x_within_block < current_x_tracker + phosphorWidth) {
+ phosphor_base_color = vec3(0.0, 0.0, 1.0);
+ video_component_intensity = video_color.b;
+ current_phosphor_startX_in_block = current_x_tracker;
+ }
+
+ if (current_phosphor_startX_in_block >= 0.0) {
+ float x_in_phosphor = (uv_cell_x_within_block - current_phosphor_startX_in_block) / phosphorWidth;
+ float horizontal_intensity_factor = pow(sin(x_in_phosphor * PI), phosphorPower);
+ float y_in_phosphor_band = uv_row_y / phosphorHeight;
+ float vertical_intensity_factor = (phosphorHeight > 0.0) ? pow(sin(y_in_phosphor_band * PI), phosphorPower) : 1.0;
+ float total_intensity_factor = horizontal_intensity_factor * vertical_intensity_factor;
+ final_color = phosphor_base_color * video_component_intensity * total_intensity_factor;
+ }
+ }
+
+ // --- Apply Cell-Based RGB Analog Noise ---
+ vec3 noise_pos = vec3(logical_cell_index_x * cell_noise_scale_xy,
+ logical_row_index * cell_noise_scale_xy,
+ time * cell_noise_speed);
+
+ vec3 cell_noise_rgb;
+ cell_noise_rgb.r = noise3d(noise_pos);
+ cell_noise_rgb.g = noise3d(noise_pos + vec3(19.0, 0.0, 0.0));
+ cell_noise_rgb.b = noise3d(noise_pos + vec3(0.0, 13.0, 0.0));
+ cell_noise_rgb = cell_noise_rgb * 2.0 - 1.0;
+ final_color += cell_noise_rgb * cell_noise_variation_amount;
+
+ // --- Apply Overall Brightness and Effects ---
+ final_color *= brightness;
+ float edge_darken_strength = 0.1;
+ float vignette_factor = 1.0 - dot(centered_uv_output, centered_uv_output) * edge_darken_strength * 2.0;
+ vignette_factor = clamp(vignette_factor, 0.0, 1.0);
+ final_color *= vignette_factor;
+
+ // --- Output ---
+ final_color = clamp(final_color, 0.0, 1.0);
+ gl_FragColor = vec4(final_color, 1.0);
+}
+
diff --git a/src/utils/BlockRenderer.js b/src/utils/BlockRenderer.js
new file mode 100644
index 0000000000000000000000000000000000000000..25febc848d0ea5fed26d8c7a74c902e9c6998478
--- /dev/null
+++ b/src/utils/BlockRenderer.js
@@ -0,0 +1,128 @@
+import { BLOCK_SIZE } from '../constants.js';
+
+/**
+ * Renders Tetris blocks with different pixel art styles per level
+ */
+export default class BlockRenderer {
+ /**
+ * Create a block texture with a specific style
+ * @param {Phaser.Scene} scene - The Phaser scene
+ * @param {number} color - The color in hex format
+ * @param {number} level - Current level (1-10) determines style
+ * @param {string} key - Texture key to create
+ */
+ static createBlockTexture(scene, color, level, key) {
+ const graphics = scene.make.graphics({ x: 0, y: 0, add: false });
+
+ // Extract RGB components
+ const r = (color >> 16) & 0xFF;
+ const g = (color >> 8) & 0xFF;
+ const b = color & 0xFF;
+
+ // Create lighter and darker shades
+ const lightColor = Phaser.Display.Color.GetColor(
+ Math.min(255, r + 60),
+ Math.min(255, g + 60),
+ Math.min(255, b + 60)
+ );
+ const darkColor = Phaser.Display.Color.GetColor(
+ Math.max(0, r - 60),
+ Math.max(0, g - 60),
+ Math.max(0, b - 60)
+ );
+
+ // Different styles based on level
+ const style = (level - 1) % 5; // 5 different styles cycling
+
+ switch (style) {
+ case 0: // Classic with border
+ this.drawClassicBlock(graphics, color, lightColor, darkColor);
+ break;
+ case 1: // Gradient style
+ this.drawGradientBlock(graphics, color, lightColor, darkColor);
+ break;
+ case 2: // Dotted pattern
+ this.drawDottedBlock(graphics, color, lightColor);
+ break;
+ case 3: // Checkered
+ this.drawCheckeredBlock(graphics, color, darkColor);
+ break;
+ case 4: // Outlined
+ this.drawOutlinedBlock(graphics, color, lightColor, darkColor);
+ break;
+ }
+
+ graphics.generateTexture(key, BLOCK_SIZE, BLOCK_SIZE);
+ graphics.destroy();
+ }
+
+ static drawClassicBlock(graphics, color, lightColor, darkColor) {
+ // Fill
+ graphics.fillStyle(color);
+ graphics.fillRect(0, 0, BLOCK_SIZE, BLOCK_SIZE);
+
+ // Light edge (top-left)
+ graphics.fillStyle(lightColor);
+ graphics.fillRect(0, 0, BLOCK_SIZE, 1);
+ graphics.fillRect(0, 0, 1, BLOCK_SIZE);
+
+ // Dark edge (bottom-right)
+ graphics.fillStyle(darkColor);
+ graphics.fillRect(0, BLOCK_SIZE - 1, BLOCK_SIZE, 1);
+ graphics.fillRect(BLOCK_SIZE - 1, 0, 1, BLOCK_SIZE);
+ }
+
+ static drawGradientBlock(graphics, color, lightColor, darkColor) {
+ // Create gradient effect with horizontal bands
+ for (let y = 0; y < BLOCK_SIZE; y++) {
+ const ratio = y / BLOCK_SIZE;
+ const r = Phaser.Math.Linear((lightColor >> 16) & 0xFF, (darkColor >> 16) & 0xFF, ratio);
+ const g = Phaser.Math.Linear((lightColor >> 8) & 0xFF, (darkColor >> 8) & 0xFF, ratio);
+ const b = Phaser.Math.Linear(lightColor & 0xFF, darkColor & 0xFF, ratio);
+ graphics.fillStyle(Phaser.Display.Color.GetColor(r, g, b));
+ graphics.fillRect(0, y, BLOCK_SIZE, 1);
+ }
+ }
+
+ static drawDottedBlock(graphics, color, lightColor) {
+ graphics.fillStyle(color);
+ graphics.fillRect(0, 0, BLOCK_SIZE, BLOCK_SIZE);
+
+ // Add dots
+ graphics.fillStyle(lightColor);
+ for (let y = 1; y < BLOCK_SIZE; y += 3) {
+ for (let x = 1; x < BLOCK_SIZE; x += 3) {
+ graphics.fillRect(x, y, 1, 1);
+ }
+ }
+ }
+
+ static drawCheckeredBlock(graphics, color, darkColor) {
+ for (let y = 0; y < BLOCK_SIZE; y += 2) {
+ for (let x = 0; x < BLOCK_SIZE; x += 2) {
+ const useMain = (x + y) % 4 === 0;
+ graphics.fillStyle(useMain ? color : darkColor);
+ graphics.fillRect(x, y, 2, 2);
+ }
+ }
+ }
+
+ static drawOutlinedBlock(graphics, color, lightColor, darkColor) {
+ // Fill
+ graphics.fillStyle(color);
+ graphics.fillRect(1, 1, BLOCK_SIZE - 2, BLOCK_SIZE - 2);
+
+ // Thick outline
+ graphics.fillStyle(darkColor);
+ graphics.fillRect(0, 0, BLOCK_SIZE, 1);
+ graphics.fillRect(0, 0, 1, BLOCK_SIZE);
+ graphics.fillRect(0, BLOCK_SIZE - 1, BLOCK_SIZE, 1);
+ graphics.fillRect(BLOCK_SIZE - 1, 0, 1, BLOCK_SIZE);
+
+ // Inner highlight
+ graphics.fillStyle(lightColor);
+ graphics.fillRect(2, 2, BLOCK_SIZE - 4, 1);
+ graphics.fillRect(2, 2, 1, BLOCK_SIZE - 4);
+ }
+}
+
diff --git a/src/utils/ColorExtractor.js b/src/utils/ColorExtractor.js
new file mode 100644
index 0000000000000000000000000000000000000000..b1614e6715e6370f40c1a29bf42bde617ae9651d
--- /dev/null
+++ b/src/utils/ColorExtractor.js
@@ -0,0 +1,84 @@
+/**
+ * Extracts dominant colors from an image to create a palette for Tetris blocks
+ */
+export default class ColorExtractor {
+ /**
+ * Extract 7 dominant colors from a texture
+ * @param {Phaser.Scene} scene - The Phaser scene
+ * @param {string} textureKey - The key of the loaded texture
+ * @returns {number[]} Array of 7 color values in hex format
+ */
+ static extractPalette(scene, textureKey) {
+ const texture = scene.textures.get(textureKey);
+ const source = texture.getSourceImage();
+
+ // Create a temporary canvas to analyze the image
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ canvas.width = source.width;
+ canvas.height = source.height;
+ ctx.drawImage(source, 0, 0);
+
+ const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ const pixels = imageData.data;
+
+ // Sample pixels (every 4th pixel for performance)
+ const colorMap = new Map();
+ for (let i = 0; i < pixels.length; i += 16) { // RGBA, skip every 4 pixels
+ const r = pixels[i];
+ const g = pixels[i + 1];
+ const b = pixels[i + 2];
+ const a = pixels[i + 3];
+
+ // Skip transparent pixels
+ if (a < 128) continue;
+
+ // Quantize colors to reduce similar shades
+ const qr = Math.round(r / 32) * 32;
+ const qg = Math.round(g / 32) * 32;
+ const qb = Math.round(b / 32) * 32;
+
+ const colorKey = (qr << 16) | (qg << 8) | qb;
+ colorMap.set(colorKey, (colorMap.get(colorKey) || 0) + 1);
+ }
+
+ // Sort colors by frequency
+ const sortedColors = Array.from(colorMap.entries())
+ .sort((a, b) => b[1] - a[1])
+ .map(entry => entry[0]);
+
+ // Get top 7 colors, ensuring variety
+ const palette = [];
+ for (let i = 0; i < sortedColors.length && palette.length < 7; i++) {
+ const color = sortedColors[i];
+
+ // Ensure color is not too dark (visible on dark backgrounds)
+ const r = (color >> 16) & 0xFF;
+ const g = (color >> 8) & 0xFF;
+ const b = color & 0xFF;
+ const brightness = (r + g + b) / 3;
+
+ if (brightness > 70) { // Skip dark colors - minimum brightness threshold
+ palette.push(color);
+ }
+ }
+
+ // Fill remaining slots with vibrant defaults if needed
+ const defaultColors = [
+ 0x00F0F0, // Cyan
+ 0xF0F000, // Yellow
+ 0xA000F0, // Purple
+ 0x00F000, // Green
+ 0xF00000, // Red
+ 0x0000F0, // Blue
+ 0xF0A000 // Orange
+ ];
+
+ while (palette.length < 7) {
+ palette.push(defaultColors[palette.length]);
+ }
+
+ return palette;
+ }
+}
+
diff --git a/src/utils/SoundGenerator.js b/src/utils/SoundGenerator.js
new file mode 100644
index 0000000000000000000000000000000000000000..5771c1d62696797b1da462d76cee5ae67a6c2de3
--- /dev/null
+++ b/src/utils/SoundGenerator.js
@@ -0,0 +1,179 @@
+/**
+ * Generate simple sound effects using Web Audio API
+ */
+export default class SoundGenerator {
+ /**
+ * Play a simple beep sound
+ * @param {number} frequency - Frequency in Hz
+ * @param {number} duration - Duration in seconds
+ * @param {number} volume - Volume (0-1)
+ */
+ static getAudioContext() {
+ if (!this.audioContext) {
+ this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+ return this.audioContext;
+ }
+
+ static playBeep(frequency, duration, volume = 0.3) {
+ try {
+ const audioContext = this.getAudioContext();
+
+ // Create oscillator
+ const oscillator = audioContext.createOscillator();
+ const gainNode = audioContext.createGain();
+
+ oscillator.connect(gainNode);
+ gainNode.connect(audioContext.destination);
+
+ oscillator.frequency.value = frequency;
+ oscillator.type = 'square'; // Retro square wave sound
+
+ // Envelope
+ gainNode.gain.setValueAtTime(volume, audioContext.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + duration);
+
+ oscillator.start(audioContext.currentTime);
+ oscillator.stop(audioContext.currentTime + duration);
+ } catch (e) {
+ console.warn('Audio not available:', e);
+ }
+ }
+
+ static playMove() {
+ this.playBeep(200, 0.05, 0.2);
+ }
+
+ static playRotate() {
+ this.playBeep(300, 0.08, 0.25);
+ }
+
+ static playDrop() {
+ this.playBeep(150, 0.15, 0.3);
+ }
+
+ static playLineClear(lineCount = 1) {
+ // Noisy 8-bit explosion
+ this.playNoisyExplosion();
+
+ // Add bonus sounds for multiple lines
+ if (lineCount === 2) {
+ setTimeout(() => this.playBeep(600, 0.15, 0.25), 100);
+ setTimeout(() => this.playBeep(800, 0.15, 0.25), 200);
+ } else if (lineCount === 3) {
+ setTimeout(() => this.playBeep(700, 0.12, 0.25), 100);
+ setTimeout(() => this.playBeep(900, 0.12, 0.25), 180);
+ setTimeout(() => this.playBeep(1100, 0.15, 0.25), 260);
+ } else if (lineCount >= 4) {
+ // Tetris! Extra exciting
+ setTimeout(() => this.playBeep(800, 0.1, 0.3), 100);
+ setTimeout(() => this.playBeep(1000, 0.1, 0.3), 180);
+ setTimeout(() => this.playBeep(1200, 0.1, 0.3), 260);
+ setTimeout(() => this.playBeep(1400, 0.2, 0.3), 340);
+ }
+ }
+
+ static playNoisyExplosion() {
+ try {
+ const audioContext = this.getAudioContext();
+
+ // Create white noise
+ const bufferSize = audioContext.sampleRate * 0.5;
+ const noiseBuffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate);
+ const noiseData = noiseBuffer.getChannelData(0);
+
+ for (let i = 0; i < bufferSize; i++) {
+ noiseData[i] = Math.random() * 2 - 1;
+ }
+
+ const noise = audioContext.createBufferSource();
+ noise.buffer = noiseBuffer;
+
+ // Filter the noise
+ const filter = audioContext.createBiquadFilter();
+ filter.type = 'lowpass';
+ filter.frequency.setValueAtTime(2000, audioContext.currentTime);
+ filter.frequency.exponentialRampToValueAtTime(80, audioContext.currentTime + 0.5);
+
+ const gainNode = audioContext.createGain();
+ gainNode.gain.setValueAtTime(0.7, audioContext.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.5);
+
+ noise.connect(filter);
+ filter.connect(gainNode);
+ gainNode.connect(audioContext.destination);
+
+ noise.start(audioContext.currentTime);
+ noise.stop(audioContext.currentTime + 0.5);
+ } catch (e) {
+ console.warn('Audio not available:', e);
+ }
+ }
+
+ static playSoftDrop() {
+ this.playBeep(120, 0.04, 0.15);
+ }
+
+ static playTetris() {
+ // Exciting sound for 4-line clear
+ this.playBeep(500, 0.1, 0.35);
+ setTimeout(() => this.playBeep(600, 0.1, 0.35), 60);
+ setTimeout(() => this.playBeep(700, 0.1, 0.35), 120);
+ setTimeout(() => this.playBeep(800, 0.2, 0.35), 180);
+ }
+
+ static playLevelUp() {
+ // Ascending tone
+ this.playBeep(400, 0.1, 0.3);
+ setTimeout(() => this.playBeep(500, 0.1, 0.3), 80);
+ setTimeout(() => this.playBeep(600, 0.1, 0.3), 160);
+ setTimeout(() => this.playBeep(700, 0.2, 0.3), 240);
+ }
+
+ static playGameOver() {
+ // Descending tone
+ this.playBeep(400, 0.15, 0.3);
+ setTimeout(() => this.playBeep(300, 0.15, 0.3), 120);
+ setTimeout(() => this.playBeep(200, 0.15, 0.3), 240);
+ setTimeout(() => this.playBeep(100, 0.3, 0.3), 360);
+ }
+
+ static playWoosh() {
+ try {
+ const audioContext = this.getAudioContext();
+
+ // Create white noise for woosh effect
+ const bufferSize = audioContext.sampleRate * 0.4;
+ const noiseBuffer = audioContext.createBuffer(1, bufferSize, audioContext.sampleRate);
+ const noiseData = noiseBuffer.getChannelData(0);
+
+ for (let i = 0; i < bufferSize; i++) {
+ noiseData[i] = Math.random() * 2 - 1;
+ }
+
+ const noise = audioContext.createBufferSource();
+ noise.buffer = noiseBuffer;
+
+ // High-pass filter for airy woosh sound
+ const filter = audioContext.createBiquadFilter();
+ filter.type = 'highpass';
+ filter.frequency.setValueAtTime(800, audioContext.currentTime);
+ filter.frequency.exponentialRampToValueAtTime(200, audioContext.currentTime + 0.4);
+
+ const gainNode = audioContext.createGain();
+ gainNode.gain.setValueAtTime(0.4, audioContext.currentTime);
+ gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.4);
+
+ noise.connect(filter);
+ filter.connect(gainNode);
+ gainNode.connect(audioContext.destination);
+
+ noise.start(audioContext.currentTime);
+ noise.stop(audioContext.currentTime + 0.4);
+ } catch (e) {
+ console.warn('Audio not available:', e);
+ }
+ }
+}
+
+
diff --git a/src/utils/SpriteBlockRenderer.js b/src/utils/SpriteBlockRenderer.js
new file mode 100644
index 0000000000000000000000000000000000000000..3f68eee314c139fcc7909178c101efa26416461c
--- /dev/null
+++ b/src/utils/SpriteBlockRenderer.js
@@ -0,0 +1,236 @@
+import { BLOCK_SIZE } from '../constants.js';
+
+/**
+ * Renders Tetris blocks using sprite sheet with color palettes from backdrops
+ */
+export default class SpriteBlockRenderer {
+ /**
+ * Create a block texture from sprite sheet with palette colors
+ * @param {Phaser.Scene} scene - The Phaser scene
+ * @param {number[]} colorPalette - Array of 7 colors extracted from backdrop
+ * @param {number} level - Current level (1-10) determines which sprite to use
+ * @param {string} key - Texture key to create
+ * @param {number} colorIndex - Which color from palette to use (0-6)
+ */
+ static createBlockTexture(scene, colorPalette, level, key, colorIndex) {
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ ctx.imageSmoothingEnabled = false;
+ canvas.width = BLOCK_SIZE;
+ canvas.height = BLOCK_SIZE;
+
+ // Get the color to use
+ const color = colorPalette[colorIndex % colorPalette.length];
+ const r = (color >> 16) & 0xFF;
+ const g = (color >> 8) & 0xFF;
+ const b = color & 0xFF;
+
+ // Get the sprite sheet and extract pattern
+ const spriteSheet = scene.textures.get('blocks-spritesheet').getSourceImage();
+ const spriteX = (level - 1) * BLOCK_SIZE;
+
+ const tempCanvas = document.createElement('canvas');
+ const tempCtx = tempCanvas.getContext('2d');
+ tempCtx.imageSmoothingEnabled = false;
+ tempCanvas.width = spriteSheet.width;
+ tempCanvas.height = spriteSheet.height;
+ tempCtx.drawImage(spriteSheet, 0, 0);
+
+ const spriteData = tempCtx.getImageData(spriteX, 0, BLOCK_SIZE, BLOCK_SIZE);
+ const pixels = spriteData.data;
+
+ // Create output image data
+ const outputData = ctx.createImageData(BLOCK_SIZE, BLOCK_SIZE);
+ const output = outputData.data;
+
+ // Colorize: use grayscale brightness to modulate the base color
+ // Grayscale values create depth (lighter/darker variations)
+ for (let i = 0; i < pixels.length; i += 4) {
+ const alpha = pixels[i + 3];
+
+ if (alpha > 0) {
+ // Get grayscale brightness (0-255)
+ const brightness = pixels[i]; // R channel (grayscale, so R=G=B)
+
+ // Normalize brightness to a multiplier (0.5 to 1.5)
+ // 128 (50% gray) = 1.0x (base color)
+ // 0 (black) = 0.5x (darkest)
+ // 255 (white) = 1.5x (lightest)
+ const multiplier = 0.5 + (brightness / 255) * 1.0;
+
+ // Apply brightness multiplier to base color
+ output[i] = Math.min(255, Math.floor(r * multiplier));
+ output[i + 1] = Math.min(255, Math.floor(g * multiplier));
+ output[i + 2] = Math.min(255, Math.floor(b * multiplier));
+ output[i + 3] = 255;
+ } else {
+ // Transparent pixel
+ output[i] = 0;
+ output[i + 1] = 0;
+ output[i + 2] = 0;
+ output[i + 3] = 0;
+ }
+ }
+
+ ctx.putImageData(outputData, 0, 0);
+
+ // Create texture and set nearest neighbor
+ const texture = scene.textures.addCanvas(key, canvas);
+ texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+ }
+
+ /**
+ * Create a crush animation frame texture
+ * @param {Phaser.Scene} scene - The Phaser scene
+ * @param {number} color - The color to apply
+ * @param {number} frameIndex - Which frame (0-4)
+ * @param {string} key - Texture key to create
+ */
+ static createCrushTexture(scene, color, frameIndex, key) {
+ // Check if texture already exists
+ if (scene.textures.exists(key)) {
+ return;
+ }
+
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ ctx.imageSmoothingEnabled = false;
+ canvas.width = BLOCK_SIZE;
+ canvas.height = BLOCK_SIZE;
+
+ const r = (color >> 16) & 0xFF;
+ const g = (color >> 8) & 0xFF;
+ const b = color & 0xFF;
+
+ // Get the crush sprite sheet
+ const spriteSheet = scene.textures.get('crush-spritesheet').getSourceImage();
+ const spriteX = frameIndex * BLOCK_SIZE;
+
+ const tempCanvas = document.createElement('canvas');
+ const tempCtx = tempCanvas.getContext('2d');
+ tempCtx.imageSmoothingEnabled = false;
+ tempCanvas.width = spriteSheet.width;
+ tempCanvas.height = spriteSheet.height;
+ tempCtx.drawImage(spriteSheet, 0, 0);
+
+ const spriteData = tempCtx.getImageData(spriteX, 0, BLOCK_SIZE, BLOCK_SIZE);
+ const pixels = spriteData.data;
+
+ const outputData = ctx.createImageData(BLOCK_SIZE, BLOCK_SIZE);
+ const output = outputData.data;
+
+ // Apply grayscale brightness to color (darker = more visible, lighter/white = transparent)
+ for (let i = 0; i < pixels.length; i += 4) {
+ const brightness = pixels[i]; // Grayscale R channel
+ const alpha = pixels[i + 3];
+
+ // Light pixels or transparent become fully transparent
+ if (brightness >= 200 || alpha === 0) {
+ output[i] = 0;
+ output[i + 1] = 0;
+ output[i + 2] = 0;
+ output[i + 3] = 0;
+ } else {
+ // Darker pixels get colored - use brightness to modulate color intensity
+ // Darker sprite pixels = darker colored blocks
+ const multiplier = 0.3 + (brightness / 255) * 0.9;
+ output[i] = Math.min(255, Math.floor(r * multiplier));
+ output[i + 1] = Math.min(255, Math.floor(g * multiplier));
+ output[i + 2] = Math.min(255, Math.floor(b * multiplier));
+ output[i + 3] = 255;
+ }
+ }
+
+ ctx.putImageData(outputData, 0, 0);
+
+ const texture = scene.textures.addCanvas(key, canvas);
+ if (texture) {
+ texture.setFilter(Phaser.Textures.FilterMode.NEAREST);
+ }
+ }
+
+ /**
+ * Subtly enhance colors with 20% extra contrast
+ * @param {number[]} palette - Original palette from backdrop
+ * @returns {number[]} Enhanced palette with subtle contrast boost
+ */
+ static enhancePalette(palette) {
+ const enhanced = [];
+
+ for (let i = 0; i < palette.length; i++) {
+ let color = palette[i];
+ let r = (color >> 16) & 0xFF;
+ let g = (color >> 8) & 0xFF;
+ let b = color & 0xFF;
+
+ // Add 20% contrast: push values away from middle gray (128)
+ const contrastFactor = 0.2;
+ r = Math.min(255, Math.max(0, Math.floor(128 + (r - 128) * (1 + contrastFactor))));
+ g = Math.min(255, Math.max(0, Math.floor(128 + (g - 128) * (1 + contrastFactor))));
+ b = Math.min(255, Math.max(0, Math.floor(128 + (b - 128) * (1 + contrastFactor))));
+
+ enhanced.push((r << 16) | (g << 8) | b);
+ }
+
+ return enhanced;
+ }
+
+ /**
+ * Ensure colors in palette are distinct from each other
+ * @param {number[]} palette - Color palette
+ * @returns {number[]} Palette with distinct colors
+ */
+ static ensureDistinctColors(palette) {
+ const result = [palette[0]];
+
+ for (let i = 1; i < palette.length; i++) {
+ let color = palette[i];
+ let attempts = 0;
+
+ // Check if too similar to existing colors
+ while (attempts < 10) {
+ let tooSimilar = false;
+
+ for (let j = 0; j < result.length; j++) {
+ if (this.colorDistance(color, result[j]) < 100) {
+ tooSimilar = true;
+ break;
+ }
+ }
+
+ if (!tooSimilar) break;
+
+ // Adjust color
+ let r = (color >> 16) & 0xFF;
+ let g = (color >> 8) & 0xFF;
+ let b = color & 0xFF;
+
+ r = (r + 60) % 256;
+ g = (g + 40) % 256;
+ b = (b + 80) % 256;
+
+ color = (r << 16) | (g << 8) | b;
+ attempts++;
+ }
+
+ result.push(color);
+ }
+
+ return result;
+ }
+
+ /**
+ * Calculate color distance
+ */
+ static colorDistance(c1, c2) {
+ const r1 = (c1 >> 16) & 0xFF;
+ const g1 = (c1 >> 8) & 0xFF;
+ const b1 = c1 & 0xFF;
+ const r2 = (c2 >> 16) & 0xFF;
+ const g2 = (c2 >> 8) & 0xFF;
+ const b2 = c2 & 0xFF;
+
+ return Math.sqrt((r1-r2)**2 + (g1-g2)**2 + (b1-b2)**2);
+ }
+}
+