commit 4c1f670e9ffcc1e028a798019d4de80b6dc6bb21 Author: martijn.diersmann@pm.me Date: Sat Sep 26 20:38:55 2026 +0200 tetris klaar diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..29fee50 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.DS_Store +README.md +Makefile +k8s/ +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..879df49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +node_modules/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fbd3e4a --- /dev/null +++ b/Dockerfile @@ -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 + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..aa6d0ca --- /dev/null +++ b/Makefile @@ -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) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9edf517 --- /dev/null +++ b/README.md @@ -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//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://: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. diff --git a/game/css/style.css b/game/css/style.css new file mode 100644 index 0000000..ac940ce --- /dev/null +++ b/game/css/style.css @@ -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; +} diff --git a/game/index.html b/game/index.html new file mode 100644 index 0000000..f72e1f0 --- /dev/null +++ b/game/index.html @@ -0,0 +1,108 @@ + + + + + + Neo Tetris + + + + +
+ +
+
+

HOLD C

+ +
+ +
+
+ Score + 0 +
+
+ Lijnen + 0 +
+
+ Level + 1 +
+
+
+ + +
+

NEO TE­TRIS

+
+ + + +
+
+ + +
+
+

NEXT

+ +
+ +
+

HIGHSCORES

+
    +
    + +
    + BESTURING + + + + + + + + + +
    ◀ ▶verschuiven
    ▼zacht laten zakken
    ▲ / Xrechtsom draaien
    Zlinksom draaien
    Spatiehard drop
    Chold
    Ppauze
    Ropnieuw starten
    +
    +
    +
    + + +
    +
    + + + +
    +
    + + + +
    +
    + + + + + + + diff --git a/game/js/constants.js b/game/js/constants.js new file mode 100644 index 0000000..eeae81e --- /dev/null +++ b/game/js/constants.js @@ -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] +]; diff --git a/game/js/main.js b/game/js/main.js new file mode 100644 index 0000000..3347bb6 --- /dev/null +++ b/game/js/main.js @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +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); diff --git a/game/js/render.js b/game/js/render.js new file mode 100644 index 0000000..ece37d5 --- /dev/null +++ b/game/js/render.js @@ -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]); + } + } + } +} diff --git a/game/js/tetris.js b/game/js/tetris.js new file mode 100644 index 0000000..1a86274 --- /dev/null +++ b/game/js/tetris.js @@ -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(); + } + } +} + diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..cc13b16 --- /dev/null +++ b/k8s/deployment.yaml @@ -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//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 diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml new file mode 100644 index 0000000..8255959 --- /dev/null +++ b/k8s/kustomization.yaml @@ -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 diff --git a/k8s/namespace.yaml b/k8s/namespace.yaml new file mode 100644 index 0000000..8096560 --- /dev/null +++ b/k8s/namespace.yaml @@ -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 diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..c7d6626 --- /dev/null +++ b/k8s/service.yaml @@ -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 + diff --git a/kind-config.yaml b/kind-config.yaml new file mode 100644 index 0000000..261cd8d --- /dev/null +++ b/kind-config.yaml @@ -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"