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
+6
View File
@@ -0,0 +1,6 @@
.git
.DS_Store
README.md
Makefile
k8s/
*.log
+3
View File
@@ -0,0 +1,3 @@
.DS_Store
node_modules/
*.log
+21
View File
@@ -0,0 +1,21 @@
# Serveert de statische Tetris-game via nginx.
# nginx:alpine draait standaard de master als root en wisselt zelf naar de
# 'nginx'-user voor de workers; hierdoor werkt de entrypoint-configuratie
# (schrijven naar /etc/nginx/conf.d en /var/cache) zonder aanpassingen.
FROM nginx:1.27-alpine
# Game-bestanden kopieren naar de web-root
COPY game/ /usr/share/nginx/html/
# Extra headers voor veiligheid en caching
RUN { \
echo 'server_tokens off;'; \
echo 'add_header X-Content-Type-Options nosniff;'; \
echo 'gzip_types text/css application/javascript application/json image/svg+xml;'; \
} > /etc/nginx/conf.d/security.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://127.0.0.1/ || exit 1
+45
View File
@@ -0,0 +1,45 @@
IMAGE ?= neo-tetris
TAG ?= latest
CTX ?= $(shell kubectl config current-context 2>/dev/null)
.PHONY: build push run deploy undeploy preview serve test pf status
## Lokaal de game draaien (zonder Docker)
serve:
python3 -m http.server 8000 --directory game
## Docker-image bouwen
build:
docker build -t $(IMAGE):$(TAG) .
## Image naar registry pushen
push:
docker push $(IMAGE):$(TAG)
## Container lokaal draaien op poort 8080
run:
docker run --rm -p 8080:80 $(IMAGE):$(TAG)
## K8s-manifests renderen/valideren (zonder iets te deployen)
preview:
kubectl kustomize k8s/
## Alles deployen naar Kubernetes
deploy:
kubectl apply -k k8s/ --context $(CTX)
## Alles verwijderen uit Kubernetes
undeploy:
kubectl delete -k k8s/ --context $(CTX)
## Logs van de pods volgen
logs:
kubectl logs -n games -l app=neo-tetris -f --context $(CTX)
## Spelen via port-forward (kiest je lokale browser op http://localhost:8088)
pf:
kubectl port-forward -n games svc/neo-tetris 8088:80 --context $(CTX)
## Status van de deployment bekijken
status:
kubectl -n games get pods,svc --context $(CTX)
+105
View File
@@ -0,0 +1,105 @@
# Neo Tetris
Een Tetris-clone als pure browser-game (HTML/CSS/JS + Canvas), zonder backend
of database. De highscore wordt per browser bijgehouden via `localStorage`.
Het geheel draait als statische content achter nginx en is bedoeld om in
Kubernetes te draaien.
## Speel het spel
**Besturing:**
| Toets | Actie |
| ---------------- | ---------------------- |
| `◀` / `▶` | verschuiven |
| `▼` | zacht laten zakken |
| `▲` / `X` | rechtsom draaien |
| `Z` | linksom draaien |
| `Spatie` | hard drop |
| `C` | hold |
| `P` | pauze |
| `R` | opnieuw starten |
Regels: vorm lijnen met de 7 tetromino's. Elke 10 lijnen ga je een level omhoog
en valt het blok sneller. Punten: 1 rij = 100, 2 = 300, 3 = 500, 4 = 800
(komma's vermenigvuldigd met je level), plus bonus voor hard/soft drop.
Verdien je een top-10 score, dan kun je je naam invullen.
## Projectstructuur
```
game/ # de statische game (index.html, css/, js/)
k8s/ # Kubernetes-manifests (kustomize)
Dockerfile # nginx-image die game/ serveert
Makefile # handige commands
```
## Lokaal ontwikkelen
Zonder Docker:
```bash
make serve
# open http://localhost:8000
```
Met Docker:
```bash
make build
make run
# open http://localhost:8080
```
## Deployen naar Kubernetes
De manifests zitten in `k8s/` (kustomize) en bestaan uit:
`namespace.yaml`, `deployment.yaml` en `service.yaml` (NodePort op `30080`).
1. Pas de `image` in `k8s/deployment.yaml` aan naar jouw registry
(bijv. `ghcr.io/<gebruiker>/neo-tetris:latest`), of gebruik een lokaal
cluster (k3d/kind) met `imagePullPolicy: IfNotPresent`.
2. Deployen:
```bash
make build && make push # image in je registry zetten
make preview # manifests valideren (kubectl kustomize)
make deploy # kubectl apply -k k8s/
```
Daarna is het spel bereikbaar op het **NodePort**-adres van elk knooppunt:
```text
http://<kube-node-ip>:30080/
```
De game is volledig stateless en kan dus eenvoudig horizontaal worden
opgeschaald (`kubectl scale deployment neo-tetris -n games --replicas=5`).
### Lokaal testen met kind (op macOS)
Op macOS draait kind in een VM en is het interne node-IP niet vanaf de host
bereikbaar. Gebruik daarvoor `kind-config.yaml` in deze repo, die poort
`30080` van het knooppunt naar `localhost` doorzet:
```bash
kind create cluster --config kind-config.yaml --name kind
kind load docker-image neo-tetris:latest --name kind
make deploy
# open http://localhost:30080
```
### Handige commando's
```bash
make status # pods/service in één oogopslag
make logs # logs van de pods volgen
make pf # alternatief: tijdelijk port-forwarden naar localhost:8088
make undeploy # alles verwijderen (kubectl delete -k k8s/)
```
## Highscores
Scores staan in `localStorage` (top 10 per browser). Wil je later één gedeelde
ranglijst voor alle spelers, dan kun je er een klein stateless API'tje
(met een database) voor toevoegen zonder de game zelf te veranderen.
+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();
}
}
}
+57
View File
@@ -0,0 +1,57 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: neo-tetris
namespace: games
labels:
app: neo-tetris
spec:
replicas: 2
selector:
matchLabels:
app: neo-tetris
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: neo-tetris
spec:
containers:
- name: game
# Pas dit aan naar je registry (bijv. ghcr.io/<user>/neo-tetris:latest)
image: neo-tetris:latest
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 80
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
readinessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 2
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /
port: http
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
securityContext:
# nginx-master draait als root en wisselt zelf naar de 'nginx'-user;
# daarom geen readOnlyRootFilesystem of capabilities.drop hier.
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
runAsNonRoot: false
+15
View File
@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
# Optioneel: eigen namespace naast 'games' kiezen via:
# namespace: games
labels:
- pairs:
app: neo-tetris
includeSelectors: true
+7
View File
@@ -0,0 +1,7 @@
apiVersion: v1
kind: Namespace
metadata:
name: games
labels:
app.kubernetes.io/name: neo-tetris
app.kubernetes.io/part-of: browser-games
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: Service
metadata:
name: neo-tetris
namespace: games
labels:
app: neo-tetris
spec:
type: NodePort
selector:
app: neo-tetris
ports:
- name: http
port: 80
targetPort: http
nodePort: 30080
+13
View File
@@ -0,0 +1,13 @@
# Lokale kind-clusterconfig voor neo-tetris.
# Map poort 30080 (NodePort) en 80/443 (optioneel, voor een toekomstige ingress)
# door naar de host, zodat de game vanaf de Mac bereikbaar is.
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: kind
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080
hostPort: 30080
protocol: TCP
listenAddress: "127.0.0.1"