Erste Version
This commit is contained in:
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