broadfield-dev's picture
Upload 51 files
78475cb verified
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');
});
}
}