SOURCE / PINNED RELEASE
Made of little things.
Pünktlich!
- Release
- 9dc6c97948c0…
- Author-recorded commit
- aebcea49a666…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-bf1ae466031
Archive hash verified: 7705a83d7bdb3f9b…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { settings } from './napplet-settings.js';
import { STORAGE_DOMAIN, storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { type KeyAction, bindKeyboard, keyLabel } from './keyboard.js';
import './styles.css';
// Pünktlich! — get Klaus to the 7:00 train without breaking a single rule.
// ── Setup ──────────────────────────────────────────────────────────────
const canvas = requireElement<HTMLCanvasElement>('#scene');
const ctx = canvas.getContext('2d')!;
const ui = {
overlay: requireElement<HTMLElement>('#overlay'),
kicker: requireElement<HTMLElement>('#cardKicker'),
title: requireElement<HTMLElement>('#cardTitle'),
body: requireElement<HTMLElement>('#cardBody'),
actions: requireElement<HTMLElement>('#cardActions'),
hint: requireElement<HTMLElement>('#cardHint'),
ordnungFill: requireElement<HTMLElement>('#ordnungFill'),
ordnungValue: requireElement<HTMLElement>('#ordnungValue'),
walk: requireElement<HTMLButtonElement>('#walkButton'),
jump: requireElement<HTMLButtonElement>('#jumpButton'),
};
function requireElement<T extends HTMLElement>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) throw new Error(`Missing required element: ${selector}`);
return element;
}
// The world is drawn in logical units: 300 tall, width follows the frame.
const H = 300;
const GROUND = 246;
const LEVEL_END = 7400;
const WALK_SPEED = 230;
const GRAVITY = 1900;
const BEST_KEY = 'best-score';
let scale = 1;
let viewW = 600;
let skyTop = 0; // Extra sky above the world on tall/portrait frames (logical units, ≤ 0).
let dpr = 1;
let cssW = 600;
function resize(): void {
dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
cssW = w;
canvas.width = Math.max(1, Math.round(w * dpr));
canvas.height = Math.max(1, Math.round(h * dpr));
// Fit the world height, but keep at least ~420 units of road visible on narrow frames.
scale = Math.min(canvas.height / H, canvas.width / 420);
viewW = canvas.width / scale;
skyTop = H - canvas.height / scale;
}
new ResizeObserver(resize).observe(canvas);
resize();
// ── Level ──────────────────────────────────────────────────────────────
type Bin = 'papier' | 'gelb' | 'glas' | 'bio' | 'rest' | 'pfand';
const BINS: { id: Bin; label: string; sub: string; color: string }[] = [
{ id: 'papier', label: 'Papier', sub: 'blau', color: '#1d4fa8' },
{ id: 'gelb', label: 'Gelber Sack', sub: 'Verpackung', color: '#d9a400' },
{ id: 'glas', label: 'Glas', sub: 'grün/weiß/braun', color: '#2f7d3a' },
{ id: 'bio', label: 'Bio', sub: 'braun', color: '#6b4423' },
{ id: 'rest', label: 'Restmüll', sub: 'schwarz', color: '#333333' },
{ id: 'pfand', label: 'Pfand', sub: 'zum Automaten', color: '#8a2be2' },
];
const TRASH: { name: string; emoji: string; bin: Bin; wrong: string }[] = [
{ name: 'Alte Zeitung', emoji: '📰', bin: 'papier', wrong: 'Papier ist PAPIER, Klaus.' },
{ name: 'Joghurtbecher (ausgespült!)', emoji: '🥛', bin: 'gelb', wrong: 'Verpackung! Gelber Sack!' },
{ name: 'Weinflasche (grün)', emoji: '🍾', bin: 'glas', wrong: 'Glas. Nach Farbe. Immer.' },
{ name: 'Bananenschale', emoji: '🍌', bin: 'bio', wrong: 'Das ist BIO. Schäm dich.' },
{ name: 'Fettiger Pizzakarton', emoji: '🍕', bin: 'rest', wrong: 'Fettig = Restmüll! Jeder weiß das!' },
{ name: 'Bierflasche mit Pfand', emoji: '🍺', bin: 'pfand', wrong: '8 Cent PFAND! Wegwerfen?! NEIN!' },
{ name: 'Eierschalen', emoji: '🥚', bin: 'bio', wrong: 'Eierschalen sind Bio!' },
{ name: 'Staubsaugerbeutel', emoji: '🧹', bin: 'rest', wrong: 'Staubsaugerbeutel: Restmüll.' },
{ name: 'Müslikarton', emoji: '📦', bin: 'papier', wrong: 'Karton ist Papier!' },
{ name: 'Alufolie', emoji: '🥡', bin: 'gelb', wrong: 'Alu gehört in den Gelben Sack!' },
];
type Entity =
| { kind: 'gnome' | 'crate' | 'dackel'; x: number; home: number; w: number; h: number; vx: number }
| { kind: 'trash'; x: number; done: boolean; item: (typeof TRASH)[number] }
| { kind: 'ampel'; x: number; phase: 'green' | 'yellow' | 'red'; t: number; triggered: boolean; ran: boolean }
| { kind: 'amt'; x: number; done: boolean }
| { kind: 'ruhe'; x: number; end: number }
| { kind: 'wurst' | 'brezel' | 'mass'; x: number; y: number; taken: boolean };
function shuffle<T>(list: T[]): T[] {
const copy = [...list];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function buildLevel(): Entity[] {
const items = shuffle(TRASH);
const obstacle = (kind: 'gnome' | 'crate' | 'dackel', x: number): Entity => {
const size = { gnome: [22, 34], crate: [34, 30], dackel: [46, 22] }[kind];
return { kind, x, home: x, w: size[0], h: size[1], vx: kind === 'dackel' ? -45 : 0 };
};
const trash = (x: number): Entity => ({ kind: 'trash', x, done: false, item: items.pop()! });
const ampel = (x: number): Entity => ({ kind: 'ampel', x, phase: 'green', t: 0, triggered: false, ran: false });
const pickup = (kind: 'wurst' | 'brezel' | 'mass', x: number, y: number): Entity => ({ kind, x, y, taken: false });
return [
obstacle('gnome', 520),
trash(900),
pickup('brezel', 1250, 70),
ampel(1550),
obstacle('dackel', 2100),
pickup('wurst', 2350, 20),
trash(2650),
obstacle('crate', 3000),
obstacle('crate', 3260),
{ kind: 'amt', x: 3600, done: false },
{ kind: 'ruhe', x: 3950, end: 4550 },
ampel(4800),
pickup('mass', 5150, 20),
obstacle('gnome', 5400),
trash(5700),
obstacle('dackel', 6150),
pickup('brezel', 6350, 90),
trash(6550),
obstacle('crate', 6900),
];
}
// ── State ──────────────────────────────────────────────────────────────
type Mode = 'title' | 'play' | 'bin' | 'amt' | 'end';
const player = { x: 80, y: 0, vy: 0, stumble: 0, safe: 0, wurst: 0, mass: 0, walkPhase: 0 };
let entities: Entity[] = [];
let mode: Mode = 'title';
let timeLeft = 60;
let timeLimit = 60;
let ordnung = 100;
let bestScore = 0;
let popups: { text: string; x: number; y: number; t: number; color: string }[] = [];
let lastFrame = performance.now();
let endTimer = 0;
let won = false;
const input = { walk: false, jumpQueued: false };
function setOrdnung(value: number): void {
ordnung = Math.max(0, Math.min(100, value));
ui.ordnungFill.style.width = `${ordnung}%`;
ui.ordnungValue.textContent = String(Math.round(ordnung));
}
function popup(text: string, color = '#dd0000'): void {
popups.push({ text, x: player.x, y: GROUND - 90, t: 1.8, color });
}
function penalty(amount: number, text: string): void {
setOrdnung(ordnung - amount);
popup(`${text} −${amount}`);
}
function startRun(): void {
timeLimit = settings.timeLimit;
timeLeft = timeLimit;
Object.assign(player, { x: 80, y: 0, vy: 0, stumble: 0, safe: 0, wurst: 0, mass: 0, walkPhase: 0 });
entities = buildLevel();
popups = [];
won = false;
setOrdnung(100);
hideCard();
mode = 'play';
}
// ── Overlay card ───────────────────────────────────────────────────────
type Action = { label: string; sub?: string; className?: string; color?: string; onPick: (button: HTMLButtonElement) => void };
function showCard(opts: { kicker?: string; title: string; body?: string; hint?: string; actions: Action[] }): void {
ui.kicker.textContent = opts.kicker ?? '';
ui.title.textContent = opts.title;
ui.body.textContent = opts.body ?? '';
ui.hint.textContent = opts.hint ?? '';
ui.actions.replaceChildren(
...opts.actions.map((action) => {
const button = document.createElement('button');
button.type = 'button';
button.className = action.className ?? '';
if (action.color) button.style.background = action.color;
button.textContent = action.label;
if (action.sub) {
const small = document.createElement('small');
small.textContent = action.sub;
button.append(small);
}
button.addEventListener('click', () => action.onPick(button));
return button;
}),
);
ui.overlay.hidden = false;
ui.actions.querySelector('button')?.focus({ preventScroll: true });
}
function hideCard(): void {
ui.overlay.hidden = true;
canvas.focus({ preventScroll: true });
}
function showTitle(): void {
mode = 'title';
showCard({
kicker: 'Formular P-7 · Pendlerantrag',
title: 'Pünktlich!',
body:
'Dein Zug fährt um 7:00:00 Uhr. Nicht 7:00:01.\n' +
'Trenne den Müll. Warte an jeder roten Ampel. Stempel alles.\n' +
'Verliere keine ORDNUNG.',
hint: `Halten: ${keyLabel(walkAction)} / GEHEN · Springen: ${keyLabel(jumpAction)} · Müll: Tasten ${binKeysLabel()}${bestScore ? ` · Rekord: ${bestScore}` : ''}`,
actions: [{ label: 'Los geht’s', className: 'primary', onPick: startRun }],
});
}
function openBins(entity: Extract<Entity, { kind: 'trash' }>): void {
mode = 'bin';
input.walk = false;
const item = entity.item;
showCard({
kicker: 'Mülltrennung · Pflicht',
title: `${item.emoji} ${item.name}`,
body: 'Wohin damit? Die Nachbarn schauen zu.',
hint: `Tasten ${binKeysLabel()}. Die Uhr läuft weiter.`,
actions: BINS.map((bin) => ({
label: bin.label,
sub: bin.sub,
className: 'bin',
color: bin.color,
onPick: (button) => pickBin(entity, bin.id, button),
})),
});
}
function pickBin(entity: Extract<Entity, { kind: 'trash' }>, bin: Bin, button?: HTMLButtonElement): void {
if (mode !== 'bin') return;
if (bin === entity.item.bin) {
entity.done = true;
setOrdnung(ordnung + 2);
popup('Richtig getrennt. Gut.', '#1f7a3a');
hideCard();
mode = 'play';
} else {
penalty(10, entity.item.wrong);
ui.body.textContent = `FALSCH! ${entity.item.wrong}`;
if (button) {
button.classList.remove('shake');
void button.offsetWidth;
button.classList.add('shake');
}
}
}
const AMT_STEPS = [
'Stempel 1 von 3',
'Stempel 2 von 3',
'Stempel 3 von 3',
'Unterschreiben',
'Durchschlag abgeben',
];
function openAmt(entity: Extract<Entity, { kind: 'amt' }>): void {
mode = 'amt';
input.walk = false;
let step = 0;
const render = () => {
showCard({
kicker: 'Bürgeramt · Schalter 3 von 47',
title: 'Formular A38',
body: 'Ohne Passierschein A38 darf niemand zum Bahnhof.\nIn dreifacher Ausfertigung, bitte.',
hint: `${keyLabel(jumpAction)} oder Enter drückt den Knopf.`,
actions: [
{
label: AMT_STEPS[step],
className: 'primary',
onPick: () => {
step += 1;
if (step >= AMT_STEPS.length) {
entity.done = true;
popup('Genehmigt. Nächstes Mal online? Nein.', '#1f7a3a');
hideCard();
mode = 'play';
} else {
render();
}
},
},
],
});
};
render();
}
async function finishRun(arrived: boolean): Promise<void> {
mode = 'end';
won = arrived;
endTimer = 0;
input.walk = false;
const secondsEarly = Math.max(0, timeLeft);
const score = arrived ? Math.round(ordnung * 10 + secondsEarly * 20) : 0;
const grade =
!arrived ? 'Ungenügend' :
ordnung >= 90 ? 'Sehr gut. Akzeptabel.' :
ordnung >= 70 ? 'Befriedigend. Gerade so.' :
ordnung >= 40 ? 'Mangelhaft. Wir sind enttäuscht.' :
'Chaos! Bist du Italiener?';
let record = '';
if (score > bestScore) {
bestScore = score;
record = ' · NEUER REKORD';
void saveBest();
}
showCard({
kicker: arrived ? 'Ankunft · Gleis 7' : 'Abfahrt · 7:00:00',
title: arrived ? 'Pünktlich!' : 'NEIN!',
body: arrived
? `Du bist ${secondsEarly.toFixed(1)} Sekunden zu früh. Gut.\n` +
`Note: ${grade}\n\n` +
'Durchsage: „Der ICE 700 hat heute ca. 45 Minuten Verspätung.\n' +
'Grund: Verzögerungen im Betriebsablauf.“\n' +
'Du hättest nicht rennen müssen.'
: 'Der Zug ist weg. Um exakt 7:00:00.\n' +
'Ausgerechnet heute war die Deutsche Bahn pünktlich.',
hint: `Punkte: ${score} · Rekord: ${bestScore}${record}`,
actions: [{ label: 'Nochmal', className: 'primary', onPick: startRun }],
});
}
async function loadBest(): Promise<void> {
if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
try {
const value = await storage.getItem(BEST_KEY);
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed > bestScore) {
bestScore = parsed;
if (mode === 'title') showTitle();
}
} catch {
// Storage is optional; the record just won't persist.
}
}
async function saveBest(): Promise<void> {
if (!runtimeHasDomain(STORAGE_DOMAIN)) return;
try {
await storage.setItem(BEST_KEY, String(bestScore));
} catch {
// Storage is optional.
}
}
// ── Simulation ─────────────────────────────────────────────────────────
const PLAYER_W = 22;
const PLAYER_H = 58;
function update(dt: number): void {
for (const p of popups) {
p.t -= dt;
p.y -= 22 * dt;
}
popups = popups.filter((p) => p.t > 0);
if (mode === 'end') {
endTimer += dt;
return;
}
if (mode === 'title') return;
// The clock never stops. Not for forms, not for bins.
timeLeft -= dt;
if (timeLeft <= 0) {
timeLeft = 0;
void finishRun(false);
return;
}
for (const e of entities) {
if (e.kind === 'dackel') {
// Dackels patrol their own little Revier.
e.x += e.vx * dt;
if (e.x < e.home - 110) e.vx = Math.abs(e.vx);
if (e.x > e.home) e.vx = -Math.abs(e.vx);
}
if (e.kind === 'ampel') updateAmpel(e, dt);
}
if (mode !== 'play') return;
player.wurst = Math.max(0, player.wurst - dt);
player.mass = Math.max(0, player.mass - dt);
player.safe = Math.max(0, player.safe - dt);
const prevX = player.x;
if (player.stumble > 0) {
player.stumble -= dt;
} else if (input.walk) {
const speed = WALK_SPEED * (player.wurst > 0 ? 1.6 : 1);
player.x += speed * dt;
player.walkPhase += dt * (player.wurst > 0 ? 16 : 10);
}
const onGround = player.y <= 0;
if (input.jumpQueued && onGround && player.stumble <= 0) {
player.vy = player.mass > 0 ? 820 : 640;
const zone = entities.find((e): e is Extract<Entity, { kind: 'ruhe' }> => e.kind === 'ruhe' && player.x > e.x && player.x < e.end);
if (zone) penalty(15, 'PSST! Ruhezone! Nachbar ruft Polizei.');
}
input.jumpQueued = false;
player.vy -= GRAVITY * dt;
player.y = Math.max(0, player.y + player.vy * dt);
if (player.y === 0) player.vy = 0;
for (const e of entities) {
switch (e.kind) {
case 'gnome':
case 'crate':
case 'dackel':
if (player.safe <= 0 && overlaps(player.x, PLAYER_W, e.x, e.w) && player.y < e.h) {
player.stumble = 0.6;
player.safe = 1.6;
player.x = Math.min(player.x, e.x - PLAYER_W / 2 - e.w / 2 - 20);
const names = { gnome: 'Gartenzwerg', crate: 'Bierkasten', dackel: 'Dackel' };
penalty(5, `${names[e.kind]} angerempelt!`);
}
break;
case 'wurst':
case 'brezel':
case 'mass':
if (!e.taken && overlaps(player.x, PLAYER_W, e.x, 24) && Math.abs(player.y + PLAYER_H / 2 - e.y - 12) < 40) {
e.taken = true;
if (e.kind === 'wurst') { player.wurst = 4; popup('Bratwurst! Schneller!', '#1f7a3a'); }
if (e.kind === 'mass') { player.mass = 7; popup('Eine Maß! Höher springen!', '#1f7a3a'); }
if (e.kind === 'brezel') { setOrdnung(ordnung + 8); popup('Brezel! +8 Ordnung', '#1f7a3a'); }
}
break;
case 'trash':
if (!e.done && player.x >= e.x) {
player.x = e.x;
openBins(e);
return;
}
break;
case 'amt':
if (!e.done && player.x >= e.x) {
player.x = e.x;
openAmt(e);
return;
}
break;
case 'ampel':
if (!e.ran && prevX < e.x && player.x >= e.x && e.phase === 'red') {
e.ran = true;
penalty(25, 'BEI ROT! Denk an die Kinder!');
}
break;
case 'ruhe':
break;
}
}
if (player.x >= LEVEL_END) {
player.x = LEVEL_END;
void finishRun(true);
}
}
function overlaps(ax: number, aw: number, bx: number, bw: number): boolean {
return Math.abs(ax - bx) < (aw + bw) / 2;
}
function updateAmpel(e: Extract<Entity, { kind: 'ampel' }>, dt: number): void {
if (!e.triggered && player.x > e.x - 260) {
e.triggered = true;
e.phase = 'yellow';
e.t = 0.7;
}
if (e.phase === 'green') return;
e.t -= dt;
if (e.t > 0) return;
if (e.phase === 'yellow') {
e.phase = 'red';
e.t = 3.2;
} else {
e.phase = 'green';
}
}
// ── Drawing ────────────────────────────────────────────────────────────
let camX = 0;
function draw(): void {
ctx.setTransform(scale, 0, 0, scale, 0, -skyTop * scale);
const target = Math.min(player.x - viewW * 0.3, LEVEL_END + 260 - viewW);
camX = Math.max(0, target);
drawBackdrop();
ctx.save();
ctx.translate(-camX, 0);
drawStation();
for (const e of entities) drawEntity(e);
drawKlaus();
for (const p of popups) {
ctx.globalAlpha = Math.min(1, p.t);
drawLabel(p.text, p.x, p.y, p.color);
}
ctx.globalAlpha = 1;
ctx.restore();
// HUD is drawn in CSS pixels so it stays the same size at any zoom.
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawClock();
drawProgress();
}
function drawBackdrop(): void {
const sky = ctx.createLinearGradient(0, skyTop, 0, GROUND);
sky.addColorStop(0, '#7fb4d8');
sky.addColorStop(1, '#e8dfc9');
ctx.fillStyle = sky;
ctx.fillRect(0, skyTop, viewW, H - skyTop);
// Distant half-timbered houses (Fachwerk), slow parallax.
const far = camX * 0.35;
for (let i = Math.floor(far / 90) - 1; i < (far + viewW) / 90 + 1; i++) {
const x = i * 90 - far;
const h = 70 + ((i * 37) % 5) * 10;
const top = GROUND - 30 - h;
ctx.fillStyle = '#efe6d2';
ctx.fillRect(x, top, 80, h);
ctx.strokeStyle = '#5a3a22';
ctx.lineWidth = 3;
ctx.strokeRect(x, top, 80, h);
ctx.beginPath();
ctx.moveTo(x, top + h / 2);
ctx.lineTo(x + 80, top + h / 2);
ctx.moveTo(x, top);
ctx.lineTo(x + 40, top + h / 2);
ctx.lineTo(x + 80, top);
ctx.moveTo(x + 40, top + h / 2);
ctx.lineTo(x + 40, top + h);
ctx.stroke();
ctx.fillStyle = '#a83a2a';
ctx.beginPath();
ctx.moveTo(x - 6, top);
ctx.lineTo(x + 40, top - 34);
ctx.lineTo(x + 86, top);
ctx.fill();
ctx.fillStyle = '#5b86a8';
ctx.fillRect(x + 12, top + 10, 14, 14);
ctx.fillRect(x + 54, top + 10, 14, 14);
}
// Pavement and cobblestones.
ctx.fillStyle = '#b9b3a7';
ctx.fillRect(0, GROUND - 30, viewW, 30);
ctx.fillStyle = '#7d776c';
ctx.fillRect(0, GROUND, viewW, H - GROUND);
ctx.fillStyle = '#6a645a';
const off = camX % 24;
for (let row = 0; row < 3; row++) {
for (let x = -off - (row % 2) * 12; x < viewW; x += 24) {
ctx.fillRect(x + 2, GROUND + 6 + row * 16, 20, 12);
}
}
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, GROUND, viewW, 3);
}
function drawLabel(text: string, x: number, y: number, color: string): void {
ctx.font = '700 13px "DIN Alternate", "Bahnschrift", "Arial Narrow", sans-serif';
const w = ctx.measureText(text).width + 12;
const left = Math.max(camX + 4, Math.min(x - w / 2, camX + viewW - w - 4));
ctx.fillStyle = '#f5f1e6';
ctx.fillRect(left, y - 14, w, 20);
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.strokeRect(left, y - 14, w, 20);
ctx.fillStyle = color;
ctx.fillText(text, left + 6, y + 1);
}
function drawSign(text: string, x: number, y: number, bg: string, fg = '#fff'): void {
ctx.font = '800 11px "DIN Alternate", "Bahnschrift", "Arial Narrow", sans-serif';
const w = ctx.measureText(text).width + 10;
ctx.fillStyle = bg;
ctx.fillRect(x - w / 2, y - 12, w, 16);
ctx.fillStyle = fg;
ctx.fillText(text, x - w / 2 + 5, y);
}
function drawEntity(e: Entity): void {
if (e.x < camX - 300 || e.x > camX + viewW + 300) return;
switch (e.kind) {
case 'gnome': {
const x = e.x - e.w / 2;
ctx.fillStyle = '#2f6fd0';
ctx.fillRect(x + 3, GROUND - 18, 16, 18);
ctx.fillStyle = '#f2c9a0';
ctx.fillRect(x + 5, GROUND - 26, 12, 9);
ctx.fillStyle = '#ffffff';
ctx.fillRect(x + 5, GROUND - 20, 12, 6);
ctx.fillStyle = '#dd0000';
ctx.beginPath();
ctx.moveTo(x + 2, GROUND - 25);
ctx.lineTo(x + 11, GROUND - 40);
ctx.lineTo(x + 20, GROUND - 25);
ctx.fill();
break;
}
case 'crate': {
const x = e.x - e.w / 2;
ctx.fillStyle = '#8a5a2b';
ctx.fillRect(x, GROUND - e.h, e.w, e.h);
ctx.fillStyle = '#5a3a1b';
ctx.fillRect(x + 4, GROUND - e.h + 4, e.w - 8, 6);
for (let i = 0; i < 4; i++) {
ctx.fillStyle = '#2f5d24';
ctx.fillRect(x + 4 + i * 7, GROUND - e.h - 10, 5, 12);
}
drawSign('BIER', e.x, GROUND - 8, '#5a3a1b', '#ffcc00');
break;
}
case 'dackel': {
const x = e.x - e.w / 2;
const leg = Math.sin(performance.now() / 80) * 2;
ctx.save();
if (e.vx > 0) {
ctx.translate(e.x * 2, 0);
ctx.scale(-1, 1);
}
ctx.fillStyle = '#7a3e14';
ctx.fillRect(x + 6, GROUND - 16, 34, 10);
ctx.fillRect(x, GROUND - 22, 12, 10);
ctx.fillRect(x - 6, GROUND - 18, 6, 5);
ctx.fillRect(x + 8, GROUND - 6, 4, 6 + leg);
ctx.fillRect(x + 32, GROUND - 6, 4, 6 - leg);
ctx.fillRect(x + 40, GROUND - 18, 8, 3);
ctx.fillStyle = '#4a2408';
ctx.fillRect(x + 6, GROUND - 22, 4, 9);
ctx.restore();
break;
}
case 'trash': {
BINS.slice(0, 5).forEach((bin, i) => {
const bx = e.x + 24 + i * 22;
ctx.fillStyle = bin.color;
ctx.fillRect(bx, GROUND - 32, 18, 32);
ctx.fillStyle = '#111';
ctx.fillRect(bx - 1, GROUND - 35, 20, 4);
});
if (!e.done) {
ctx.font = '22px sans-serif';
ctx.fillText(e.item.emoji, e.x - 4, GROUND - 4);
}
break;
}
case 'ampel': {
ctx.fillStyle = '#ffffff';
for (let i = 0; i < 5; i++) ctx.fillRect(e.x + 16 + i * 14, GROUND, 8, H - GROUND);
ctx.fillStyle = '#333';
ctx.fillRect(e.x - 3, GROUND - 120, 6, 120);
ctx.fillStyle = '#111';
ctx.fillRect(e.x - 12, GROUND - 160, 24, 46);
const red = e.phase !== 'green';
ctx.fillStyle = red ? '#ff2a2a' : '#3a1010';
ctx.fillRect(e.x - 8, GROUND - 156, 16, 18);
ctx.fillStyle = red ? '#3a1010' : '#2aff6a';
ctx.fillRect(e.x - 8, GROUND - 136, 16, 18);
// Ampelmännchen silhouette.
ctx.fillStyle = '#111';
if (red) ctx.fillRect(e.x - 3, GROUND - 152, 6, 10);
else {
ctx.fillRect(e.x - 4, GROUND - 132, 3, 10);
ctx.fillRect(e.x + 1, GROUND - 130, 3, 8);
}
// Disapproving Oma.
drawOma(e.x - 46, red && player.x > e.x - 200 && player.x < e.x + 200);
if (red && player.x > e.x - 260 && player.x < e.x) drawLabel('ROT. WARTEN.', e.x, GROUND - 176, '#dd0000');
break;
}
case 'amt': {
ctx.fillStyle = '#d8d2c4';
ctx.fillRect(e.x - 20, GROUND - 140, 150, 140);
ctx.fillStyle = '#9b9486';
for (let r = 0; r < 3; r++) for (let c = 0; c < 4; c++) ctx.fillRect(e.x - 8 + c * 34, GROUND - 128 + r * 34, 22, 22);
ctx.fillStyle = '#5a3a22';
ctx.fillRect(e.x - 4, GROUND - 40, 26, 40);
drawSign('BÜRGERAMT', e.x + 55, GROUND - 146, '#1d4fa8');
break;
}
case 'ruhe': {
ctx.fillStyle = 'rgba(29, 79, 168, 0.12)';
ctx.fillRect(e.x, GROUND - 200, e.end - e.x, 200);
drawSign('RUHEZONE · NICHT SPRINGEN', e.x + 110, GROUND - 180, '#1d4fa8');
drawSign('ENDE RUHEZONE', e.end - 50, GROUND - 180, '#555');
ctx.fillStyle = '#efe6d2';
ctx.fillRect(e.x + 220, GROUND - 120, 110, 90);
ctx.fillStyle = '#ffd86b';
ctx.fillRect(e.x + 240, GROUND - 105, 26, 22);
ctx.fillStyle = '#111';
ctx.font = '700 14px sans-serif';
const z = (performance.now() / 400) % 3;
ctx.fillText('z'.repeat(1 + Math.floor(z)), e.x + 270, GROUND - 110);
break;
}
case 'wurst':
case 'brezel':
case 'mass': {
if (e.taken) break;
const y = GROUND - e.y - 24 + Math.sin(performance.now() / 250 + e.x) * 3;
ctx.font = '24px sans-serif';
ctx.fillText({ wurst: '🌭', brezel: '🥨', mass: '🍺' }[e.kind], e.x - 12, y + 22);
break;
}
}
}
function drawOma(x: number, angry: boolean): void {
ctx.fillStyle = '#7a5c8a';
ctx.fillRect(x - 8, GROUND - 38, 16, 38);
ctx.fillStyle = '#f2c9a0';
ctx.fillRect(x - 6, GROUND - 50, 12, 12);
ctx.fillStyle = '#cfcfcf';
ctx.fillRect(x - 7, GROUND - 54, 14, 6);
ctx.fillStyle = '#5a3a22';
ctx.fillRect(x + 9, GROUND - 30, 6, 10);
if (angry) drawLabel('Kinder schauen zu!', x, GROUND - 64, '#7a5c8a');
}
function drawStation(): void {
const x = LEVEL_END;
if (x < camX - 400 || x > camX + viewW + 400) return;
ctx.fillStyle = '#c9c2b3';
ctx.fillRect(x - 60, GROUND - 180, 320, 180);
ctx.fillStyle = '#a63a2a';
ctx.fillRect(x - 70, GROUND - 196, 340, 18);
drawSign('HAUPTBAHNHOF', x + 100, GROUND - 160, '#1d4fa8');
// Departure board.
ctx.fillStyle = '#0d2a5a';
ctx.fillRect(x + 10, GROUND - 140, 180, 46);
ctx.font = '700 11px ui-monospace, monospace';
ctx.fillStyle = '#ffffff';
ctx.fillText('07:00 ICE 700 Gleis 7', x + 16, GROUND - 124);
ctx.fillStyle = won || mode === 'end' ? '#ffcc00' : '#ffffff';
ctx.fillText(won ? 'ca. +45 Min' : 'pünktlich', x + 16, GROUND - 106);
// The train: present if we're late (it leaves on time, of course).
if (mode === 'end' && !won) {
const tx = x + 40 + endTimer * 260;
ctx.fillStyle = '#ffffff';
ctx.fillRect(tx, GROUND - 60, 260, 46);
ctx.fillStyle = '#dd0000';
ctx.fillRect(tx, GROUND - 30, 260, 5);
ctx.fillStyle = '#223';
for (let i = 0; i < 7; i++) ctx.fillRect(tx + 14 + i * 34, GROUND - 52, 24, 14);
}
}
function drawKlaus(): void {
const x = player.x;
const base = GROUND - player.y;
const swing = player.stumble > 0 ? 0 : Math.sin(player.walkPhase) * 6;
ctx.save();
if (player.safe > 0 && Math.floor(player.safe * 10) % 2) ctx.globalAlpha = 0.5;
if (player.stumble > 0) {
ctx.translate(x, base);
ctx.rotate(-0.4);
ctx.translate(-x, -base);
}
// Legs: white socks, sandals.
for (const s of [-1, 1]) {
const lx = x - 4 + s * 4 + swing * s * 0.6;
ctx.fillStyle = '#f2c9a0';
ctx.fillRect(lx - 2, base - 24, 5, 10);
ctx.fillStyle = '#ffffff';
ctx.fillRect(lx - 2, base - 14, 5, 11);
ctx.fillStyle = '#6b4423';
ctx.fillRect(lx - 3, base - 3, 9, 3);
}
// Lederhosen with suspenders.
ctx.fillStyle = '#6b4423';
ctx.fillRect(x - 10, base - 34, 20, 12);
ctx.fillStyle = '#e8e8e8';
ctx.fillRect(x - 10, base - 50, 20, 16);
ctx.fillStyle = '#6b4423';
ctx.fillRect(x - 7, base - 50, 3, 16);
ctx.fillRect(x + 4, base - 50, 3, 16);
ctx.fillRect(x - 7, base - 44, 14, 3);
// Arms: one holds a Maß when boosted.
ctx.fillStyle = '#f2c9a0';
ctx.fillRect(x - 14, base - 48 - swing * 0.3, 4, 14);
ctx.fillRect(x + 10, base - 48 + swing * 0.3, 4, 14);
if (player.mass > 0) {
ctx.fillStyle = '#ffcc00';
ctx.fillRect(x + 12, base - 44, 9, 12);
ctx.fillStyle = '#ffffff';
ctx.fillRect(x + 12, base - 47, 9, 4);
}
// Head, moustache, Tirolerhut with feather.
ctx.fillStyle = '#f2c9a0';
ctx.fillRect(x - 7, base - 62, 14, 12);
ctx.fillStyle = '#5a3a1b';
ctx.fillRect(x - 5, base - 54, 12, 3);
ctx.fillStyle = '#111';
ctx.fillRect(x + 2, base - 59, 2, 2);
ctx.fillStyle = '#2f5d24';
ctx.fillRect(x - 10, base - 64, 20, 3);
ctx.fillRect(x - 6, base - 70, 12, 7);
ctx.fillStyle = '#dd0000';
ctx.fillRect(x - 6, base - 66, 12, 2);
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x - 5, base - 68);
ctx.lineTo(x - 12, base - 80);
ctx.stroke();
ctx.restore();
if (player.wurst > 0) drawSign('WURST-POWER', x, base - 88, '#dd0000');
}
function drawClock(): void {
// A Bahnhofsuhr: white face, black bars, red-disc second hand.
const small = canvas.height / dpr < 240 || cssW < 300;
const r = small ? 18 : canvas.height / dpr > 700 ? 44 : 30;
const cx = cssW - r - 10;
const cy = r + 8;
const total = 7 * 3600 - timeLeft;
const sec = total % 60;
const min = (total / 60) % 60;
const hour = (total / 3600) % 12;
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#111';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
for (let i = 0; i < 12; i++) {
const a = (i / 12) * Math.PI * 2;
ctx.lineWidth = i % 3 === 0 ? 4 : 2;
ctx.beginPath();
ctx.moveTo(cx + Math.sin(a) * r * 0.72, cy - Math.cos(a) * r * 0.72);
ctx.lineTo(cx + Math.sin(a) * r * 0.9, cy - Math.cos(a) * r * 0.9);
ctx.stroke();
}
const hand = (angle: number, len: number, width: number, color: string) => {
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.sin(angle) * len, cy - Math.cos(angle) * len);
ctx.stroke();
};
hand((hour / 12) * Math.PI * 2, r * 0.5, 4, '#111');
hand((min / 60) * Math.PI * 2, r * 0.8, 3, '#111');
const sa = (Math.floor(sec) / 60) * Math.PI * 2;
hand(sa, r * 0.62, 1.5, '#dd0000');
ctx.fillStyle = '#dd0000';
ctx.beginPath();
ctx.arc(cx + Math.sin(sa) * r * 0.62, cy - Math.cos(sa) * r * 0.62, r * 0.11, 0, Math.PI * 2);
ctx.fill();
const s = Math.floor(sec);
const urgent = mode === 'play' || mode === 'bin' || mode === 'amt' ? timeLeft < 10 : false;
ctx.font = `800 ${small ? 10 : 13}px ui-monospace, monospace`;
const text = `0${Math.floor(total / 3600)}:${String(Math.floor(min)).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
const tw = ctx.measureText(text).width;
const tx = Math.min(cx - tw / 2, cssW - tw - 8);
const th = small ? 13 : 17;
ctx.fillStyle = urgent && Math.floor(performance.now() / 250) % 2 ? '#dd0000' : '#111';
ctx.fillRect(tx - 4, cy + r + 4, tw + 8, th);
ctx.fillStyle = '#ffffff';
ctx.fillText(text, tx, cy + r + th);
}
function drawProgress(): void {
// Sits under the Ordnung pill, left of the clock.
const w = Math.max(40, Math.min(200, cssW - 110));
const x = 10;
const y = cssW < 300 || canvas.height / dpr < 240 ? 38 : 46;
ctx.fillStyle = '#111';
ctx.fillRect(x, y, w, 4);
ctx.fillStyle = '#ffcc00';
ctx.fillRect(x, y, (w * Math.min(player.x, LEVEL_END)) / LEVEL_END, 4);
ctx.font = '12px sans-serif';
ctx.fillText('🚉', x + w + 2, y + 9);
}
// ── Input ──────────────────────────────────────────────────────────────
function clickFocusedCardButton(): void {
const focused = document.activeElement;
if (focused instanceof HTMLButtonElement && ui.actions.contains(focused)) focused.click();
}
function pickBinByKey(index: number): void {
if (mode !== 'bin') return;
const trash = entities.find((e): e is Extract<Entity, { kind: 'trash' }> => e.kind === 'trash' && !e.done && player.x >= e.x);
if (trash) pickBin(trash, BINS[index].id, ui.actions.children[index] as HTMLButtonElement);
}
const walkAction: KeyAction = {
id: 'run.walk',
label: 'Gehen (halten)',
defaultKey: 'ArrowRight',
fallbackCodes: ['ArrowRight', 'KeyD'],
fallbackLabel: '→ / D',
onPress: () => { if (mode === 'play') input.walk = true; },
onRelease: () => { input.walk = false; },
};
// Jump doubles as "press the focused form button": a bound Space no longer
// reaches the button natively, and Formular A38 is stamped with it.
const jumpAction: KeyAction = {
id: 'run.jump',
label: 'Springen',
defaultKey: 'Space',
fallbackCodes: ['Space', 'ArrowUp', 'KeyW'],
fallbackLabel: 'Leertaste / ↑',
onPress: (repeat) => {
if (repeat) return;
if (mode === 'play') input.jumpQueued = true;
else if (mode !== 'bin') clickFocusedCardButton();
},
};
const binActions: KeyAction[] = BINS.map((bin, index) => ({
id: `bins.${bin.id}`,
label: `Müll: ${bin.label}`,
defaultKey: String(index + 1),
fallbackCodes: [`Digit${index + 1}`, `Numpad${index + 1}`],
fallbackLabel: String(index + 1),
onPress: (repeat) => { if (!repeat) pickBinByKey(index); },
}));
function binKeysLabel(): string {
const labels = binActions.map(keyLabel);
return labels.join('') === '123456' ? '1–6' : labels.join(' ');
}
bindKeyboard([walkAction, jumpAction, ...binActions], () => {
if (mode === 'title') showTitle();
});
function holdButton(button: HTMLButtonElement, onDown: () => void, onUp?: () => void): void {
button.addEventListener('pointerdown', (event) => {
event.preventDefault();
button.setPointerCapture(event.pointerId);
button.classList.add('is-down');
onDown();
});
const release = () => {
button.classList.remove('is-down');
onUp?.();
};
button.addEventListener('pointerup', release);
button.addEventListener('pointercancel', release);
button.addEventListener('lostpointercapture', release);
}
holdButton(ui.walk, () => { if (mode === 'play') input.walk = true; }, () => { input.walk = false; });
holdButton(ui.jump, () => { if (mode === 'play') input.jumpQueued = true; });
// ── Loop ───────────────────────────────────────────────────────────────
function frame(now: number): void {
const dt = Math.min(0.05, (now - lastFrame) / 1000);
lastFrame = now;
update(dt);
draw();
requestAnimationFrame(frame);
}
canvas.tabIndex = -1;
setOrdnung(100);
showTitle();
void loadBest();
requestAnimationFrame(frame);
