From b8e855446442ff632fd8be9eb8d653143fff4be7 Mon Sep 17 00:00:00 2001 From: Holger Weber Date: Sat, 4 Apr 2026 15:09:32 +0200 Subject: [PATCH] Erste Version --- .gitignore | 1 + bottlesort/AGENTS.md | 4 + bottlesort/README.md | 26 ++++ bottlesort/SPEC.md | 57 +++++++ bottlesort/STYLE_GUIDE.md | 56 +++++++ bottlesort/index.html | 32 ++++ bottlesort/package.json | 9 ++ bottlesort/src/audio.js | 84 ++++++++++ bottlesort/src/gameLogic.js | 199 ++++++++++++++++++++++++ bottlesort/src/main.js | 240 +++++++++++++++++++++++++++++ bottlesort/style.css | 82 ++++++++++ bottlesort/tests/gameLogic.test.js | 53 +++++++ 12 files changed, 843 insertions(+) create mode 100644 .gitignore create mode 100644 bottlesort/AGENTS.md create mode 100644 bottlesort/README.md create mode 100644 bottlesort/SPEC.md create mode 100644 bottlesort/STYLE_GUIDE.md create mode 100644 bottlesort/index.html create mode 100644 bottlesort/package.json create mode 100644 bottlesort/src/audio.js create mode 100644 bottlesort/src/gameLogic.js create mode 100644 bottlesort/src/main.js create mode 100644 bottlesort/style.css create mode 100644 bottlesort/tests/gameLogic.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8811476 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/.codex diff --git a/bottlesort/AGENTS.md b/bottlesort/AGENTS.md new file mode 100644 index 0000000..ec2aa76 --- /dev/null +++ b/bottlesort/AGENTS.md @@ -0,0 +1,4 @@ +- Prefer simple solutions over clever ones +- Do not introduce new dependencies without reason +- Always write tests for new features +- Adapt SPEC.md if specification changes or features are added or removed \ No newline at end of file diff --git a/bottlesort/README.md b/bottlesort/README.md new file mode 100644 index 0000000..c832941 --- /dev/null +++ b/bottlesort/README.md @@ -0,0 +1,26 @@ +# Flaschen-Sortier-Spiel + +## Spiel starten +Das Spiel verwendet JavaScript-Module und muss ueber einen lokalen HTTP-Server gestartet werden (nicht per `file://`). + +Im Projektordner ausfuehren: + +```bash +cd /home/holger/Projects/Sternenlabor/minigames/bottlesort +python3 -m http.server 8000 +``` + +Danach im Browser oeffnen: + +`http://localhost:8000` + +## Steuerung +- Bewegung: `WASD` oder Pfeiltasten +- Aktion (Aufnehmen/Ablegen): `Leertaste` + +## Tests +Wenn Node.js installiert ist, im Projektordner: + +```bash +node --test +``` diff --git a/bottlesort/SPEC.md b/bottlesort/SPEC.md new file mode 100644 index 0000000..a0cf6e2 --- /dev/null +++ b/bottlesort/SPEC.md @@ -0,0 +1,57 @@ +# Flaschen-Sortier-Spiel - Produktspezifikation + +## 1. Ziel des Spiels +Der Spieler lernt, leere Flaschen nach Sorte korrekt in passende Kisten zu sortieren, zum Beispiel: +- Bierflaschen in die Bierkiste +- Wasserflaschen in die Wasserkiste + +Die Unterscheidung der Flaschensorten erfolgt visuell über: +- Farbe +- Form + +## 2. Spielprinzip +- Das Spiel läuft im Browser. +- Perspektive: Top-Down-Ansicht auf einen Level, der eine Küche darstellt. +- Im Level stehen leere Flaschen an verschiedenen Positionen. +- Der Spieler steuert ein Männchen, das Flaschen einsammelt. +- Das Männchen kann immer nur **eine** Flasche gleichzeitig tragen. +- Getragene Flaschen werden zu den passenden Kisten gebracht und dort abgelegt. + +## 3. Level-Struktur und Progression +Es gibt insgesamt **4 Level**. + +### Level 1 +- Flaschensorten: 2 +- Leere Flaschen gesamt: 10 + +### Skalierung pro weiterem Level +In jedem folgenden Level verdoppeln sich: +- Anzahl der Flaschensorten +- Anzahl der Flaschen + +Daraus ergibt sich: +- Level 2: 4 Sorten, 20 Flaschen +- Level 3: 8 Sorten, 40 Flaschen +- Level 4: 16 Sorten, 80 Flaschen + +## 4. Erfolg, Fehler und Spielende +- Ein Level ist abgeschlossen, wenn alle Flaschen korrekt einsortiert wurden. +- Wird eine Flasche in die falsche Kiste einsortiert, startet der Spieler **im aktuellen Level von vorn**. +- Nach erfolgreichem Abschluss von Level 4 ist das Spiel beendet. + +## 5. Nicht-funktionale Vorgaben +- Die visuelle Stilvorgabe ist ausgelagert in [STYLE_GUIDE.md](./STYLE_GUIDE.md), damit derselbe Stil auch für ähnliche Spiele wiederverwendet werden kann. + +## 6. Implementierungsrahmen (final festgelegt) +- Plattform: Desktop-Browser (aktuelles Chrome/Firefox/Edge) +- Steuerung: Tastatur (Pfeiltasten oder WASD), Aktionstaste zum Aufnehmen/Ablegen +- Kollisionslogik: Kollision mit Waenden/Moebeln und Kisten aktiv +- Levelziel: Alle 4 Level muessen erfolgreich abgeschlossen werden +- Fehlerfall: Bei falscher Einsortierung sofortiger Neustart des aktuellen Levels +- Zeitlimit/Punkte: Kein Zeitlimit, kein Punktesystem +- Hindernisse: Ja, Waende und weitere Hindernisse im Level +- Sortenunterscheidung: Ausschliesslich ueber Farbe und Form +- Tutorial: Kein separates Tutorial +- Sprache: Deutsch +- Audio: 8-Bit-Hintergrundmusik sowie Soundeffekte (mindestens Aufnehmen/Ablegen; weitere Effekte erlaubt) +- Speicherstand: Kein Persistenzspeicher, Neustart bei Seiten-Reload diff --git a/bottlesort/STYLE_GUIDE.md b/bottlesort/STYLE_GUIDE.md new file mode 100644 index 0000000..d9b9e8d --- /dev/null +++ b/bottlesort/STYLE_GUIDE.md @@ -0,0 +1,56 @@ +# 8-Bit Style Guide fuer Browser-Minispiele + +Diese Datei definiert einen wiederverwendbaren visuellen Stil fuer 8-Bit-Spiele mit Top-Down-Perspektive. + +## 1. Stilziel +- Klarer Retro-Look im 8-Bit-Stil +- Gut lesbare Spielfiguren und Objekte +- Schnelle Erkennbarkeit von interaktiven Elementen + +## 2. Perspektive und Raster +- Top-Down-Perspektive +- Tile-basierter Aufbau (empfohlen: 16x16 oder 32x32 Pixel pro Tile) +- Bewegungen vorzugsweise rasterbasiert oder klar quantisiert + +## 3. Pixel-Art-Regeln +- Harte Pixelkanten, keine weichgezeichneten Kanten +- Kein Anti-Aliasing innerhalb von Sprites +- Begrenzte Details pro Sprite, damit Objekte auf den ersten Blick erkennbar bleiben +- Klare Silhouetten fuer Figur, Flaschen und Kisten + +## 4. Farbpalette +- Begrenzte, konsistente Palette pro Spiel +- Hoher Kontrast zwischen: + - Hintergrund und interaktiven Objekten + - verschiedenen Flaschensorten untereinander + - Kistenarten untereinander +- Farben tragen aktiv zur Sortenunterscheidung bei + +## 5. Figuren und Objekte +- Spielfigur (Maennchen) mit klarer Blick- und Bewegungsrichtung +- Flaschen jeder Sorte mit eindeutigem Profil aus: + - Form (Silhouette) + - Farbe +- Kisten visuell passend zur Flaschensorte markiert (Farbe, Symbol oder Textlabel) + +## 6. Umgebung (Kueche) +- Kueche als klar strukturierter Spielraum mit gut lesbaren Wegen +- Dekoration sparsam einsetzen, um Gameplay-Elemente nicht zu ueberdecken +- Kollisionen mit Moebeln/Objekten visuell nachvollziehbar machen + +## 7. UI und Feedback +- Pixel-Font oder retro-nahe Schrift mit hoher Lesbarkeit +- HUD minimal halten (z. B. aktuelles Level, verbleibende Flaschen) +- Eindeutiges visuelles Feedback fuer: + - Flasche aufgenommen + - Flasche abgelegt + - korrekt/falsch einsortiert +- Bei Fehlern klarer Reset-Hinweis + +## 8. Animation und Effekte +- Kurze, klare Pixel-Animationen (z. B. 2-4 Frames fuer Laufzyklus) +- Dezente Retro-Effekte beim Einsammeln/Ablegen +- Keine ueberladenen Partikeleffekte + +## 9. Wiederverwendung in anderen Spielen +Dieser Style Guide ist absichtlich generisch gehalten und kann fuer andere Top-Down-Minispiele mit Sortier-, Sammel- oder Liefermechaniken direkt uebernommen werden. diff --git a/bottlesort/index.html b/bottlesort/index.html new file mode 100644 index 0000000..9b30c6c --- /dev/null +++ b/bottlesort/index.html @@ -0,0 +1,32 @@ + + + + + + Flaschen-Sortier-Spiel + + + +
+
+

