-
-
Save shricodev/393445572cd7069a35270326ab96ace5 to your computer and use it in GitHub Desktop.
Blog - Tetris (Gemini 2.5 Pro)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!doctype html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta | |
name="viewport" | |
content="width=device-width, initial-scale=1.0, user-scalable=no" | |
/> | |
<title>HTML5 Tetris</title> | |
<style> | |
body { | |
background-color: #0a0a0a; | |
color: #e0e0e0; | |
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
justify-content: center; | |
min-height: 100vh; | |
margin: 0; | |
padding: 10px; | |
box-sizing: border-box; | |
touch-action: manipulation; | |
} | |
#game-container { | |
/* Main container for all game related elements */ | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
} | |
#stats-container { | |
width: 100%; | |
max-width: 500px; /* Max width to align with game area */ | |
display: flex; | |
justify-content: space-around; | |
margin-bottom: 10px; | |
padding: 10px; | |
background-color: #181818; | |
border-radius: 8px; | |
box-shadow: 0 0 10px rgba(0, 255, 255, 0.3); | |
box-sizing: border-box; | |
} | |
.stat-item { | |
font-size: clamp(0.8em, 2.5vw, 1.1em); /* Responsive font size */ | |
color: #fff; | |
text-align: center; | |
} | |
.stat-item span { | |
color: #00ffff; | |
font-weight: bold; | |
} | |
#game-area { | |
display: flex; | |
align-items: flex-start; | |
justify-content: center; | |
gap: clamp(10px, 2vw, 20px); /* Responsive gap */ | |
width: 100%; | |
} | |
.side-panel { | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
gap: 10px; | |
background-color: #181818; | |
border: 1px solid #333; | |
border-radius: 8px; | |
padding: clamp(5px, 1.5vw, 10px); | |
text-align: center; | |
box-shadow: 0 0 8px rgba(0, 255, 255, 0.2); | |
flex-basis: clamp(80px, 20vw, 120px); /* Responsive width */ | |
min-width: 70px; | |
} | |
.side-panel h3 { | |
margin-top: 0; | |
margin-bottom: 5px; | |
color: #00ffff; | |
font-size: clamp(0.9em, 2.5vw, 1.2em); | |
text-transform: uppercase; | |
} | |
#hold-canvas, | |
#next-canvas { | |
background-color: #101010; | |
border-radius: 4px; | |
} | |
#tetris-canvas { | |
border: 2px solid #444; | |
border-radius: 8px; | |
box-shadow: | |
0 0 15px rgba(0, 255, 255, 0.4), | |
inset 0 0 10px rgba(0, 0, 0, 0.4); | |
background-color: #101010; /* Fallback, though drawn over */ | |
/* Actual width/height set by JS, this makes it responsive */ | |
max-width: 60vw; /* Adjust as needed, relative to game layout */ | |
max-height: 80vh; | |
aspect-ratio: 0.5; /* For a 10x20 grid */ | |
} | |
#controls-container { | |
margin-top: 15px; | |
display: flex; | |
gap: clamp(5px, 1.5vw, 10px); | |
flex-wrap: wrap; /* Allow buttons to wrap */ | |
justify-content: center; | |
} | |
button { | |
background-color: #00ffff; | |
color: #0a0a0a; | |
border: none; | |
padding: clamp(8px, 2vw, 12px) clamp(10px, 2.5vw, 18px); | |
border-radius: 5px; | |
font-size: clamp(0.8em, 2vw, 1em); | |
font-weight: bold; | |
cursor: pointer; | |
transition: | |
background-color 0.2s, | |
transform 0.1s; | |
box-shadow: 0 0 8px rgba(0, 255, 255, 0.7); | |
} | |
button:hover { | |
background-color: #00bcbc; /* Darker Cyan */ | |
transform: scale(1.05); | |
} | |
button:active { | |
transform: scale(0.95); | |
} | |
#message-overlay { | |
position: fixed; /* Use fixed to cover viewport */ | |
top: 0; | |
left: 0; | |
width: 100%; | |
height: 100%; | |
background-color: rgba(0, 0, 0, 0.75); | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
justify-content: center; | |
text-align: center; | |
z-index: 100; | |
} | |
#message-overlay h2 { | |
font-size: clamp(2em, 8vw, 3.5em); | |
margin-bottom: 10px; | |
} | |
#message-overlay p { | |
font-size: clamp(1em, 4vw, 1.8em); | |
color: #e0e0e0; | |
} | |
#game-over-title { | |
color: #ff0000; | |
text-shadow: 0 0 10px #ff0000; | |
} | |
#pause-title { | |
color: #ffff00; | |
text-shadow: 0 0 10px #ffff00; | |
} | |
/* Responsive adjustments */ | |
@media (max-width: 600px) { | |
/* More aggressive stacking for small screens */ | |
#game-area { | |
flex-direction: column; | |
align-items: center; | |
} | |
#tetris-canvas { | |
order: 0; /* Playfield first */ | |
max-width: 80vw; /* Allow it to be wider in column layout */ | |
} | |
.side-panel { | |
order: 1; /* Side panels below */ | |
flex-direction: row; /* Horizontal items in side panel */ | |
justify-content: space-around; | |
width: 80vw; /* Make side panels wide */ | |
flex-basis: auto; /* Reset basis */ | |
} | |
.side-panel canvas { | |
/* Ensure canvases in side panels scale */ | |
max-width: 30%; | |
} | |
#stats-container { | |
max-width: 80vw; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<div id="game-container"> | |
<div id="stats-container"> | |
<div class="stat-item">Score: <span id="score">0</span></div> | |
<div class="stat-item">Level: <span id="level">1</span></div> | |
<div class="stat-item">Lines: <span id="lines">0</span></div> | |
<div class="stat-item">High Score: <span id="high-score">0</span></div> | |
</div> | |
<div id="game-area"> | |
<div id="game-info-left" class="side-panel"> | |
<h3>HOLD</h3> | |
<canvas id="hold-canvas"></canvas> | |
</div> | |
<canvas id="tetris-canvas"></canvas> | |
<div id="game-info-right" class="side-panel"> | |
<h3>NEXT</h3> | |
<canvas id="next-canvas"></canvas> | |
</div> | |
</div> | |
<div id="controls-container"> | |
<button id="pause-button">Pause (P)</button> | |
<button id="reset-button">Reset (R)</button> | |
<button id="fullscreen-button">Fullscreen (F)</button> | |
<button id="mute-button">Mute</button> | |
</div> | |
<div id="message-overlay" style="display: none"> | |
<h2 id="message-title"></h2> | |
<p id="message-text"></p> | |
</div> | |
</div> | |
<script> | |
(function () { | |
// DOM Elements | |
const tetrisCanvas = document.getElementById("tetris-canvas"); | |
const holdCanvas = document.getElementById("hold-canvas"); | |
const nextCanvas = document.getElementById("next-canvas"); | |
const scoreEl = document.getElementById("score"); | |
const levelEl = document.getElementById("level"); | |
const linesEl = document.getElementById("lines"); | |
const highScoreEl = document.getElementById("high-score"); | |
const pauseButton = document.getElementById("pause-button"); | |
const resetButton = document.getElementById("reset-button"); | |
const fullscreenButton = document.getElementById("fullscreen-button"); | |
const muteButton = document.getElementById("mute-button"); | |
const messageOverlay = document.getElementById("message-overlay"); | |
const messageTitleEl = document.getElementById("message-title"); | |
const messageTextEl = document.getElementById("message-text"); | |
// Canvas Contexts | |
const ctx = tetrisCanvas.getContext("2d"); | |
const holdCtx = holdCanvas.getContext("2d"); | |
const nextCtx = nextCanvas.getContext("2d"); | |
// Game Constants | |
const PLAYFIELD_COLS = 10; | |
const PLAYFIELD_ROWS = 20; | |
const HIDDEN_ROWS = 2; // Rows above visible playfield for spawning | |
const TOTAL_ROWS = PLAYFIELD_ROWS + HIDDEN_ROWS; | |
let BLOCK_SIZE = calculateBlockSize(); | |
const NEXT_QUEUE_SIZE = 3; | |
const PREVIEW_BOX_SIZE = 4; // 4x4 grid for piece previews | |
// Colors (Dark theme with neon) | |
const COLORS = { | |
I: "#00FFFF", // Cyan | |
O: "#FFFF00", // Yellow | |
T: "#AA00FF", // Purple (was #800080) | |
S: "#00FF00", // Green | |
Z: "#FF0000", // Red | |
J: "#0000FF", // Blue | |
L: "#FFA500", // Orange | |
GHOST: "rgba(255, 255, 255, 0.2)", // Ghost piece color | |
GRID: "#333333", // Grid line color | |
BACKGROUND: "#101010", // Playfield background | |
}; | |
// Tetromino Shapes (matrices for each rotation) | |
// Pivots are roughly center. Standard Tetris placements. | |
const TETROMINOES = { | |
I: { | |
color: COLORS.I, | |
shapes: [ | |
[ | |
[0, 0, 0, 0], | |
[1, 1, 1, 1], | |
[0, 0, 0, 0], | |
[0, 0, 0, 0], | |
], // 0 | |
[ | |
[0, 0, 1, 0], | |
[0, 0, 1, 0], | |
[0, 0, 1, 0], | |
[0, 0, 1, 0], | |
], // 1 | |
[ | |
[0, 0, 0, 0], | |
[0, 0, 0, 0], | |
[1, 1, 1, 1], | |
[0, 0, 0, 0], | |
], // 2 (same as 0 in some systems, needs different offset) | |
[ | |
[0, 1, 0, 0], | |
[0, 1, 0, 0], | |
[0, 1, 0, 0], | |
[0, 1, 0, 0], | |
], // 3 (same as 1 in some systems) | |
], | |
pivot: { x: 2, y: 2 }, // Approximate pivot for 4x4 | |
}, | |
O: { | |
color: COLORS.O, | |
shapes: [ | |
[ | |
[1, 1], | |
[1, 1], | |
], // O-piece only has one rotation state | |
], | |
pivot: { x: 0.5, y: 0.5 }, // For 2x2 box | |
}, | |
T: { | |
color: COLORS.T, | |
shapes: [ | |
[ | |
[0, 1, 0], | |
[1, 1, 1], | |
[0, 0, 0], | |
], // 0 | |
[ | |
[0, 1, 0], | |
[0, 1, 1], | |
[0, 1, 0], | |
], // 1 | |
[ | |
[0, 0, 0], | |
[1, 1, 1], | |
[0, 1, 0], | |
], // 2 | |
[ | |
[0, 1, 0], | |
[1, 1, 0], | |
[0, 1, 0], | |
], // 3 | |
], | |
pivot: { x: 1, y: 1 }, // For 3x3 box | |
}, | |
S: { | |
color: COLORS.S, | |
shapes: [ | |
[ | |
[0, 1, 1], | |
[1, 1, 0], | |
[0, 0, 0], | |
], | |
[ | |
[0, 1, 0], | |
[0, 1, 1], | |
[0, 0, 1], | |
], | |
], | |
pivot: { x: 1, y: 1 }, | |
}, | |
Z: { | |
color: COLORS.Z, | |
shapes: [ | |
[ | |
[1, 1, 0], | |
[0, 1, 1], | |
[0, 0, 0], | |
], | |
[ | |
[0, 0, 1], | |
[0, 1, 1], | |
[0, 1, 0], | |
], | |
], | |
pivot: { x: 1, y: 1 }, | |
}, | |
J: { | |
color: COLORS.J, | |
shapes: [ | |
[ | |
[1, 0, 0], | |
[1, 1, 1], | |
[0, 0, 0], | |
], | |
[ | |
[0, 1, 1], | |
[0, 1, 0], | |
[0, 1, 0], | |
], | |
[ | |
[0, 0, 0], | |
[1, 1, 1], | |
[0, 0, 1], | |
], | |
[ | |
[0, 1, 0], | |
[0, 1, 0], | |
[1, 1, 0], | |
], | |
], | |
pivot: { x: 1, y: 1 }, | |
}, | |
L: { | |
color: COLORS.L, | |
shapes: [ | |
[ | |
[0, 0, 1], | |
[1, 1, 1], | |
[0, 0, 0], | |
], | |
[ | |
[0, 1, 0], | |
[0, 1, 0], | |
[0, 1, 1], | |
], | |
[ | |
[0, 0, 0], | |
[1, 1, 1], | |
[1, 0, 0], | |
], | |
[ | |
[1, 1, 0], | |
[0, 1, 0], | |
[0, 1, 0], | |
], | |
], | |
pivot: { x: 1, y: 1 }, | |
}, | |
}; | |
// S and Z only have 2 distinct rotation states, cycle them | |
TETROMINOES.S.shapes[2] = TETROMINOES.S.shapes[0]; | |
TETROMINOES.S.shapes[3] = TETROMINOES.S.shapes[1]; | |
TETROMINOES.Z.shapes[2] = TETROMINOES.Z.shapes[0]; | |
TETROMINOES.Z.shapes[3] = TETROMINOES.Z.shapes[1]; | |
// SRS Kick Data (x, y offsets) | |
// Standard kicks for J, L, S, T, Z pieces | |
const KICK_DATA_JLSTZ = [ | |
// 0 -> 1 (R) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: -1, y: 0 }, | |
{ x: -1, y: -1 }, | |
{ x: 0, y: 2 }, | |
{ x: -1, y: 2 }, | |
], | |
// 1 -> 0 (L) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: 1, y: 0 }, | |
{ x: 1, y: 1 }, | |
{ x: 0, y: -2 }, | |
{ x: 1, y: -2 }, | |
], | |
// 1 -> 2 (R) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: 1, y: 0 }, | |
{ x: 1, y: 1 }, | |
{ x: 0, y: -2 }, | |
{ x: 1, y: -2 }, | |
], | |
// 2 -> 1 (L) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: -1, y: 0 }, | |
{ x: -1, y: -1 }, | |
{ x: 0, y: 2 }, | |
{ x: -1, y: 2 }, | |
], | |
// 2 -> 3 (R) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: 1, y: 0 }, | |
{ x: 1, y: -1 }, | |
{ x: 0, y: 2 }, | |
{ x: 1, y: 2 }, | |
], | |
// 3 -> 2 (L) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: -1, y: 0 }, | |
{ x: -1, y: 1 }, | |
{ x: 0, y: -2 }, | |
{ x: -1, y: -2 }, | |
], | |
// 3 -> 0 (R) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: -1, y: 0 }, | |
{ x: -1, y: 1 }, | |
{ x: 0, y: -2 }, | |
{ x: -1, y: -2 }, | |
], | |
// 0 -> 3 (L) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: 1, y: 0 }, | |
{ x: 1, y: -1 }, | |
{ x: 0, y: 2 }, | |
{ x: 1, y: 2 }, | |
], | |
]; | |
// Standard kicks for I piece | |
const KICK_DATA_I = [ | |
// 0 -> 1 (R) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: -2, y: 0 }, | |
{ x: 1, y: 0 }, | |
{ x: -2, y: 1 }, | |
{ x: 1, y: -2 }, | |
], | |
// 1 -> 0 (L) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: 2, y: 0 }, | |
{ x: -1, y: 0 }, | |
{ x: 2, y: -1 }, | |
{ x: -1, y: 2 }, | |
], | |
// 1 -> 2 (R) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: -1, y: 0 }, | |
{ x: 2, y: 0 }, | |
{ x: -1, y: -2 }, | |
{ x: 2, y: 1 }, | |
], | |
// 2 -> 1 (L) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: 1, y: 0 }, | |
{ x: -2, y: 0 }, | |
{ x: 1, y: 2 }, | |
{ x: -2, y: -1 }, | |
], | |
// 2 -> 3 (R) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: 2, y: 0 }, | |
{ x: -1, y: 0 }, | |
{ x: 2, y: -1 }, | |
{ x: -1, y: 2 }, | |
], | |
// 3 -> 2 (L) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: -2, y: 0 }, | |
{ x: 1, y: 0 }, | |
{ x: -2, y: 1 }, | |
{ x: 1, y: -2 }, | |
], | |
// 3 -> 0 (R) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: 1, y: 0 }, | |
{ x: -2, y: 0 }, | |
{ x: 1, y: 2 }, | |
{ x: -2, y: -1 }, | |
], | |
// 0 -> 3 (L) | |
[ | |
{ x: 0, y: 0 }, | |
{ x: -1, y: 0 }, | |
{ x: 2, y: 0 }, | |
{ x: -1, y: -2 }, | |
{ x: 2, y: 1 }, | |
], | |
]; | |
// Note: Y-axis for kicks is often inverted in definitions. Standard SRS assumes Y increases upwards. | |
// My canvas Y increases downwards, so I've used -Y for "up" and +Y for "down" in kick data. | |
// Game State | |
let playfield; | |
let currentPiece; | |
let nextPieces; | |
let heldPiece; | |
let canHold; | |
let score; | |
let level; | |
let linesClearedTotal; | |
let highScore; | |
let isPaused; | |
let isGameOver; | |
let isMuted = false; | |
// Timers and Counters | |
let gravityTimer; | |
let gravityInterval; // ms per step down | |
const BASE_GRAVITY_INTERVAL = 800; // Level 1 speed | |
const LOCK_DELAY_THRESHOLD = 500; // ms | |
let lockDelayTimer; | |
let isLocking; | |
let pieceBag; | |
// Line Clearing Animation | |
let linesBeingCleared = []; | |
let lineClearAnimationTimer = 0; | |
const LINE_CLEAR_ANIMATION_DURATION = 300; // ms | |
// Audio Context and Sounds | |
let audioCtx; | |
try { | |
audioCtx = new (window.AudioContext || window.webkitAudioContext)(); | |
} catch (e) { | |
console.warn("Web Audio API not supported."); | |
audioCtx = null; | |
} | |
const sounds = {}; | |
function createSound(name, freq, type, duration, vol = 0.1) { | |
sounds[name] = () => { | |
if (!audioCtx || isMuted) return; | |
const oscillator = audioCtx.createOscillator(); | |
const gainNode = audioCtx.createGain(); | |
oscillator.type = type; | |
oscillator.frequency.setValueAtTime(freq, audioCtx.currentTime); | |
gainNode.gain.setValueAtTime(vol, audioCtx.currentTime); | |
gainNode.gain.exponentialRampToValueAtTime( | |
0.00001, | |
audioCtx.currentTime + duration, | |
); | |
oscillator.connect(gainNode); | |
gainNode.connect(audioCtx.destination); | |
oscillator.start(); | |
oscillator.stop(audioCtx.currentTime + duration); | |
}; | |
} | |
function createComplexSound(name, sequence, vol = 0.1) { | |
// sequence: [{freq, duration, type, delay}] | |
sounds[name] = () => { | |
if (!audioCtx || isMuted) return; | |
let currentTime = audioCtx.currentTime; | |
sequence.forEach((note) => { | |
const oscillator = audioCtx.createOscillator(); | |
const gainNode = audioCtx.createGain(); | |
oscillator.type = note.type || "sine"; | |
oscillator.frequency.setValueAtTime( | |
note.freq, | |
currentTime + (note.delay || 0), | |
); | |
gainNode.gain.setValueAtTime( | |
vol, | |
currentTime + (note.delay || 0), | |
); | |
gainNode.gain.exponentialRampToValueAtTime( | |
0.00001, | |
currentTime + (note.delay || 0) + note.duration, | |
); | |
oscillator.connect(gainNode); | |
gainNode.connect(audioCtx.destination); | |
oscillator.start(currentTime + (note.delay || 0)); | |
oscillator.stop(currentTime + (note.delay || 0) + note.duration); | |
}); | |
}; | |
} | |
// Define sounds | |
createSound("move", 200, "square", 0.05, 0.03); | |
createSound("rotate", 300, "triangle", 0.05, 0.03); | |
createSound("softDrop", 150, "sine", 0.03, 0.02); | |
createSound("hardDrop", 400, "sawtooth", 0.1, 0.05); | |
createSound("lock", 100, "sine", 0.1, 0.05); | |
createComplexSound( | |
"lineClear", | |
[ | |
{ freq: 440, duration: 0.1, type: "square" }, | |
{ freq: 880, duration: 0.1, type: "square", delay: 0.05 }, | |
], | |
0.08, | |
); | |
createComplexSound( | |
"tetrisClear", | |
[ | |
{ freq: 523.25, duration: 0.08, type: "triangle" }, // C5 | |
{ freq: 659.25, duration: 0.08, type: "triangle", delay: 0.08 }, // E5 | |
{ freq: 783.99, duration: 0.08, type: "triangle", delay: 0.16 }, // G5 | |
{ freq: 1046.5, duration: 0.15, type: "triangle", delay: 0.24 }, // C6 | |
], | |
0.1, | |
); | |
createComplexSound( | |
"gameOver", | |
[ | |
{ freq: 200, duration: 0.2, type: "sawtooth" }, | |
{ freq: 150, duration: 0.2, type: "sawtooth", delay: 0.15 }, | |
{ freq: 100, duration: 0.3, type: "sawtooth", delay: 0.3 }, | |
], | |
0.15, | |
); | |
createSound("pause", 600, "sine", 0.1, 0.05); | |
createSound("unpause", 700, "sine", 0.1, 0.05); | |
// --- Game Initialization --- | |
function initGame() { | |
playfield = Array.from({ length: TOTAL_ROWS }, () => | |
Array(PLAYFIELD_COLS).fill(0), | |
); | |
pieceBag = []; | |
fillPieceBag(); | |
nextPieces = []; | |
for (let i = 0; i < NEXT_QUEUE_SIZE; i++) { | |
nextPieces.push(getRandomPieceFromBag()); | |
} | |
spawnNewPiece(); | |
heldPiece = null; | |
canHold = true; | |
score = 0; | |
level = 1; | |
linesClearedTotal = 0; | |
highScore = localStorage.getItem("tetrisHighScore") || 0; | |
isPaused = false; | |
isGameOver = false; | |
isLocking = false; | |
lockDelayTimer = 0; | |
updateGravityInterval(); | |
updateUI(); | |
if (messageOverlay.style.display !== "none") { | |
messageOverlay.style.display = "none"; | |
} | |
if (gameLoopId) cancelAnimationFrame(gameLoopId); // Clear previous loop if any | |
gravityTimer = 0; // Reset gravity timer | |
lastTime = performance.now(); // Reset lastTime for gameLoop delta | |
gameLoop(); | |
} | |
// --- Piece Generation --- | |
function fillPieceBag() { | |
const pieceTypes = Object.keys(TETROMINOES); | |
// Shuffle | |
for (let i = pieceTypes.length - 1; i > 0; i--) { | |
const j = Math.floor(Math.random() * (i + 1)); | |
[pieceTypes[i], pieceTypes[j]] = [pieceTypes[j], pieceTypes[i]]; | |
} | |
pieceBag.push(...pieceTypes); | |
} | |
function getRandomPieceFromBag() { | |
if (pieceBag.length === 0) { | |
fillPieceBag(); | |
} | |
return pieceBag.shift(); | |
} | |
function spawnNewPiece() { | |
const type = nextPieces.shift(); | |
nextPieces.push(getRandomPieceFromBag()); | |
const pieceData = TETROMINOES[type]; | |
currentPiece = { | |
type: type, | |
rotation: 0, | |
shape: pieceData.shapes[0], | |
color: pieceData.color, | |
x: | |
Math.floor(PLAYFIELD_COLS / 2) - | |
Math.floor(pieceData.shapes[0][0].length / 2), // Centered | |
y: 0, // Start at top, hidden rows adjust actual spawn position | |
}; | |
// Adjust spawn Y for I and O pieces to align with standard Tetris guidelines | |
// (spawn flat on row 21, or row 1 if 0-indexed from top visible area) | |
// My current Y means top of piece matrix. Some pieces spawn "higher" in matrix. | |
// For example, I piece is on 2nd row of its 4x4 matrix. | |
if (type === "I") | |
currentPiece.y = 0; // I piece has its blocks on the second row of its matrix | |
else if (type === "O") | |
currentPiece.y = 0; // O piece matrix starts with blocks | |
else currentPiece.y = 0; // Other pieces, check if they have empty rows on top of matrix | |
// Check for empty top rows in shape definition | |
let topEmptyRows = 0; | |
for (let r = 0; r < currentPiece.shape.length; r++) { | |
if (currentPiece.shape[r].every((cell) => cell === 0)) { | |
topEmptyRows++; | |
} else { | |
break; | |
} | |
} | |
currentPiece.y -= topEmptyRows; // Adjust so piece appears just above visible area | |
isLocking = false; | |
lockDelayTimer = 0; | |
if ( | |
!isValidPosition(currentPiece.x, currentPiece.y, currentPiece.shape) | |
) { | |
isGameOver = true; | |
sounds.gameOver && sounds.gameOver(); | |
showGameOverMessage(); | |
updateHighScore(); | |
} | |
} | |
// --- Game Loop --- | |
let lastTime = 0; | |
let gameLoopId; | |
function gameLoop(timestamp = 0) { | |
const deltaTime = timestamp - lastTime; | |
lastTime = timestamp; | |
if (!isPaused && !isGameOver) { | |
if (linesBeingCleared.length > 0) { | |
lineClearAnimationTimer -= deltaTime; | |
if (lineClearAnimationTimer <= 0) { | |
finishLineClearing(); | |
} | |
} else { | |
gravityTimer += deltaTime; | |
if (gravityTimer >= gravityInterval) { | |
gravityTimer = 0; | |
if (isLocking) { | |
lockDelayTimer += gravityInterval; // Use actual interval step | |
if (lockDelayTimer >= LOCK_DELAY_THRESHOLD) { | |
lockPiece(); | |
} else { | |
// Still in lock delay, check if it can still move down | |
if ( | |
!checkCollision( | |
currentPiece.x, | |
currentPiece.y + 1, | |
currentPiece.shape, | |
) | |
) { | |
// Piece can move down again (e.g. block below cleared), cancel lock | |
isLocking = false; | |
lockDelayTimer = 0; | |
movePieceDown(); | |
} | |
} | |
} else { | |
movePieceDown(); | |
} | |
} | |
} | |
} | |
draw(); | |
gameLoopId = requestAnimationFrame(gameLoop); | |
} | |
// --- Piece Movement & Collision --- | |
function movePiece(dx, dy) { | |
if (isGameOver || isPaused || linesBeingCleared.length > 0) | |
return false; | |
const newX = currentPiece.x + dx; | |
const newY = currentPiece.y + dy; | |
if (isValidPosition(newX, newY, currentPiece.shape)) { | |
currentPiece.x = newX; | |
currentPiece.y = newY; | |
// If piece moved while in lock delay, reset lock delay | |
if (isLocking) { | |
lockDelayTimer = 0; | |
// If moved off a surface, it's no longer locking immediately | |
if ( | |
!checkCollision( | |
currentPiece.x, | |
currentPiece.y + 1, | |
currentPiece.shape, | |
) | |
) { | |
isLocking = false; | |
} | |
} | |
return true; | |
} | |
return false; | |
} | |
function movePieceDown() { | |
if (!movePiece(0, 1)) { | |
// Cannot move down | |
if (!isLocking) { | |
isLocking = true; | |
lockDelayTimer = 0; | |
// First check if we need to lock immediately (e.g., after hard drop) | |
// For now, always use lock delay. Hard drop can set lockDelayTimer high. | |
} | |
} else { | |
// Successfully moved down, reset lock status if it was about to lock from non-movement | |
// isLocking is set when piece LANDS. Moving down means it hasn't landed yet or just did. | |
} | |
} | |
function rotatePiece(direction) { | |
// 1 for clockwise (R), -1 for counter-clockwise (L) | |
if ( | |
isGameOver || | |
isPaused || | |
linesBeingCleared.length > 0 || | |
currentPiece.type === "O" | |
) | |
return; | |
const pieceData = TETROMINOES[currentPiece.type]; | |
let newRotation = | |
(currentPiece.rotation + direction + pieceData.shapes.length) % | |
pieceData.shapes.length; | |
const newShape = pieceData.shapes[newRotation]; | |
// SRS Wall Kicks | |
let kickTable; | |
if (currentPiece.type === "I") { | |
kickTable = KICK_DATA_I; | |
} else { | |
kickTable = KICK_DATA_JLSTZ; | |
} | |
// Determine which kick data set to use (0->1, 1->0, etc.) | |
// direction 1 (R): 0->1, 1->2, 2->3, 3->0 | |
// direction -1 (L): 1->0, 2->1, 3->2, 0->3 (indices +4 for table) | |
let kickDataIndex; | |
if (direction === 1) { | |
// Clockwise | |
kickDataIndex = currentPiece.rotation * 2; | |
} else { | |
// Counter-clockwise | |
kickDataIndex = currentPiece.rotation * 2 + 1; | |
} | |
const kicks = kickTable[kickDataIndex]; | |
for (let i = 0; i < kicks.length; i++) { | |
const kick = kicks[i]; | |
// Apply kick: kick.x for horizontal, -kick.y for vertical because my Y is downwards | |
// but standard SRS tables assume Y upwards. | |
const testX = currentPiece.x + kick.x; | |
const testY = currentPiece.y - kick.y; // SRS Y is inverted compared to canvas Y | |
if (isValidPosition(testX, testY, newShape)) { | |
currentPiece.x = testX; | |
currentPiece.y = testY; | |
currentPiece.rotation = newRotation; | |
currentPiece.shape = newShape; | |
sounds.rotate && sounds.rotate(); | |
// If piece rotated while in lock delay, reset lock delay timer | |
if (isLocking) { | |
lockDelayTimer = 0; | |
// If rotated off a surface, it's no longer locking immediately | |
if ( | |
!checkCollision( | |
currentPiece.x, | |
currentPiece.y + 1, | |
currentPiece.shape, | |
) | |
) { | |
isLocking = false; | |
} | |
} | |
return; | |
} | |
} | |
} | |
function isValidPosition(testX, testY, shape) { | |
for (let r = 0; r < shape.length; r++) { | |
for (let c = 0; c < shape[r].length; c++) { | |
if (shape[r][c]) { | |
const boardX = testX + c; | |
const boardY = testY + r; | |
if ( | |
boardX < 0 || | |
boardX >= PLAYFIELD_COLS || | |
boardY >= TOTAL_ROWS | |
) { | |
return false; // Out of bounds (left, right, bottom) | |
} | |
// No need to check boardY < 0, pieces spawn at top and move down | |
if (boardY >= 0 && playfield[boardY][boardX]) { | |
return false; // Collision with existing block | |
} | |
} | |
} | |
} | |
return true; | |
} | |
function checkCollision(testX, testY, shape) { | |
// Simplified for just checking, not validating full position | |
for (let r = 0; r < shape.length; r++) { | |
for (let c = 0; c < shape[r].length; c++) { | |
if (shape[r][c]) { | |
const boardX = testX + c; | |
const boardY = testY + r; | |
if ( | |
boardX < 0 || | |
boardX >= PLAYFIELD_COLS || | |
boardY >= TOTAL_ROWS || | |
(boardY >= 0 && playfield[boardY][boardX]) | |
) { | |
return true; | |
} | |
} | |
} | |
} | |
return false; | |
} | |
function softDrop() { | |
if (isGameOver || isPaused || linesBeingCleared.length > 0) return; | |
if (movePiece(0, 1)) { | |
score += 1; // Soft drop bonus | |
updateUI(); | |
// Reset gravity timer to make soft drop responsive | |
gravityTimer = 0; | |
sounds.softDrop && sounds.softDrop(); | |
} else { | |
// If soft drop results in landing, start lock delay | |
if (!isLocking) { | |
isLocking = true; | |
lockDelayTimer = 0; | |
} | |
} | |
} | |
function hardDrop() { | |
if (isGameOver || isPaused || linesBeingCleared.length > 0) return; | |
let rowsDropped = 0; | |
while ( | |
isValidPosition( | |
currentPiece.x, | |
currentPiece.y + 1, | |
currentPiece.shape, | |
) | |
) { | |
currentPiece.y++; | |
rowsDropped++; | |
} | |
score += rowsDropped * 2; // Hard drop bonus | |
sounds.hardDrop && sounds.hardDrop(); | |
lockPiece(); // Lock immediately after hard drop | |
} | |
function holdPieceAction() { | |
if ( | |
isGameOver || | |
isPaused || | |
!canHold || | |
linesBeingCleared.length > 0 | |
) | |
return; | |
if (!heldPiece) { | |
heldPiece = currentPiece.type; | |
spawnNewPiece(); | |
} else { | |
const tempType = currentPiece.type; | |
const pieceData = TETROMINOES[heldPiece]; | |
currentPiece = { | |
type: heldPiece, | |
rotation: 0, | |
shape: pieceData.shapes[0], | |
color: pieceData.color, | |
x: | |
Math.floor(PLAYFIELD_COLS / 2) - | |
Math.floor(pieceData.shapes[0][0].length / 2), | |
y: 0, | |
}; | |
// Adjust spawn Y for piece from hold | |
let topEmptyRows = 0; | |
for (let r = 0; r < currentPiece.shape.length; r++) { | |
if (currentPiece.shape[r].every((cell) => cell === 0)) | |
topEmptyRows++; | |
else break; | |
} | |
currentPiece.y -= topEmptyRows; | |
heldPiece = tempType; | |
} | |
canHold = false; | |
isLocking = false; // New piece, reset lock status | |
lockDelayTimer = 0; | |
updateUI(); // To show new held piece | |
} | |
// --- Locking and Line Clearing --- | |
function lockPiece() { | |
if (!currentPiece) return; | |
for (let r = 0; r < currentPiece.shape.length; r++) { | |
for (let c = 0; c < currentPiece.shape[r].length; c++) { | |
if (currentPiece.shape[r][c]) { | |
const boardX = currentPiece.x + c; | |
const boardY = currentPiece.y + r; | |
if ( | |
boardY >= 0 && | |
boardY < TOTAL_ROWS && | |
boardX >= 0 && | |
boardX < PLAYFIELD_COLS | |
) { | |
// Ensure within bounds before assignment | |
playfield[boardY][boardX] = currentPiece.color; | |
} | |
} | |
} | |
} | |
sounds.lock && sounds.lock(); | |
isLocking = false; // Piece is locked | |
lockDelayTimer = 0; | |
checkForLineClears(); | |
if (linesBeingCleared.length === 0) { | |
// Only spawn new piece if no lines are clearing | |
spawnNewPiece(); | |
canHold = true; // Allow hold for the new piece | |
} | |
updateUI(); | |
} | |
function checkForLineClears() { | |
linesBeingCleared = []; | |
for (let r = HIDDEN_ROWS; r < TOTAL_ROWS; r++) { | |
// Check only visible rows | |
if (playfield[r].every((cell) => cell !== 0)) { | |
linesBeingCleared.push(r); | |
} | |
} | |
if (linesBeingCleared.length > 0) { | |
lineClearAnimationTimer = LINE_CLEAR_ANIMATION_DURATION; | |
if (linesBeingCleared.length === 4) | |
sounds.tetrisClear && sounds.tetrisClear(); | |
else sounds.lineClear && sounds.lineClear(); | |
} | |
} | |
function finishLineClearing() { | |
let numCleared = linesBeingCleared.length; | |
if (numCleared > 0) { | |
// Remove lines from bottom up to maintain correct indexing | |
linesBeingCleared | |
.sort((a, b) => b - a) | |
.forEach((rowIndex) => { | |
playfield.splice(rowIndex, 1); | |
playfield.unshift(Array(PLAYFIELD_COLS).fill(0)); // Add new empty line at the top | |
}); | |
linesClearedTotal += numCleared; | |
// Scoring based on Tetris Guideline | |
let pointsEarned = 0; | |
if (numCleared === 1) pointsEarned = 100 * level; | |
else if (numCleared === 2) pointsEarned = 300 * level; | |
else if (numCleared === 3) pointsEarned = 500 * level; | |
else if (numCleared >= 4) pointsEarned = 800 * level; // Tetris | |
// TODO: Add T-spin and combo scoring later if desired | |
score += pointsEarned; | |
// Update level | |
const newLevel = Math.floor(linesClearedTotal / 10) + 1; | |
if (newLevel > level) { | |
level = newLevel; | |
updateGravityInterval(); | |
} | |
} | |
linesBeingCleared = []; | |
lineClearAnimationTimer = 0; | |
// After lines are cleared, spawn new piece and allow hold | |
spawnNewPiece(); | |
canHold = true; | |
updateUI(); | |
} | |
// --- Drawing --- | |
function calculateBlockSize() { | |
// Determine block size based on canvas dimensions for responsiveness | |
const availableHeight = | |
tetrisCanvas.clientHeight || | |
tetrisCanvas.parentNode.clientHeight * 0.8; // Fallback to parent if canvas not sized | |
const availableWidth = | |
tetrisCanvas.clientWidth || | |
tetrisCanvas.parentNode.clientWidth * 0.5; | |
let sizeBasedOnHeight = availableHeight / PLAYFIELD_ROWS; | |
let sizeBasedOnWidth = availableWidth / PLAYFIELD_COLS; | |
return Math.min(sizeBasedOnHeight, sizeBasedOnWidth, 30); // Cap max block size | |
} | |
function resizeCanvases() { | |
BLOCK_SIZE = Math.floor( | |
Math.min( | |
(window.innerHeight * 0.7) / PLAYFIELD_ROWS, // Ensure it fits height-wise | |
(window.innerWidth * 0.3) / PLAYFIELD_COLS, // Ensure it fits width-wise if layout demands | |
), | |
); | |
if (window.innerWidth < 600) { | |
// For column layout on small screens | |
BLOCK_SIZE = Math.floor( | |
Math.min( | |
(window.innerHeight * 0.5) / PLAYFIELD_ROWS, | |
(window.innerWidth * 0.7) / PLAYFIELD_COLS, | |
), | |
); | |
} | |
BLOCK_SIZE = Math.max(10, BLOCK_SIZE); // Minimum block size | |
tetrisCanvas.width = PLAYFIELD_COLS * BLOCK_SIZE; | |
tetrisCanvas.height = PLAYFIELD_ROWS * BLOCK_SIZE; | |
holdCanvas.width = PREVIEW_BOX_SIZE * BLOCK_SIZE; | |
holdCanvas.height = PREVIEW_BOX_SIZE * BLOCK_SIZE; | |
nextCanvas.width = PREVIEW_BOX_SIZE * BLOCK_SIZE; | |
nextCanvas.height = | |
PREVIEW_BOX_SIZE * BLOCK_SIZE * NEXT_QUEUE_SIZE + | |
(NEXT_QUEUE_SIZE - 1) * (BLOCK_SIZE / 2); | |
draw(); // Redraw after resize | |
} | |
window.addEventListener("resize", resizeCanvases); | |
function drawBlock( | |
ctxToUse, | |
x, | |
y, | |
color, | |
currentBlockSize = BLOCK_SIZE, | |
isGhost = false, | |
) { | |
if (isGhost) { | |
ctxToUse.fillStyle = COLORS.GHOST; | |
ctxToUse.strokeStyle = "rgba(255, 255, 255, 0.5)"; | |
ctxToUse.lineWidth = 1; | |
} else { | |
// Create subtle gradient | |
const gradient = ctxToUse.createLinearGradient( | |
x * currentBlockSize, | |
y * currentBlockSize, | |
x * currentBlockSize + currentBlockSize, | |
y * currentBlockSize + currentBlockSize, | |
); | |
gradient.addColorStop(0, lightenColor(color, 20)); | |
gradient.addColorStop(1, color); | |
ctxToUse.fillStyle = gradient; | |
ctxToUse.strokeStyle = darkenColor(color, 30); // Soft border | |
ctxToUse.lineWidth = currentBlockSize * 0.05; // Relative border size | |
} | |
// Rounded corners | |
const radius = currentBlockSize * 0.15; | |
ctxToUse.beginPath(); | |
ctxToUse.moveTo(x * currentBlockSize + radius, y * currentBlockSize); | |
ctxToUse.lineTo( | |
x * currentBlockSize + currentBlockSize - radius, | |
y * currentBlockSize, | |
); | |
ctxToUse.quadraticCurveTo( | |
x * currentBlockSize + currentBlockSize, | |
y * currentBlockSize, | |
x * currentBlockSize + currentBlockSize, | |
y * currentBlockSize + radius, | |
); | |
ctxToUse.lineTo( | |
x * currentBlockSize + currentBlockSize, | |
y * currentBlockSize + currentBlockSize - radius, | |
); | |
ctxToUse.quadraticCurveTo( | |
x * currentBlockSize + currentBlockSize, | |
y * currentBlockSize + currentBlockSize, | |
x * currentBlockSize + currentBlockSize - radius, | |
y * currentBlockSize + currentBlockSize, | |
); | |
ctxToUse.lineTo( | |
x * currentBlockSize + radius, | |
y * currentBlockSize + currentBlockSize, | |
); | |
ctxToUse.quadraticCurveTo( | |
x * currentBlockSize, | |
y * currentBlockSize + currentBlockSize, | |
x * currentBlockSize, | |
y * currentBlockSize + currentBlockSize - radius, | |
); | |
ctxToUse.lineTo(x * currentBlockSize, y * currentBlockSize + radius); | |
ctxToUse.quadraticCurveTo( | |
x * currentBlockSize, | |
y * currentBlockSize, | |
x * currentBlockSize + radius, | |
y * currentBlockSize, | |
); | |
ctxToUse.closePath(); | |
ctxToUse.fill(); | |
ctxToUse.stroke(); | |
} | |
function lightenColor(hex, percent) { | |
hex = hex.replace(/^#/, ""); | |
const r = parseInt(hex.substring(0, 2), 16); | |
const g = parseInt(hex.substring(2, 4), 16); | |
const b = parseInt(hex.substring(4, 6), 16); | |
const newR = Math.min( | |
255, | |
r + Math.floor((255 - r) * (percent / 100)), | |
); | |
const newG = Math.min( | |
255, | |
g + Math.floor((255 - g) * (percent / 100)), | |
); | |
const newB = Math.min( | |
255, | |
b + Math.floor((255 - b) * (percent / 100)), | |
); | |
return `#${newR.toString(16).padStart(2, "0")}${newG.toString(16).padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`; | |
} | |
function darkenColor(hex, percent) { | |
hex = hex.replace(/^#/, ""); | |
const r = parseInt(hex.substring(0, 2), 16); | |
const g = parseInt(hex.substring(2, 4), 16); | |
const b = parseInt(hex.substring(4, 6), 16); | |
const newR = Math.max(0, r - Math.floor(r * (percent / 100))); | |
const newG = Math.max(0, g - Math.floor(g * (percent / 100))); | |
const newB = Math.max(0, b - Math.floor(b * (percent / 100))); | |
return `#${newR.toString(16).padStart(2, "0")}${newG.toString(16).padStart(2, "0")}${newB.toString(16).padStart(2, "0")}`; | |
} | |
function drawPlayfield() { | |
// Background | |
ctx.fillStyle = COLORS.BACKGROUND; | |
ctx.fillRect(0, 0, tetrisCanvas.width, tetrisCanvas.height); | |
// Grid lines (subtle) | |
ctx.strokeStyle = COLORS.GRID; | |
ctx.lineWidth = 0.5; | |
for (let c = 1; c < PLAYFIELD_COLS; c++) { | |
ctx.beginPath(); | |
ctx.moveTo(c * BLOCK_SIZE, 0); | |
ctx.lineTo(c * BLOCK_SIZE, PLAYFIELD_ROWS * BLOCK_SIZE); | |
ctx.stroke(); | |
} | |
for (let r = 1; r < PLAYFIELD_ROWS; r++) { | |
ctx.beginPath(); | |
ctx.moveTo(0, r * BLOCK_SIZE); | |
ctx.lineTo(PLAYFIELD_COLS * BLOCK_SIZE, r * BLOCK_SIZE); | |
ctx.stroke(); | |
} | |
// Landed blocks | |
for (let r = HIDDEN_ROWS; r < TOTAL_ROWS; r++) { | |
for (let c = 0; c < PLAYFIELD_COLS; c++) { | |
if (playfield[r][c]) { | |
// Check if this line is being cleared | |
if (linesBeingCleared.includes(r)) { | |
const 사라짐정도 = | |
lineClearAnimationTimer / LINE_CLEAR_ANIMATION_DURATION; | |
const alpha = Math.max(0, 사라짐정도 * 1.5 - 0.5); // Fade out effect | |
const flashColor = `rgba(255, 255, 255, ${alpha})`; | |
// Draw original block slightly faded, then flash overlay | |
drawBlock( | |
ctx, | |
c, | |
r - HIDDEN_ROWS, | |
playfield[r][c], | |
BLOCK_SIZE, | |
); | |
const xPos = c * BLOCK_SIZE; | |
const yPos = (r - HIDDEN_ROWS) * BLOCK_SIZE; | |
ctx.fillStyle = flashColor; | |
ctx.fillRect(xPos, yPos, BLOCK_SIZE, BLOCK_SIZE); | |
} else { | |
drawBlock( | |
ctx, | |
c, | |
r - HIDDEN_ROWS, | |
playfield[r][c], | |
BLOCK_SIZE, | |
); | |
} | |
} | |
} | |
} | |
} | |
function drawPiece( | |
piece, | |
targetCtx, | |
offsetX = 0, | |
offsetY = 0, | |
blockSize = BLOCK_SIZE, | |
isGhost = false, | |
) { | |
const { shape, color, x, y } = piece; | |
targetCtx.globalAlpha = isGhost ? 0.5 : 1; // Ghost piece transparency | |
for (let r = 0; r < shape.length; r++) { | |
for (let c = 0; c < shape[r].length; c++) { | |
if (shape[r][c]) { | |
const drawX = x + c + offsetX; | |
const drawY = y + r + offsetY - HIDDEN_ROWS; // Adjust for hidden rows when drawing on playfield | |
// Only draw visible parts of the piece | |
if (drawY >= 0) { | |
drawBlock(targetCtx, drawX, drawY, color, blockSize, isGhost); | |
} | |
} | |
} | |
} | |
targetCtx.globalAlpha = 1; // Reset alpha | |
} | |
function drawPieceInPreview( | |
targetCtx, | |
type, | |
canvasWidth, | |
canvasHeight, | |
) { | |
targetCtx.clearRect(0, 0, canvasWidth, canvasHeight); | |
if (!type) return; | |
const pieceData = TETROMINOES[type]; | |
const shape = pieceData.shapes[0]; // Default rotation | |
const color = pieceData.color; | |
// Calculate scale and offsets to center the piece | |
const pieceHeight = shape.length; // Assuming square piece matrices | |
const pieceWidth = shape[0].length; // Or find actual max width/height of blocks | |
// Use smaller block size for previews if needed, or scale canvas drawing | |
const previewBlockSize = Math.min( | |
canvasWidth / PREVIEW_BOX_SIZE, | |
canvasHeight / PREVIEW_BOX_SIZE, | |
); | |
const offsetX = (PREVIEW_BOX_SIZE - pieceWidth) / 2; | |
const offsetY = (PREVIEW_BOX_SIZE - pieceHeight) / 2; | |
for (let r = 0; r < shape.length; r++) { | |
for (let c = 0; c < shape[r].length; c++) { | |
if (shape[r][c]) { | |
drawBlock( | |
targetCtx, | |
c + offsetX, | |
r + offsetY, | |
color, | |
previewBlockSize, | |
); | |
} | |
} | |
} | |
} | |
function drawGhostPiece() { | |
if (!currentPiece || isGameOver) return; | |
let ghostY = currentPiece.y; | |
while ( | |
isValidPosition(currentPiece.x, ghostY + 1, currentPiece.shape) | |
) { | |
ghostY++; | |
} | |
if (ghostY > currentPiece.y) { | |
const ghostPiece = { | |
...currentPiece, | |
y: ghostY, | |
color: COLORS.GHOST, | |
}; | |
drawPiece(ghostPiece, ctx, 0, 0, BLOCK_SIZE, true); | |
} | |
} | |
function drawNextQueue() { | |
nextCtx.clearRect(0, 0, nextCanvas.width, nextCanvas.height); | |
const previewHeightPerPiece = nextCanvas.height / NEXT_QUEUE_SIZE; | |
for (let i = 0; i < NEXT_QUEUE_SIZE; i++) { | |
if (nextPieces[i]) { | |
const type = nextPieces[i]; | |
const pieceData = TETROMINOES[type]; | |
const shape = pieceData.shapes[0]; | |
const color = pieceData.color; | |
const previewBlockSize = Math.min( | |
nextCanvas.width / PREVIEW_BOX_SIZE, | |
(previewHeightPerPiece / PREVIEW_BOX_SIZE) * 0.8, | |
); // 0.8 for padding | |
const pieceMatrixHeight = | |
shape.filter((row) => row.some((cell) => cell === 1)).length || | |
1; | |
const pieceMatrixWidth = | |
Math.max( | |
...shape | |
.map((row) => row.lastIndexOf(1) - row.indexOf(1) + 1) | |
.filter((w) => w > 0), | |
) || 1; | |
const offsetX = | |
(nextCanvas.width / previewBlockSize - pieceMatrixWidth) / 2; | |
// Center vertically within its slot | |
const slotCenterY = | |
(i * previewHeightPerPiece + previewHeightPerPiece / 2) / | |
previewBlockSize; | |
const offsetY = slotCenterY - pieceMatrixHeight / 2; | |
for (let r = 0; r < shape.length; r++) { | |
for (let c = 0; c < shape[r].length; c++) { | |
if (shape[r][c]) { | |
// Find actual top-left of piece blocks to align | |
let minR = shape.length, | |
minC = shape[0].length; | |
for (let sr = 0; sr < shape.length; sr++) | |
for (let sc = 0; sc < shape[sr].length; sc++) | |
if (shape[sr][sc]) { | |
minR = Math.min(minR, sr); | |
minC = Math.min(minC, sc); | |
} | |
drawBlock( | |
nextCtx, | |
c - minC + offsetX, | |
r - minR + offsetY, | |
color, | |
previewBlockSize, | |
); | |
} | |
} | |
} | |
} | |
} | |
} | |
function drawHeldPiece() { | |
drawPieceInPreview( | |
holdCtx, | |
heldPiece, | |
holdCanvas.width, | |
holdCanvas.height, | |
); | |
} | |
function draw() { | |
ctx.clearRect(0, 0, tetrisCanvas.width, tetrisCanvas.height); | |
drawPlayfield(); | |
if (!isGameOver && currentPiece) { | |
drawGhostPiece(); | |
drawPiece(currentPiece, ctx); | |
} | |
drawNextQueue(); | |
drawHeldPiece(); | |
} | |
// --- UI Updates & Messages --- | |
function updateUI() { | |
scoreEl.textContent = score; | |
levelEl.textContent = level; | |
linesEl.textContent = linesClearedTotal; | |
highScoreEl.textContent = highScore; | |
} | |
function updateHighScore() { | |
if (score > highScore) { | |
highScore = score; | |
localStorage.setItem("tetrisHighScore", highScore); | |
} | |
} | |
function updateGravityInterval() { | |
// Based on classic Tetris speeds (frames per grid cell at 60 FPS) | |
// Levels cap at 29 in terms of speed generally. | |
const levelSpeeds = [ | |
800, | |
717, | |
633, | |
550, | |
467, | |
383, | |
300, | |
217, | |
133, | |
100, // Levels 1-10 | |
83, | |
83, | |
83, | |
67, | |
67, | |
67, | |
50, | |
50, | |
50, | |
33, // Levels 11-20 | |
// Speeds continue to increase for higher levels (e.g. 33ms for 19-28, then 17ms for 29+) | |
]; | |
// For levels beyond this array, use the last defined speed or a faster one. | |
let speedIndex = Math.min(level - 1, levelSpeeds.length - 1); | |
if (level > 20 && level <= 29) speedIndex = levelSpeeds.length - 1; // 33ms | |
if (level > 29) speedIndex = levelSpeeds.length - 1; // Could go to 17ms (1 frame) | |
gravityInterval = levelSpeeds[speedIndex]; | |
} | |
function showGameOverMessage() { | |
messageTitleEl.textContent = "Game Over"; | |
messageTitleEl.id = "game-over-title"; // For specific styling | |
messageTextEl.textContent = `Score: ${score}. Press R to Restart.`; | |
messageOverlay.style.display = "flex"; | |
} | |
function togglePause() { | |
if (isGameOver) return; | |
isPaused = !isPaused; | |
pauseButton.textContent = isPaused ? "Resume (P)" : "Pause (P)"; | |
if (isPaused) { | |
sounds.pause && sounds.pause(); | |
messageTitleEl.textContent = "Paused"; | |
messageTitleEl.id = "pause-title"; | |
messageTextEl.textContent = "Press P to Resume"; | |
messageOverlay.style.display = "flex"; | |
// Stop game loop updates but keep rendering for pause screen | |
// The gameLoop structure already handles this by skipping updates if isPaused | |
} else { | |
sounds.unpause && sounds.unpause(); | |
messageOverlay.style.display = "none"; | |
// Resume game loop (gravity timer might need adjustment for pause duration if not using delta time) | |
lastTime = performance.now(); // Reset lastTime to avoid large deltaTime jump | |
gravityTimer = 0; // Optionally reset gravity timer to prevent immediate drop after unpause | |
} | |
} | |
// --- Input Handling --- | |
function handleKeyDown(event) { | |
if (isGameOver && event.key.toLowerCase() !== "r") return; // Only allow reset if game over | |
// Allow R and P always (if not game over for P) | |
if (event.key.toLowerCase() === "r") { | |
initGame(); | |
return; | |
} | |
if (event.key.toLowerCase() === "p") { | |
togglePause(); | |
return; | |
} | |
if (event.key.toLowerCase() === "f") { | |
toggleFullScreen(); | |
return; | |
} | |
if (isPaused || linesBeingCleared.length > 0) return; // No piece controls if paused or clearing lines | |
switch (event.key.toLowerCase()) { | |
case "arrowleft": | |
case "a": | |
if (movePiece(-1, 0)) sounds.move && sounds.move(); | |
break; | |
case "arrowright": | |
case "d": | |
if (movePiece(1, 0)) sounds.move && sounds.move(); | |
break; | |
case "arrowdown": | |
case "s": | |
softDrop(); | |
break; | |
case "arrowup": | |
case "w": | |
rotatePiece(1); // Clockwise | |
// rotatePiece(-1); // Counter-clockwise could be another key e.g. 'z' or 'ctrl' | |
break; | |
case " ": // Space for hard drop | |
event.preventDefault(); // Prevent page scroll | |
hardDrop(); | |
break; | |
case "shift": // Hold | |
case "c": | |
event.preventDefault(); | |
holdPieceAction(); | |
break; | |
} | |
draw(); // Redraw immediately on input for responsiveness | |
} | |
document.addEventListener("keydown", handleKeyDown); | |
// Button event listeners | |
pauseButton.addEventListener("click", togglePause); | |
resetButton.addEventListener("click", initGame); | |
fullscreenButton.addEventListener("click", toggleFullScreen); | |
muteButton.addEventListener("click", () => { | |
isMuted = !isMuted; | |
muteButton.textContent = isMuted ? "Unmute" : "Mute"; | |
}); | |
// --- Fullscreen --- | |
function toggleFullScreen() { | |
if (!document.fullscreenElement) { | |
document.documentElement.requestFullscreen().catch((err) => { | |
console.warn( | |
`Error attempting to enable full-screen mode: ${err.message} (${err.name})`, | |
); | |
}); | |
} else { | |
if (document.exitFullscreen) { | |
document.exitFullscreen(); | |
} | |
} | |
} | |
// --- Initial Setup --- | |
resizeCanvases(); // Initial sizing | |
initGame(); // Start the game | |
})(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment