tetris klaar

This commit is contained in:
martijn.diersmann@pm.me
2026-09-26 20:38:55 +02:00
commit 4c1f670e9f
16 changed files with 1565 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
'use strict';
/* Centrale configuratie voor het spel */
const CONSTANTS = {
COLS: 10, // breedte van het bord
ROWS: 20, // hoogte van het bord
CELL: 30, // pixelgrootte van een cel op het bord
PREVIEW_CELL: 18, // pixelgrootte van een cel in de previews
PREVIEW_COUNT: 3, // aantal zichtbare volgende blokken
COLORS: {
I: '#00e5f0',
O: '#f7d000',
T: '#b14ce0',
S: '#35d23d',
Z: '#f03c3c',
J: '#3c6cf0',
L: '#f09a20'
},
SCORE: {
SINGLE: 100, // 1 rij
DOUBLE: 300, // 2 rijen
TRIPLE: 500, // 3 rijen
TETRIS: 800, // 4 rijen
SOFT_DROP: 1, // punt per cel bij zachte drop
HARD_DROP: 2, // punten per cel bij harde drop
LINES_PER_LEVEL: 10 // aantal rijen nodig voor een nieuw level
},
GRAVITY: [800, 720, 630, 550, 470, 380, 300, 220, 160, 100,
80, 70, 60, 50, 40, 30, 20, 15, 10, 10], // ms per zetje per level
HIGHSCORE_KEY: 'neo-tetris.highscores',
PLAYER_NAME_KEY: 'neo-tetris.playername',
MAX_HIGHSCORES: 10
};
/* Punten per aantal tegelijk gewiste rijen (index = aantal rijen) */
const SCORE_POINTS = [0, 100, 300, 500, 800];
/* Definitie van de 7 tetromino's als matrices (1 = gevuld blokje) */
const SHAPES = {
I: [
[0, 0, 0, 0],
[1, 1, 1, 1],
[0, 0, 0, 0],
[0, 0, 0, 0]
],
O: [
[1, 1],
[1, 1]
],
T: [
[0, 1, 0],
[1, 1, 1],
[0, 0, 0]
],
S: [
[0, 1, 1],
[1, 1, 0],
[0, 0, 0]
],
Z: [
[1, 1, 0],
[0, 1, 1],
[0, 0, 0]
],
J: [
[1, 0, 0],
[1, 1, 1],
[0, 0, 0]
],
L: [
[0, 0, 1],
[1, 1, 1],
[0, 0, 0]
]
};
const PIECE_TYPES = Object.keys(SHAPES);
/* Eenvoudige wall-kicks die proberen een blokje langs obstakels te schuiven bij rotatie */
const WALL_KICKS = [
[ 0, 0],
[-1, 0],
[ 1, 0],
[ 0, -1],
[-2, 0],
[ 2, 0]
];
+274
View File
@@ -0,0 +1,274 @@
'use strict';
/* ---------- DOM-referenties ---------- */
const boardCanvas = document.getElementById('canvas-board');
const holdCanvas = document.getElementById('canvas-hold');
const nextCanvas = document.getElementById('canvas-next');
const elScore = document.getElementById('stat-score');
const elLines = document.getElementById('stat-lines');
const elLevel = document.getElementById('stat-level');
const overlay = document.getElementById('overlay');
const overlayTitle = document.getElementById('overlay-title');
const overlaySub = document.getElementById('overlay-sub');
const nameForm = document.getElementById('name-form');
const nameInput = document.getElementById('name-input');
const nameError = document.getElementById('name-error');
const submitBtn = document.getElementById('btn-submit');
const btnPrimary = document.getElementById('btn-primary');
const btnSecondary = document.getElementById('btn-secondary');
const highscoreList = document.getElementById('highscore-list');
const game = new TetrisGame();
game.onUpdate = renderUI;
let gameOverHandled = false;
/* ---------- highscore-opslag in localStorage ---------- */
function loadScores() {
try {
const raw = localStorage.getItem(CONSTANTS.HIGHSCORE_KEY);
const list = raw ? JSON.parse(raw) : [];
return Array.isArray(list) ? list : [];
} catch (e) {
return [];
}
}
function saveScores(list) {
try {
localStorage.setItem(
CONSTANTS.HIGHSCORE_KEY,
JSON.stringify(list.slice(0, CONSTANTS.MAX_HIGHSCORES))
);
} catch (e) {
/* opslag niet beschikbaar (bv. private browsing) - negeren */
}
}
function qualifies(score) {
if (score <= 0) return false;
const list = loadScores();
if (list.length < CONSTANTS.MAX_HIGHSCORES) return true;
return score > list[list.length - 1].score;
}
function recordScore(name, score, lines, level) {
const list = loadScores();
const entry = {
name: name || 'Speler',
score,
lines,
level,
date: new Date().toISOString()
};
list.push(entry);
list.sort((a, b) => b.score - a.score || a.date.localeCompare(b.date));
saveScores(list);
return list;
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderHighscores() {
const list = loadScores();
highscoreList.innerHTML = '';
if (list.length === 0) {
const li = document.createElement('li');
li.className = 'no-scores';
li.textContent = 'Nog geen scores - wees de eerste!';
highscoreList.appendChild(li);
return;
}
list.forEach((entry, i) => {
const li = document.createElement('li');
const rank = document.createElement('span');
rank.className = 'hs-rank';
rank.textContent = i + 1;
const name = document.createElement('span');
name.className = 'hs-name';
name.textContent = entry.name;
const score = document.createElement('span');
score.className = 'hs-score';
score.textContent = entry.score;
li.appendChild(rank);
li.appendChild(name);
li.appendChild(score);
highscoreList.appendChild(li);
});
}
/* ---------- UI-panelen verversen ---------- */
function renderUI() {
elScore.textContent = game.score;
elLines.textContent = game.lines;
elLevel.textContent = game.level;
drawHold(holdCanvas, game.hold);
drawNext(nextCanvas, game);
}
/* ---------- overlays (pauze / game over) ---------- */
function setOverlay(title, sub, opts = {}) {
overlayTitle.textContent = title;
overlaySub.textContent = sub || '';
nameForm.classList.toggle('hidden', !opts.showName);
btnPrimary.textContent = opts.primaryLabel || 'Opnieuw spelen';
btnSecondary.classList.toggle('hidden', !opts.showSecondary);
overlay.classList.remove('hidden');
}
function hideOverlay() {
overlay.classList.add('hidden');
}
function togglePause() {
if (game.over) return;
game.paused = !game.paused;
if (game.paused) {
setOverlay('PAUZE', 'Druk op P of "Doorgaan" om verder te spelen.', {
showSecondary: true,
primaryLabel: 'Opnieuw spelen'
});
} else {
hideOverlay();
}
}
function restartGame() {
game.reset();
game.paused = false;
gameOverHandled = false;
nameForm.classList.add('hidden');
nameError.classList.add('hidden');
hideOverlay();
renderUI();
}
function onGameOver() {
const topTen = qualifies(game.score);
if (topTen) {
nameInput.value = localStorage.getItem(CONSTANTS.PLAYER_NAME_KEY) || '';
setOverlay('GAME OVER', 'Top 10! Voer je naam in:', {
showName: true,
primaryLabel: 'Opnieuw spelen'
});
nameInput.focus();
} else {
setOverlay('GAME OVER', `Score: ${game.score}`, {
primaryLabel: 'Opnieuw spelen'
});
}
renderHighscores();
}
function handleSubmitScore() {
const name = nameInput.value.trim();
if (!name) {
nameError.classList.remove('hidden');
nameInput.focus();
return;
}
localStorage.setItem(CONSTANTS.PLAYER_NAME_KEY, name);
recordScore(name.slice(0, 12), game.score, game.lines, game.level);
nameForm.classList.add('hidden');
overlaySub.textContent = 'Score opgeslagen in de highscore-lijst!';
renderHighscores();
btnPrimary.focus();
}
/* ---------- game-loop ---------- */
let lastTime = performance.now();
function loop(now) {
const dt = Math.min((now - lastTime) / 1000, 0.25);
lastTime = now;
game.update(dt * 1000);
drawBoard(boardCanvas, game);
renderUI();
if (game.over && !gameOverHandled) {
gameOverHandled = true;
onGameOver();
}
requestAnimationFrame(loop);
}
/* ---------- toetsenbord ---------- */
const KEYMAP = {
ArrowLeft: { fn: () => game.move(-1), prevent: true },
ArrowRight: { fn: () => game.move(1), prevent: true },
ArrowDown: { fn: () => game.softDrop(), prevent: true },
ArrowUp: { fn: () => game.rotate(1), prevent: true },
KeyX: { fn: () => game.rotate(1), prevent: true },
KeyZ: { fn: () => game.rotate(-1), prevent: false },
Space: { fn: () => game.hardDrop(), prevent: true },
KeyC: { fn: () => game.holdPiece(), prevent: false },
KeyP: { fn: () => togglePause(), prevent: false },
KeyR: { fn: () => restartGame(), prevent: false }
};
window.addEventListener('keydown', (event) => {
// typen in het naamveld: alleen Enter voor opslaan
if (event.target === nameInput) {
if (event.key === 'Enter') {
event.preventDefault();
handleSubmitScore();
}
return;
}
if (event.key === 'Enter' && !overlay.classList.contains('hidden')) {
if (game.over) restartGame();
else if (game.paused) togglePause();
return;
}
const binding = KEYMAP[event.code];
if (!binding) return;
if (binding.prevent) event.preventDefault();
binding.fn();
});
/* ---------- knoppen + touch-controls ---------- */
btnPrimary.addEventListener('click', () => restartGame());
btnSecondary.addEventListener('click', () => {
if (game.paused) togglePause();
});
submitBtn.addEventListener('click', handleSubmitScore);
// voorkom dat Enter in het naamveld de pagina ververst (default form-submit)
nameForm.addEventListener('submit', (e) => {
e.preventDefault();
handleSubmitScore();
});
const ACTION_MAP = {
rotate: () => game.rotate(1),
rotateCcw: () => game.rotate(-1),
hold: () => game.holdPiece(),
hard: () => game.hardDrop(),
left: () => game.move(-1),
right: () => game.move(1),
drop: () => game.softDrop()
};
document.querySelectorAll('.t-btn').forEach((btn) => {
btn.addEventListener('click', () => {
if (ACTION_MAP[btn.dataset.action]) ACTION_MAP[btn.dataset.action]();
});
});
/* ---------- start ---------- */
renderUI();
renderHighscores();
requestAnimationFrame(loop);
+141
View File
@@ -0,0 +1,141 @@
'use strict';
/* Tekenen van een blokje met simpele 3D-schaduwen */
function drawCell(ctx, px, py, size, color) {
ctx.fillStyle = color;
ctx.fillRect(px, py, size, size);
ctx.fillStyle = 'rgba(255,255,255,0.22)';
ctx.fillRect(px, py, size, size * 0.28);
ctx.fillRect(px, py, size * 0.28, size);
ctx.fillStyle = 'rgba(0,0,0,0.25)';
ctx.fillRect(px, py + size * 0.72, size, size * 0.28);
ctx.fillRect(px + size * 0.72, py, size * 0.28, size);
}
/* Ghost piece: alleen een omlijning, half transparant */
function drawGhostCell(ctx, px, py, size, color) {
ctx.save();
ctx.globalAlpha = 0.45;
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(2, size * 0.12);
ctx.strokeRect(px + 3, py + 3, size - 6, size - 6);
ctx.restore();
}
/* Het hoofd-bord */
function drawBoard(canvas, game) {
const ctx = canvas.getContext('2d');
const size = CONSTANTS.CELL;
const W = CONSTANTS.COLS * size;
const H = CONSTANTS.ROWS * size;
ctx.clearRect(0, 0, W, H);
// achtergrond met subtiel verloop
const grad = ctx.createLinearGradient(0, 0, 0, H);
grad.addColorStop(0, '#121722');
grad.addColorStop(1, '#070a11');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, W, H);
// gridlijnen
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
for (let c = 1; c < CONSTANTS.COLS; c++) {
ctx.beginPath();
ctx.moveTo(c * size, 0);
ctx.lineTo(c * size, H);
ctx.stroke();
}
for (let r = 1; r < CONSTANTS.ROWS; r++) {
ctx.beginPath();
ctx.moveTo(0, r * size);
ctx.lineTo(W, r * size);
ctx.stroke();
}
// vastgezette blokken
for (let r = 0; r < CONSTANTS.ROWS; r++) {
for (let c = 0; c < CONSTANTS.COLS; c++) {
const color = game.board[r][c];
if (color) drawCell(ctx, c * size, r * size, size, color);
}
}
// flitsende rijen tijdens het wissen
if (game.flashLines.length) {
ctx.fillStyle = 'rgba(255,255,255,0.75)';
for (const r of game.flashLines) {
ctx.fillRect(0, r * size, W, size);
}
}
// ghost + actief blok
if (game.piece && !game.over) {
if (!game.locking) {
const ghostY = game.ghostY();
if (ghostY !== game.piece.y) {
const m = game.piece.matrix;
for (let r = 0; r < m.length; r++) {
for (let c = 0; c < m[r].length; c++) {
if (!m[r][c]) continue;
drawGhostCell(ctx, (game.piece.x + c) * size, (ghostY + r) * size, size, game.piece.color);
}
}
}
const m = game.piece.matrix;
for (let r = 0; r < m.length; r++) {
for (let c = 0; c < m[r].length; c++) {
if (!m[r][c]) continue;
const py = (game.piece.y + r) * size;
if (py < 0) continue; // boven het bord: niet tekenen
drawCell(ctx, (game.piece.x + c) * size, py, size, game.piece.color);
}
}
}
}
}
/* Kleine preview (hold) */
function drawHold(canvas, type) {
const ctx = canvas.getContext('2d');
const cell = CONSTANTS.PREVIEW_CELL;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!type) return;
const shape = SHAPES[type];
const w = shape[0].length * cell;
const h = shape.length * cell;
const ox = (canvas.width - w) / 2;
const oy = (canvas.height - h) / 2;
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r].length; c++) {
if (!shape[r][c]) continue;
drawCell(ctx, ox + c * cell, oy + r * cell, cell, CONSTANTS.COLORS[type]);
}
}
}
/* Volgende blokken onder elkaar */
function drawNext(canvas, game) {
const ctx = canvas.getContext('2d');
const cell = CONSTANTS.PREVIEW_CELL;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const list = game.preview;
const band = canvas.height / CONSTANTS.PREVIEW_COUNT;
for (let i = 0; i < list.length; i++) {
const type = list[i];
const shape = SHAPES[type];
const w = shape[0].length * cell;
const h = shape.length * cell;
const ox = (canvas.width - w) / 2;
const oy = i * band + (band - h) / 2;
for (let r = 0; r < shape.length; r++) {
for (let c = 0; c < shape[r].length; c++) {
if (!shape[r][c]) continue;
drawCell(ctx, ox + c * cell, oy + r * cell, cell, CONSTANTS.COLORS[type]);
}
}
}
}
+263
View File
@@ -0,0 +1,263 @@
'use strict';
/* Helpers */
function cloneMatrix(matrix) {
return matrix.map((row) => row.slice());
}
/*
* Roteert een matrix 90 graden.
* dir = 1 : rechtsom (met de klok mee)
* dir = -1 : linksom (tegen de klok in)
*/
function rotateMatrix(matrix, dir) {
const n = matrix.length;
const m = matrix[0].length;
const out = Array.from({ length: m }, () => Array(n).fill(0));
for (let r = 0; r < n; r++) {
for (let c = 0; c < m; c++) {
if (!matrix[r][c]) continue;
if (dir > 0) out[c][n - 1 - r] = 1;
else out[m - 1 - c][r] = 1;
}
}
return out;
}
/**
* Volledige Tetris-spelstaat en -regels.
* De UI hangt een onUpdate-callback op voor het verversen van de panelen.
*/
class TetrisGame {
constructor() {
this.onUpdate = null;
this.reset();
}
notify() {
if (this.onUpdate) this.onUpdate();
}
reset() {
this.board = Array.from({ length: CONSTANTS.ROWS }, () =>
Array(CONSTANTS.COLS).fill(null)
);
this.queue = []; // komende bloktypes (7-bag)
this.hold = null; // vastgehouden bloktype
this.canHold = true; // mag er een keer vastgehouden worden?
this.piece = null; // actief blok
this.score = 0;
this.lines = 0;
this.level = 1;
this.over = false;
this.paused = false;
this.locking = false; // true tijdens wis-animatie
this.flashLines = []; // rijen die op dit moment flitsen
this.dropTimer = 0;
this.dropInterval = this.gravity();
this.spawn();
}
/* ---------- 7-bag generator: elk blok komt 1x per zak voor ---------- */
generateBag() {
const bag = PIECE_TYPES.slice();
for (let i = bag.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[bag[i], bag[j]] = [bag[j], bag[i]];
}
this.queue.push(...bag);
}
nextType() {
while (this.queue.length === 0) this.generateBag();
return this.queue.shift();
}
get preview() {
return this.queue.slice(0, CONSTANTS.PREVIEW_COUNT);
}
/* ---------- spawn / plaatsing ---------- */
placePiece(type) {
const shape = cloneMatrix(SHAPES[type]);
this.piece = {
type,
matrix: shape,
x: Math.floor((CONSTANTS.COLS - shape[0].length) / 2),
y: 0,
color: CONSTANTS.COLORS[type]
};
if (this.collides(this.piece.matrix, this.piece.x, this.piece.y)) {
this.over = true; // bord is te vol: game over
}
}
spawn() {
const type = this.nextType();
this.placePiece(type);
this.canHold = true;
this.dropTimer = 0;
this.dropInterval = this.gravity();
}
gravity() {
return CONSTANTS.GRAVITY[Math.min(this.level - 1, CONSTANTS.GRAVITY.length - 1)];
}
/* ---------- botsing / beweging ---------- */
collides(matrix, x, y) {
for (let r = 0; r < matrix.length; r++) {
for (let c = 0; c < matrix[r].length; c++) {
if (!matrix[r][c]) continue;
const bx = x + c;
const by = y + r;
if (bx < 0 || bx >= CONSTANTS.COLS || by >= CONSTANTS.ROWS) return true;
if (by >= 0 && this.board[by][bx]) return true;
}
}
return false;
}
ghostY() {
let y = this.piece.y;
while (!this.collides(this.piece.matrix, this.piece.x, y + 1)) y++;
return y;
}
move(dx) {
if (this.over || this.paused || this.locking) return false;
if (!this.collides(this.piece.matrix, this.piece.x + dx, this.piece.y)) {
this.piece.x += dx;
return true;
}
return false;
}
rotate(dir) {
if (this.over || this.paused || this.locking) return false;
const rotated = rotateMatrix(this.piece.matrix, dir);
for (const [xOff, yOff] of WALL_KICKS) {
if (!this.collides(rotated, this.piece.x + xOff, this.piece.y + yOff)) {
this.piece.matrix = rotated;
this.piece.x += xOff;
this.piece.y += yOff;
return true;
}
}
return false;
}
stepDown() {
if (this.collides(this.piece.matrix, this.piece.x, this.piece.y + 1)) {
this.lock();
return false; // kon niet verder: blok is vastgezet
}
this.piece.y++;
return true;
}
softDrop() {
if (this.over || this.paused || this.locking) return;
if (this.stepDown()) this.addScore(CONSTANTS.SCORE.SOFT_DROP);
}
hardDrop() {
if (this.over || this.paused || this.locking) return;
let dropped = 0;
while (this.stepDown()) dropped++;
this.addScore(dropped * CONSTANTS.SCORE.HARD_DROP);
}
holdPiece() {
if (this.over || this.paused || this.locking || !this.canHold) return;
const current = this.piece.type;
this.canHold = false;
if (this.hold) {
const swap = this.hold;
this.hold = current;
this.placePiece(swap);
} else {
this.hold = current;
this.spawn();
this.canHold = false; // spawn() zet dit weer op true; hier terugzetten
}
this.dropTimer = 0;
this.notify();
}
addScore(pts) {
this.score += pts;
this.notify();
}
/* ---------- vastzetten + rijen wissen ---------- */
lock() {
const { matrix, x, y, color } = this.piece;
for (let r = 0; r < matrix.length; r++) {
for (let c = 0; c < matrix[r].length; c++) {
if (!matrix[r][c]) continue;
const by = y + r;
if (by < 0) {
this.over = true; // blok steekt te ver boven het bord uit
return;
}
this.board[by][x + c] = color;
}
}
const fullRows = [];
for (let r = 0; r < CONSTANTS.ROWS; r++) {
if (this.board[r].every((cell) => cell !== null)) fullRows.push(r);
}
if (fullRows.length === 0) {
this.spawn();
this.notify();
return;
}
// wis-animatie: kort flitsen, daarna pas de rijen daadwerkelijk verwijderen
this.locking = true;
this.flashLines = fullRows.slice();
const cleared = fullRows.length;
const rowsToRemove = fullRows.slice();
setTimeout(() => {
this.locking = false;
this.flashLines = [];
const kept = this.board.filter((row, r) => !rowsToRemove.includes(r));
while (kept.length < CONSTANTS.ROWS) {
kept.unshift(Array(CONSTANTS.COLS).fill(null));
}
this.board = kept;
this.lines += cleared;
this.addScore(SCORE_POINTS[cleared] * this.level);
const newLevel = Math.floor(this.lines / CONSTANTS.SCORE.LINES_PER_LEVEL) + 1;
if (newLevel !== this.level) {
this.level = newLevel;
this.dropInterval = this.gravity();
}
if (!this.over) this.spawn();
this.notify();
}, 140);
this.notify();
}
/* ---------- updates door de game-loop (delta-tijd in ms) ---------- */
update(dtMs) {
if (this.over || this.paused || this.locking) return;
this.dropTimer += dtMs;
while (this.dropTimer >= this.dropInterval) {
this.dropTimer -= this.dropInterval;
if (this.locking || this.over) return;
this.stepDown();
}
}
}