Flaschen-Sortier-Spiel

+

Drücke eine Bewegungstaste, um zu starten.

+
+ Level 1/4 + 0/0 sortiert + Trage: nichts +
+
+ + +
+

+ Steuerung: WASD oder Pfeiltasten bewegen, Leertaste auf Flasche/Kiste zum + Aufnehmen/Ablegen. +

+
+ +
+ + + diff --git a/bottlesort/package.json b/bottlesort/package.json new file mode 100644 index 0000000..534cc39 --- /dev/null +++ b/bottlesort/package.json @@ -0,0 +1,9 @@ +{ + "name": "bottlesort", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + } +} diff --git a/bottlesort/src/audio.js b/bottlesort/src/audio.js new file mode 100644 index 0000000..a6935d0 --- /dev/null +++ b/bottlesort/src/audio.js @@ -0,0 +1,84 @@ +export class AudioManager { + constructor() { + this.ctx = null; + this.musicOn = false; + this.musicTimer = null; + this.musicStep = 0; + } + + ensureContext() { + if (!this.ctx) { + this.ctx = new (window.AudioContext || window.webkitAudioContext)(); + } + if (this.ctx.state === "suspended") { + this.ctx.resume(); + } + } + + tone(freq, duration = 0.1, type = "square", volume = 0.05) { + this.ensureContext(); + const now = this.ctx.currentTime; + const osc = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, now); + gain.gain.setValueAtTime(volume, now); + gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); + osc.connect(gain); + gain.connect(this.ctx.destination); + osc.start(now); + osc.stop(now + duration); + } + + playPickup() { + this.tone(740, 0.08, "square", 0.04); + } + + playDropOk() { + this.tone(523, 0.08, "square", 0.05); + setTimeout(() => this.tone(659, 0.1, "square", 0.05), 50); + } + + playDropWrong() { + this.tone(180, 0.2, "sawtooth", 0.06); + } + + playLevelDone() { + const notes = [523, 659, 784, 1046]; + notes.forEach((n, i) => setTimeout(() => this.tone(n, 0.1, "square", 0.05), i * 80)); + } + + toggleMusic() { + if (this.musicOn) { + this.stopMusic(); + } else { + this.startMusic(); + } + return this.musicOn; + } + + startMusic() { + if (this.musicOn) { + return; + } + this.ensureContext(); + this.musicOn = true; + const bass = [131, 147, 165, 147]; + const lead = [262, 330, 392, 330, 294, 370, 440, 370]; + this.musicTimer = setInterval(() => { + const b = bass[this.musicStep % bass.length]; + const l = lead[this.musicStep % lead.length]; + this.tone(b, 0.16, "square", 0.025); + this.tone(l, 0.08, "triangle", 0.02); + this.musicStep += 1; + }, 180); + } + + stopMusic() { + this.musicOn = false; + if (this.musicTimer) { + clearInterval(this.musicTimer); + this.musicTimer = null; + } + } +} diff --git a/bottlesort/src/gameLogic.js b/bottlesort/src/gameLogic.js new file mode 100644 index 0000000..86e8b74 --- /dev/null +++ b/bottlesort/src/gameLogic.js @@ -0,0 +1,199 @@ +export const LEVELS = [ + { types: 2, bottles: 10 }, + { types: 4, bottles: 20 }, + { types: 8, bottles: 40 }, + { types: 16, bottles: 80 }, +]; + +const SHAPES = ["lang", "rund", "eckig", "kurz"]; +const COLORS = [ + "#f94144", + "#f3722c", + "#f9c74f", + "#90be6d", + "#43aa8b", + "#577590", + "#9b5de5", + "#f15bb5", + "#00bbf9", + "#00f5d4", + "#ff9f1c", + "#2ec4b6", + "#e71d36", + "#5c7cfa", + "#ffd166", + "#6a994e", +]; + +export const WORLD = { + cols: 28, + rows: 20, +}; + +function cellKey(x, y) { + return `${x},${y}`; +} + +export function createBottleTypes(count) { + if (count > COLORS.length) { + throw new Error("Zu viele Sorten angefordert."); + } + const types = []; + for (let i = 0; i < count; i += 1) { + types.push({ + id: `type-${i + 1}`, + color: COLORS[i], + shape: SHAPES[i % SHAPES.length], + label: `Sorte ${i + 1}`, + }); + } + return types; +} + +function randomFreeCell(occupied, blocked, rng) { + for (let i = 0; i < 5000; i += 1) { + const x = 1 + Math.floor(rng() * (WORLD.cols - 2)); + const y = 1 + Math.floor(rng() * (WORLD.rows - 2)); + const key = cellKey(x, y); + if (!occupied.has(key) && !blocked.has(key)) { + occupied.add(key); + return { x, y }; + } + } + throw new Error("Konnte keine freie Position finden."); +} + +function buildWalls(levelIndex) { + const walls = []; + for (let x = 0; x < WORLD.cols; x += 1) { + walls.push({ x, y: 0 }); + walls.push({ x, y: WORLD.rows - 1 }); + } + for (let y = 1; y < WORLD.rows - 1; y += 1) { + walls.push({ x: 0, y }); + walls.push({ x: WORLD.cols - 1, y }); + } + + const obstacleRows = [4, 7, 10, 13, 16]; + const internalWalls = Math.min(2 + levelIndex * 2, obstacleRows.length); + for (let i = 0; i < internalWalls; i += 1) { + const y = obstacleRows[i]; + for (let x = 3; x < WORLD.cols - 3; x += 1) { + if (x % 6 !== 0) { + walls.push({ x, y }); + } + } + } + return walls; +} + +function buildCrates(types) { + const crates = []; + const spacing = Math.floor((WORLD.cols - 2) / types.length); + for (let i = 0; i < types.length; i += 1) { + const x = Math.max(1, Math.min(WORLD.cols - 2, 1 + i * spacing)); + const y = i % 2 === 0 ? 1 : WORLD.rows - 2; + crates.push({ + x, + y, + typeId: types[i].id, + color: types[i].color, + shape: types[i].shape, + label: types[i].label, + }); + } + return crates; +} + +export function createLevelState(levelIndex, rng = Math.random) { + const cfg = LEVELS[levelIndex]; + if (!cfg) { + throw new Error("Ungültiger Levelindex."); + } + + const types = createBottleTypes(cfg.types); + const walls = buildWalls(levelIndex); + const crates = buildCrates(types); + + const blocked = new Set(); + walls.forEach((w) => blocked.add(cellKey(w.x, w.y))); + crates.forEach((c) => blocked.add(cellKey(c.x, c.y))); + + const occupied = new Set(); + const bottles = []; + for (let i = 0; i < cfg.bottles; i += 1) { + const type = types[i % types.length]; + const pos = randomFreeCell(occupied, blocked, rng); + bottles.push({ id: `bottle-${i + 1}`, typeId: type.id, ...pos }); + } + + let player = { x: 2, y: 2 }; + if (blocked.has(cellKey(player.x, player.y))) { + player = randomFreeCell(new Set(), blocked, rng); + } + + return { + levelIndex, + types, + crates, + walls, + bottles, + player, + carrying: null, + sortedCount: 0, + totalCount: cfg.bottles, + completed: false, + }; +} + +export function isBlocked(x, y, walls) { + return walls.some((w) => w.x === x && w.y === y); +} + +export function findBottleAt(state, x, y) { + return state.bottles.find((b) => b.x === x && b.y === y) || null; +} + +export function findCrateAt(state, x, y) { + return state.crates.find((c) => c.x === x && c.y === y) || null; +} + +export function tryPickBottle(state) { + if (state.carrying) { + return { ok: false, reason: "bereits_voll" }; + } + const bottle = findBottleAt(state, state.player.x, state.player.y); + if (!bottle) { + return { ok: false, reason: "keine_flasche" }; + } + state.carrying = bottle; + state.bottles = state.bottles.filter((b) => b.id !== bottle.id); + return { ok: true }; +} + +export function tryDropBottle(state) { + if (!state.carrying) { + return { ok: false, reason: "nichts_getragen" }; + } + const crate = findCrateAt(state, state.player.x, state.player.y); + if (!crate) { + return { ok: false, reason: "keine_kiste" }; + } + if (crate.typeId !== state.carrying.typeId) { + return { ok: false, reason: "falsch" }; + } + state.carrying = null; + state.sortedCount += 1; + if (state.sortedCount >= state.totalCount) { + state.completed = true; + } + return { ok: true, reason: "korrekt" }; +} + +export function createSeededRandom(seed) { + let value = seed >>> 0; + return () => { + value = (value * 1664525 + 1013904223) >>> 0; + return value / 0x100000000; + }; +} diff --git a/bottlesort/src/main.js b/bottlesort/src/main.js new file mode 100644 index 0000000..29f4d9f --- /dev/null +++ b/bottlesort/src/main.js @@ -0,0 +1,240 @@ +import { + WORLD, + LEVELS, + createLevelState, + isBlocked, + tryPickBottle, + tryDropBottle, +} from "./gameLogic.js"; +import { AudioManager } from "./audio.js"; + +const canvas = document.getElementById("gameCanvas"); +const ctx = canvas.getContext("2d"); + +const statusEl = document.getElementById("status"); +const levelInfoEl = document.getElementById("levelInfo"); +const progressInfoEl = document.getElementById("progressInfo"); +const carryInfoEl = document.getElementById("carryInfo"); +const restartBtn = document.getElementById("restartBtn"); +const musicBtn = document.getElementById("musicBtn"); + +const tile = 28; +const audio = new AudioManager(); + +let levelIndex = 0; +let state = createLevelState(levelIndex); + +const moveState = { + up: false, + down: false, + left: false, + right: false, +}; +let moveCooldown = 0; + +function setStatus(text, type = "") { + statusEl.textContent = text; + statusEl.className = type; +} + +function shapeLabel(shape) { + return shape; +} + +function getCarryingText() { + if (!state.carrying) { + return "Trage: nichts"; + } + const type = state.types.find((t) => t.id === state.carrying.typeId); + return `Trage: ${type.label} (${shapeLabel(type.shape)})`; +} + +function updateHud() { + levelInfoEl.textContent = `Level ${levelIndex + 1}/4`; + progressInfoEl.textContent = `${state.sortedCount}/${state.totalCount} sortiert`; + carryInfoEl.textContent = getCarryingText(); +} + +function restartLevel(message = "Level neu gestartet.") { + state = createLevelState(levelIndex); + setStatus(message, ""); + updateHud(); +} + +function nextLevel() { + if (levelIndex >= LEVELS.length - 1) { + setStatus("Geschafft! Alle 4 Level erfolgreich abgeschlossen.", "success"); + return; + } + levelIndex += 1; + state = createLevelState(levelIndex); + setStatus(`Level ${levelIndex + 1} startet.`, "success"); + updateHud(); +} + +function handleAction() { + audio.ensureContext(); + if (!state.carrying) { + const pick = tryPickBottle(state); + if (pick.ok) { + setStatus("Flasche aufgenommen."); + audio.playPickup(); + updateHud(); + } + return; + } + + const drop = tryDropBottle(state); + if (!drop.ok) { + if (drop.reason === "falsch") { + audio.playDropWrong(); + restartLevel("Falsch einsortiert. Level wurde zurückgesetzt."); + setStatus("Falsch einsortiert. Level wurde zurückgesetzt.", "error"); + return; + } + return; + } + + audio.playDropOk(); + updateHud(); + if (state.completed) { + audio.playLevelDone(); + setStatus(`Level ${levelIndex + 1} geschafft!`, "success"); + setTimeout(nextLevel, 900); + } else { + setStatus("Korrekt einsortiert."); + } +} + +function tryMove(dx, dy) { + const nx = state.player.x + dx; + const ny = state.player.y + dy; + if (isBlocked(nx, ny, state.walls)) { + return; + } + state.player.x = nx; + state.player.y = ny; +} + +function processMovement(dt) { + moveCooldown -= dt; + if (moveCooldown > 0) { + return; + } + if (moveState.up) { + tryMove(0, -1); + moveCooldown = 0.11; + } else if (moveState.down) { + tryMove(0, 1); + moveCooldown = 0.11; + } else if (moveState.left) { + tryMove(-1, 0); + moveCooldown = 0.11; + } else if (moveState.right) { + tryMove(1, 0); + moveCooldown = 0.11; + } +} + +function drawBottle(x, y, type) { + const px = x * tile; + const py = y * tile; + ctx.fillStyle = type.color; + if (type.shape === "lang") { + ctx.fillRect(px + 10, py + 4, 8, 18); + ctx.fillRect(px + 11, py + 2, 6, 4); + } else if (type.shape === "rund") { + ctx.fillRect(px + 8, py + 6, 12, 14); + ctx.fillRect(px + 10, py + 3, 8, 4); + } else if (type.shape === "eckig") { + ctx.fillRect(px + 7, py + 5, 14, 14); + ctx.fillRect(px + 10, py + 3, 8, 4); + } else { + ctx.fillRect(px + 9, py + 8, 10, 11); + ctx.fillRect(px + 11, py + 5, 6, 4); + } +} + +function drawCrate(crate) { + const px = crate.x * tile; + const py = crate.y * tile; + ctx.fillStyle = "#8d5524"; + ctx.fillRect(px + 2, py + 2, tile - 4, tile - 4); + ctx.strokeStyle = "#4e2d12"; + ctx.lineWidth = 2; + ctx.strokeRect(px + 2, py + 2, tile - 4, tile - 4); + const type = state.types.find((t) => t.id === crate.typeId); + ctx.fillStyle = type.color; + ctx.fillRect(px + 8, py + 8, 12, 12); +} + +function render() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (let y = 0; y < WORLD.rows; y += 1) { + for (let x = 0; x < WORLD.cols; x += 1) { + ctx.fillStyle = (x + y) % 2 === 0 ? "#355834" : "#2f4f2f"; + ctx.fillRect(x * tile, y * tile, tile, tile); + } + } + + ctx.fillStyle = "#6f4e37"; + state.walls.forEach((w) => { + ctx.fillRect(w.x * tile, w.y * tile, tile, tile); + }); + + state.crates.forEach(drawCrate); + + state.bottles.forEach((b) => { + const type = state.types.find((t) => t.id === b.typeId); + drawBottle(b.x, b.y, type); + }); + + const p = state.player; + const px = p.x * tile; + const py = p.y * tile; + ctx.fillStyle = "#4cc9f0"; + ctx.fillRect(px + 7, py + 8, 14, 14); + ctx.fillStyle = "#ffe0bd"; + ctx.fillRect(px + 8, py + 3, 12, 8); + if (state.carrying) { + const type = state.types.find((t) => t.id === state.carrying.typeId); + drawBottle(p.x, p.y - 1, type); + } +} + +let last = performance.now(); +function loop(now) { + const dt = Math.min(0.05, (now - last) / 1000); + last = now; + processMovement(dt); + render(); + requestAnimationFrame(loop); +} + +function onKeyChange(event, isDown) { + const key = event.key.toLowerCase(); + if ( + ["arrowup", "arrowdown", "arrowleft", "arrowright", "w", "a", "s", "d", " "].includes(key) + ) { + event.preventDefault(); + } + if (key === "arrowup" || key === "w") moveState.up = isDown; + if (key === "arrowdown" || key === "s") moveState.down = isDown; + if (key === "arrowleft" || key === "a") moveState.left = isDown; + if (key === "arrowright" || key === "d") moveState.right = isDown; + if (key === " " && isDown) handleAction(); +} + +document.addEventListener("keydown", (e) => onKeyChange(e, true)); +document.addEventListener("keyup", (e) => onKeyChange(e, false)); + +restartBtn.addEventListener("click", () => restartLevel("Level neu gestartet.")); +musicBtn.addEventListener("click", () => { + const on = audio.toggleMusic(); + musicBtn.textContent = on ? "Musik: An" : "Musik: Aus"; + setStatus(on ? "8-Bit-Musik aktiviert." : "Musik pausiert."); +}); + +updateHud(); +requestAnimationFrame(loop); diff --git a/bottlesort/style.css b/bottlesort/style.css new file mode 100644 index 0000000..4d6218a --- /dev/null +++ b/bottlesort/style.css @@ -0,0 +1,82 @@ +:root { + --bg: #1f1b27; + --panel: #352f44; + --panel-border: #8f7a5f; + --text: #f5deb3; + --accent: #c7f464; + --danger: #ff6b6b; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 20% 10%, #3d2d60 0%, transparent 35%), + radial-gradient(circle at 80% 80%, #2e6f65 0%, transparent 30%), + var(--bg); + color: var(--text); + font-family: "Courier New", Courier, monospace; +} + +.layout { + display: grid; + gap: 14px; + justify-content: center; + padding: 16px; +} + +.hud { + border: 4px solid var(--panel-border); + background: var(--panel); + padding: 10px 12px; +} + +h1 { + margin: 0 0 8px; + font-size: 24px; +} + +.hud-row { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin: 6px 0; +} + +button { + border: 2px solid var(--panel-border); + background: #1a2533; + color: var(--text); + font-family: inherit; + padding: 6px 10px; + cursor: pointer; +} + +button:hover { + border-color: var(--accent); +} + +.help { + margin: 8px 0 0; + font-size: 14px; +} + +#status.error { + color: var(--danger); +} + +#status.success { + color: var(--accent); +} + +#gameCanvas { + border: 4px solid var(--panel-border); + image-rendering: pixelated; + background: #223322; + max-width: calc(100vw - 32px); + height: auto; +} diff --git a/bottlesort/tests/gameLogic.test.js b/bottlesort/tests/gameLogic.test.js new file mode 100644 index 0000000..e0aec13 --- /dev/null +++ b/bottlesort/tests/gameLogic.test.js @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + LEVELS, + WORLD, + createBottleTypes, + createLevelState, + createSeededRandom, + tryPickBottle, + tryDropBottle, +} from "../src/gameLogic.js"; + +test("Level-Skalierung stimmt", () => { + assert.deepEqual(LEVELS, [ + { types: 2, bottles: 10 }, + { types: 4, bottles: 20 }, + { types: 8, bottles: 40 }, + { types: 16, bottles: 80 }, + ]); +}); + +test("Flaschensorten sind eindeutig", () => { + const types = createBottleTypes(16); + const ids = new Set(types.map((t) => t.id)); + assert.equal(types.length, 16); + assert.equal(ids.size, 16); +}); + +test("Level wird mit korrekter Anzahl und ohne ungueltige Positionen erzeugt", () => { + const rng = createSeededRandom(1234); + const state = createLevelState(2, rng); + assert.equal(state.bottles.length, 40); + assert.equal(state.types.length, 8); + state.bottles.forEach((b) => { + assert.ok(b.x > 0 && b.x < WORLD.cols - 1); + assert.ok(b.y > 0 && b.y < WORLD.rows - 1); + }); +}); + +test("Falsches Ablegen wird erkannt", () => { + const rng = createSeededRandom(2); + const state = createLevelState(0, rng); + const firstBottle = state.bottles[0]; + state.player = { x: firstBottle.x, y: firstBottle.y }; + const pick = tryPickBottle(state); + assert.equal(pick.ok, true); + + const wrongCrate = state.crates.find((c) => c.typeId !== state.carrying.typeId); + state.player = { x: wrongCrate.x, y: wrongCrate.y }; + const drop = tryDropBottle(state); + assert.equal(drop.ok, false); + assert.equal(drop.reason, "falsch"); +});