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
+403
View File
@@ -0,0 +1,403 @@
/* ---------- basis ---------- */
:root {
--bg: #070a11;
--panel: #10141f;
--panel-border: #1e2637;
--text: #dbe4f0;
--muted: #75839c;
--accent: #35d23d;
--accent2: #3c6cf0;
--radius: 10px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
min-height: 100%;
}
body {
background: radial-gradient(1200px 800px at 50% -10%, #152137 0%, var(--bg) 60%);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 20px 16px 60px;
}
kbd {
font-family: inherit;
font-size: 0.7em;
background: #1d2638;
border: 1px solid #2c3952;
border-radius: 4px;
padding: 1px 6px;
color: var(--muted);
margin-left: 6px;
font-weight: 600;
}
/* ---------- layout ---------- */
.layout {
display: grid;
grid-template-columns: 210px 300px 260px;
gap: 22px;
justify-content: center;
align-items: start;
max-width: 1000px;
margin: 0 auto;
}
.title {
text-align: center;
font-size: 1.6rem;
font-weight: 900;
letter-spacing: 0.35em;
margin-bottom: 14px;
color: var(--text);
text-shadow: 0 0 18px rgba(53, 210, 61, 0.35), 0 0 40px rgba(60, 108, 240, 0.25);
white-space: nowrap;
}
.title span {
color: var(--accent);
}
/* ---------- panelen ---------- */
.panel {
display: flex;
flex-direction: column;
gap: 18px;
}
.box {
background: linear-gradient(180deg, var(--panel) 0%, #0d111b 100%);
border: 1px solid var(--panel-border);
border-radius: var(--radius);
padding: 14px;
}
.box-title {
font-size: 0.78rem;
letter-spacing: 0.28em;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 12px;
font-weight: 700;
}
.box canvas {
display: block;
margin: 0 auto;
}
/* statistieken */
.stats {
display: flex;
flex-direction: column;
gap: 10px;
}
.stat {
display: flex;
justify-content: space-between;
align-items: baseline;
padding-bottom: 8px;
border-bottom: 1px dashed rgba(255, 255, 255, 0.08);
}
.stat:last-child {
border-bottom: none;
padding-bottom: 0;
}
.stat .label {
color: var(--muted);
font-size: 0.85rem;
letter-spacing: 0.08em;
}
.stat .value {
font-size: 1.35rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--text);
}
/* ---------- speelbord ---------- */
.play-area {
width: 300px;
}
.board-wrap {
position: relative;
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 0 0 1px var(--panel-border), 0 20px 60px rgba(0, 0, 0, 0.55);
}
#canvas-board {
display: block;
width: 100%;
height: auto;
image-rendering: pixelated;
background: #0a0e17;
}
/* ---------- overlay ---------- */
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5, 8, 14, 0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 10;
}
.overlay.hidden {
display: none;
}
.overlay-card {
background: linear-gradient(180deg, #141a29, #0d1120);
border: 1px solid var(--panel-border);
border-radius: 14px;
padding: 26px 30px;
width: 84%;
text-align: center;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
}
.overlay-card h2 {
font-size: 1.7rem;
letter-spacing: 0.24em;
margin-bottom: 10px;
}
#overlay-sub {
color: var(--muted);
#name-form label {
font-size: 0.8rem;
color: var(--muted);
letter-spacing: 0.06em;
}
#name-form.hidden {
display: none;
}
#name-input {
background: #0a0f1a;
border: 1px solid #2c3952;
border-radius: 7px;
color: var(--text);
font-size: 1rem;
padding: 9px 12px;
outline: none;
}
#name-input:focus {
border-color: var(--accent);
}
.error {
color: #ff6b6b;
font-size: 0.8rem;
margin: 0;
}
.overlay-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.btn {
cursor: pointer;
border-radius: 8px;
padding: 11px 18px;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--text);
background: #1d2638;
border: 1px solid #2c3952;
transition: transform 0.05s ease, filter 0.15s ease;
}
.btn:hover {
filter: brightness(1.15);
}
.btn:active {
transform: scale(0.98);
}
.btn-accent {
background: linear-gradient(180deg, var(--accent), #1f9c26);
border-color: transparent;
color: #05160b;
}
.overlay-hint {
margin-top: 14px;
font-size: 0.72rem;
color: var(--muted);
letter-spacing: 0.1em;
}
/* ---------- highscores ---------- */
.highscore-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 6px;
max-height: 300px;
overflow-y: auto;
padding-right: 4px;
}
.highscore-list li {
display: flex;
align-items: baseline;
gap: 10px;
font-size: 0.9rem;
padding: 6px 8px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.03);
}
.highscore-list li.no-scores {
color: var(--muted);
font-style: italic;
background: transparent;
}
.hs-rank {
width: 20px;
color: var(--muted);
font-weight: 700;
flex-shrink: 0;
}
.hs-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hs-score {
font-weight: 800;
font-variant-numeric: tabular-nums;
color: var(--accent);
}
/* ---------- besturing ---------- */
.controls-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.controls-table td {
padding: 4px 0;
color: var(--muted);
}
.controls-table td:first-child {
width: 70px;
color: var(--text);
font-weight: 600;
}
/* ---------- touch-controls (mobiel) ---------- */
.touch-controls {
display: none;
}
/* ---------- responsief ---------- */
@media (max-width: 880px) {
.layout {
grid-template-columns: 300px;
gap: 16px;
}
.panel-left,
.panel-right {
display: grid;
grid-template-columns: 1fr 1fr;
width: 300px;
margin: 0 auto;
}
.panel-left .box:first-child {
grid-column: 1 / -1;
}
.touch-controls {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: grid;
gap: 8px;
padding: 10px 12px 14px;
background: linear-gradient(180deg, transparent, rgba(7, 10, 17, 0.95) 30%);
z-index: 20;
}
.touch-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
max-width: 360px;
margin: 0 auto;
width: 100%;
}
.t-btn {
padding: 14px 0;
font-size: 1.1rem;
font-weight: 700;
color: var(--text);
background: rgba(29, 38, 56, 0.85);
border: 1px solid #2c3952;
border-radius: 10px;
cursor: pointer;
-webkit-user-select: none;
user-select: none;
touch-action: manipulation;
}
.t-btn:active {
background: var(--accent2);
}
body {
padding-bottom: 180px;
}
}
margin-bottom: 16px;
font-size: 0.95rem;
}
#name-form {
display: flex;
flex-direction: column;
gap: 8px;
text-align: left;
margin-bottom: 14px;
}
.hidden {
display: none !important;
}
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>Neo Tetris</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' fill='%230b1118'/%3E%3Crect x='3' y='3' width='4' height='4' fill='%2335d23d'/%3E%3Crect x='9' y='3' width='4' height='4' fill='%2335d23d'/%3E%3Crect x='9' y='9' width='4' height='4' fill='%2335d23d'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<main class="layout">
<!-- Linkerpaneel: hold + statistieken -->
<section class="panel panel-left">
<div class="box">
<h2 class="box-title">HOLD <kbd>C</kbd></h2>
<canvas id="canvas-hold" width="120" height="120"></canvas>
</div>
<div class="box stats">
<div class="stat">
<span class="label">Score</span>
<span id="stat-score" class="value">0</span>
</div>
<div class="stat">
<span class="label">Lijnen</span>
<span id="stat-lines" class="value">0</span>
</div>
<div class="stat">
<span class="label">Level</span>
<span id="stat-level" class="value">1</span>
</div>
</div>
</section>
<!-- Centrum: het speelbord -->
<section class="play-area">
<h1 class="title">NEO <span>TE&shy;TRIS</span></h1>
<div class="board-wrap">
<canvas id="canvas-board" width="300" height="600"></canvas>
<div id="overlay" class="overlay hidden">
<div class="overlay-card">
<h2 id="overlay-title">PAUZE</h2>
<p id="overlay-sub"></p>
<form id="name-form" class="hidden" autocomplete="off">
<label for="name-input">Naam:</label>
<input id="name-input" type="text" maxlength="12" placeholder="Speler" />
<p id="name-error" class="error hidden">Vul een naam in.</p>
<button type="button" id="btn-submit" class="btn btn-accent">Opslaan</button>
</form>
<div class="overlay-actions">
<button type="button" id="btn-primary" class="btn btn-accent">Opnieuw spelen</button>
<button type="button" id="btn-secondary" class="btn hidden">Doorgaan</button>
</div>
<p class="overlay-hint">ENTER of klik op een knop</p>
</div>
</div>
</div>
</section>
<!-- Rechterpaneel: next + highscores + besturing -->
<section class="panel panel-right">
<div class="box">
<h2 class="box-title">NEXT</h2>
<canvas id="canvas-next" width="120" height="252"></canvas>
</div>
<div class="box">
<h2 class="box-title">HIGHSCORES</h2>
<ol id="highscore-list" class="highscore-list"></ol>
</div>
<details class="box controls" open>
<summary class="box-title">BESTURING</summary>
<table class="controls-table">
<tr><td>◀ ▶</td><td>verschuiven</td></tr>
<tr><td>▼</td><td>zacht laten zakken</td></tr>
<tr><td>▲ / X</td><td>rechtsom draaien</td></tr>
<tr><td>Z</td><td>linksom draaien</td></tr>
<tr><td>Spatie</td><td>hard drop</td></tr>
<tr><td>C</td><td>hold</td></tr>
<tr><td>P</td><td>pauze</td></tr>
<tr><td>R</td><td>opnieuw starten</td></tr>
</table>
</details>
</section>
</main>
<!-- Touch-controls (mobiel) -->
<div class="touch-controls">
<div class="touch-row">
<button type="button" class="t-btn" data-action="rotate" aria-label="Draaien">⟳</button>
<button type="button" class="t-btn" data-action="hold" aria-label="Hold">HOLD</button>
<button type="button" class="t-btn" data-action="hard" aria-label="Hard drop">⤓</button>
</div>
<div class="touch-row">
<button type="button" class="t-btn" data-action="left" aria-label="Links">◀</button>
<button type="button" class="t-btn" data-action="drop" aria-label="Omlaag">▼</button>
<button type="button" class="t-btn" data-action="right" aria-label="Rechts">▶</button>
</div>
</div>
<script src="js/constants.js"></script>
<script src="js/tetris.js"></script>
<script src="js/render.js"></script>
<script src="js/main.js"></script>
</body>
</html>
+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();
}
}
}