Back to Pünktlich!
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.

src/main.ts
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);