tetris klaar
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
README.md
|
||||||
|
Makefile
|
||||||
|
k8s/
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
*.log
|
||||||
+21
@@ -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
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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­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>
|
||||||
@@ -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
@@ -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, '>')
|
||||||
|
.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);
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user