Erste Version
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
**/.codex
|
||||||
4
bottlesort/AGENTS.md
Normal file
4
bottlesort/AGENTS.md
Normal 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
26
bottlesort/README.md
Normal 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
57
bottlesort/SPEC.md
Normal 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
56
bottlesort/STYLE_GUIDE.md
Normal 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
32
bottlesort/index.html
Normal 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
9
bottlesort/package.json
Normal 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
84
bottlesort/src/audio.js
Normal 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
199
bottlesort/src/gameLogic.js
Normal 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
240
bottlesort/src/main.js
Normal 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
82
bottlesort/style.css
Normal 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;
|
||||||
|
}
|
||||||
53
bottlesort/tests/gameLogic.test.js
Normal file
53
bottlesort/tests/gameLogic.test.js
Normal 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");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user