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:

+
    +
  1. Click "Download Template" to save the template image
  2. +
  3. Open the template in your image editor (Photoshop, GIMP, etc.)
  4. +
  5. The BLACK area with MAGENTA border is the play area (80×160 pixels)
  6. +
  7. The play area will be BLACK in-game, so design around it
  8. +
  9. Create your artwork on layers below the template
  10. +
  11. Delete the template layer when done
  12. +
  13. Export as 256×224 PNG
  14. +
  15. Save to public/assets/backdrops/level-X/backdrop.png
  16. +
+
+ + + + + + +
+

Specifications:

+ +
+ + + + + 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); + } +} +