Erste Version

This commit is contained in:
2026-04-04 15:09:32 +02:00
commit b8e8554464
12 changed files with 843 additions and 0 deletions

4
bottlesort/AGENTS.md Normal file
View File

@@ -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

26
bottlesort/README.md Normal file
View File

@@ -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
```

57
bottlesort/SPEC.md Normal file
View File

@@ -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

56
bottlesort/STYLE_GUIDE.md Normal file
View File

@@ -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.

32
bottlesort/index.html Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flaschen-Sortier-Spiel</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main class="layout">
<header class="hud">
<h1>Flaschen-Sortier-Spiel</h1>
<p id="status">Drücke eine Bewegungstaste, um zu starten.</p>
<div class="hud-row">
<span id="levelInfo">Level 1/4</span>
<span id="progressInfo">0/0 sortiert</span>
<span id="carryInfo">Trage: nichts</span>
</div>
<div class="hud-row">
<button id="restartBtn" type="button">Level neu starten</button>
<button id="musicBtn" type="button">Musik: Aus</button>
</div>
<p class="help">
Steuerung: WASD oder Pfeiltasten bewegen, Leertaste auf Flasche/Kiste zum
Aufnehmen/Ablegen.
</p>
</header>
<canvas id="gameCanvas" width="784" height="560"></canvas>
</main>
<script type="module" src="./src/main.js"></script>
</body>
</html>

9
bottlesort/package.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "bottlesort",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "node --test"
}
}

84
bottlesort/src/audio.js Normal file
View File

@@ -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;
}
}
}

199
bottlesort/src/gameLogic.js Normal file
View File

@@ -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;
};
}

240
bottlesort/src/main.js Normal file
View File

@@ -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);

82
bottlesort/style.css Normal file
View File

@@ -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;
}

View File

@@ -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");
});