🎮ArcadeLab

UNDRAWN

by PlasmaRanger47
4317 lines154.0 KB
▶ Play
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>UNDRAWN — desenhe rápido. corra mais rápido.</title>
<meta name="description" content="UNDRAWN: a Borracha está apagando o caderno — desenhe plataformas, corra e sobreviva. The Eraser is coming: draw platforms, run and survive.">
<meta name="theme-color" content="#fdfdf6">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='88'%3E✏️%3C/text%3E%3C/svg%3E">
<meta property="og:type" content="website">
<meta property="og:title" content="UNDRAWN">
<meta property="og:description" content="Draw fast. Run faster. Don't get erased.">
<style>
/* UNDRAWN — UI de papel desenhada à mão */
:root {
  --ink: #1f1f1f;
  --paper: #fdfdf6;
  --dim: #9a9a92;
  --accent: #e8483f;
  --green: #1f8a5f;
  --blue: #2f7fd6;
  --card: #fffef9;
}

* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }

html, body {
  width: 100%; height: 100%;
  overflow: hidden;
  background: var(--paper);
  font-family: system-ui, 'Segoe UI', sans-serif, "Comic Sans MS", cursive;
  color: var(--ink);
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
}

#game {
  position: fixed; inset: 0;
  display: block;
  cursor: none;
}

/* ------------------------------------------------ overlays */
.overlay {
  position: fixed; inset: 0;
  display: flex; align-items: center; justify-content: center;
  z-index: 20;
  padding: 14px;
  overflow-y: auto;
}
.overlay[hidden] { display: none; }

.card {
  position: relative;
  background: var(--card);
  border: 3px solid var(--ink);
  border-radius: 255px 18px 225px 18px / 18px 225px 18px 255px; /* borda "à mão" */
  box-shadow: 5px 6px 0 rgba(31,31,31,.85);
  padding: 26px 34px 22px;
  width: min(480px, 94vw);
  max-height: 94vh;
  overflow-y: auto;
  text-align: center;
  transform: rotate(-0.5deg);
}
.card.small { width: min(360px, 92vw); }

/* ------------------------------------------------ menu */
#logo {
  font-size: 58px;
  letter-spacing: 4px;
  line-height: 1;
  margin: 6px 0 2px;
  text-shadow: 3px 3px 0 rgba(232,72,63,.28);
}
#logo span { display: inline-block; }
#logo span:nth-child(odd)  { transform: rotate(-3deg) translateY(1px); }
#logo span:nth-child(even) { transform: rotate(2.5deg) translateY(-2px); }
#logo span:nth-child(4)    { color: var(--accent); }

#tagline { color: var(--dim); font-size: 18px; margin-bottom: 4px; }

#best-line { font-size: 15px; color: var(--dim); min-height: 20px; margin-bottom: 10px; }
#best-line b { color: var(--ink); }

button {
  font-family: inherit;
  color: var(--ink);
  background: var(--paper);
  border: 2.6px solid var(--ink);
  border-radius: 225px 14px 255px 14px / 14px 255px 14px 225px;
  cursor: pointer;
  transition: transform .08s, box-shadow .08s, background .12s;
}
button:hover { transform: translate(-1px,-2px) rotate(-.5deg); box-shadow: 3px 4px 0 rgba(31,31,31,.75); }
button:active { transform: translate(1px,1px); box-shadow: none; }

button.big {
  display: block; width: 100%;
  font-size: 27px;
  padding: 10px 0 8px;
  margin: 8px 0;
  background: var(--ink); color: var(--paper);
  box-shadow: 4px 5px 0 rgba(31,31,31,.35);
}
button.big:hover { background: #000; }
button.mid {
  display: block; width: 100%;
  font-size: 19px;
  padding: 7px 0 5px;
  margin: 7px 0;
}
button.ghost { border-color: var(--dim); color: var(--dim); }
button.ghost:hover { border-color: var(--ink); color: var(--ink); }

#lang-switch { position: absolute; top: 10px; right: 14px; display: flex; gap: 5px; }
.lang-opt {
  font-size: 13px; padding: 2px 9px 1px;
  border-width: 2px; color: var(--dim);
}
.lang-opt.active { background: var(--ink); color: var(--paper); border-color: var(--ink); }

/* fileiras de cartas (dificuldade / canetas / pets) */
.row-title { margin: 13px 0 6px; font-size: 15px; color: var(--dim); letter-spacing: 2px; }
#pens, #pets, #diffs { display: flex; gap: 9px; justify-content: center; }
#pets .pen-card { max-width: 104px; }
#diffs .pen-card { max-width: 146px; }
#diffs .pen-card .p-desc { font-size: 11.5px; }
.pen-card {
  flex: 1; max-width: 132px;
  padding: 8px 6px 7px;
  font-size: 14px;
  line-height: 1.15;
  position: relative;
}
.pen-card .p-ico { font-size: 23px; display: block; }
.pen-card .p-name { font-size: 15px; display: block; }
.pen-card .p-desc { font-size: 12px; color: var(--dim); display: block; }
.pen-card.sel { background: var(--ink); }
.pen-card.sel .p-name, .pen-card.sel .p-ico { color: var(--paper); }
.pen-card.sel .p-desc { color: #c4c4bc; }
.pen-card.locked { opacity: .55; border-style: dashed; cursor: default; }
.pen-card.locked:hover { transform: none; box-shadow: none; }
.pen-card .p-lock { font-size: 12px; color: var(--accent); display: block; }
.pen-card .p-bar { height: 5px; border: 1.5px solid var(--dim); border-radius: 4px; margin-top: 3px; overflow: hidden; }
.pen-card .p-bar i { display: block; height: 100%; background: var(--accent); }

/* como jogar */
#howto {
  margin-top: 14px;
  text-align: left;
  font-size: 15px;
  line-height: 1.55;
  border-top: 2px dashed #d8d8cf;
  padding-top: 9px;
}
#howto .dim { color: var(--dim); }
#howto .warn { color: var(--accent); margin-top: 2px; }
kbd {
  display: inline-block;
  border: 1.8px solid var(--ink);
  border-radius: 5px;
  padding: 0 6px;
  font-size: 12.5px;
  font-family: inherit;
  background: #f4f4ec;
  box-shadow: 0 2px 0 rgba(31,31,31,.6);
  margin: 0 1px;
  transform: translateY(-1px);
}

.menu-foot {
  margin-top: 13px;
  display: flex; align-items: center; justify-content: center; gap: 8px;
  flex-wrap: wrap;
}
.chip { font-size: 14px; padding: 3px 12px 2px; }
.chip.off { color: var(--dim); border-color: var(--dim); text-decoration: line-through; }
#credits { font-size: 13px; color: var(--dim); width: 100%; margin-top: 4px; }

/* ------------------------------------------------ fim de corrida */
#over .card { transform: rotate(.6deg); }
#over-title { font-size: 46px; letter-spacing: 3px; color: var(--accent); line-height: 1; margin-top: 4px; }
#over-title.fell { color: var(--blue); }
.taunt { color: var(--dim); font-size: 17px; min-height: 24px; margin: 4px 0 8px; }
#over-record {
  color: var(--green); font-size: 21px; margin: 2px 0 6px;
  animation: record-pop .5s ease-out;
}
@keyframes record-pop { 0% { transform: scale(.3) rotate(-6deg); } 70% { transform: scale(1.15) rotate(2deg); } 100% { transform: scale(1); } }

#over-stats {
  display: grid; grid-template-columns: 1fr 1fr;
  gap: 4px 18px;
  font-size: 17px;
  margin: 8px auto 4px;
  width: fit-content;
  text-align: left;
}
#over-stats .st-l { color: var(--dim); }
#over-stats .st-v { text-align: right; }
#over-stats .st-v b { font-size: 20px; }

#over-unlock { margin: 10px 0 4px; font-size: 15px; }
#over-unlock .u-new {
  color: var(--green); font-size: 19px;
  animation: record-pop .5s ease-out;
}
#over-unlock .u-bar { height: 8px; border: 2px solid var(--ink); border-radius: 6px; overflow: hidden; margin-top: 4px; }
#over-unlock .u-bar i { display: block; height: 100%; background: var(--green); transition: width .8s ease-out; }
#over-unlock .u-txt { color: var(--dim); font-size: 13.5px; margin-top: 2px; }

.over-btns { margin-top: 10px; }

/* ------------------------------------------------ toque */
#touch { position: fixed; inset: 0; z-index: 15; pointer-events: none; }
.t-cluster { position: absolute; bottom: 18px; display: flex; gap: 12px; pointer-events: none; }
.t-left { left: 16px; }
.t-right { right: 16px; }
.t-btn {
  pointer-events: auto;
  width: 66px; height: 66px;
  font-size: 26px;
  border-radius: 50%;
  background: rgba(253,253,246,.75);
  border-width: 3px;
}
.t-btn.t-big { width: 84px; height: 84px; font-size: 34px; }
.t-btn:active, .t-btn.on { background: var(--ink); color: var(--paper); }

/* ------------------------------------------------ toasts */
#toast {
  position: fixed; left: 50%; bottom: 96px;
  transform: translateX(-50%);
  z-index: 40; pointer-events: none;
  display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.toast-msg {
  background: var(--ink); color: var(--paper);
  font-size: 16px;
  padding: 5px 16px 4px;
  border-radius: 200px 10px 200px 10px / 10px 200px 10px 200px;
  animation: toast-in .22s ease-out, toast-out .4s ease-in 2.1s forwards;
  max-width: 84vw;
}
@keyframes toast-in { from { transform: translateY(14px) scale(.8); opacity: 0; } }
@keyframes toast-out { to { transform: translateY(-8px); opacity: 0; } }

@media (max-height: 640px) {
  .card { padding: 16px 22px 14px; }
  #logo { font-size: 44px; }
  #howto { font-size: 13.5px; line-height: 1.4; }
  .row-title { margin: 8px 0 4px; }
}

@media (max-height: 760px) {
  #menu-card { transform: rotate(0deg) scale(.92); }
}

</style>
</head>
<body>

<canvas id="game"></canvas>

<!-- Controles de toque (só aparecem em telas touch, durante o jogo) -->
<div id="touch" hidden>
  <div class="t-cluster t-left">
    <button id="t-left"  class="t-btn">◀</button>
    <button id="t-right" class="t-btn">▶</button>
  </div>
  <div class="t-cluster t-right">
    <button id="t-jump" class="t-btn t-big">⤒</button>
  </div>
</div>

<!-- ===================== MENU ===================== -->
<div id="menu" class="overlay">
  <div class="card" id="menu-card">
    <div id="lang-switch">
      <button class="lang-opt" data-lang="pt">PT</button>
      <button class="lang-opt" data-lang="en">EN</button>
    </div>

    <h1 id="logo"><span>U</span><span>N</span><span>D</span><span>R</span><span>A</span><span>W</span><span>N</span></h1>
    <div id="tagline" data-i18n="menu.tag">desenhe rápido. corra mais rápido.</div>
    <div id="best-line"></div>

    <button id="btn-play" class="big" data-i18n="menu.play">▶ CORRER</button>
    <button id="btn-daily" class="mid"></button>

    <div id="diff-title" class="row-title" data-i18n="menu.diffTitle">DIFICULDADE</div>
    <div id="diffs"></div>

    <div id="pens-title" class="row-title" data-i18n="menu.pens">SUAS CANETAS</div>
    <div id="pens"></div>

    <div id="pets-title" class="row-title" data-i18n="menu.pets">SEU PET</div>
    <div id="pets"></div>

    <div id="howto">
      <div><b data-i18n="how.moveT">correr:</b> <kbd>A</kbd><kbd>D</kbd> <span class="dim">/</span> <kbd>◀</kbd><kbd>▶</kbd> · <b data-i18n="how.jumpT">pular:</b> <kbd data-i18n="how.jumpK">ESPAÇO</kbd></div>
      <div><b data-i18n="how.flipT">cambalhota:</b> <span data-i18n="how.flipB">pule de novo no ar — gira, sobe e vai mais longe</span></div>
      <div><b data-i18n="how.drawT">desenhar:</b> <span data-i18n="how.drawB">segure o <kbd>mouse</kbd> — o tempo desacelera enquanto você traça</span></div>
      <div><b data-i18n="how.inkT">tinta:</b> <span data-i18n="how.inkB">é limitada e os traços esfarelam — pegue as gotas 💧</span></div>
      <div class="warn" data-i18n="how.eraser">⚠ a BORRACHA apaga tudo. inclusive você.</div>
    </div>

    <div class="menu-foot">
      <button id="btn-sound" class="chip"></button>
      <button id="btn-music" class="chip"></button>
      <span id="credits" data-i18n="menu.credits">feito com ✏️ e rancor de papelaria</span>
    </div>
  </div>
</div>

<!-- ===================== PAUSA ===================== -->
<div id="pause" class="overlay" hidden>
  <div class="card small">
    <h2 data-i18n="pause.title">✎ pausado</h2>
    <button id="btn-resume" class="big" data-i18n="pause.resume">continuar</button>
    <button id="btn-p-restart" class="mid" data-i18n="pause.restart">recomeçar</button>
    <button id="btn-p-menu" class="mid ghost" data-i18n="pause.menu">voltar ao menu</button>
  </div>
</div>

<!-- ===================== FIM DE CORRIDA ===================== -->
<div id="over" class="overlay" hidden>
  <div class="card">
    <h2 id="over-title">APAGADO</h2>
    <div id="over-taunt" class="taunt"></div>
    <div id="over-record" hidden>★ <span data-i18n="over.newBest">NOVO RECORDE!</span> ★</div>
    <div id="over-stats"></div>
    <div id="over-unlock"></div>
    <div class="over-btns">
      <button id="btn-again" class="big"></button>
      <button id="btn-copy" class="mid" data-i18n="over.copy">📋 copiar resultado</button>
      <button id="btn-o-menu" class="mid ghost" data-i18n="over.menu">menu</button>
    </div>
  </div>
</div>

<div id="toast"></div>

<script>
(function () {
  var v = '1.1';
  var files = [
    'js/util.js', 'js/config.js', 'js/i18n.js', 'js/save.js', 'js/audio.js',
    'js/input.js', 'js/world.js', 'js/ink.js', 'js/enemies.js', 'js/player.js',
    'js/eraser.js', 'js/pet.js', 'js/game.js', 'js/render.js', 'js/main.js'
  ];
  files.forEach(function (s) { document.write('<script src="' + s + '?v=' + v + '"><\/script>'); });
})();
</script>
<script>
// UNDRAWN — utilidades
window.UD = window.UD || {};
(function () {
  const U = {};

  U.now = () => performance.now();
  U.clamp = (v, a, b) => v < a ? a : (v > b ? b : v);
  U.lerp = (a, b, t) => a + (b - a) * t;
  U.dist = (ax, ay, bx, by) => Math.hypot(ax - bx, ay - by);

  // ruído determinístico barato — a linha "fervendo" do desenho à mão
  U.noise = (seed, i, t) =>
    Math.sin(seed * 12.9898 + i * 78.233 + t * 2.1) *
    Math.cos(seed * 3.14 + i * 0.71 - t * 1.3);

  // RNG determinístico (mulberry32) — usado no gerador de mundo / desafio diário
  U.rng = function (seed) {
    let a = seed >>> 0;
    return function () {
      a = (a + 0x6D2B79F5) | 0;
      let x = Math.imul(a ^ (a >>> 15), 1 | a);
      x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
      return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
    };
  };

  U.pick = (arr, r) => arr[Math.floor((r ? r() : Math.random()) * arr.length)];

  // ponto mais próximo de p num segmento a-b
  U.closestOnSeg = function (px, py, ax, ay, bx, by) {
    const abx = bx - ax, aby = by - ay;
    const l2 = abx * abx + aby * aby;
    if (l2 === 0) return { x: ax, y: ay };
    let t = ((px - ax) * abx + (py - ay) * aby) / l2;
    t = U.clamp(t, 0, 1);
    return { x: ax + abx * t, y: ay + aby * t };
  };

  // colisão círculo × lista de segmentos. Muta pos/vel; retorna contatos.
  // seg: {ax,ay,bx,by,half, bounce?, mover?, stroke?}
  U.collideCircle = function (body, r, segs, onContact) {
    const contacts = [];
    for (let it = 0; it < 3; it++) {
      let any = false;
      for (const s of segs) {
        const q = U.closestOnSeg(body.x, body.y, s.ax, s.ay, s.bx, s.by);
        let dx = body.x - q.x, dy = body.y - q.y;
        let d = Math.hypot(dx, dy);
        const R = r + (s.half || 3);
        if (d >= R) continue;
        if (d < 0.0001) { dx = 0; dy = -1; d = 1; }
        const nx = dx / d, ny = dy / d;
        body.x += nx * (R - d);
        body.y += ny * (R - d);
        const vn = body.vx * nx + body.vy * ny;
        if (vn < 0) {
          if (onContact) onContact(s, nx, ny, vn);
          // rebote da caneta-mola tratado pelo onContact; padrão: mata a velocidade normal
          if (!s.bounce || ny > -0.35 || vn > -4) {
            body.vx -= vn * nx;
            body.vy -= vn * ny;
          }
        }
        contacts.push({ seg: s, nx, ny, vn });
        any = true;
      }
      if (!any) break;
    }
    return contacts;
  };

  U.wrapText = (text, max) => {
    const words = String(text).split(/\s+/), lines = [];
    let cur = '';
    for (const w of words) {
      if ((cur + ' ' + w).trim().length > max && cur) { lines.push(cur); cur = w; }
      else cur = (cur + ' ' + w).trim();
    }
    if (cur) lines.push(cur);
    return lines;
  };

  U.showToast = (text) => {
    const box = document.getElementById('toast');
    if (!box) return;
    const el = document.createElement('div');
    el.className = 'toast-msg';
    el.textContent = text;
    box.appendChild(el);
    setTimeout(() => el.remove(), 2600);
  };

  // mistura duas cores hex '#rrggbb'
  U.mixColor = function (c1, c2, t) {
    const p = (c) => [parseInt(c.slice(1, 3), 16), parseInt(c.slice(3, 5), 16), parseInt(c.slice(5, 7), 16)];
    const a = p(c1), b = p(c2);
    const m = a.map((v, i) => Math.round(U.lerp(v, b[i], t)));
    return `rgb(${m[0]},${m[1]},${m[2]})`;
  };

  UD.U = U;
})();

// UNDRAWN — constantes de tuning (unidades: px, frames de 60Hz, ms)
(function () {
  UD.C = {
    STEP: 1000 / 60,
    M2PX: 24,               // 24 px = 1 "metro" de corrida
    VOID_Y: 430,            // caiu da página: fim
    PAGE_SEAM: 2400,        // emenda de página (fita adesiva) a cada X px

    PLAYER: {
      r: 13,
      speed: 5.9, accelG: 0.62, accelA: 0.33,
      jumpV: 12.4, g: 0.62, gFall: 0.82, gFast: 1.35, vMax: 17.5,
      coyoteMs: 110, bufferMs: 130, cutV: 5,
      airJumpV: 10.3, airJumpBoost: 1.5,   // cambalhota: 2º pulo no ar
      stunMs: 480, iframesMs: 950, knockX: 6.6, knockY: 6.4
    },

    // dificuldades: mundo, borracha e checkpoints compatíveis
    DIFF: {
      easy: {
        ico: '🌱', rampScale: 0.55, gapScale: 0.82, enemyScale: 0.55,
        checkpointM: 300, scoreMult: 1, inkEvery: 700,
        eraser: { base: 2.55, cap: 4.8, ramp: 0.7, farMin: 5.95, lungeFrom: 26000, lungeEvery: [27000, 42000] }
      },
      standard: {
        ico: '✏️', rampScale: 1, gapScale: 1, enemyScale: 1,
        checkpointM: 500, scoreMult: 1, inkEvery: 900,
        eraser: { base: 3.0, cap: 5.45, ramp: 1, farMin: 6.4, lungeFrom: 14000, lungeEvery: [19000, 33000] }
      },
      hardcore: {
        ico: '💀', rampScale: 1.4, gapScale: 1.1, enemyScale: 1.35,
        checkpointM: 0, scoreMult: 1.5, inkEvery: 1050,
        eraser: { base: 3.3, cap: 6.0, ramp: 1.35, farMin: 6.9, lungeFrom: 9000, lungeEvery: [14000, 26000] }
      }
    },

    // pets: desbloqueados com estrelas ★ acumuladas em todas as corridas
    PETS: [
      { id: 'balloon', ico: '🎈', unlock: 15 },
      { id: 'birdie',  ico: '🐤', unlock: 40 },
      { id: 'rocket',  ico: '🚀', unlock: 90 },
      { id: 'starly',  ico: '🌟', unlock: 160 }
    ],

    INK: {
      max: 100, regen: 13,          // regen por segundo
      costPerPx: 0.082,
      minDist: 7, maxPts: 160, maxStrokes: 7,
      lifeMs: 8200, warnMs: 5800, crumbleMs: 420,
      half: 4,                       // meia-espessura de colisão do traço
      slowmo: 0.35                   // escala de tempo enquanto desenha
    },

    PENS: [
      { id: 'graphite', ico: '✏️', unlock: 0,    cost: 1    },
      { id: 'bounce',   ico: '🖊️', unlock: 3000, cost: 1.2,  bounce: true },
      { id: 'red',      ico: '🖍️', unlock: 8000, cost: 1.45, deadly: true }
    ],

    ERASER: {
      startGap: 780,          // distância inicial atrás do jogador
      base: 3.0, ramp: 0.000048, cap: 5.45,
      farGap: 1500, farBoost: 1.5,     // muito longe → acelera (rubber band)
      nearGap: 230, nearEase: 0.9,     // colado → leve piedade
      lungeFromPx: 14000,              // investidas a partir de ~580m
      lungeEvery: [19000, 33000],      // intervalo entre investidas (ms)
      lungeWarnMs: 1150, lungeMs: 1500, lungeSpeed: 9.2,
      width: 175
    },

    ENEMY: {
      scribble: { r: 15, hopVx: 3.7, hopVy: 7.4, everyMs: 860, aggro: 470 },
      plane:    { r: 14, speed: 4.5, amp: 36 }
    },

    COMBO: { windowMs: 4200, max: 9 },
    NEARMISS: { gap: 270, holdMs: 1500, cooldownMs: 7000, bonus: 150 },

    BIOME_LEN: 15600,        // ~650 m por bioma

    // paletas: papel, tinta, traço fraco, acento, vão (buraco), cor das canetas
    PALETTES: [
      { id: 'notebook',  paper: '#fdfdf6', ink: '#1f1f1f', faint: '#c9c9bf', accent: '#e8483f', void: '#3a2d1e',
        ruled: '#cfe0ee', margin: '#f0b9b4', penBounce: '#2f7fd6', penRed: '#d43a2f' },
      { id: 'graph',     paper: '#fbfcf1', ink: '#25313b', faint: '#c5cdc2', accent: '#e8483f', void: '#332e26',
        ruled: '#dbe4d5', margin: null,      penBounce: '#2f7fd6', penRed: '#d43a2f' },
      { id: 'kraft',     paper: '#cfae80', ink: '#3a2a16', faint: '#b3906a', accent: '#e8483f', void: '#2a1d10',
        ruled: null,      margin: null,      penBounce: '#1d5fa8', penRed: '#c22f24', drawInk: '#fffef2' },
      { id: 'blueprint', paper: '#16324f', ink: '#e4eef8', faint: '#3f6183', accent: '#7fd4ff', void: '#0a1420',
        ruled: '#23486e', margin: null,      penBounce: '#8fe3ff', penRed: '#ff8d7a' },
      { id: 'dark',      paper: '#191919', ink: '#efefe6', faint: '#3d3d38', accent: '#ff5f56', void: '#000000',
        ruled: '#262626', margin: null,      penBounce: '#7db8ff', penRed: '#ff6f66' }
    ]
  };
})();

// UNDRAWN — textos PT/EN
(function () {
  const dict = {
    pt: {
      'menu.tag': 'desenhe rápido. corra mais rápido.',
      'menu.play': '▶ CORRER',
      'menu.daily': '🗓️ desafio de hoje',
      'menu.dailyBest': 'melhor de hoje: {m}m',
      'menu.pens': '— SUAS CANETAS —',
      'menu.best': 'recorde: <b>{m}m</b> · <b>{pts}</b> pts &nbsp;·&nbsp; total: <b>{total}m</b> · <b>{stars}★</b>',
      'menu.firstRun': 'ninguém correu ainda. seja a primeira vítima.',
      'menu.sound': 'som',
      'menu.music': 'música',
      'menu.credits': 'feito com ✏️ e rancor de papelaria',

      'how.moveT': 'correr:',
      'how.jumpT': 'pular:',
      'how.jumpK': 'ESPAÇO',
      'how.drawT': 'desenhar:',
      'how.drawB': 'segure o <kbd>mouse</kbd> e trace — o tempo desacelera enquanto você desenha',
      'how.inkT': 'tinta:',
      'how.inkB': 'é limitada e os traços esfarelam — pegue as gotas 💧',
      'how.eraser': '⚠ a BORRACHA apaga tudo. inclusive você.',

      'pen.graphite.name': 'Grafite',
      'pen.graphite.desc': 'confiável. tipo você, às vezes.',
      'pen.bounce.name': 'Caneta-Mola',
      'pen.bounce.desc': 'traços que quicam. física? sim.',
      'pen.red.name': 'Vermelhão',
      'pen.red.desc': 'traço quente: torra rabiscos vivos.',
      'pen.locked': 'faltam {m}m',

      'menu.diffTitle': '— DIFICULDADE —',
      'diff.easy': 'fácil',
      'diff.easy.d': 'bandeira a cada 300m · borracha calma',
      'diff.standard': 'normal',
      'diff.standard.d': 'bandeira a cada 500m · o jogo como ele é',
      'diff.hardcore': 'hardcore',
      'diff.hardcore.d': 'sem bandeira · borracha brava · pontos ×1.5',

      'menu.pets': '— SEU PET —',
      'pet.none.name': 'sozinho',
      'pet.none.desc': 'só você e o lápis.',
      'pet.balloon.name': 'Balão',
      'pet.balloon.desc': 'flutua e torce por você.',
      'pet.birdie.name': 'Passarinho',
      'pet.birdie.desc': 'esvoaça pios de incentivo.',
      'pet.rocket.name': 'Foguetinho',
      'pet.rocket.desc': 'deixa um rastro de faísca.',
      'pet.starly.name': 'Estrelinha',
      'pet.starly.desc': 'brilha até na página escura.',
      'pet.lockedStars': 'faltam {n}★',

      'pause.title': '✎ pausado',
      'pause.resume': 'continuar',
      'pause.restart': 'recomeçar',
      'pause.menu': 'voltar ao menu',

      'over.erasedT': 'APAGADO',
      'over.fellT': 'FORA DA PÁGINA',
      'over.newBest': 'NOVO RECORDE!',
      'over.again': '↻ de novo ({k})',
      'over.copy': '📋 copiar resultado',
      'over.copied': 'copiado! cola no grupo 😏',
      'over.menu': 'menu',
      'over.dist': 'distância',
      'over.score': 'pontos',
      'over.best': 'recorde',
      'over.stars': 'estrelas',
      'over.stomps': 'rabiscos pisoteados',
      'over.maxCombo': 'combo máximo',
      'over.taunts': 'a borracha manda lembranças.|"limpinho", disse a borracha.|você virou pó de borracha.|era uma vez. e agora não é mais.|apagado com carinho e força.|a papelaria sempre vence.|F no chat do fichário.',
      'over.tauntsFell': 'a gravidade não estava no desenho.|caiu da página. clássico.|lá embaixo é só mesa. e arrependimento.|o vão te olhou de volta.',
      'over.unlockNew': '🎉 nova caneta: <b>{name}</b>!',
      'over.unlockPet': '🎉 novo pet: <b>{name}</b>!',
      'over.unlockNext': 'próxima caneta: <b>{name}</b>',
      'over.unlockLeft': 'faltam {m}m de corrida total',
      'over.nextPet': 'próximo pet: <b>{name}</b> — faltam {n}★',
      'over.allUnlocked': 'todas as canetas desbloqueadas ✔',
      'over.diff': 'dificuldade',
      'over.deaths': 'voltas no checkpoint',
      'over.copyText': '🏃✏️ corri {m}m e fiz {pts} pts no UNDRAWN antes de ser apagado. duvido você passar disso.',

      'hint.run': 'ela vem aí. CORRA →',
      'hint.draw': 'segure o MOUSE e desenhe uma ponte!',
      'hint.jump': 'ESPAÇO pula · S desce rápido',
      'hint.flip': 'pule 2× no ar = CAMBALHOTA',
      'hint.pen': '1 2 3 trocam de caneta',

      'banner.m': '{m}m!',
      'banner.near': 'POR POUCO! +{n}',
      'banner.cp': '📍 checkpoint!',
      'banner.back': '↩ de volta ao checkpoint',
      'biome.notebook': 'caderno',
      'biome.graph': 'papel quadriculado',
      'biome.kraft': 'papelão',
      'biome.blueprint': 'blueprint',
      'biome.dark': 'página escura',
      'banner.biome': 'novo papel: {b}',

      'bub.start': 'ah não. ELA de novo.|hoje eu não viro pó!|pernas, não me decepcionem.|corre que é 2B!',
      'bub.respawn': 'segunda chance!|a página me quer vivo!|dessa vez vai!|voltei. de novo.',
      'bub.milestone': 'ainda inteiro!|tô voando (quase)|isso aí, eu mesmo me desenhei|nem suei a tinta',
      'bub.near': 'UFA!|essa raspou!|quase virei rascunho!|senti o cheiro de borracha!',
      'bub.noInk': 'sem tinta!!',
      'bub.eraserTaunt': 'volte aqui, erro ortográfico!|isso é serviço, não perseguição.|você nem estava no orçamento do desenho.|vou te deixar LIMPO.|rabisco fujão dá mais trabalho.',
      'bub.eraserKill': 'serviço concluído. ✔',
      'bub.eraserLunge': 'CANSEI. VEM CÁ.',

      'toast.pen': '{name} equipada',
      'toast.penUnlock': '🎉 caneta desbloqueada: {name}!',
      'toast.pet': '{name} vai junto!',
      'toast.petUnlock': '🎉 pet desbloqueado: {name}!',
      'toast.petLocked': '{name} ainda dorme — faltam {n}★',
      'toast.noInk': 'tinta esgotada — pegue as gotas 💧',
      'toast.daily': 'desafio de {d} — mesma página (regras: normal ✏️)',
      'toast.paused': 'pausado',
      'how.flipT': 'cambalhota:',
      'how.flipB': 'pule de novo no ar — gira, sobe e vai mais longe'
    },

    en: {
      'menu.tag': 'draw fast. run faster.',
      'menu.play': '▶ RUN',
      'menu.daily': '🗓️ daily challenge',
      'menu.dailyBest': 'today’s best: {m}m',
      'menu.pens': '— YOUR PENS —',
      'menu.best': 'best: <b>{m}m</b> · <b>{pts}</b> pts &nbsp;·&nbsp; lifetime: <b>{total}m</b> · <b>{stars}★</b>',
      'menu.firstRun': 'nobody has run yet. be the first victim.',
      'menu.sound': 'sound',
      'menu.music': 'music',
      'menu.credits': 'made with ✏️ and stationery grudges',

      'how.moveT': 'run:',
      'how.jumpT': 'jump:',
      'how.jumpK': 'SPACE',
      'how.drawT': 'draw:',
      'how.drawB': 'hold the <kbd>mouse</kbd> and sketch — time slows while you draw',
      'how.inkT': 'ink:',
      'how.inkB': 'it’s limited and strokes crumble — grab the drops 💧',
      'how.eraser': '⚠ the ERASER erases everything. including you.',

      'pen.graphite.name': 'Graphite',
      'pen.graphite.desc': 'reliable. like you, sometimes.',
      'pen.bounce.name': 'Spring Pen',
      'pen.bounce.desc': 'bouncy strokes. physics? yes.',
      'pen.red.name': 'Big Red',
      'pen.red.desc': 'hot line: fries living scribbles.',
      'pen.locked': '{m}m to go',

      'menu.diffTitle': '— DIFFICULTY —',
      'diff.easy': 'easy',
      'diff.easy.d': 'flag every 300m · gentle eraser',
      'diff.standard': 'standard',
      'diff.standard.d': 'flag every 500m · the game as intended',
      'diff.hardcore': 'hardcore',
      'diff.hardcore.d': 'no flags · angry eraser · ×1.5 score',

      'menu.pets': '— YOUR PET —',
      'pet.none.name': 'solo',
      'pet.none.desc': 'just you and the pencil.',
      'pet.balloon.name': 'Balloon',
      'pet.balloon.desc': 'floats along and cheers.',
      'pet.birdie.name': 'Birdie',
      'pet.birdie.desc': 'flutters words of courage.',
      'pet.rocket.name': 'Lil Rocket',
      'pet.rocket.desc': 'leaves a spark trail.',
      'pet.starly.name': 'Twinkle',
      'pet.starly.desc': 'shines even on the dark page.',
      'pet.lockedStars': '{n}★ to go',

      'pause.title': '✎ paused',
      'pause.resume': 'resume',
      'pause.restart': 'restart',
      'pause.menu': 'back to menu',

      'over.erasedT': 'ERASED',
      'over.fellT': 'OFF THE PAGE',
      'over.newBest': 'NEW BEST!',
      'over.again': '↻ again ({k})',
      'over.copy': '📋 copy result',
      'over.copied': 'copied! go brag 😏',
      'over.menu': 'menu',
      'over.dist': 'distance',
      'over.score': 'score',
      'over.best': 'best',
      'over.stars': 'stars',
      'over.stomps': 'scribbles stomped',
      'over.maxCombo': 'max combo',
      'over.taunts': 'the eraser sends its regards.|"spotless", said the eraser.|you are eraser dust now.|once upon a time. not anymore.|erased with love and force.|the stationery always wins.|F in the binder chat.',
      'over.tauntsFell': 'gravity wasn’t in the drawing.|fell off the page. classic.|down there it’s just desk. and regret.|the gap stared back.',
      'over.unlockNew': '🎉 new pen: <b>{name}</b>!',
      'over.unlockPet': '🎉 new pet: <b>{name}</b>!',
      'over.unlockNext': 'next pen: <b>{name}</b>',
      'over.unlockLeft': '{m}m of lifetime running to go',
      'over.nextPet': 'next pet: <b>{name}</b> — {n}★ to go',
      'over.allUnlocked': 'all pens unlocked ✔',
      'over.diff': 'difficulty',
      'over.deaths': 'checkpoint returns',
      'over.copyText': '🏃✏️ I ran {m}m and scored {pts} pts in UNDRAWN before getting erased. beat that.',

      'hint.run': 'it’s coming. RUN →',
      'hint.draw': 'hold the MOUSE and draw a bridge!',
      'hint.jump': 'SPACE jumps · S fast-falls',
      'hint.flip': 'jump 2× mid-air = FLIP',
      'hint.pen': '1 2 3 switch pens',

      'banner.m': '{m}m!',
      'banner.near': 'CLOSE ONE! +{n}',
      'banner.cp': '📍 checkpoint!',
      'banner.back': '↩ back to checkpoint',
      'biome.notebook': 'notebook',
      'biome.graph': 'graph paper',
      'biome.kraft': 'cardboard',
      'biome.blueprint': 'blueprint',
      'biome.dark': 'dark page',
      'banner.biome': 'new paper: {b}',

      'bub.start': 'oh no. HER again.|not turning into dust today!|legs, don’t fail me now.|run, it’s a 2B!',
      'bub.respawn': 'second chance!|the page wants me alive!|this time for real!|I’m back. again.',
      'bub.milestone': 'still in one piece!|basically flying|drew myself pretty good huh|didn’t even sweat ink',
      'bub.near': 'PHEW!|that one grazed me!|almost became a draft!|I smelled rubber!',
      'bub.noInk': 'out of ink!!',
      'bub.eraserTaunt': 'come back here, typo!|it’s a job, not a chase.|you weren’t even in the budget.|I will leave you SPOTLESS.|runaway doodles are extra work.',
      'bub.eraserKill': 'job done. ✔',
      'bub.eraserLunge': 'THAT’S IT. COME HERE.',

      'toast.pen': '{name} equipped',
      'toast.penUnlock': '🎉 pen unlocked: {name}!',
      'toast.pet': '{name} tags along!',
      'toast.petUnlock': '🎉 pet unlocked: {name}!',
      'toast.petLocked': '{name} is still asleep — {n}★ to go',
      'toast.noInk': 'ink depleted — grab the drops 💧',
      'toast.daily': 'challenge of {d} — same page (rules: standard ✏️)',
      'toast.paused': 'paused',
      'how.flipT': 'flip:',
      'how.flipB': 'jump again mid-air — spin, rise and reach further'
    }
  };

  const I = { lang: 'pt' };

  I.t = function (key, vars) {
    let s = (dict[I.lang] && dict[I.lang][key]) || dict.pt[key] || key;
    if (vars) for (const k in vars) s = s.replaceAll('{' + k + '}', vars[k]);
    return s;
  };

  I.setLang = function (lang) {
    I.lang = dict[lang] ? lang : 'pt';
    document.documentElement.lang = I.lang === 'pt' ? 'pt-BR' : 'en';
    I.apply();
    document.querySelectorAll('.lang-opt').forEach(b =>
      b.classList.toggle('active', b.dataset.lang === I.lang));
  };

  I.apply = function () {
    document.querySelectorAll('[data-i18n]').forEach(el => {
      el.innerHTML = I.t(el.dataset.i18n);
    });
    document.querySelectorAll('[data-i18n-title]').forEach(el => {
      el.title = I.t(el.dataset.i18nTitle);
    });
  };

  UD.i18n = I;
  UD.t = I.t;
})();

// UNDRAWN — áudio 100% procedural (WebAudio): efeitos, rangido do lápis,
// ronco da borracha por proximidade e música generativa que acelera com o perigo.
(function () {
  const A = {
    ctx: null, master: null, sfxG: null, musG: null,
    sound: true, music: true, intensity: 0,
    _noise: null, _brown: null,
    _scratch: null, _rumble: null,
    _musOn: false, _musTimer: null, _step: 0, _nextT: 0
  };

  // primeira interação do usuário → cria o contexto
  A.unlock = function () {
    if (A.ctx) { if (A.ctx.state === 'suspended') A.ctx.resume(); return; }
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return;
    A.ctx = new AC();
    A.master = A.ctx.createGain(); A.master.gain.value = 0.9;
    A.master.connect(A.ctx.destination);
    A.sfxG = A.ctx.createGain(); A.sfxG.gain.value = A.sound ? 1 : 0;
    A.sfxG.connect(A.master);
    A.musG = A.ctx.createGain(); A.musG.gain.value = A.music ? 0.16 : 0;
    A.musG.connect(A.master);
  };

  A.setSound = function (b) { A.sound = b; if (A.sfxG) A.sfxG.gain.value = b ? 1 : 0; };
  A.setMusic = function (b) { A.music = b; if (A.musG) A.musG.gain.setTargetAtTime(b ? 0.16 : 0, A.ctx ? A.ctx.currentTime : 0, 0.1); };

  function noiseBuf() {
    if (A._noise) return A._noise;
    const b = A.ctx.createBuffer(1, A.ctx.sampleRate, A.ctx.sampleRate);
    const d = b.getChannelData(0);
    for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
    return (A._noise = b);
  }
  function brownBuf() {
    if (A._brown) return A._brown;
    const b = A.ctx.createBuffer(1, A.ctx.sampleRate * 2, A.ctx.sampleRate);
    const d = b.getChannelData(0);
    let last = 0;
    for (let i = 0; i < d.length; i++) {
      last = (last + (Math.random() * 2 - 1) * 0.04) * 0.985;
      d[i] = last * 6;
    }
    return (A._brown = b);
  }

  // osc curto com envelope — o tijolinho de todos os efeitos
  function blip(type, f0, f1, dur, gain, when, dest) {
    const t = when || A.ctx.currentTime;
    const o = A.ctx.createOscillator(), g = A.ctx.createGain();
    o.type = type;
    o.frequency.setValueAtTime(f0, t);
    if (f1) o.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t + dur);
    g.gain.setValueAtTime(gain, t);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    o.connect(g); g.connect(dest || A.sfxG);
    o.start(t); o.stop(t + dur + 0.02);
  }
  function thud(dur, gain, freq, when) {
    const t = when || A.ctx.currentTime;
    const s = A.ctx.createBufferSource(); s.buffer = noiseBuf();
    const f = A.ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = freq || 420;
    const g = A.ctx.createGain();
    g.gain.setValueAtTime(gain, t);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    s.connect(f); f.connect(g); g.connect(A.sfxG);
    s.start(t); s.stop(t + dur + 0.02);
  }

  A.play = function (name, p) {
    if (!A.ctx || !A.sound) return;
    const t = A.ctx.currentTime;
    p = p || 1;
    switch (name) {
      case 'jump':    blip('triangle', 280, 640, 0.11, 0.22); break;
      case 'flip':    blip('triangle', 420, 980, 0.14, 0.2); blip('sine', 980, 520, 0.12, 0.1, t + 0.12); break;
      case 'checkpoint':
        blip('triangle', 659.25, 659.25, 0.14, 0.15); blip('triangle', 987.77, 987.77, 0.2, 0.15, t + 0.11);
        break;
      case 'respawn':
        [392, 523.25, 659.25].forEach((f, i) => blip('sine', f, f, 0.16, 0.14, t + i * 0.07));
        break;
      case 'land':    thud(0.09, 0.10 * Math.min(1.6, p), 380); break;
      case 'stomp':   blip('square', 240, 80, 0.1, 0.24); thud(0.07, 0.16, 900); break;
      case 'pop':     blip('square', 520, 130, 0.09, 0.2); break;
      case 'ink':     blip('sine', 740, 990, 0.09, 0.18); break;
      case 'star':    blip('sine', 880, 880, 0.1, 0.15); blip('sine', 1318, 1318, 0.14, 0.13, t + 0.07); break;
      case 'hurt':    blip('sawtooth', 240, 70, 0.2, 0.22); thud(0.14, 0.12, 500); break;
      case 'boing':   blip('sine', 160, 620, 0.16, 0.22); blip('sine', 620, 330, 0.12, 0.12, t + 0.13); break;
      case 'noink':   blip('square', 130, 90, 0.12, 0.15); break;
      case 'ui':      blip('triangle', 500, 380, 0.05, 0.1); break;
      case 'pen':     blip('triangle', 620, 900, 0.06, 0.12); break;
      case 'near':    blip('sine', 520, 1040, 0.18, 0.14); break;
      case 'lunge':   blip('square', 660, 660, 0.1, 0.16); blip('square', 470, 470, 0.1, 0.16, t + 0.14);
                      blip('square', 660, 660, 0.1, 0.16, t + 0.28); break;
      case 'milestone':
        [523.25, 659.25, 783.99].forEach((f, i) => blip('triangle', f, f, 0.16, 0.13, t + i * 0.07));
        break;
      case 'unlock':
        [523.25, 659.25, 783.99, 1046.5, 1318.5].forEach((f, i) => blip('triangle', f, f, 0.2, 0.13, t + i * 0.09));
        break;
      case 'dead': {
        // rangido longo da borracha + descida triste
        const s = A.ctx.createBufferSource(); s.buffer = noiseBuf(); s.loop = true;
        const f = A.ctx.createBiquadFilter(); f.type = 'bandpass'; f.frequency.value = 300; f.Q.value = 1.2;
        const g = A.ctx.createGain();
        g.gain.setValueAtTime(0.28, t);
        g.gain.exponentialRampToValueAtTime(0.0001, t + 0.9);
        f.frequency.exponentialRampToValueAtTime(90, t + 0.9);
        s.connect(f); f.connect(g); g.connect(A.sfxG);
        s.start(t); s.stop(t + 1);
        blip('sawtooth', 300, 55, 0.8, 0.14, t + 0.1);
        break;
      }
      case 'fell':    blip('sine', 600, 90, 0.7, 0.18); break;
    }
  };

  // ---------------------------------------------------------------- lápis riscando
  A.scratch = function (level, speed) {
    if (!A.ctx) return;
    if (!A._scratch) {
      const s = A.ctx.createBufferSource(); s.buffer = noiseBuf(); s.loop = true;
      const f = A.ctx.createBiquadFilter(); f.type = 'bandpass'; f.Q.value = 2.2; f.frequency.value = 2400;
      const g = A.ctx.createGain(); g.gain.value = 0;
      s.connect(f); f.connect(g); g.connect(A.sfxG);
      s.start();
      A._scratch = { g, f };
    }
    const t = A.ctx.currentTime;
    A._scratch.g.gain.setTargetAtTime(0.11 * level, t, 0.03);
    A._scratch.f.frequency.setTargetAtTime(1600 + Math.min(1, speed) * 2600, t, 0.05);
  };
  A.scratchStop = function () {
    if (A._scratch) A._scratch.g.gain.setTargetAtTime(0, A.ctx.currentTime, 0.04);
  };

  // ---------------------------------------------------------------- ronco da borracha
  A.setRumble = function (v) {
    if (!A.ctx) return;
    if (!A._rumble) {
      const s = A.ctx.createBufferSource(); s.buffer = brownBuf(); s.loop = true;
      const f = A.ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 130;
      const g = A.ctx.createGain(); g.gain.value = 0;
      s.connect(f); f.connect(g); g.connect(A.sfxG);
      s.start();
      A._rumble = { g };
    }
    A._rumble.g.gain.setTargetAtTime(0.5 * Math.min(1, v), A.ctx.currentTime, 0.15);
  };

  // ---------------------------------------------------------------- música generativa
  // pentatônica de lá menor sobre Am–F–C–G; o BPM sobe conforme a borracha aproxima.
  const CHORDS = [110, 87.31, 130.81, 98];              // graves A F C G
  const PENTA = [440, 523.25, 587.33, 659.25, 783.99, 880];

  A.musicStart = function () {
    if (!A.ctx || A._musOn) return;
    A._musOn = true;
    A._step = 0;
    A._nextT = A.ctx.currentTime + 0.1;
    A._musTimer = setInterval(schedule, 90);
  };
  A.musicStop = function () {
    A._musOn = false;
    if (A._musTimer) { clearInterval(A._musTimer); A._musTimer = null; }
  };

  function schedule() {
    if (!A._musOn || !A.ctx) return;
    const bpm = 96 + A.intensity * 36;
    const eighth = 60 / bpm / 2;
    while (A._nextT < A.ctx.currentTime + 0.3) {
      const st = A._step, t = A._nextT;
      const chord = CHORDS[Math.floor(st / 8) % 4];
      if (st % 8 === 0) blip('sine', chord, chord, eighth * 5, 0.5, t, A.musG);
      if (st % 8 === 4) blip('sine', chord * 1.5, chord * 1.5, eighth * 2.4, 0.22, t, A.musG);
      // melodia: passeio aleatório na pentatônica com pausas
      if (Math.random() < 0.42 + A.intensity * 0.25) {
        const up = A.intensity > 0.55 && Math.random() < 0.4 ? 2 : 1;
        const f = PENTA[Math.floor(Math.random() * PENTA.length)] * up;
        blip('triangle', f, f, eighth * 1.6, 0.34, t, A.musG);
      }
      // chimbal de papel
      if (st % 2 === 1 && A.intensity > 0.3) {
        const s = A.ctx.createBufferSource(); s.buffer = noiseBuf();
        const f = A.ctx.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 6000;
        const g = A.ctx.createGain();
        g.gain.setValueAtTime(0.05 + A.intensity * 0.06, t);
        g.gain.exponentialRampToValueAtTime(0.0001, t + 0.05);
        s.connect(f); f.connect(g); g.connect(A.musG);
        s.start(t); s.stop(t + 0.06);
      }
      A._nextT += eighth;
      A._step++;
    }
  }

  UD.audio = A;
})();

// UNDRAWN — teclado, mouse e toque
(function () {
  const I = {
    state: { left: false, right: false, down: false, jumpHeld: false },
    jumpPressedAt: -1e9,
    pointer: { sx: -100, sy: -100, over: false },
    drawId: null,
    isTouch: false
  };

  I.init = function (cv) {
    // ---------------- teclado
    const keyMap = (code) => {
      switch (code) {
        case 'KeyA': case 'ArrowLeft': return 'left';
        case 'KeyD': case 'ArrowRight': return 'right';
        case 'KeyS': case 'ArrowDown': return 'down';
        case 'KeyW': case 'ArrowUp': case 'Space': return 'jump';
      }
      return null;
    };

    addEventListener('keydown', (e) => {
      if (e.repeat) return;
      const k = keyMap(e.code);
      if (k === 'jump') {
        I.state.jumpHeld = true;
        I.jumpPressedAt = performance.now();
        e.preventDefault();
      } else if (k) {
        I.state[k] = true;
        e.preventDefault();
      }
      if (e.code === 'Digit1') UD.game.setPen(0);
      if (e.code === 'Digit2') UD.game.setPen(1);
      if (e.code === 'Digit3') UD.game.setPen(2);
      if (e.code === 'KeyP' || e.code === 'Escape') UD.game.pauseToggle();
      if (e.code === 'KeyM') UD.game.toggleSound();
      if (e.code === 'KeyR') UD.game.hotRestart();
    });
    addEventListener('keyup', (e) => {
      const k = keyMap(e.code);
      if (k === 'jump') I.state.jumpHeld = false;
      else if (k) I.state[k] = false;
    });

    // ---------------- mouse / caneta / toque no canvas (desenho)
    cv.addEventListener('pointerdown', (e) => {
      UD.audio.unlock();
      if (e.pointerType === 'touch') I.isTouch = true;
      I.pointer.sx = e.clientX; I.pointer.sy = e.clientY; I.pointer.over = true;
      if (e.button === 2) { // botão direito: apaga o traço mais próximo (devolve tinta)
        const w = UD.render.screenToWorld(e.clientX, e.clientY);
        UD.ink.eraseNearest(w.x, w.y);
        return;
      }
      if (e.button !== 0) return;
      if (UD.game.state !== 'running') return;
      // chips de caneta no HUD são clicáveis
      const chips = UD.render.chipRects || [];
      for (const c of chips) {
        if (e.clientX >= c.x && e.clientX <= c.x + c.w && e.clientY >= c.y && e.clientY <= c.y + c.h) {
          UD.game.setPen(c.idx);
          return;
        }
      }
      I.drawId = e.pointerId;
      try { cv.setPointerCapture(e.pointerId); } catch (err) {}
      const w = UD.render.screenToWorld(e.clientX, e.clientY);
      UD.ink.start(w.x, w.y);
    });

    cv.addEventListener('pointermove', (e) => {
      I.pointer.sx = e.clientX; I.pointer.sy = e.clientY; I.pointer.over = true;
      if (I.drawId === e.pointerId && UD.ink.drawing) {
        const w = UD.render.screenToWorld(e.clientX, e.clientY);
        UD.ink.move(w.x, w.y);
      }
    });

    const endDraw = (e) => {
      if (I.drawId === e.pointerId) {
        I.drawId = null;
        UD.ink.end();
      }
    };
    cv.addEventListener('pointerup', endDraw);
    cv.addEventListener('pointercancel', endDraw);
    cv.addEventListener('pointerleave', () => { I.pointer.over = false; });
    cv.addEventListener('contextmenu', (e) => e.preventDefault());

    // ---------------- botões de toque
    const bind = (id, key) => {
      const el = document.getElementById(id);
      if (!el) return;
      const on = (e) => {
        e.preventDefault();
        UD.audio.unlock();
        el.classList.add('on');
        if (key === 'jump') { I.state.jumpHeld = true; I.jumpPressedAt = performance.now(); }
        else I.state[key] = true;
      };
      const off = (e) => {
        e.preventDefault();
        el.classList.remove('on');
        if (key === 'jump') I.state.jumpHeld = false;
        else I.state[key] = false;
      };
      el.addEventListener('pointerdown', on);
      el.addEventListener('pointerup', off);
      el.addEventListener('pointercancel', off);
      el.addEventListener('pointerleave', off);
    };
    bind('t-left', 'left');
    bind('t-right', 'right');
    bind('t-jump', 'jump');

    addEventListener('touchstart', () => { I.isTouch = true; }, { passive: true, once: true });

    // aba perdeu o foco no meio da corrida → pausa sozinho
    addEventListener('blur', () => { if (UD.game.state === 'running') UD.game.pauseToggle(true); });
  };

  UD.input = I;
})();

// UNDRAWN — sistema de desenho: traços viram plataformas, tinta é recurso,
// traços envelhecem e esfarelam. Canetas mudam o comportamento do traço.
(function () {
  const U = UD.U, C = UD.C.INK;

  const K = {
    meter: C.max,
    strokes: [],     // {pts, segs, pen, born, len, state:'alive'|'dying', dieAt, minX}
    drawing: null,
    pen: 'graphite'
  };

  K.penDef = (id) => UD.C.PENS.find(p => p.id === (id || K.pen)) || UD.C.PENS[0];

  K.reset = function () {
    K.meter = C.max;
    K.strokes = [];
    K.drawing = null;
    K.pen = UD.save.penUnlocked(UD.save.data.pen) ? UD.save.data.pen : 'graphite';
  };

  // ---------------------------------------------------------------- desenho
  K.start = function (wx, wy) {
    if (UD.game.state !== 'running' || K.drawing) return;
    if (K.meter < 3) { UD.game.noInkFx(); return; }
    const s = {
      pts: [{ x: wx, y: wy }], segs: [],
      pen: K.pen, born: UD.game.time, len: 0,
      state: 'alive', dieAt: 0, minX: wx
    };
    K.strokes.push(s);
    K.drawing = s;
    UD.audio.scratch(0.6, 0.3);
  };

  K.move = function (wx, wy) {
    const s = K.drawing;
    if (!s) return;
    const last = s.pts[s.pts.length - 1];
    const d = U.dist(wx, wy, last.x, last.y);
    if (d < C.minDist) return;
    const pen = K.penDef(s.pen);
    const cost = d * C.costPerPx * pen.cost;
    if (K.meter < cost || s.pts.length >= C.maxPts) {
      K.end();
      UD.game.noInkFx();
      return;
    }
    K.meter -= cost;
    s.pts.push({ x: wx, y: wy });
    s.segs.push({
      ax: last.x, ay: last.y, bx: wx, by: wy,
      half: C.half, bounce: !!pen.bounce, deadly: !!pen.deadly, stroke: s
    });
    s.len += d;
    s.minX = Math.min(s.minX, wx);
    UD.audio.scratch(1, d / 22);
  };

  K.end = function () {
    const s = K.drawing;
    K.drawing = null;
    UD.audio.scratchStop();
    if (s && s.pts.length < 2) {
      const i = K.strokes.indexOf(s);
      if (i >= 0) K.strokes.splice(i, 1);
    }
  };

  // ---------------------------------------------------------------- vida do traço
  K.update = function (dtSec) {
    if (!K.drawing) K.meter = Math.min(C.max, K.meter + C.regen * dtSec);
    const t = UD.game.time;

    // excesso de traços vivos → o mais velho começa a morrer
    const alive = K.strokes.filter(s => s.state === 'alive' && s !== K.drawing);
    if (alive.length > C.maxStrokes) startDying(alive[0], t);

    for (let i = K.strokes.length - 1; i >= 0; i--) {
      const s = K.strokes[i];
      if (s.state === 'alive' && s !== K.drawing && t - s.born > C.lifeMs) startDying(s, t);
      if (s.state === 'dying' && t - s.dieAt > C.crumbleMs) K.strokes.splice(i, 1);
    }
  };

  function startDying(s, t) {
    if (s.state !== 'alive') return;
    s.state = 'dying';
    s.dieAt = t;
    crumbsAlong(s, 1);
  }

  function crumbsAlong(s, density) {
    for (let i = 0; i < s.pts.length; i += Math.max(1, Math.round(3 / density))) {
      const p = s.pts[i];
      UD.game.spawnCrumbs(p.x, p.y, 1, 'ink');
    }
  }

  // borracha alcançou: destrói traços tocados (farelos voam à frente dela, onde dá pra ver)
  K.eraseBefore = function (x) {
    for (let i = K.strokes.length - 1; i >= 0; i--) {
      const s = K.strokes[i];
      if (s.minX < x) {
        if (s === K.drawing) K.end();
        for (let k = 0; k < s.pts.length; k += 4) {
          const p = s.pts[k];
          UD.game.spawnCrumbs(Math.max(p.x, x + 14), p.y, 1, 'eraser');
        }
        K.strokes.splice(i, 1);
      }
    }
  };

  // botão direito: apaga o traço mais próximo e devolve metade da tinta
  K.eraseNearest = function (wx, wy) {
    let best = null, bd = 70;
    for (const s of K.strokes) {
      if (s === K.drawing) continue;
      for (const p of s.pts) {
        const d = U.dist(wx, wy, p.x, p.y);
        if (d < bd) { bd = d; best = s; }
      }
    }
    if (!best) return;
    const pen = K.penDef(best.pen);
    K.meter = Math.min(C.max, K.meter + best.len * C.costPerPx * pen.cost * 0.5);
    crumbsAlong(best, 0.6);
    K.strokes.splice(K.strokes.indexOf(best), 1);
    UD.audio.play('pop');
  };

  // ---------------------------------------------------------------- consultas
  K.segsNear = function (x0, x1) {
    const out = [];
    for (const s of K.strokes) {
      if (s.state !== 'alive') continue;
      for (const g of s.segs) {
        if (Math.max(g.ax, g.bx) < x0 || Math.min(g.ax, g.bx) > x1) continue;
        out.push(g);
      }
    }
    return out;
  };

  UD.ink = K;
})();

// UNDRAWN — o Rabisco: controlador de plataforma com coyote time, buffer de pulo,
// pulo variável, squash & stretch e trombadas (o que mata é a Borracha, não a pancada).
(function () {
  const U = UD.U, C = UD.C.PLAYER;

  const P = {
    x: 0, y: 0, vx: 0, vy: 0, r: C.r,
    facing: 1, phase: 0,
    grounded: false, groundedAt: 0, lastJumpAt: 0,
    airJumps: 1, flipT: -1,             // cambalhota: 2º pulo no ar (flipT = progresso do giro)
    stunUntil: 0, iframesUntil: 0, daze: 0,
    squash: 0, stretch: 0,
    inCoffee: false, state: 'run',      // 'run' | 'dead'
    deadAt: 0, deadCause: '',
    ai: { right: true, jumpAt: 0 }      // piloto do modo vitrine (menu)
  };

  P.reset = function (x, y) {
    P.x = x; P.y = y; P.vx = 0; P.vy = 0;
    P.facing = 1; P.phase = 0;
    P.grounded = false; P.groundedAt = 0; P.lastJumpAt = 0;
    P.airJumps = 1; P.flipT = -1;
    P.stunUntil = 0; P.iframesUntil = 0; P.daze = 0;
    P.squash = 0; P.stretch = 0;
    P.state = 'run'; P.deadCause = '';
  };

  P.update = function (dt) {
    const now = performance.now();
    const I = UD.input, G = UD.game;
    if (P.state === 'dead') { P.phase += dt * 4; return; }

    // decaimentos visuais
    P.squash = Math.max(0, P.squash - dt * 5);
    P.stretch = Math.max(0, P.stretch - dt * 5);
    P.daze = Math.max(0, P.daze - dt * 0.5);

    // ------------------------------------------------ input (jogador ou vitrine)
    let left = false, right = false, down = false, jumpHeld = false, jumpBuffered = false;
    const stunned = now < P.stunUntil;
    if (G.attract) {
      attractAI(now);
      right = P.ai.right;
      jumpHeld = now - P.ai.jumpAt < 180;
      jumpBuffered = now - P.ai.jumpAt < C.bufferMs;
    } else if (!stunned) {
      left = I.state.left; right = I.state.right; down = I.state.down;
      jumpHeld = I.state.jumpHeld;
      jumpBuffered = now - I.jumpPressedAt < C.bufferMs;
    }

    // ------------------------------------------------ movimento horizontal
    let dir = 0;
    if (left) dir -= 1;
    if (right) dir += 1;
    if (dir !== 0) P.facing = dir;
    const slow = P.inCoffee ? 0.52 : 1;
    const target = dir * C.speed * slow;
    const accel = P.grounded ? C.accelG : C.accelA;
    P.vx += U.clamp(target - P.vx, -accel, accel);

    // ------------------------------------------------ gravidade e pulo
    let g = P.vy < 0 ? (jumpHeld ? C.g : C.gFall) : C.gFall;
    if (down && P.vy > -2) g = C.gFast;
    P.vy = Math.min(C.vMax, P.vy + g);

    const canCoyote = now - P.groundedAt < C.coyoteMs;
    if (jumpBuffered && canCoyote && now - P.lastJumpAt > 200) {
      P.vy = -C.jumpV * (P.inCoffee ? 0.8 : 1);
      P.lastJumpAt = now;
      P.groundedAt = -1e9;
      UD.input.jumpPressedAt = -1e9;
      P.stretch = 1;
      UD.audio.play('jump');
      G.spawnDust(P.x, P.y + P.r, 4);
    } else if (jumpBuffered && !canCoyote && P.airJumps > 0 && now - P.lastJumpAt > 160) {
      // CAMBALHOTA: 2º pulo no ar — gira, sobe um pouco menos e ganha embalo
      P.airJumps--;
      P.vy = -C.airJumpV * (P.inCoffee ? 0.85 : 1);
      P.vx = U.clamp(P.vx + P.facing * C.airJumpBoost, -8.2, 8.2);
      P.lastJumpAt = now;
      UD.input.jumpPressedAt = -1e9;
      P.flipT = 0;
      P.stretch = 1;
      UD.audio.play('flip');
      G.spawnDust(P.x, P.y + P.r * 0.4, 6);
    }
    if (!jumpHeld && P.vy < -C.cutV) P.vy = -C.cutV;   // pulo variável

    // progresso do giro da cambalhota
    if (P.flipT >= 0) {
      P.flipT += dt * 2.1;
      if (P.flipT >= 1) P.flipT = -1;
    }

    // ------------------------------------------------ integra + colide
    // sub-passos: em queda rápida o deslocamento por frame passava da espessura
    // do chão e o boneco atravessava (tunneling) — dividir o passo resolve.
    const wasGrounded = P.grounded;
    const fallV = P.vy;
    P.grounded = false;
    P.inCoffee = false;
    let mover = null;

    const onContact = (s, nx, ny, vn) => {
      if (ny < -0.55) {
        P.grounded = true;
        P.groundedAt = now;
        P.airJumps = 1;
        P.flipT = -1;
        if (s.mover) mover = s.mover;
      }
      if (s.bounce && vn < -4 && ny < -0.35) {
        // caneta-mola: reflete a velocidade normal com ganho mínimo
        const dvn = -vn * 0.84 - vn;
        P.vx += nx * dvn;
        P.vy = Math.min(P.vy + ny * dvn, -9.6);
        P.airJumps = 1;   // quicar devolve a cambalhota
        P.stretch = 1;
        UD.audio.play('boing');
        G.spawnDust(P.x, P.y + P.r, 6);
        G.bumpCombo(0);   // renova a janela do combo sem subir
      }
    };
    const sub = Math.min(3, Math.max(1, Math.ceil(Math.max(Math.abs(P.vx), Math.abs(P.vy)) / 9)));
    for (let i = 0; i < sub; i++) {
      P.x += P.vx / sub;
      P.y += P.vy / sub;
      const segs = UD.world.solidsNear(P.x - 70, P.x + 70)
        .concat(UD.ink.segsNear(P.x - 70, P.x + 70));
      U.collideCircle(P, P.r, segs, onContact);
    }
    if (mover) P.y += mover.dy;   // viaja junto com a plataforma-lápis

    if (P.grounded && !wasGrounded && fallV > 5) {
      P.squash = Math.min(1, fallV / 15);
      UD.audio.play('land', fallV / 12);
      G.spawnDust(P.x, P.y + P.r, Math.round(fallV / 3));
    }

    // ------------------------------------------------ perigos de chão
    if (now > P.iframesUntil) {
      for (const s of UD.world.spikes) {
        if (P.x > s.x0 - 4 && P.x < s.x1 + 4 && P.y + P.r > s.y - 16) {
          P.hit(P.vx >= 0 ? -1 : 1, true);
          break;
        }
      }
    }
    for (const cz of UD.world.coffee) {
      if (P.x > cz.x0 && P.x < cz.x1 && Math.abs(P.y + P.r - cz.y) < 26) { P.inCoffee = true; break; }
    }

    // ------------------------------------------------ animação
    if (P.grounded && Math.abs(P.vx) > 0.4) P.phase += Math.abs(P.vx) * 0.05;
    else if (!P.grounded) P.phase += 0.07;
  };

  // trombada: derruba p/ trás e atordoa — o perigo real é perder terreno p/ a Borracha
  P.hit = function (dirx, spike) {
    const now = performance.now();
    if (now < P.iframesUntil || P.state === 'dead') return;
    P.vx = dirx * C.knockX;
    P.vy = -C.knockY;
    P.stunUntil = now + C.stunMs;
    P.iframesUntil = now + C.iframesMs;
    P.daze = 1;
    UD.audio.play('hurt');
    UD.game.onPlayerHit(spike);
  };

  P.die = function (cause) {
    if (P.state === 'dead') return;
    P.state = 'dead';
    P.deadAt = performance.now();
    P.deadCause = cause;
  };

  // ---------------------------------------------------------------- piloto da vitrine
  function attractAI(now) {
    const W = UD.world;
    P.ai.right = true;
    if (!P.grounded) {
      // caindo num vão sem chão à vista → gasta a cambalhota
      if (P.vy > 3 && P.airJumps > 0 && !W.hasGroundAhead(P.x + 30, P.x + 150)) P.ai.jumpAt = now;
      return;
    }
    // buraco ou parede logo à frente → pula
    const noGround = !W.hasGroundAhead(P.x + 40, P.x + 170);
    let wallAhead = false;
    for (const w of W.walls) {
      if (w.x > P.x + 10 && w.x < P.x + 80 && w.yTop < P.y + 6) { wallAhead = true; break; }
    }
    let hazardAhead = false;
    for (const s of W.spikes) if (s.x0 > P.x && s.x0 < P.x + 90) { hazardAhead = true; break; }
    if (noGround || wallAhead || hazardAhead) P.ai.jumpAt = now;
  }

  UD.player = P;
})();

// UNDRAWN — a BORRACHA: antagonista, cronômetro e faxineira. Avança sempre,
// dá investidas, resmunga e apaga o mundo (e você) com esmero profissional.
(function () {
  const U = UD.U, C = UD.C.ERASER;

  const E = {
    x: 0, speed: 0,
    lunge: null,           // {state:'warn'|'go', until}
    nextLungeAt: 0,
    nextTauntAt: 0,
    grind: 0               // fase da animação de esfregar
  };

  E.reset = function (playerX, attract) {
    E.x = playerX - (attract ? 1500 : C.startGap) - C.width;
    E.lunge = null;
    E.nextLungeAt = UD.game.time + 24000;
    E.nextTauntAt = UD.game.time + 6000;
  };

  E.frontX = () => E.x + C.width / 2;

  E.update = function (dt) {
    const G = UD.game, P = UD.player;
    E.grind += dt * 9;

    if (G.attract) {
      // na vitrine ela só acompanha de longe, ameaçadora
      const targetX = P.x - 1350 - C.width;
      E.x += U.clamp(targetX - E.x, -2, 8) * dt * 60 * 0.05;
      UD.ink.eraseBefore(E.frontX());
      return;
    }

    const now = G.time;
    const gap = P.x - E.frontX();
    const DC = G.diffCfg().eraser;   // temperamento por dificuldade

    // velocidade base sobe com a distância percorrida
    let sp = Math.min(DC.cap, DC.base + Math.max(0, E.x) * C.ramp * DC.ramp);
    if (gap > C.farGap) sp = Math.max(sp * C.farBoost, DC.farMin);   // rubber band: fugiu demais? ela ACELERA
    else if (gap < C.nearGap) sp *= C.nearEase;                      // piedade mínima
    // teto duro de vantagem: a pressão nunca desaparece de vez
    if (gap > 2000) E.x = P.x - 2000 - C.width / 2;

    // ---------------- investidas
    if (!E.lunge && E.x > DC.lungeFrom && now > E.nextLungeAt && P.state !== 'dead') {
      E.lunge = { state: 'warn', until: now + C.lungeWarnMs };
      UD.audio.play('lunge');
      G.say('eraser', UD.t('bub.eraserLunge'));
      G.shake(5);
    }
    if (E.lunge) {
      if (E.lunge.state === 'warn') {
        sp *= 0.4;   // recua o ombro…
        if (now > E.lunge.until) E.lunge = { state: 'go', until: now + C.lungeMs };
      } else {
        sp = C.lungeSpeed;   // …e ATACA
        G.shake(2.4);
        if (now > E.lunge.until) {
          E.lunge = null;
          E.nextLungeAt = now + DC.lungeEvery[0] + Math.random() * (DC.lungeEvery[1] - DC.lungeEvery[0]);
        }
      }
    }

    E.speed = sp;
    E.x += sp * dt * 60;

    // ---------------- apaga o que alcançou
    UD.ink.eraseBefore(E.frontX());

    // farelos de borracha voando na frente
    if (Math.random() < 0.5) {
      G.spawnCrumbs(E.frontX() + Math.random() * 24, -280 + Math.random() * 500, 1, 'eraser');
    }

    // ---------------- pressão sonora
    const prox = U.clamp(1 - gap / 1150, 0, 1);
    UD.audio.setRumble(prox * (E.lunge ? 1.4 : 1));
    UD.audio.intensity = U.clamp(1 - gap / 1400, 0, 1);

    // resmungos quando está perto
    if (gap < 560 && now > E.nextTauntAt && P.state !== 'dead') {
      E.nextTauntAt = now + 9000 + Math.random() * 5000;
      G.say('eraser', U.pick(UD.t('bub.eraserTaunt').split('|')));
    }

    // ---------------- pegou
    if (P.state !== 'dead' && E.frontX() >= P.x - 4) {
      G.over('erased');
    }
  };

  UD.eraser = E;
})();

// UNDRAWN — o caderno infinito: gerador procedural de chunks, buracos rasgados,
// percevejos, café, plataformas-lápis, colecionáveis e rabiscos de fundo (paralaxe).
(function () {
  const U = UD.U, C = UD.C;

  const W = {
    pieces: [],    // chão: {x0,x1,y0,y1, float?}
    walls: [],     // faces verticais: {x, yTop, yBot}
    gaps: [],      // buracos rasgados: {x0,x1,yL,yR}
    spikes: [],    // percevejos: {x0,x1,y}
    coffee: [],    // poças de café (gruda): {x0,x1,y}
    movers: [],    // plataformas-lápis: {cx,len,yMid,amp,phase,spd,y,dy}
    pickups: [],   // {type:'ink'|'star', x,y,phase,taken}
    props: [[], []],   // rabiscos de fundo por camada de paralaxe
    time: 0,
    genX: 0, curY: 0, rnd: Math.random,
    sinceInk: 0, lastKind: '', kindRep: 0,
    propGenX: [0, 0],
    seed: 1
  };

  const PROP_F = [0.35, 0.62];   // fatores de paralaxe

  W.biomeIdx = function (x) {
    const i = Math.max(0, Math.floor(x / C.BIOME_LEN));
    return i < 5 ? i : 2 + ((i - 5) % 3);   // depois do 5º, cicla papelão→blueprint→escuro
  };

  // ---------------------------------------------------------------- reset
  W.reset = function (seed) {
    W.seed = seed;
    W.rnd = U.rng(seed);
    W.pieces = []; W.walls = []; W.gaps = []; W.spikes = []; W.coffee = [];
    W.movers = []; W.pickups = []; W.props = [[], []];
    W.time = 0;
    W.curY = 0;
    W.genX = -1600;
    W.sinceInk = 0; W.lastKind = ''; W.kindRep = 0;
    W.propGenX = [-2000, -2000];
    piece(2500, 0);              // pista inicial garantida (spawn em x=0)
  };

  // ---------------------------------------------------------------- helpers de geração
  const R = () => W.rnd();
  const ri = (a, b) => a + Math.floor(R() * (b - a + 1));
  const rf = (a, b) => a + R() * (b - a);
  const DC = () => UD.game.diffCfg();   // parâmetros da dificuldade escolhida
  const diff = () => U.clamp((W.genX - 600) / (52000 / DC().rampScale), 0, 1);

  function piece(len, yEnd, float) {
    const p = { x0: W.genX, x1: W.genX + len, y0: W.curY, y1: yEnd, float: !!float };
    if (!float) {
      W.pieces.push(p);
      W.genX += len;
      W.curY = yEnd;
    } else {
      W.pieces.push(p);
    }
    return p;
  }
  // plataforma flutuante independente do fluxo do chão
  function floatPiece(x0, x1, y) {
    W.pieces.push({ x0, x1, y0: y, y1: y, float: true });
  }
  function wall(x, yTop, yBot) { W.walls.push({ x, yTop, yBot }); }

  function gap(w, dy) {
    const yL = W.curY;
    const yR = U.clamp(W.curY + (dy || 0), -230, 50);
    W.gaps.push({ x0: W.genX, x1: W.genX + w, yL, yR });
    wall(W.genX, yL, yL + 340);            // borda esquerda do rasgo
    wall(W.genX + w, yR, yR + 340);        // borda direita
    W.genX += w;
    W.curY = yR;
  }

  function dropInk(x, y) { W.pickups.push({ type: 'ink', x, y, phase: R() * 7, taken: false }); W.sinceInk = 0; }
  function dropStar(x, y) { W.pickups.push({ type: 'star', x, y, phase: R() * 7, taken: false }); }

  function inkBefore(n) {
    // garante tinta ANTES de um trecho que pede desenho
    for (let i = 0; i < n; i++) dropInk(W.genX - 60 - i * 46, W.curY - 46);
  }

  function starArc(x0, x1, yBase, h) {
    const n = 5;
    for (let i = 0; i < n; i++) {
      const t = i / (n - 1);
      const x = U.lerp(x0, x1, t);
      const y = yBase - h * Math.sin(t * Math.PI);
      dropStar(x, y);
    }
  }

  // ---------------------------------------------------------------- chunks
  function cFlat() {
    const len = ri(200, 380);
    piece(len, W.curY);
    if (W.sinceInk > DC().inkEvery) dropInk(W.genX - len / 2, W.curY - 50);
    else if (R() < 0.3) dropStar(W.genX - len / 2, W.curY - 70 - R() * 50);
  }

  function cSlope() {
    const up = R() < 0.5;
    const dy = ri(50, 130) * (up ? -1 : 1);
    const yEnd = U.clamp(W.curY + dy, -230, 50);
    piece(Math.max(150, Math.abs(yEnd - W.curY) * rf(2.2, 3.2)), yEnd);
  }

  function cGap() {
    const D = diff();
    piece(ri(90, 150), W.curY);
    const w = Math.round(ri(100, 185 + Math.round(D * 280)) * DC().gapScale);
    const dy = ri(-40, 30);
    if (w > 250) inkBefore(2);            // além da cambalhota: vai precisar de ponte
    if (R() < 0.45) starArc(W.genX + 20, W.genX + w - 20, W.curY - 20, 90 + w * 0.14);
    gap(w, dy);
    piece(ri(110, 190), W.curY);
  }

  function cTacks() {
    piece(ri(90, 140), W.curY);
    const w = Math.round(ri(80, 130 + Math.round(diff() * 80)) * DC().gapScale);   // sempre pulável
    const y = W.curY;
    piece(w, y);
    W.spikes.push({ x0: W.genX - w, x1: W.genX, y });
    if (R() < 0.5) dropStar(W.genX - w / 2, y - 110);
    piece(ri(100, 160), y);
  }

  function cWall() {
    const D = diff();
    piece(ri(120, 200), W.curY);
    const h = Math.round(ri(95, 150 + Math.round(D * 110)) * DC().gapScale);
    const yTop = U.clamp(W.curY - h, -240, 50);
    if (W.curY - yTop > 190) inkBefore(2);   // acima do alcance da cambalhota: rampa desenhada
    wall(W.genX, yTop, W.curY);
    W.curY = yTop;
    piece(ri(220, 360), W.curY);
    dropStar(W.genX - 120, W.curY - 60);
    // às vezes desce do outro lado
    if (R() < 0.6) {
      const yDown = U.clamp(W.curY + ri(70, 140), -230, 50);
      wall(W.genX, W.curY, yDown);
      W.curY = yDown;
      piece(ri(130, 220), W.curY);
    }
  }

  function cPlatforms() {
    piece(ri(90, 130), W.curY);
    const w = Math.round(ri(360, 520) * DC().gapScale);
    const yL = W.curY;
    const n = ri(2, 3);
    for (let i = 0; i < n; i++) {
      const t = (i + 1) / (n + 1);
      const px = W.genX + w * t;
      const py = yL - 40 - Math.sin(t * Math.PI) * 60 + rf(-18, 18);
      floatPiece(px - 55, px + 55, py);
      if (R() < 0.4) dropStar(px, py - 60);
    }
    gap(w, ri(-30, 20));
    piece(ri(120, 200), W.curY);
  }

  function cMover() {
    piece(ri(90, 130), W.curY);
    const w = Math.round(ri(320, 430) * DC().gapScale);
    const yL = W.curY;
    W.movers.push({
      cx: W.genX + w / 2, len: 130,
      yMid: yL - 60, amp: ri(40, 85), phase: R() * 7, spd: rf(0.02, 0.033),
      y: yL - 60, dy: 0
    });
    if (R() < 0.5) starArc(W.genX + 40, W.genX + w - 40, yL - 60, 90);
    gap(w, ri(-20, 20));
    piece(ri(120, 200), W.curY);
  }

  function cNest() {
    const len = ri(260, 360);
    const y = W.curY;
    piece(len, y);
    const es = DC().enemyScale;
    const n = 1 + (R() < diff() * es ? 1 : 0) + (R() < diff() * 0.5 * es ? 1 : 0);
    for (let i = 0; i < n; i++)
      UD.enemies.spawn('scribble', W.genX - len + 90 + i * 80 + rf(-20, 20), y - 24);
    if (R() < 0.4) dropInk(W.genX - 50, y - 50);
  }

  function cPlanes() {
    const len = ri(380, 480);
    const y = W.curY;
    piece(len, y);
    UD.enemies.spawn('plane', W.genX + ri(250, 500), y - ri(70, 130));
    if (R() < 0.5 * DC().enemyScale) UD.enemies.spawn('plane', W.genX + ri(600, 850), y - ri(60, 140));
  }

  function cCoffee() {
    piece(ri(80, 120), W.curY);
    const w = ri(140, 240);
    const y = W.curY;
    piece(w, y);
    W.coffee.push({ x0: W.genX - w, x1: W.genX, y });
    piece(ri(80, 130), y);
    // café + buraco logo depois = clássico da maldade
    if (diff() > 0.35 && R() < 0.5) { gap(ri(120, 200), ri(-20, 20)); piece(ri(110, 170), W.curY); }
  }

  function cBonus() {
    const len = ri(300, 420);
    const y = W.curY;
    piece(len, y);
    starArc(W.genX - len + 50, W.genX - 50, y - 30, 130 + R() * 60);
    dropInk(W.genX - len / 2, y - 40);
  }

  // ---------------------------------------------------------------- seletor de chunks
  function pickChunk() {
    const D = diff();
    const pool = [
      ['flat', cFlat, 1.1 - D * 0.55],
      ['slope', cSlope, 0.9],
      ['gap', cGap, 0.8 + D * 0.9],
      ['tacks', cTacks, 0.35 + D * 0.85],
      ['wall', cWall, 0.3 + D * 0.7],
      ['plats', cPlatforms, 0.3 + D * 0.8],
      ['mover', cMover, 0.25 + D * 0.7],
      ['nest', cNest, 0.35 + D * 0.9],
      ['planes', cPlanes, 0.2 + D * 0.8],
      ['coffee', cCoffee, 0.3 + D * 0.6],
      ['bonus', cBonus, 0.35]
    ];
    let total = 0;
    for (const c of pool) if (!(c[0] === W.lastKind && W.kindRep >= 2)) total += c[2];
    let pickV = R() * total;
    for (const c of pool) {
      if (c[0] === W.lastKind && W.kindRep >= 2) continue;
      pickV -= c[2];
      if (pickV <= 0) {
        W.kindRep = (c[0] === W.lastKind) ? W.kindRep + 1 : 1;
        W.lastKind = c[0];
        const before = W.genX;
        c[1]();
        W.sinceInk += W.genX - before;
        return;
      }
    }
  }

  // ---------------------------------------------------------------- rabiscos de fundo
  const PROPS_BY_BIOME = [
    ['cloud', 'sun', 'house', 'flower', 'bird', 'squiggle'],
    ['equation', 'triangle', 'ruler', 'cloud', 'squiggle', 'graphline'],
    ['stamp', 'tape', 'fragile', 'squiggle', 'box'],
    ['gear', 'ruler', 'equation', 'compassArc', 'graphline'],
    ['star', 'moon', 'comet', 'constellation', 'squiggle']
  ];

  function genProps(layer, untilPX) {
    while (W.propGenX[layer] < untilPX) {
      const x = W.propGenX[layer];
      const biome = W.biomeIdx(x / PROP_F[layer]);   // bioma aproximado na posição real
      const types = PROPS_BY_BIOME[biome];
      W.props[layer].push({
        x: x + rf(0, 200),
        y: layer === 0 ? rf(-540, -160) : rf(-420, -60),
        type: types[ri(0, types.length - 1)],
        seed: ri(1, 9999),
        s: rf(0.7, 1.3)
      });
      W.propGenX[layer] += ri(230, 420);
    }
  }

  // ---------------------------------------------------------------- respawn em checkpoint
  // A borracha já comeu o mundo atrás da bandeira e o trecho à frente pode ter sido
  // podado. Solução: chão seguro sob a bandeira e o gerador recomeça dali.
  W.respawnAt = function (cp) {
    W.pieces = []; W.walls = []; W.gaps = []; W.spikes = [];
    W.coffee = []; W.movers = []; W.pickups = [];
    W.genX = cp.x - 650;
    W.curY = cp.y;
    piece(1250, cp.y);            // pista de respiro: cp.x-650 até cp.x+600
    W.sinceInk = 0;
    W.lastKind = ''; W.kindRep = 0;
    dropInk(cp.x + 260, cp.y - 50);
    dropInk(cp.x + 320, cp.y - 50);
  };

  // ---------------------------------------------------------------- consultas
  W.groundYAt = function (x) {
    let best = null;
    for (const p of W.pieces) {
      if (x < p.x0 || x > p.x1) continue;
      const t = (x - p.x0) / Math.max(1, p.x1 - p.x0);
      const y = U.lerp(p.y0, p.y1, t);
      if (best === null || y < best) best = y;
    }
    return best;
  };

  W.hasGroundAhead = function (x0, x1) {
    for (const p of W.pieces) if (p.x1 >= x0 && p.x0 <= x1) return true;
    for (const m of W.movers) if (m.cx + m.len / 2 >= x0 && m.cx - m.len / 2 <= x1) return true;
    return false;
  };

  W.solidsNear = function (x0, x1) {
    const segs = [];
    for (const p of W.pieces) {
      if (p.x1 < x0 || p.x0 > x1) continue;
      segs.push({ ax: p.x0, ay: p.y0, bx: p.x1, by: p.y1, half: 3, ground: true });
    }
    for (const w of W.walls) {
      if (w.x < x0 - 8 || w.x > x1 + 8) continue;
      segs.push({ ax: w.x, ay: w.yTop, bx: w.x, by: w.yBot, half: 3, wall: true });
    }
    for (const m of W.movers) {
      const hx = m.len / 2;
      if (m.cx + hx < x0 || m.cx - hx > x1) continue;
      segs.push({ ax: m.cx - hx, ay: m.y, bx: m.cx + hx, by: m.y, half: 5.5, ground: true, mover: m });
    }
    return segs;
  };

  // ---------------------------------------------------------------- passo
  W.update = function (dt, camX, camRight, eraserX) {
    W.time += dt;
    for (const m of W.movers) {
      const ny = m.yMid + Math.sin(W.time * m.spd * 60 + m.phase) * m.amp;
      m.dy = ny - m.y;
      m.y = ny;
    }
    while (W.genX < camRight + 1200) pickChunk();
    for (let l = 0; l < 2; l++) genProps(l, camX * PROP_F[l] + camRight - camX + 600);

    // poda o que ficou para trás da borracha
    const minX = eraserX - 500;
    W.pieces = W.pieces.filter(p => p.x1 > minX);
    W.walls = W.walls.filter(w => w.x > minX);
    W.gaps = W.gaps.filter(g => g.x1 > minX);
    W.spikes = W.spikes.filter(s => s.x1 > minX);
    W.coffee = W.coffee.filter(c => c.x1 > minX);
    W.movers = W.movers.filter(m => m.cx + m.len / 2 > minX);
    W.pickups = W.pickups.filter(p => !p.taken && p.x > minX);
    for (let l = 0; l < 2; l++) {
      const pMin = camX * PROP_F[l] - 900;
      W.props[l] = W.props[l].filter(p => p.x > pMin);
    }
  };

  UD.world = W;
})();

// UNDRAWN — criaturas do caderno: bolotas de rabisco (pulam em você) e
// aviõezinhos de papel (voam em onda). Pisar em cima resolve os dois.
(function () {
  const U = UD.U, C = UD.C.ENEMY;

  const E = { list: [] };

  E.reset = function () { E.list = []; };

  E.spawn = function (type, x, y) {
    if (type === 'scribble') {
      E.list.push({
        type, x, y, vx: 0, vy: 0, r: C.scribble.r,
        grounded: false, nextHop: 0, phase: Math.random() * 7, seed: Math.random() * 999
      });
    } else if (type === 'plane') {
      E.list.push({
        type, x, y, baseY: y, vx: -C.plane.speed, vy: 0, r: C.plane.r,
        phase: Math.random() * 7, seed: Math.random() * 999
      });
    }
  };

  E.update = function (dt) {
    const P = UD.player, G = UD.game;
    const now = G.time;
    const eraserX = UD.eraser.frontX();

    for (let i = E.list.length - 1; i >= 0; i--) {
      const e = E.list[i];

      // engolido pela borracha
      if (e.x < eraserX + 20) {
        G.spawnCrumbs(e.x, e.y, 5, 'eraser');
        E.list.splice(i, 1);
        continue;
      }
      // ficou longe demais na frente (ainda não entrou em cena): dorme
      if (e.x > P.x + 1400) continue;

      if (e.type === 'scribble') {
        e.phase += dt * 6;
        e.vy += 0.62;
        if (e.vy > 16) e.vy = 16;
        // pulinho na direção do jogador
        const dx = P.x - e.x;
        if (e.grounded && Math.abs(dx) < C.scribble.aggro && now > e.nextHop) {
          e.vx = Math.sign(dx) * C.scribble.hopVx * (0.8 + Math.random() * 0.45);
          e.vy = -C.scribble.hopVy * (0.85 + Math.random() * 0.3);
          e.nextHop = now + C.scribble.everyMs + Math.random() * 400;
        }
        if (e.grounded) e.vx *= 0.86;
        e.x += e.vx; e.y += e.vy;

        e.grounded = false;
        const segs = UD.world.solidsNear(e.x - 40, e.x + 40)
          .concat(UD.ink.segsNear(e.x - 40, e.x + 40));
        U.collideCircle(e, e.r, segs, (s, nx, ny) => {
          if (ny < -0.5) e.grounded = true;
          if (s.deadly) e.fried = true;      // traço da caneta vermelha
        });
        if (e.fried) { G.onEnemyFried(e); E.list.splice(i, 1); continue; }
        if (e.y > UD.C.VOID_Y) { E.list.splice(i, 1); continue; }

      } else { // plane
        e.phase += dt * 3.2;
        e.x += e.vx;
        e.y = e.baseY + Math.sin(e.phase) * C.plane.amp;
        e.tilt = Math.cos(e.phase) * 0.3;
        // caneta vermelha derruba aviões também
        for (const s of UD.ink.segsNear(e.x - 20, e.x + 20)) {
          if (!s.deadly) continue;
          const q = U.closestOnSeg(e.x, e.y, s.ax, s.ay, s.bx, s.by);
          if (U.dist(e.x, e.y, q.x, q.y) < e.r + 6) { e.fried = true; break; }
        }
        if (e.fried) { G.onEnemyFried(e); E.list.splice(i, 1); continue; }
        if (e.x < eraserX - 100) { E.list.splice(i, 1); continue; }
      }

      // contato com o jogador
      if (P.state !== 'dead') {
        const rr = e.r + UD.C.PLAYER.r;
        if (Math.abs(P.x - e.x) < rr && Math.abs(P.y - e.y) < rr) {
          const stomp = P.vy > 1.4 && P.y < e.y - e.r * 0.25;
          if (stomp) {
            G.onStomp(e);
            E.list.splice(i, 1);
          } else {
            P.hit(Math.sign(P.x - e.x) || 1);
          }
        }
      }
    }
  };

  UD.enemies = E;
})();

// UNDRAWN — pets: companheiros de corrida desbloqueados com estrelas ★.
// Puramente carismáticos: seguem, torcem, entram em pânico e lamentam junto.
(function () {
  const U = UD.U;

  const PET = { id: 'none', x: 0, y: 0, phase: 0, panic: 0, trailAt: 0 };

  PET.reset = function () {
    PET.id = UD.save.petUnlocked(UD.save.data.pet) ? UD.save.data.pet : 'none';
    const P = UD.player;
    PET.x = P.x - 40;
    PET.y = P.y - 52;
    PET.phase = Math.random() * 7;
  };

  PET.update = function (dt) {
    if (PET.id === 'none') return;
    const P = UD.player, G = UD.game;
    PET.phase += dt * 4;
    const dead = P.state === 'dead';

    // pânico quando a borracha encosta
    const gap = P.x - UD.eraser.frontX();
    PET.panic = dead ? 0 : U.clamp(1 - gap / 430, 0, 1);

    // âncora atrás/acima do dono — cada pet tem seu jeito de seguir
    let ax = P.x - P.facing * 38, ay = P.y - 46, k = 3.2;
    if (PET.id === 'balloon') {
      ay = P.y - 76 + Math.sin(PET.phase) * 4;
      k = 2.1;
    } else if (PET.id === 'birdie') {
      ax = P.x - P.facing * 30 + Math.sin(PET.phase * 0.7) * 12;
      ay = P.y - 58 + Math.cos(PET.phase) * 8;
      k = 4.2;
    } else if (PET.id === 'rocket') {
      ay = P.y - 40;
      k = 6;
    } else if (PET.id === 'starly') {
      ax = P.x - P.facing * 34 + Math.cos(PET.phase * 0.5) * 9;
      ay = P.y - 64 + Math.sin(PET.phase * 0.5) * 10;
      k = 3;
    }
    if (dead) {
      if (PET.id === 'balloon') { ax = PET.x; ay = PET.y - 50; k = 0.5; }   // o balão se despede…
      else { ax = P.x - 30; ay = P.y - 26; k = 1.4; }                        // …os outros velam do lado
    }
    // ficou longe demais (respawn, investida, teleporte)? aparece do lado — pet não se perde
    if (Math.abs(ax - PET.x) > 420 || Math.abs(ay - PET.y) > 420) { PET.x = ax; PET.y = ay; }
    const s = 1 - Math.exp(-dt * k);
    PET.x += (ax - PET.x) * s;
    PET.y += (ay - PET.y) * s;
    if (PET.panic > 0.3) {
      PET.x += (Math.random() - 0.5) * PET.panic * 2.6;
      PET.y += (Math.random() - 0.5) * PET.panic * 2;
    }

    // faíscas do foguetinho
    if (PET.id === 'rocket' && !dead && G.time > PET.trailAt && Math.abs(P.vx) > 2) {
      PET.trailAt = G.time + 90;
      G.spawnCrumbs(PET.x - P.facing * 12, PET.y + 3, 1, 'star');
    }
  };

  UD.pet = PET;
})();

// UNDRAWN — renderizador: papel vivo, linha que "ferve", paralaxe de rabiscos,
// a Borracha monumental e HUD desenhado à mão. Tudo em canvas 2D, sem assets.
(function () {
  const U = UD.U, C = UD.C;
  const R = { cam: { x: 0, y: -120, zoom: 1, shx: 0, shy: 0 }, chipRects: [] };
  let cv, ctx, dpr = 1, boil = 0;
  let nextBlink = 0, blinkUntil = 0;

  const FONT = (s) => s + 'px system-ui, 'Segoe UI', sans-serif, "Comic Sans MS", cursive';

  R.init = function (canvas) {
    cv = canvas;
    ctx = cv.getContext('2d');
    resize();
    addEventListener('resize', resize);
  };

  function resize() {
    dpr = Math.min(2, window.devicePixelRatio || 1);
    cv.width = Math.round(innerWidth * dpr);
    cv.height = Math.round(innerHeight * dpr);
    cv.style.width = innerWidth + 'px';
    cv.style.height = innerHeight + 'px';
  }

  R.resetCam = function () {
    const P = UD.player;
    R.cam.zoom = U.clamp(innerHeight / 820, 0.78, 1.08);
    R.cam.x = P.x + 230;
    R.cam.y = P.y - 90;
  };

  R.screenToWorld = (sx, sy) => ({
    x: R.cam.x + (sx - innerWidth / 2) / R.cam.zoom,
    y: R.cam.y + (sy - innerHeight / 2) / R.cam.zoom
  });
  R.worldToScreen = (wx, wy) => ({
    x: (wx - R.cam.x) * R.cam.zoom + innerWidth / 2,
    y: (wy - R.cam.y) * R.cam.zoom + innerHeight / 2
  });

  function updateCam(dt) {
    const P = UD.player, G = UD.game;
    R.cam.zoom = U.clamp(innerHeight / 820, 0.78, 1.08);
    const tx = P.x + 230;
    const ty = U.clamp(P.y - 90, -330, 170);
    const k = 1 - Math.exp(-dt * 5.2);
    R.cam.x += (tx - R.cam.x) * k;
    R.cam.y += (ty - R.cam.y) * k;
    R.cam.shx = (Math.random() - 0.5) * G.shakeAmt;
    R.cam.shy = (Math.random() - 0.5) * G.shakeAmt;
  }

  // ---------------------------------------------------------------- paleta com transição
  function paletteAt(x) {
    const L = C.BIOME_LEN, W = UD.world;
    const i = W.biomeIdx(x);
    const j = W.biomeIdx(x + L * 0.14);
    const a = C.PALETTES[i];
    if (i === j) return a;
    const f = ((x % L) + L) % L / L;
    if (f < 0.86) return a;
    const t = (f - 0.86) / 0.14;
    const b = C.PALETTES[j];
    const mix = (k) => (a[k] && b[k]) ? U.mixColor(a[k], b[k], t) : (t < 0.5 ? a[k] : b[k]);
    return {
      id: t < 0.5 ? a.id : b.id,
      paper: mix('paper'), ink: mix('ink'), faint: mix('faint'), accent: mix('accent'),
      void: mix('void'), ruled: t < 0.5 ? a.ruled : b.ruled, margin: t < 0.5 ? a.margin : b.margin,
      penBounce: mix('penBounce'), penRed: mix('penRed'),
      drawInk: (a.drawInk || b.drawInk) ? (t < 0.5 ? (a.drawInk || a.ink) : (b.drawInk || b.ink)) : null
    };
  }

  function penColor(pal, pen) {
    if (pen === 'bounce') return pal.penBounce;
    if (pen === 'red') return pal.penRed;
    return pal.drawInk || pal.ink;
  }

  // ---------------------------------------------------------------- frame
  R.draw = function (dt) {
    if (innerWidth > 0 && cv.width !== Math.round(innerWidth * dpr)) resize();
    const G = UD.game, P = UD.player, W = UD.world, E = UD.eraser, cam = R.cam;
    const t = performance.now();
    boil = Math.floor(t / 360);
    updateCam(dt);

    const pal = paletteAt(cam.x);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.fillStyle = pal.paper;
    ctx.fillRect(0, 0, innerWidth, innerHeight);

    const vw = innerWidth, vh = innerHeight;
    const left = cam.x - vw / (2 * cam.zoom), right = cam.x + vw / (2 * cam.zoom);
    const top = cam.y - vh / (2 * cam.zoom), bottom = cam.y + vh / (2 * cam.zoom);

    // ---------------- camadas de paralaxe (rabiscos ao fundo)
    for (let l = 0; l < 2; l++) drawPropLayer(l, pal, t);

    // ---------------- espaço do mundo
    ctx.save();
    ctx.translate(vw / 2 + cam.shx, vh / 2 + cam.shy);
    ctx.scale(cam.zoom, cam.zoom);
    ctx.translate(-cam.x, -cam.y);

    drawPagePattern(pal, left, right, top, bottom);
    drawSeams(pal, left, right, top, bottom);
    drawGaps(pal, left, right);
    drawPieces(pal, left, right);
    drawCoffee(pal, t);
    drawTacks(pal);
    drawMovers(pal);
    drawCheckpoints(pal, t);
    drawHintLabels(pal);
    drawPickups(pal, t);
    drawStrokes(pal, t);
    drawEnemies(pal, t);
    drawPet(pal, t);
    drawPlayer(pal, t);
    drawParticles(pal);
    drawFloats(pal);
    drawErased(pal, left, top, bottom);
    drawEraser(pal, t, top, bottom);

    ctx.restore();

    // ---------------- espaço de tela
    drawFx(pal, vw, vh);
    if (!G.attract) {
      drawBubbles(pal, t);
      drawHud(pal, vw, vh, t);
      drawBanner(pal, vw, vh);
      drawCursor(pal, t);
    }
  };

  // ---------------------------------------------------------------- padrões do papel
  function drawPagePattern(pal, l, r, tp, bt) {
    const id = pal.id;
    ctx.lineWidth = 1.4;
    if (id === 'notebook' && pal.ruled) {
      ctx.strokeStyle = pal.ruled;
      ctx.beginPath();
      for (let y = Math.floor(tp / 46) * 46; y <= bt; y += 46) { ctx.moveTo(l, y); ctx.lineTo(r, y); }
      ctx.stroke();
    } else if (id === 'graph' && pal.ruled) {
      ctx.strokeStyle = pal.ruled;
      ctx.beginPath();
      for (let y = Math.floor(tp / 34) * 34; y <= bt; y += 34) { ctx.moveTo(l, y); ctx.lineTo(r, y); }
      for (let x = Math.floor(l / 34) * 34; x <= r; x += 34) { ctx.moveTo(x, tp); ctx.lineTo(x, bt); }
      ctx.stroke();
    } else if (id === 'kraft') {
      // fibras do papelão
      ctx.strokeStyle = pal.faint;
      ctx.globalAlpha = 0.5;
      ctx.beginPath();
      for (let x = Math.floor(l / 90) * 90; x <= r; x += 90)
        for (let y = Math.floor(tp / 70) * 70; y <= bt; y += 70) {
          const ox = U.noise(3, x * 0.01 + y, 1) * 30, oy = U.noise(5, y * 0.01 + x, 1) * 20;
          ctx.moveTo(x + ox, y + oy); ctx.lineTo(x + ox + 12, y + oy + 2);
        }
      ctx.stroke();
      ctx.globalAlpha = 1;
    } else if (id === 'blueprint' && pal.ruled) {
      ctx.strokeStyle = pal.ruled;
      ctx.beginPath();
      for (let y = Math.floor(tp / 40) * 40; y <= bt; y += 40) { ctx.moveTo(l, y); ctx.lineTo(r, y); }
      for (let x = Math.floor(l / 40) * 40; x <= r; x += 40) { ctx.moveTo(x, tp); ctx.lineTo(x, bt); }
      ctx.stroke();
    } else if (id === 'dark' && pal.ruled) {
      ctx.fillStyle = pal.ruled;
      for (let y = Math.floor(tp / 52) * 52; y <= bt; y += 52)
        for (let x = Math.floor(l / 52) * 52; x <= r; x += 52)
          ctx.fillRect(x, y, 2, 2);
    }
  }

  // emendas de página: fita adesiva unindo as folhas
  function drawSeams(pal, l, r, tp, bt) {
    const S = C.PAGE_SEAM;
    for (let x = Math.floor(l / S) * S; x <= r; x += S) {
      if (x < l - 60) continue;
      ctx.strokeStyle = pal.faint;
      ctx.lineWidth = 1.6;
      ctx.setLineDash([9, 8]);
      ctx.beginPath(); ctx.moveTo(x, tp); ctx.lineTo(x, bt); ctx.stroke();
      ctx.setLineDash([]);
      for (const y of [-300, -40, 90]) {
        ctx.save();
        ctx.translate(x, y);
        ctx.rotate(0.5 + U.noise(x, y, 1) * 0.15);
        ctx.fillStyle = 'rgba(200,200,180,0.4)';
        ctx.strokeStyle = pal.faint;
        ctx.lineWidth = 1.2;
        ctx.fillRect(-30, -10, 60, 20);
        ctx.strokeRect(-30, -10, 60, 20);
        ctx.restore();
      }
    }
  }

  // ---------------------------------------------------------------- buracos rasgados
  function drawGaps(pal, l, r) {
    for (const g of UD.world.gaps) {
      if (g.x1 < l - 40 || g.x0 > r + 40) continue;
      const yB = C.VOID_Y + 160;
      const voidPath = () => {
        ctx.beginPath();
        jagLine(g.x0, g.yL, g.x0 + 4, yB, g.x0, true);
        ctx.lineTo(g.x1 + 4, yB);
        jagLineRev(g.x1, g.yR, g.x1 - 2, yB, g.x1);
        ctx.closePath();
      };
      // por baixo da página: a mesa
      ctx.fillStyle = pal.void;
      voidPath();
      ctx.fill();
      // veios da madeira da mesa (sutil, para o buraco não virar um bloco chapado)
      ctx.save();
      voidPath();
      ctx.clip();
      ctx.strokeStyle = 'rgba(255,255,255,0.07)';
      ctx.lineWidth = 2.4;
      for (let gy = Math.min(g.yL, g.yR) + 40; gy < yB; gy += 44) {
        ctx.beginPath();
        for (let gx = g.x0 - 10; gx <= g.x1 + 10; gx += 30)
          gx === g.x0 - 10
            ? ctx.moveTo(gx, gy + U.noise(g.x0, gx * 0.03, 1) * 7)
            : ctx.lineTo(gx, gy + U.noise(g.x0, gx * 0.03, 1) * 7);
        ctx.stroke();
      }
      ctx.restore();
      // borda rasgada (branco do papel + traço)
      for (const side of [[g.x0, g.yL], [g.x1, g.yR]]) {
        ctx.strokeStyle = pal.paper;
        ctx.lineWidth = 5;
        strokeJag(side[0], side[1], yB, side[0]);
        ctx.strokeStyle = pal.faint;
        ctx.lineWidth = 1.8;
        strokeJag(side[0], side[1], yB, side[0] + 31);
      }
    }
    function jagLine(x, y0, x1, y1, seed, start) {
      if (start) ctx.moveTo(x, y0);
      for (let y = y0; y <= y1; y += 34) ctx.lineTo(x + U.noise(seed, y * 0.13, 1) * 9, y);
      ctx.lineTo(x1, y1);
    }
    function jagLineRev(x, y0, x1, y1, seed) {
      for (let y = y1; y >= y0; y -= 34) ctx.lineTo(x + U.noise(seed, y * 0.13, 1) * 9, y);
      ctx.lineTo(x, y0);
    }
    function strokeJag(x, y0, y1, seed) {
      ctx.beginPath();
      ctx.moveTo(x, y0);
      for (let y = y0; y <= y1; y += 34) ctx.lineTo(x + U.noise(seed, y * 0.13, 1) * 9, y);
      ctx.stroke();
    }
  }

  // ---------------------------------------------------------------- chão
  function drawPieces(pal, l, r) {
    const W = UD.world;
    ctx.lineCap = 'round'; ctx.lineJoin = 'round';
    for (const p of W.pieces) {
      if (p.x1 < l - 30 || p.x0 > r + 30) continue;
      ctx.strokeStyle = pal.ink;
      ctx.lineWidth = p.float ? 5 : 3.6;
      ctx.beginPath();
      const step = 26, len = p.x1 - p.x0;
      for (let x = 0; x <= len; x += step) {
        const y = U.lerp(p.y0, p.y1, x / len) + U.noise(7, (p.x0 + x) * 0.04, boil) * 1.8;
        x === 0 ? ctx.moveTo(p.x0 + x, y) : ctx.lineTo(p.x0 + x, y);
      }
      const yEnd = p.y1 + U.noise(7, p.x1 * 0.04, boil) * 1.8;
      ctx.lineTo(p.x1, yEnd);
      ctx.stroke();
      if (p.float) {
        // pontinhas da plataforma flutuante
        ctx.lineWidth = 3;
        ctx.beginPath();
        ctx.moveTo(p.x0 + 2, p.y0 - 6); ctx.lineTo(p.x0 - 2, p.y0 + 7);
        ctx.moveTo(p.x1 - 2, p.y1 - 6); ctx.lineTo(p.x1 + 2, p.y1 + 7);
        ctx.stroke();
      } else {
        // hachura sob o chão
        ctx.strokeStyle = pal.faint;
        ctx.lineWidth = 2;
        ctx.beginPath();
        for (let x = Math.max(p.x0, Math.floor(l / 56) * 56); x <= Math.min(p.x1, r); x += 56) {
          const y = U.lerp(p.y0, p.y1, (x - p.x0) / len);
          ctx.moveTo(x, y + 8); ctx.lineTo(x - 15, y + 25);
        }
        ctx.stroke();
      }
    }
    // faces verticais (degraus / bordas)
    ctx.strokeStyle = pal.ink;
    ctx.lineWidth = 3.4;
    for (const w of W.walls) {
      if (w.x < l - 20 || w.x > r + 20) continue;
      if (w.yBot - w.yTop > 330) continue;    // bordas de rasgo já têm visual próprio
      ctx.beginPath();
      for (let y = w.yTop; y <= w.yBot; y += 22) {
        const x = w.x + U.noise(11, y * 0.06, boil) * 1.6;
        y === w.yTop ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
      }
      ctx.stroke();
    }
  }

  function drawCoffee(pal, t) {
    for (const cz of UD.world.coffee) {
      const w = cz.x1 - cz.x0;
      ctx.fillStyle = 'rgba(122,79,40,0.55)';
      ctx.strokeStyle = '#6b4425';
      ctx.lineWidth = 2.2;
      ctx.beginPath();
      ctx.moveTo(cz.x0, cz.y);
      for (let x = cz.x0; x <= cz.x1; x += 12)
        ctx.lineTo(x, cz.y - 5 - Math.sin(x * 0.09 + t * 0.004) * 2.5);
      ctx.lineTo(cz.x1, cz.y);
      ctx.closePath();
      ctx.fill(); ctx.stroke();
      // vapor
      ctx.strokeStyle = 'rgba(122,79,40,0.4)';
      ctx.lineWidth = 1.6;
      for (let i = 0; i < 3; i++) {
        const x = cz.x0 + w * (0.25 + i * 0.25);
        const ph = t * 0.002 + i;
        ctx.beginPath();
        ctx.moveTo(x + Math.sin(ph) * 4, cz.y - 10);
        ctx.quadraticCurveTo(x + Math.sin(ph + 1) * 7, cz.y - 24, x + Math.sin(ph + 2) * 4, cz.y - 38);
        ctx.stroke();
      }
      // mancha em volta
      ctx.strokeStyle = 'rgba(122,79,40,0.25)';
      ctx.beginPath();
      ctx.ellipse((cz.x0 + cz.x1) / 2, cz.y + 3, w * 0.62, 8, 0, 0, 7);
      ctx.stroke();
    }
  }

  function drawTacks(pal) {
    for (const s of UD.world.spikes) {
      for (let x = s.x0 + 9; x < s.x1; x += 19) {
        ctx.strokeStyle = pal.ink;
        ctx.fillStyle = pal.accent;
        ctx.lineWidth = 2;
        // espinho
        ctx.beginPath();
        ctx.moveTo(x, s.y);
        ctx.lineTo(x, s.y - 11);
        ctx.stroke();
        // cabeça do percevejo
        ctx.beginPath();
        ctx.ellipse(x, s.y - 13, 6, 3.4, 0, 0, 7);
        ctx.fill(); ctx.stroke();
      }
    }
  }

  function drawMovers(pal) {
    for (const m of UD.world.movers) {
      const h = m.len / 2;
      ctx.save();
      ctx.translate(m.cx, m.y);
      // corpo do lápis (a plataforma É um lápis)
      ctx.strokeStyle = pal.ink;
      ctx.fillStyle = pal.id === 'blueprint' || pal.id === 'dark' ? 'rgba(255,255,255,0.14)' : '#f5d67a';
      ctx.lineWidth = 2.6;
      ctx.beginPath();
      ctx.rect(-h + 14, -6, m.len - 34, 12);
      ctx.fill(); ctx.stroke();
      // ponta
      ctx.beginPath();
      ctx.moveTo(h - 20, -6); ctx.lineTo(h, 0); ctx.lineTo(h - 20, 6); ctx.closePath();
      ctx.fillStyle = pal.paper; ctx.fill(); ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(h - 7, -2.4); ctx.lineTo(h, 0); ctx.lineTo(h - 7, 2.4); ctx.closePath();
      ctx.fillStyle = pal.ink; ctx.fill();
      // borrachinha
      ctx.fillStyle = '#e89aa4';
      ctx.beginPath();
      ctx.rect(-h, -6, 14, 12);
      ctx.fill(); ctx.stroke();
      // linhas do corpo
      ctx.lineWidth = 1.4;
      ctx.beginPath();
      ctx.moveTo(-h + 14, -2); ctx.lineTo(h - 20, -2);
      ctx.moveTo(-h + 14, 2); ctx.lineTo(h - 20, 2);
      ctx.stroke();
      ctx.restore();
    }
  }

  // ---------------------------------------------------------------- bandeiras de checkpoint
  function drawCheckpoints(pal, t) {
    const G = UD.game;
    for (const cp of G.checkpoints) {
      const armed = G.cpArmed && G.lastCp === cp;
      const age = Math.min(1, (G.time - cp.born) / 400);   // sobe ao ser plantada
      // mastro
      ctx.strokeStyle = pal.ink;
      ctx.lineWidth = 3;
      ctx.lineCap = 'round';
      ctx.beginPath();
      ctx.moveTo(cp.x, cp.y);
      for (let y = cp.y; y >= cp.y - 112; y -= 20)
        ctx.lineTo(cp.x + U.noise(cp.x, y * 0.05, boil) * 1.4, y);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(cp.x - 8, cp.y); ctx.lineTo(cp.x + 8, cp.y);
      ctx.stroke();
      // bandeirola (viva quando armada, murcha quando já foi usada)
      const fy = cp.y - 112 + (1 - age) * 40;
      const wave = armed ? Math.sin(t * 0.006 + cp.x) * 4 : -6;
      ctx.fillStyle = armed ? pal.accent : pal.faint;
      ctx.beginPath();
      ctx.moveTo(cp.x, fy);
      ctx.lineTo(cp.x + 40 + wave, fy + 10 + (armed ? 0 : 6));
      ctx.lineTo(cp.x, fy + 22);
      ctx.closePath();
      ctx.fill();
    }
  }

  // ---------------------------------------------------------------- pet
  function drawPet(pal, t) {
    const PT = UD.pet;
    if (PT.id === 'none') return;
    const P = UD.player;
    const dead = P.state === 'dead';
    const panic = PT.panic;
    const dir = Math.sign(P.x - PT.x) || 1;

    ctx.save();
    ctx.translate(PT.x, PT.y);
    ctx.strokeStyle = pal.ink;
    ctx.lineWidth = 2;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    // carinha compartilhada (feliz / apavorada / de luto)
    const face = (fx, fy, s) => {
      ctx.fillStyle = pal.ink;
      const er = panic > 0.5 ? 1.6 : 1.1;
      ctx.beginPath(); ctx.arc(fx - 2.6 * s, fy, er, 0, 7); ctx.fill();
      ctx.beginPath(); ctx.arc(fx + 2.6 * s, fy, er, 0, 7); ctx.fill();
      ctx.lineWidth = 1.4;
      ctx.beginPath();
      if (dead) ctx.arc(fx, fy + 4.6, 2.2, Math.PI * 1.15, Math.PI * 1.85);       // boca triste
      else if (panic > 0.5) ctx.arc(fx, fy + 3.6, 1.8, 0, 7);                      // "AAAA"
      else ctx.arc(fx, fy + 2.4, 2.4, Math.PI * 0.15, Math.PI * 0.85);             // sorriso
      ctx.stroke();
      ctx.lineWidth = 2;
    };

    if (PT.id === 'balloon') {
      // barbante
      ctx.beginPath();
      ctx.moveTo(0, 13);
      ctx.quadraticCurveTo(Math.sin(t * 0.004) * 5, 24, Math.sin(t * 0.003) * 7, 34);
      ctx.stroke();
      // balão (aperta quando entra em pânico)
      const sq = 1 - panic * 0.14;
      ctx.fillStyle = pal.accent;
      ctx.beginPath();
      ctx.ellipse(0, 0, 10 * (2 - sq) * 0.55, 12 * sq, 0, 0, 7);
      ctx.fill(); ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(-3, 12); ctx.lineTo(0, 15) ; ctx.lineTo(3, 12);
      ctx.closePath();
      ctx.fillStyle = pal.accent; ctx.fill();
      // brilho
      ctx.strokeStyle = 'rgba(255,255,255,0.7)';
      ctx.beginPath(); ctx.arc(-3.4, -4, 3.4, Math.PI * 0.9, Math.PI * 1.5); ctx.stroke();
      ctx.strokeStyle = pal.ink;
      face(0, -2, 1);
    } else if (PT.id === 'birdie') {
      const flap = Math.sin(t * (0.02 + panic * 0.02)) * (5 + panic * 3);
      ctx.fillStyle = pal.paper;
      ctx.beginPath(); ctx.ellipse(0, 0, 7.5, 6.5, 0, 0, 7); ctx.fill(); ctx.stroke();
      // asas
      ctx.beginPath();
      ctx.moveTo(-4, -2); ctx.quadraticCurveTo(-12, -2 - flap, -15, 2 - flap);
      ctx.moveTo(4, -2); ctx.quadraticCurveTo(12, -2 - flap, 15, 2 - flap);
      ctx.stroke();
      // bico na direção do dono
      ctx.fillStyle = pal.accent;
      ctx.beginPath();
      ctx.moveTo(dir * 6, 0); ctx.lineTo(dir * 11, 1.6); ctx.lineTo(dir * 6, 3.4);
      ctx.closePath(); ctx.fill();
      // topete
      ctx.beginPath(); ctx.moveTo(0, -6); ctx.lineTo(-2, -10); ctx.moveTo(1, -6); ctx.lineTo(2, -10.5); ctx.stroke();
      face(dir * 1.4, -1.6, 0.8);
    } else if (PT.id === 'rocket') {
      ctx.rotate(U.clamp(P.vx * 0.05, -0.5, 0.5) * (dead ? 0 : 1) + (dead ? 0.9 : 0));
      // chama
      if (!dead && Math.abs(P.vx) > 1.5) {
        ctx.strokeStyle = pal.accent;
        ctx.beginPath();
        ctx.moveTo(-9, 0); ctx.lineTo(-15 - Math.random() * 5, -2 + Math.random() * 4);
        ctx.moveTo(-9, 1); ctx.lineTo(-13 - Math.random() * 4, 2);
        ctx.stroke();
        ctx.strokeStyle = pal.ink;
      }
      // corpo
      ctx.fillStyle = pal.paper;
      ctx.beginPath();
      ctx.moveTo(-9, -4.5); ctx.lineTo(5, -4.5);
      ctx.quadraticCurveTo(12, 0, 5, 4.5);
      ctx.lineTo(-9, 4.5); ctx.closePath();
      ctx.fill(); ctx.stroke();
      // aletas
      ctx.fillStyle = pal.accent;
      ctx.beginPath(); ctx.moveTo(-9, -4.5); ctx.lineTo(-13, -8); ctx.lineTo(-9, -1); ctx.closePath(); ctx.fill(); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(-9, 4.5); ctx.lineTo(-13, 8); ctx.lineTo(-9, 1); ctx.closePath(); ctx.fill(); ctx.stroke();
      // janelinha com carinha
      ctx.fillStyle = pal.paper;
      ctx.beginPath(); ctx.arc(1, 0, 4.4, 0, 7); ctx.fill(); ctx.stroke();
      face(1, -0.8, 0.7);
    } else if (PT.id === 'starly') {
      ctx.rotate(dead ? 0.4 : Math.sin(t * 0.0016) * 0.5);
      const gold = pal.id === 'kraft' ? '#7a5210' : '#e2b23f';
      ctx.strokeStyle = gold;
      ctx.fillStyle = pal.paper;
      ctx.lineWidth = 2.4;
      ctx.beginPath();
      for (let i = 0; i < 10; i++) {
        const a = i * Math.PI / 5 - Math.PI / 2;
        const rr = i % 2 === 0 ? 11 : 5;
        i === 0 ? ctx.moveTo(Math.cos(a) * rr, Math.sin(a) * rr) : ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr);
      }
      ctx.closePath();
      ctx.fill(); ctx.stroke();
      // cintilos
      if (!dead && Math.floor(t / 300) % 3 === 0) {
        ctx.beginPath();
        ctx.moveTo(14, -10); ctx.lineTo(17, -13); ctx.moveTo(17, -10); ctx.lineTo(14, -13);
        ctx.stroke();
      }
      ctx.strokeStyle = pal.ink;
      ctx.lineWidth = 2;
      face(0, 0.4, 0.85);
    }
    ctx.restore();
  }

  // ---------------------------------------------------------------- colecionáveis
  function drawPickups(pal, t) {
    for (const k of UD.world.pickups) {
      if (k.taken) continue;
      const bob = Math.sin(t * 0.004 + k.phase) * 5;
      ctx.save();
      ctx.translate(k.x, k.y + bob);
      if (k.type === 'ink') {
        ctx.fillStyle = pal.drawInk || pal.ink;
        ctx.strokeStyle = pal.ink;
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(0, -12);
        ctx.bezierCurveTo(7, -2, 8, 4, 0, 9);
        ctx.bezierCurveTo(-8, 4, -7, -2, 0, -12);
        ctx.closePath();
        ctx.fill(); ctx.stroke();
        ctx.fillStyle = pal.paper;
        ctx.beginPath(); ctx.arc(-2.4, 1.5, 1.8, 0, 7); ctx.fill();
      } else {
        ctx.strokeStyle = pal.accent;
        ctx.fillStyle = pal.paper;
        ctx.lineWidth = 2.4;
        ctx.rotate(Math.sin(t * 0.002 + k.phase) * 0.35);
        ctx.beginPath();
        for (let i = 0; i < 10; i++) {
          const a = i * Math.PI / 5 - Math.PI / 2;
          const rr = i % 2 === 0 ? 11 : 4.8;
          const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
          i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
        }
        ctx.closePath();
        ctx.fill(); ctx.stroke();
      }
      ctx.restore();
    }
  }

  // ---------------------------------------------------------------- traços do jogador
  function drawStrokes(pal, t) {
    ctx.lineCap = 'round'; ctx.lineJoin = 'round';
    const now = UD.game.time;
    for (const s of UD.ink.strokes) {
      const col = penColor(pal, s.pen);
      let alpha = 1, jit = 1.1;
      const age = now - s.born;
      if (s.state === 'dying') {
        const f = (now - s.dieAt) / C.INK.crumbleMs;
        alpha = Math.max(0, 1 - f);
        jit = 1.1 + f * 6;
      } else if (age > C.INK.warnMs) {
        jit = 1.1 + ((age - C.INK.warnMs) / (C.INK.lifeMs - C.INK.warnMs)) * 2.4;   // treme antes de morrer
        alpha = 0.75 + 0.25 * Math.sin(t * 0.02);
      }
      ctx.globalAlpha = alpha;
      ctx.strokeStyle = col;
      ctx.lineWidth = 7;
      ctx.beginPath();
      for (let i = 0; i < s.pts.length; i++) {
        const p = s.pts[i];
        const ox = U.noise(s.born % 97, i, boil) * jit;
        const oy = U.noise(s.born % 89 + 31, i, boil) * jit;
        i === 0 ? ctx.moveTo(p.x + ox, p.y + oy) : ctx.lineTo(p.x + ox, p.y + oy);
      }
      ctx.stroke();
      if (s.pen === 'bounce' && s.state === 'alive') {
        // molinhas decorativas sob o traço
        ctx.globalAlpha = alpha * 0.7;
        ctx.lineWidth = 1.8;
        ctx.beginPath();
        for (let i = 2; i < s.pts.length - 1; i += 4) {
          const p = s.pts[i];
          ctx.moveTo(p.x - 4, p.y + 8);
          ctx.lineTo(p.x + 4, p.y + 12);
          ctx.moveTo(p.x + 4, p.y + 8);
          ctx.lineTo(p.x - 4, p.y + 12);
        }
        ctx.stroke();
      }
      ctx.globalAlpha = 1;
    }
  }

  // ---------------------------------------------------------------- inimigos
  function drawEnemies(pal, t) {
    for (const e of UD.enemies.list) {
      ctx.save();
      ctx.translate(e.x, e.y);
      ctx.strokeStyle = pal.ink;
      ctx.fillStyle = pal.ink;
      ctx.lineWidth = 2.2;
      if (e.type === 'scribble') {
        const sq = e.grounded ? 1 : U.clamp(1 - e.vy * 0.02, 0.8, 1.2);
        ctx.scale(1 / sq, sq);
        // novelo de rabisco raivoso
        ctx.beginPath();
        for (let i = 0; i <= 26; i++) {
          const a = i * 0.9 + e.seed;
          const rr = e.r * (0.55 + 0.42 * Math.abs(U.noise(e.seed, i, boil)));
          const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
          i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
        }
        ctx.stroke();
        // olhos bravos
        const f = Math.sign(UD.player.x - e.x) || 1;
        ctx.fillStyle = pal.paper;
        ctx.beginPath(); ctx.arc(f * 3 - 4, -4, 3.6, 0, 7); ctx.fill();
        ctx.beginPath(); ctx.arc(f * 3 + 4, -4, 3.6, 0, 7); ctx.fill();
        ctx.fillStyle = pal.ink;
        ctx.beginPath(); ctx.arc(f * 3 - 3, -4, 1.5, 0, 7); ctx.fill();
        ctx.beginPath(); ctx.arc(f * 3 + 5, -4, 1.5, 0, 7); ctx.fill();
        ctx.lineWidth = 1.8;
        ctx.beginPath();
        ctx.moveTo(f * 3 - 7, -9); ctx.lineTo(f * 3 - 1, -6.5);
        ctx.moveTo(f * 3 + 8, -9); ctx.lineTo(f * 3 + 2, -6.5);
        ctx.stroke();
      } else {
        // aviãozinho de papel — bico apontando para onde ele VOA (esquerda)
        ctx.rotate(-(e.tilt || 0) + 0.15);
        ctx.fillStyle = pal.paper;
        ctx.lineWidth = 2;
        ctx.beginPath();
        ctx.moveTo(16, 6); ctx.lineTo(-18, -2); ctx.lineTo(10, -9); ctx.closePath();
        ctx.fill(); ctx.stroke();
        ctx.beginPath();
        ctx.moveTo(16, 6); ctx.lineTo(-18, -2); ctx.lineTo(6, 2); ctx.closePath();
        ctx.fill(); ctx.stroke();
        ctx.beginPath(); ctx.arc(-12, -3.4, 1.3, 0, 7); ctx.fillStyle = pal.ink; ctx.fill();
      }
      ctx.restore();
    }
  }

  // ---------------------------------------------------------------- Rabisco
  function ik(hx, hy, fx, fy, l1, l2, bend) {
    const dx = fx - hx, dy = fy - hy;
    let D = U.clamp(Math.hypot(dx, dy), 0.5, l1 + l2 - 0.3);
    const base = Math.atan2(dy, dx);
    const cosA = U.clamp((l1 * l1 + D * D - l2 * l2) / (2 * l1 * D), -1, 1);
    const a = Math.acos(cosA);
    return { x: hx + Math.cos(base + bend * a) * l1, y: hy + Math.sin(base + bend * a) * l1 };
  }

  function drawPlayer(pal, t) {
    const P = UD.player, G = UD.game, E = UD.eraser;
    const f = P.facing, ph = P.phase;
    const dead = P.state === 'dead';
    const deadAge = dead ? (performance.now() - P.deadAt) : 0;
    if (dead && P.deadCause === 'erased' && deadAge > 900) return;   // já virou pó

    ctx.save();
    ctx.translate(P.x, P.y);
    if (dead && P.deadCause === 'erased') {
      ctx.globalAlpha = Math.max(0, 1 - deadAge / 900);
      ctx.translate(Math.sin(deadAge * 0.05) * 6, 0);
    }
    const lean = U.clamp(P.vx * 0.03, -0.3, 0.3);
    // cambalhota: giro completo no 2º pulo
    const flip = P.flipT >= 0 ? P.flipT * Math.PI * 2 * (P.facing >= 0 ? 1 : -1) : 0;
    ctx.rotate(lean + flip + (dead && P.deadCause === 'fell' ? deadAge * 0.004 : 0));
    // squash & stretch
    const sy = 1 - P.squash * 0.28 + P.stretch * 0.18;
    const sx = 1 + P.squash * 0.3 - P.stretch * 0.12;
    ctx.translate(0, 16 * (1 - sy));
    ctx.scale(sx, sy);

    ctx.strokeStyle = pal.ink;
    ctx.fillStyle = pal.ink;
    ctx.lineWidth = 2.8;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';

    const hipY = 5, footY = 16;
    const running = P.grounded && Math.abs(P.vx) > 0.4;
    const spd = U.clamp(Math.abs(P.vx) / 5, 0, 1.5);

    // pés
    let fl, fr;
    if (!P.grounded) {
      fl = { x: -6 * f, y: footY - 2 };
      fr = { x: 7 * f, y: footY - 7 + U.clamp(P.vy * 0.35, -4, 5) };
    } else if (running) {
      const amp = U.clamp(Math.abs(P.vx) * 1.6, 4, 11);
      fl = { x: Math.sin(ph) * amp, y: footY - Math.max(0, Math.cos(ph)) * 5 * spd };
      fr = { x: Math.sin(ph + Math.PI) * amp, y: footY - Math.max(0, Math.cos(ph + Math.PI)) * 5 * spd };
    } else {
      fl = { x: -4.5, y: footY }; fr = { x: 4.5, y: footY };
    }
    const kl = ik(-1.5, hipY, fl.x, fl.y, 7, 8, f >= 0 ? -1 : 1);
    const kr = ik(1.5, hipY, fr.x, fr.y, 7, 8, f >= 0 ? -1 : 1);
    ctx.beginPath();
    ctx.moveTo(-1.5, hipY); ctx.lineTo(kl.x, kl.y); ctx.lineTo(fl.x, fl.y);
    ctx.moveTo(1.5, hipY); ctx.lineTo(kr.x, kr.y); ctx.lineTo(fr.x, fr.y);
    ctx.stroke();

    // tronco
    ctx.beginPath(); ctx.moveTo(0, hipY); ctx.lineTo(0, -8); ctx.stroke();

    // braços — segurando o lápis quando está desenhando
    const drawing = !!UD.ink.drawing;
    let hl, hr;
    if (drawing) {
      hl = { x: -6 * f, y: 1 };
      hr = { x: 11 * f, y: -6 };
    } else if (!P.grounded) {
      hl = { x: -8 * f, y: -13 }; hr = { x: 9 * f, y: -11 };
    } else if (running) {
      const amp = 6 * U.clamp(spd, 0.4, 1.3);
      hl = { x: Math.sin(ph + Math.PI) * amp, y: 2 };
      hr = { x: Math.sin(ph) * amp, y: 2 };
    } else {
      hl = { x: -4, y: 3 }; hr = { x: 4, y: 3 };
    }
    const el = ik(0, -7, hl.x, hl.y, 6.5, 7, f >= 0 ? -1 : 1);
    const er = ik(0, -7, hr.x, hr.y, 6.5, 7, f >= 0 ? 1 : -1);
    ctx.beginPath();
    ctx.moveTo(0, -7); ctx.lineTo(el.x, el.y); ctx.lineTo(hl.x, hl.y);
    ctx.moveTo(0, -7); ctx.lineTo(er.x, er.y); ctx.lineTo(hr.x, hr.y);
    ctx.stroke();
    if (drawing) {
      // lapisinho na mão
      ctx.save();
      ctx.translate(hr.x, hr.y);
      ctx.rotate(f > 0 ? -0.7 : -2.4);
      ctx.lineWidth = 3.4;
      ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(10, -8); ctx.stroke();
      ctx.restore();
    }

    // cabeça
    ctx.fillStyle = pal.paper;
    ctx.beginPath(); ctx.arc(0, -14.5, 6.8, 0, 7); ctx.fill(); ctx.stroke();

    // rosto: medo quando a Borracha está perto
    const gap = P.x - E.frontX();
    const scared = (gap < 430 && !UD.game.attract) || (!P.grounded && P.vy > 9);
    ctx.fillStyle = pal.ink;
    if (t > nextBlink) { blinkUntil = t + 130; nextBlink = t + 2200 + Math.random() * 2800; }
    const blinking = t < blinkUntil && !scared && !dead;
    const exL = f * 1.8 - 2.2, exR = f * 1.8 + 2.2, ey = -15.5;
    if (dead) {
      ctx.lineWidth = 1.5;
      for (const ex of [exL, exR]) {
        ctx.beginPath();
        ctx.moveTo(ex - 1.7, ey - 1.7); ctx.lineTo(ex + 1.7, ey + 1.7);
        ctx.moveTo(ex + 1.7, ey - 1.7); ctx.lineTo(ex - 1.7, ey + 1.7);
        ctx.stroke();
      }
    } else if (P.daze > 0.4) {
      ctx.lineWidth = 1.2;
      for (const ex of [exL, exR]) {
        ctx.beginPath();
        for (let k = 0; k <= 12; k++) {
          const a = k / 12 * Math.PI * 2.4 + t * 0.012, rr = 0.4 + k * 0.14;
          const px = ex + Math.cos(a) * rr, py = ey + Math.sin(a) * rr;
          k ? ctx.lineTo(px, py) : ctx.moveTo(px, py);
        }
        ctx.stroke();
      }
    } else if (blinking) {
      ctx.lineWidth = 1.4;
      ctx.beginPath();
      ctx.moveTo(exL - 1.2, ey); ctx.lineTo(exL + 1.2, ey);
      ctx.moveTo(exR - 1.2, ey); ctx.lineTo(exR + 1.2, ey);
      ctx.stroke();
    } else {
      const er2 = scared ? 1.8 : 1.1;
      ctx.beginPath(); ctx.arc(exL, ey, er2, 0, 7); ctx.fill();
      ctx.beginPath(); ctx.arc(exR, ey, er2, 0, 7); ctx.fill();
    }
    ctx.lineWidth = 1.6;
    const mx = f * 2.2, my = -11.6;
    ctx.beginPath();
    if (dead) { ctx.moveTo(mx - 2.4, my + 1); ctx.lineTo(mx + 2.4, my); }
    else if (scared) { ctx.arc(mx, my, 2.2, 0, 7); }
    else { ctx.arc(mx, my - 1, 2.8, Math.PI * 0.15, Math.PI * 0.85); }
    ctx.stroke();

    // suor de medo
    if (scared && !dead) {
      ctx.strokeStyle = pal.id === 'blueprint' ? '#bfe9ff' : '#69b7e8';
      ctx.lineWidth = 1.8;
      ctx.beginPath();
      const sy2 = -22 + (t % 600) / 600 * 6;
      ctx.moveTo(-6, sy2); ctx.lineTo(-6.6, sy2 + 3.4);
      ctx.stroke();
    }
    ctx.restore();
  }

  // ---------------------------------------------------------------- partículas & floats
  function drawParticles(pal) {
    for (const p of UD.game.parts) {
      const al = 1 - p.life / p.max;
      if (p.type === 'eraser') ctx.strokeStyle = `rgba(226,152,160,${al.toFixed(2)})`;
      else if (p.type === 'star') ctx.strokeStyle = `rgba(226,178,63,${al.toFixed(2)})`;
      else if (p.type === 'dust') ctx.strokeStyle = pal.id === 'blueprint' || pal.id === 'dark'
        ? `rgba(255,255,255,${(al * 0.4).toFixed(2)})` : `rgba(120,120,110,${(al * 0.5).toFixed(2)})`;
      else ctx.strokeStyle = pal.id === 'blueprint' || pal.id === 'dark'
        ? `rgba(235,235,225,${al.toFixed(2)})` : `rgba(31,31,31,${al.toFixed(2)})`;
      ctx.lineWidth = 2.2;
      ctx.beginPath();
      ctx.moveTo(p.x, p.y);
      ctx.lineTo(p.x + Math.cos(p.ang) * p.len, p.y + Math.sin(p.ang) * p.len);
      ctx.stroke();
    }
  }

  function drawFloats(pal) {
    ctx.textAlign = 'center';
    for (const fl of UD.game.floats) {
      const age = (UD.game.time - fl.born) / 950;
      ctx.globalAlpha = 1 - age;
      ctx.fillStyle = pal.ink;
      ctx.font = FONT(17);
      ctx.fillText(fl.txt, fl.x, fl.y - age * 34);
    }
    ctx.globalAlpha = 1;
    ctx.textAlign = 'left';
  }

  // ---------------------------------------------------------------- dicas na página (início)
  function drawHintLabels(pal) {
    const G = UD.game;
    if (G.attract || UD.player.x > 1500) return;
    ctx.fillStyle = pal.faint;
    ctx.textAlign = 'center';
    const hints = [
      [180, -150, UD.t('hint.run'), -0.03],
      [640, -140, UD.t('hint.draw'), 0.03],
      [1080, -150, UD.t('hint.jump'), -0.02],
      [1440, -140, UD.t('hint.flip'), 0.02]
    ];
    if (UD.save.data.totalM >= C.PENS[1].unlock) hints.push([1800, -150, UD.t('hint.pen'), -0.02]);
    for (const h of hints) {
      ctx.save();
      ctx.translate(h[0], h[1]);
      ctx.rotate(h[3]);
      ctx.font = FONT(19);
      ctx.fillText(h[2], 0, 0);
      ctx.restore();
    }
    ctx.textAlign = 'left';
  }

  // ---------------------------------------------------------------- região apagada + BORRACHA
  function drawErased(pal, left, top, bottom) {
    const E = UD.eraser;
    const fx = E.frontX();
    if (fx < left - 60) return;
    ctx.fillStyle = pal.paper;
    ctx.beginPath();
    ctx.moveTo(left - 200, top - 50);
    ctx.lineTo(fx, top - 50);
    for (let y = top - 50; y <= bottom + 50; y += 40)
      ctx.lineTo(fx + U.noise(3, y * 0.05, boil) * 8, y);
    ctx.lineTo(left - 200, bottom + 50);
    ctx.closePath();
    ctx.fill();
    // fantasmas do que existiu
    ctx.strokeStyle = pal.faint;
    ctx.globalAlpha = 0.25;
    ctx.lineWidth = 3;
    for (let i = 0; i < 4; i++) {
      const y = -180 + i * 90 + U.noise(i, 1, boil) * 10;
      ctx.beginPath();
      ctx.moveTo(fx - 190 - i * 60, y);
      ctx.quadraticCurveTo(fx - 110, y + 14, fx - 36, y - 6);
      ctx.stroke();
    }
    ctx.globalAlpha = 1;
  }

  function drawEraser(pal, t, top, bottom) {
    const E = UD.eraser;
    const wob = Math.sin(E.grind) * 3;
    const lungeWarn = E.lunge && E.lunge.state === 'warn';
    const lean = lungeWarn ? -0.085 : (E.lunge ? 0.05 : -0.035 + Math.sin(E.grind * 0.5) * 0.012);
    const w = C.ERASER.width;

    ctx.save();
    ctx.translate(E.x, 40 + wob);
    ctx.rotate(lean);
    const h0 = top - 260, h1 = bottom + 260;

    // sombra no papel à frente
    ctx.fillStyle = 'rgba(31,31,31,0.08)';
    ctx.fillRect(w / 2 - 4, h0, 26, h1 - h0);

    // corpo bicolor (azul em cima, rosa embaixo — a clássica)
    const split = -90;
    ctx.fillStyle = '#7ba7d9';
    ctx.strokeStyle = '#40608a';
    ctx.lineWidth = 4;
    rounded(-w / 2, h0, w, split - h0 + 20, 22);
    ctx.fill(); ctx.stroke();
    ctx.fillStyle = '#ef9aa2';
    ctx.strokeStyle = '#b05560';
    rounded(-w / 2, split, w, h1 - split, 22);
    ctx.fill(); ctx.stroke();

    // desgaste da frente (borda que esfrega)
    ctx.strokeStyle = 'rgba(255,255,255,0.55)';
    ctx.lineWidth = 7;
    ctx.beginPath();
    ctx.moveTo(w / 2 - 3, split + 30);
    ctx.lineTo(w / 2 - 3, h1 - 80);
    ctx.stroke();

    // luva de papel com rótulo
    ctx.fillStyle = '#f6f1e4';
    ctx.strokeStyle = '#9a9284';
    ctx.lineWidth = 3;
    rounded(-w / 2 - 6, split - 150, w + 12, 190, 8);
    ctx.fill(); ctx.stroke();
    ctx.fillStyle = '#4a4a44';
    ctx.textAlign = 'center';
    ctx.font = FONT(30);
    ctx.save();
    ctx.translate(0, split - 52);
    ctx.rotate(-0.02);
    ctx.fillText('B-2000', 0, 0);
    ctx.font = FONT(15);
    ctx.fillText('extra forte ★★★', 0, 24);
    ctx.restore();

    // carinha má rabiscada na luva
    ctx.strokeStyle = '#4a4a44';
    ctx.lineWidth = 2.6;
    const eyeY = split - 108;
    ctx.beginPath();
    ctx.moveTo(-26, eyeY - 8); ctx.lineTo(-8, eyeY);    // sobrancelha brava
    ctx.moveTo(26, eyeY - 8); ctx.lineTo(8, eyeY);
    ctx.stroke();
    ctx.fillStyle = '#4a4a44';
    ctx.beginPath(); ctx.arc(-14, eyeY + 6, 3.4, 0, 7); ctx.fill();
    ctx.beginPath(); ctx.arc(14, eyeY + 6, 3.4, 0, 7); ctx.fill();

    ctx.restore();
    ctx.textAlign = 'left';

    // aviso de investida: risquinhos de alerta na frente
    if (lungeWarn && Math.floor(t / 90) % 2 === 0) {
      ctx.strokeStyle = pal.accent;
      ctx.lineWidth = 3;
      for (let i = 0; i < 3; i++) {
        const y = -160 + i * 120;
        ctx.beginPath();
        ctx.moveTo(E.frontX() + 26, y);
        ctx.lineTo(E.frontX() + 52, y);
        ctx.stroke();
      }
    }

    function rounded(x, y, ww, hh, r) {
      ctx.beginPath();
      ctx.moveTo(x + r, y);
      ctx.arcTo(x + ww, y, x + ww, y + hh, r);
      ctx.arcTo(x + ww, y + hh, x, y + hh, r);
      ctx.arcTo(x, y + hh, x, y, r);
      ctx.arcTo(x, y, x + ww, y, r);
      ctx.closePath();
    }
  }

  // ---------------------------------------------------------------- rabiscos de fundo
  function drawPropLayer(l, pal, t) {
    const f = [0.35, 0.62][l];
    const cam = R.cam;
    ctx.save();
    ctx.translate(innerWidth / 2, innerHeight / 2);
    ctx.scale(cam.zoom, cam.zoom);
    ctx.translate(-cam.x * f, -cam.y * 0.6);
    ctx.strokeStyle = pal.faint;
    ctx.globalAlpha = l === 0 ? 0.5 : 0.75;
    ctx.lineWidth = 2;
    ctx.lineCap = 'round';
    for (const p of UD.world.props[l]) {
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.scale(p.s, p.s);
      drawProp(p, pal, t);
      ctx.restore();
    }
    ctx.restore();
    ctx.globalAlpha = 1;
  }

  function drawProp(p, pal, t) {
    const n = (i) => U.noise(p.seed, i, 1);
    switch (p.type) {
      case 'cloud':
        ctx.beginPath();
        ctx.moveTo(-34, 8);
        ctx.bezierCurveTo(-40, -8, -18, -16, -10, -8);
        ctx.bezierCurveTo(-4, -20, 16, -18, 18, -6);
        ctx.bezierCurveTo(34, -8, 38, 6, 26, 8);
        ctx.closePath(); ctx.stroke();
        break;
      case 'sun':
        ctx.beginPath(); ctx.arc(0, 0, 15, 0, 7); ctx.stroke();
        for (let i = 0; i < 8; i++) {
          const a = i / 8 * Math.PI * 2;
          ctx.beginPath();
          ctx.moveTo(Math.cos(a) * 19, Math.sin(a) * 19);
          ctx.lineTo(Math.cos(a) * 27, Math.sin(a) * 27);
          ctx.stroke();
        }
        break;
      case 'house':
        ctx.strokeRect(-18, -6, 36, 26);
        ctx.beginPath();
        ctx.moveTo(-22, -6); ctx.lineTo(0, -24); ctx.lineTo(22, -6);
        ctx.stroke();
        ctx.strokeRect(-4, 6, 9, 14);
        break;
      case 'flower':
        ctx.beginPath(); ctx.moveTo(0, 22); ctx.lineTo(0, 2); ctx.stroke();
        for (let i = 0; i < 5; i++) {
          const a = i / 5 * Math.PI * 2;
          ctx.beginPath(); ctx.arc(Math.cos(a) * 6, -2 + Math.sin(a) * 6, 5, 0, 7); ctx.stroke();
        }
        break;
      case 'bird':
        ctx.beginPath();
        ctx.moveTo(-12, 0); ctx.quadraticCurveTo(-5, -8, 0, 0);
        ctx.quadraticCurveTo(5, -8, 12, 0);
        ctx.stroke();
        break;
      case 'squiggle':
        ctx.beginPath();
        ctx.moveTo(-20, 0);
        for (let i = 1; i <= 8; i++) ctx.lineTo(-20 + i * 5, n(i) * 9);
        ctx.stroke();
        break;
      case 'equation':
        ctx.font = FONT(17);
        ctx.fillStyle = pal.faint;
        ctx.fillText(['2+2=5?', 'x²−1', '∑ zzz', 'a=πr²', '7×8=?'][Math.abs(Math.floor(n(1) * 5)) % 5], -20, 0);
        break;
      case 'triangle':
        ctx.beginPath();
        ctx.moveTo(0, -14); ctx.lineTo(14, 12); ctx.lineTo(-14, 12); ctx.closePath();
        ctx.stroke();
        break;
      case 'ruler':
        ctx.strokeRect(-28, -5, 56, 12);
        for (let i = -22; i <= 22; i += 8) { ctx.beginPath(); ctx.moveTo(i, -5); ctx.lineTo(i, 0); ctx.stroke(); }
        break;
      case 'graphline':
        ctx.beginPath();
        ctx.moveTo(-26, 10);
        for (let i = 1; i <= 6; i++) ctx.lineTo(-26 + i * 9, 10 - i * 4 + n(i) * 8);
        ctx.stroke();
        break;
      case 'stamp':
        ctx.strokeRect(-16, -20, 32, 40);
        ctx.beginPath(); ctx.arc(0, -6, 8, 0, 7); ctx.stroke();
        ctx.beginPath(); ctx.moveTo(-10, 10); ctx.lineTo(10, 10); ctx.stroke();
        break;
      case 'tape':
        ctx.save(); ctx.rotate(0.4);
        ctx.strokeRect(-24, -8, 48, 16);
        ctx.restore();
        break;
      case 'fragile':
        ctx.font = FONT(16);
        ctx.fillStyle = pal.faint;
        ctx.fillText('FRÁGIL ↑', -24, 0);
        break;
      case 'box':
        ctx.strokeRect(-16, -12, 32, 24);
        ctx.beginPath(); ctx.moveTo(-16, -12); ctx.lineTo(-8, -20); ctx.lineTo(24, -20); ctx.lineTo(16, -12); ctx.stroke();
        break;
      case 'gear':
        ctx.beginPath(); ctx.arc(0, 0, 12, 0, 7); ctx.stroke();
        for (let i = 0; i < 8; i++) {
          const a = i / 8 * Math.PI * 2;
          ctx.beginPath();
          ctx.moveTo(Math.cos(a) * 12, Math.sin(a) * 12);
          ctx.lineTo(Math.cos(a) * 17, Math.sin(a) * 17);
          ctx.stroke();
        }
        break;
      case 'compassArc':
        ctx.beginPath(); ctx.arc(0, 10, 22, Math.PI * 1.15, Math.PI * 1.85); ctx.stroke();
        ctx.beginPath(); ctx.moveTo(0, 10); ctx.lineTo(-14, -8); ctx.moveTo(0, 10); ctx.lineTo(14, -8); ctx.stroke();
        break;
      case 'star':
        ctx.beginPath();
        for (let i = 0; i < 10; i++) {
          const a = i * Math.PI / 5 - Math.PI / 2, rr = i % 2 ? 4 : 9;
          i ? ctx.lineTo(Math.cos(a) * rr, Math.sin(a) * rr) : ctx.moveTo(Math.cos(a) * rr, Math.sin(a) * rr);
        }
        ctx.closePath(); ctx.stroke();
        break;
      case 'moon':
        ctx.beginPath(); ctx.arc(0, 0, 13, Math.PI * 0.35, Math.PI * 1.65); ctx.stroke();
        ctx.beginPath(); ctx.arc(6, 0, 10, Math.PI * 0.5, Math.PI * 1.5, true); ctx.stroke();
        break;
      case 'comet':
        ctx.beginPath(); ctx.arc(10, 0, 5, 0, 7); ctx.stroke();
        ctx.beginPath(); ctx.moveTo(4, -2); ctx.lineTo(-18, -8); ctx.moveTo(4, 2); ctx.lineTo(-16, 6); ctx.stroke();
        break;
      case 'constellation': {
        let px = -20, py = 8;
        for (let i = 0; i < 4; i++) {
          const nx = px + 12 + n(i) * 6, ny = py - 8 + n(i + 4) * 10;
          ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(nx, ny); ctx.stroke();
          ctx.beginPath(); ctx.arc(nx, ny, 1.6, 0, 7); ctx.stroke();
          px = nx; py = ny;
        }
        break;
      }
    }
  }

  // ---------------------------------------------------------------- efeitos de tela
  function drawFx(pal, vw, vh) {
    const G = UD.game, P = UD.player, E = UD.eraser;
    // flash de dano
    if (G.hitFlash > 0.02) {
      ctx.fillStyle = `rgba(232,72,63,${(G.hitFlash * 0.2).toFixed(2)})`;
      ctx.fillRect(0, 0, vw, vh);
    }
    // brilho rosa de perigo na esquerda
    if (!G.attract) {
      const gap = P.x - E.frontX();
      const danger = U.clamp(1 - gap / 800, 0, 1);
      if (danger > 0.03) {
        const gr = ctx.createLinearGradient(0, 0, vw * 0.3, 0);
        gr.addColorStop(0, `rgba(239,154,162,${(danger * 0.5).toFixed(2)})`);
        gr.addColorStop(1, 'rgba(239,154,162,0)');
        ctx.fillStyle = gr;
        ctx.fillRect(0, 0, vw * 0.3, vh);
      }
    }
    // vinheta do slow-mo (desenhando)
    const slow = 1 - G.timescale;
    if (slow > 0.1) {
      const gr = ctx.createRadialGradient(vw / 2, vh / 2, vh * 0.36, vw / 2, vh / 2, vh * 0.85);
      gr.addColorStop(0, 'rgba(31,31,60,0)');
      gr.addColorStop(1, `rgba(31,31,60,${(slow * 0.45).toFixed(2)})`);
      ctx.fillStyle = gr;
      ctx.fillRect(0, 0, vw, vh);
    }
  }

  // ---------------------------------------------------------------- HUD
  function drawHud(pal, vw, vh, t) {
    const G = UD.game, P = UD.player, E = UD.eraser, K = UD.ink;
    ctx.textAlign = 'left';

    // pontuação + combo
    ctx.fillStyle = pal.ink;
    ctx.font = FONT(36);
    ctx.fillText(G.score, 18, 44);
    if (G.combo > 1) {
      const pulse = 1 + Math.sin(t * 0.012) * 0.08;
      ctx.save();
      ctx.translate(26, 74);
      ctx.scale(pulse, pulse);
      ctx.fillStyle = pal.accent;
      ctx.font = FONT(26);
      ctx.fillText('×' + G.combo, 0, 0);
      ctx.restore();
      // janelinha do combo
      const fr = U.clamp((G.comboUntil - G.time) / C.COMBO.windowMs, 0, 1);
      ctx.strokeStyle = pal.accent;
      ctx.lineWidth = 2.4;
      ctx.beginPath();
      ctx.moveTo(74, 68); ctx.lineTo(74 + 46 * fr, 68);
      ctx.stroke();
    }

    // metros (centro) — com o selo da dificuldade quando não for a normal
    ctx.textAlign = 'center';
    ctx.font = FONT(27);
    ctx.fillStyle = pal.ink;
    const dTag = G.effDiff !== 'standard' ? ' ' + G.diffCfg().ico : '';
    ctx.fillText(Math.floor(G.meters) + 'm' + dTag, vw / 2, 40);
    if (G.mode === 'daily') {
      ctx.font = FONT(14);
      ctx.fillStyle = pal.faint;
      ctx.fillText('🗓️ daily', vw / 2, 60);
    }

    // estrelas (direita)
    ctx.textAlign = 'right';
    ctx.font = FONT(24);
    ctx.fillStyle = pal.accent;
    ctx.fillText('★ ' + G.stars, vw - 18, 40);

    // distância da borracha quando fora da tela
    const es = R.worldToScreen(E.frontX(), 0);
    if (es.x < -10) {
      const gapM = Math.round((P.x - E.frontX()) / C.M2PX);
      const puls = 0.75 + Math.sin(t * 0.01) * 0.25;
      ctx.textAlign = 'left';
      ctx.globalAlpha = puls;
      ctx.fillStyle = '#d76a76';
      ctx.font = FONT(21);
      ctx.fillText('◀ 🧽 ' + gapM + 'm', 14, vh / 2);
      ctx.globalAlpha = 1;
    }

    // ------------- barra de tinta + chips de caneta
    const bw = Math.min(250, vw * 0.4), bx = vw / 2 - bw / 2, by = vh - 42;
    const frac = K.meter / C.INK.max;
    const low = frac < 0.25;
    ctx.strokeStyle = low && Math.floor(t / 240) % 2 === 0 ? pal.accent : pal.ink;
    ctx.lineWidth = 2.6;
    ctx.fillStyle = pal.paper;
    hand(bx, by, bw, 15);
    ctx.fill(); ctx.stroke();
    ctx.fillStyle = penColor(pal, K.pen);
    if (frac > 0.01) {
      hand(bx + 2.4, by + 2.4, (bw - 4.8) * frac, 10.2);
      ctx.fill();
    }
    ctx.textAlign = 'center';
    ctx.font = FONT(15);
    ctx.fillStyle = pal.faint;
    ctx.fillText('💧', bx - 16, by + 13);

    // chips
    R.chipRects = [];
    const nP = C.PENS.length, cw = 42;
    for (let i = 0; i < nP; i++) {
      const p = C.PENS[i];
      const cx = bx + bw + 20 + i * (cw + 8);
      const cy = by - 12;
      const unlocked = UD.save.penUnlocked(p.id);
      const sel = K.pen === p.id;
      ctx.globalAlpha = unlocked ? 1 : 0.35;
      ctx.strokeStyle = sel ? pal.accent : pal.ink;
      ctx.lineWidth = sel ? 3 : 2;
      ctx.fillStyle = pal.paper;
      hand(cx, cy, cw, 38);
      ctx.fill(); ctx.stroke();
      ctx.font = FONT(19);
      ctx.fillStyle = pal.ink;
      ctx.fillText(unlocked ? p.ico : '🔒', cx + cw / 2, cy + 24);
      ctx.font = FONT(11);
      ctx.fillStyle = pal.faint;
      ctx.fillText(String(i + 1), cx + cw / 2, cy + 35);
      ctx.globalAlpha = 1;
      R.chipRects.push({ x: cx, y: cy, w: cw, h: 38, idx: i });
    }
    ctx.textAlign = 'left';

    function hand(x, y, w, h) {
      // retângulo levemente torto (desenhado à mão)
      ctx.beginPath();
      ctx.moveTo(x + U.noise(1, x, boil), y + U.noise(2, y, boil));
      ctx.lineTo(x + w + U.noise(3, x, boil), y + U.noise(4, y, boil) + 1);
      ctx.lineTo(x + w + U.noise(5, x, boil) - 1, y + h + U.noise(6, y, boil));
      ctx.lineTo(x + U.noise(7, x, boil), y + h + U.noise(8, y, boil) - 1);
      ctx.closePath();
    }
  }

  function drawBanner(pal, vw, vh) {
    const G = UD.game;
    if (!G.banner) return;
    const age = G.time - G.banner.born;
    let sc = 1, al = 1;
    if (age < 200) sc = 0.4 + (age / 200) * 0.6;
    if (age > 1700) al = Math.max(0, 1 - (age - 1700) / 500);
    ctx.save();
    ctx.translate(vw / 2, vh * 0.26);
    ctx.scale(sc, sc);
    ctx.globalAlpha = al;
    ctx.rotate(-0.015);
    ctx.textAlign = 'center';
    ctx.font = FONT(44);
    ctx.fillStyle = pal.ink;
    ctx.fillText(G.banner.text, 2, 2);
    ctx.fillStyle = pal.accent;
    ctx.fillText(G.banner.text, 0, 0);
    ctx.restore();
    ctx.textAlign = 'left';
  }

  // ---------------------------------------------------------------- balões de fala
  function drawBubbles(pal, t) {
    const G = UD.game;
    if (G.bubbles.player) {
      const P = UD.player;
      const sp = R.worldToScreen(P.x, P.y - 28);
      bubble(G.bubbles.player, sp.x, sp.y, pal, false);
    }
    if (G.bubbles.eraser) {
      const E = UD.eraser;
      const sp = R.worldToScreen(E.frontX() + 30, 0);
      bubble(G.bubbles.eraser, Math.max(120, sp.x + 60), Math.min(innerHeight * 0.36, Math.max(120, sp.y - 160)), pal, true);
    }
  }

  function bubble(b, sx, sy, pal, angry) {
    const rest = b.until - UD.game.time;
    const alpha = rest < 300 ? Math.max(0, rest / 300) : 1;
    ctx.globalAlpha = alpha;
    ctx.font = FONT(16);
    const lines = U.wrapText(b.text, 24);
    let wMax = 0;
    for (const l of lines) wMax = Math.max(wMax, ctx.measureText(l).width);
    const bw = wMax + 22, bh = lines.length * 19 + 13;
    const bx = U.clamp(sx - bw / 2, 8, innerWidth - bw - 8);
    const by = U.clamp(sy - bh - 16, 8, innerHeight - bh - 8);
    ctx.fillStyle = pal.ink;
    ctx.fillRect(bx + 3, by + 3, bw, bh);
    ctx.fillStyle = angry ? '#ffe9ec' : pal.paper;
    ctx.strokeStyle = pal.ink;
    ctx.lineWidth = 2;
    ctx.fillRect(bx, by, bw, bh);
    ctx.strokeRect(bx, by, bw, bh);
    const tx = U.clamp(sx, bx + 14, bx + bw - 14);
    ctx.beginPath();
    ctx.moveTo(tx - 6, by + bh);
    ctx.lineTo(tx + 6, by + bh);
    ctx.lineTo(U.clamp(sx, tx - 10, tx + 10), by + bh + 9);
    ctx.closePath();
    ctx.fill(); ctx.stroke();
    ctx.fillStyle = angry ? '#ffe9ec' : pal.paper;
    ctx.fillRect(tx - 5.5, by + bh - 2, 11, 3);
    ctx.fillStyle = pal.id === 'blueprint' || pal.id === 'dark' ? '#1c1c1c' : pal.ink;
    ctx.fillStyle = '#1f1f1f';
    lines.forEach((l, i) => ctx.fillText(l, bx + 11, by + 20 + i * 19));
    ctx.globalAlpha = 1;
  }

  // ---------------------------------------------------------------- cursor-lápis
  function drawCursor(pal, t) {
    const I = UD.input;
    if (!I.pointer.over || I.isTouch) return;
    const x = I.pointer.sx, y = I.pointer.sy;
    const drawing = !!UD.ink.drawing;
    const noInk = UD.ink.meter < 3;
    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(-0.62 + (drawing ? Math.sin(t * 0.03) * 0.05 : 0));
    ctx.strokeStyle = noInk ? pal.accent : pal.ink;
    ctx.lineWidth = 5;
    ctx.lineCap = 'round';
    ctx.beginPath();
    ctx.moveTo(0, 0); ctx.lineTo(0, -26);
    ctx.stroke();
    ctx.strokeStyle = penColor(pal, UD.ink.pen);
    ctx.lineWidth = 5;
    ctx.beginPath();
    ctx.moveTo(0, -24); ctx.lineTo(0, -34);
    ctx.stroke();
    // ponta
    ctx.fillStyle = noInk ? pal.accent : pal.ink;
    ctx.beginPath();
    ctx.moveTo(0, 0); ctx.lineTo(-2.6, -6); ctx.lineTo(2.6, -6);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }

  UD.render = R;
})();

// UNDRAWN — progresso persistente (localStorage)
(function () {
  const KEY = 'undrawn.save.v1';

  const S = {
    data: {
      best: 0,          // melhor pontuação
      bestM: 0,         // melhor distância (m)
      totalM: 0,        // metros acumulados (desbloqueia canetas)
      totalStars: 0,    // estrelas acumuladas (desbloqueia pets)
      plays: 0,
      pen: 'graphite',
      pet: 'none',
      diff: 'standard',
      lang: null,
      sound: true,
      music: true,
      daily: {}         // { 'YYYYMMDD': melhorM }
    }
  };

  S.load = function () {
    try {
      const raw = localStorage.getItem(KEY);
      if (raw) Object.assign(S.data, JSON.parse(raw));
    } catch (e) { /* modo anônimo etc — segue sem salvar */ }
  };

  S.commit = function () {
    try { localStorage.setItem(KEY, JSON.stringify(S.data)); } catch (e) {}
  };

  S.penUnlocked = function (id) {
    const p = UD.C.PENS.find(p => p.id === id);
    return !!p && S.data.totalM >= p.unlock;
  };

  S.petUnlocked = function (id) {
    if (id === 'none') return true;
    const p = UD.C.PETS.find(p => p.id === id);
    return !!p && S.data.totalStars >= p.unlock;
  };

  // registra uma corrida; retorna { newBest, newBestM, unlocked:[penDefs], unlockedPets:[petDefs] }
  S.addRun = function (meters, score, dailySeed, stars) {
    const d = S.data;
    const beforeM = d.totalM;
    const beforeS = d.totalStars;
    d.totalM += meters;
    d.totalStars += (stars || 0);
    d.plays++;
    const out = { newBest: false, newBestM: false, unlocked: [], unlockedPets: [] };
    if (score > d.best) { d.best = score; out.newBest = true; }
    if (meters > d.bestM) { d.bestM = meters; out.newBestM = true; }
    if (dailySeed) {
      const cur = d.daily[dailySeed] || 0;
      if (meters > cur) d.daily[dailySeed] = meters;
      // guarda só os últimos 10 dias
      const keys = Object.keys(d.daily).sort();
      while (keys.length > 10) delete d.daily[keys.shift()];
    }
    for (const p of UD.C.PENS)
      if (p.unlock > 0 && beforeM < p.unlock && d.totalM >= p.unlock) out.unlocked.push(p);
    for (const p of UD.C.PETS)
      if (beforeS < p.unlock && d.totalStars >= p.unlock) out.unlockedPets.push(p);
    S.commit();
    return out;
  };

  UD.save = S;
})();

// UNDRAWN — estado do jogo: corrida, pontuação, combo, marcos, partículas,
// balões de fala, fim de corrida e o modo vitrine que roda atrás do menu.
(function () {
  const U = UD.U, C = UD.C;

  const G = {
    state: 'menu',            // menu | running | paused | over
    mode: 'endless',
    attract: true,
    dailySeed: null,
    effDiff: 'standard',      // dificuldade em vigor (daily trava em 'standard')
    time: 0,                  // tempo de jogo (ms, escalado pelo slow-mo)
    timescale: 1,

    score: 0, bonus: 0, meters: 0,
    stars: 0, stomps: 0, deaths: 0,
    combo: 1, comboUntil: 0, maxCombo: 1,

    checkpoints: [],          // bandeiras plantadas: {x, y, born}
    lastCp: null, cpArmed: false, nextCpM: Infinity,

    parts: [], floats: [],
    bubbles: { player: null, eraser: null },
    banner: null,
    hitFlash: 0, shakeAmt: 0,

    nextMilestone: 500,
    lastBiome: 0,
    nearSince: 0, nearCooldownUntil: 0,
    _noInkAt: -1e9, _overTimer: null
  };

  G.diffCfg = () => C.DIFF[G.effDiff] || C.DIFF.standard;

  // ---------------------------------------------------------------- ciclo de vida
  function resetSim(seed, attract) {
    G.time = 0;
    G.parts = []; G.floats = [];
    G.bubbles = { player: null, eraser: null };
    G.banner = null;
    G.score = 0; G.bonus = 0; G.meters = 0;
    G.stars = 0; G.stomps = 0; G.deaths = 0;
    G.combo = 1; G.comboUntil = 0; G.maxCombo = 1;
    G.checkpoints = []; G.lastCp = null; G.cpArmed = false;
    G.nextCpM = G.diffCfg().checkpointM || Infinity;
    G.nextMilestone = 500;
    G.lastBiome = 0;
    G.nearSince = 0; G.nearCooldownUntil = 0;
    G.hitFlash = 0; G.shakeAmt = 0;

    UD.world.reset(seed);
    UD.enemies.reset();
    UD.ink.reset();
    UD.player.reset(0, -60);
    UD.eraser.reset(0, attract);
    UD.pet.reset();
    UD.render.resetCam();
  }

  G.initAttract = function () {
    G.attract = true;
    G.state = 'menu';
    G.effDiff = 'standard';
    resetSim(Math.floor(Math.random() * 1e9), true);
  };

  G.dateSeed = function () {
    const d = new Date();
    return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate();
  };

  G.start = function (mode) {
    if (G._overTimer) { clearTimeout(G._overTimer); G._overTimer = null; }
    G.mode = mode || 'endless';
    G.attract = false;
    G.dailySeed = G.mode === 'daily' ? G.dateSeed() : null;
    // daily trava em 'normal' para a página ser a mesma para todo mundo
    G.effDiff = G.mode === 'daily' ? 'standard' : (UD.save.data.diff || 'standard');
    resetSim(G.dailySeed || Math.floor(Math.random() * 1e9), false);
    G.state = 'running';
    overlay(null);
    document.getElementById('touch').hidden = !UD.input.isTouch;
    UD.audio.unlock();
    if (UD.save.data.music) UD.audio.musicStart();
    if (G.mode === 'daily') {
      const d = String(G.dailySeed);
      U.showToast(UD.t('toast.daily', { d: d.slice(6) + '/' + d.slice(4, 6) }));
    }
    G.say('player', U.pick(UD.t('bub.start').split('|')));
  };

  G.hotRestart = function () {
    if (G.state === 'running' || G.state === 'over') G.start(G.mode);
  };

  G.pauseToggle = function (auto) {
    if (G.state === 'running') {
      G.state = 'paused';
      UD.audio.musicStop();
      UD.audio.scratchStop();
      UD.audio.setRumble(0);
      UD.ink.end();
      overlay('pause');
    } else if (G.state === 'paused' && !auto) {
      G.state = 'running';
      if (UD.save.data.music) UD.audio.musicStart();
      overlay(null);
    }
  };

  G.toMenu = function () {
    if (G._overTimer) { clearTimeout(G._overTimer); G._overTimer = null; }
    UD.audio.musicStop();
    UD.audio.setRumble(0);
    UD.audio.scratchStop();
    G.initAttract();
    overlay('menu');
    document.getElementById('touch').hidden = true;
    if (UD.main) UD.main.refreshMenu();
  };

  function overlay(name) {
    for (const id of ['menu', 'pause', 'over'])
      document.getElementById(id).hidden = (id !== name);
  }

  // ---------------------------------------------------------------- fim de corrida
  G.over = function (cause) {
    if (G.state !== 'running') return;
    // tem bandeira armada? então não acabou: volta ao checkpoint
    if (G.cpArmed && G.lastCp) { respawnAtCp(cause); return; }
    G.state = 'over';
    UD.player.die(cause);
    UD.ink.end();
    UD.audio.scratchStop();
    UD.audio.musicStop();
    UD.audio.play(cause === 'erased' ? 'dead' : 'fell');
    G.shake(cause === 'erased' ? 8 : 4);
    if (cause === 'erased') {
      G.say('eraser', UD.t('bub.eraserKill'));
      for (let i = 0; i < 26; i++)
        G.spawnCrumbs(UD.player.x + Math.random() * 40 - 20, UD.player.y + Math.random() * 40 - 20, 1, 'eraser');
    }

    const m = Math.floor(G.meters);
    const res = UD.save.addRun(m, G.score, G.dailySeed, G.stars);

    G._overTimer = setTimeout(() => {
      if (G.state !== 'over') return;
      fillOverScreen(cause, m, res);
      overlay('over');
      UD.audio.setRumble(0);
      if (res.unlocked.length || res.unlockedPets.length) UD.audio.play('unlock');
    }, 1000);
  };

  // ---------------------------------------------------------------- respawn no checkpoint
  // Cada bandeira vale UMA volta: morrer de novo antes da próxima bandeira = fim.
  function respawnAtCp(cause) {
    const cp = G.lastCp;
    G.cpArmed = false;
    G.deaths++;
    G.combo = 1;
    G.hitFlash = 1;

    UD.ink.end();
    UD.audio.scratchStop();
    UD.ink.strokes = [];
    UD.ink.meter = Math.max(UD.ink.meter, 65);

    UD.world.respawnAt(cp);
    UD.enemies.reset();
    UD.player.reset(cp.x, cp.y - 50);
    UD.player.iframesUntil = performance.now() + 1600;
    UD.eraser.reset(cp.x, false);
    UD.pet.reset();
    UD.render.resetCam();

    G.banner = { text: UD.t('banner.back'), born: G.time };
    G.say('player', U.pick(UD.t('bub.respawn').split('|')));
    UD.audio.play('respawn');
    G.shake(3);
  }

  // planta uma bandeira a cada N metros (dificuldade define N; hardcore = nunca)
  function checkpoints() {
    const cpM = G.diffCfg().checkpointM;
    if (!cpM || G.meters < G.nextCpM) return;
    const P = UD.player;
    if (!P.grounded || P.state === 'dead') return;   // espera pisar em chão firme
    const gy = UD.world.groundYAt(P.x);
    if (gy === null) return;
    const cp = { x: P.x, y: gy, born: G.time };
    G.checkpoints.push(cp);
    if (G.checkpoints.length > 4) G.checkpoints.shift();
    G.lastCp = cp;
    G.cpArmed = true;
    G.nextCpM = Math.floor(G.meters / cpM) * cpM + cpM;
    G.banner = { text: UD.t('banner.cp'), born: G.time };
    UD.audio.play('checkpoint');
  }

  function fillOverScreen(cause, m, res) {
    const t = UD.t, S = UD.save.data;
    const title = document.getElementById('over-title');
    title.textContent = t(cause === 'erased' ? 'over.erasedT' : 'over.fellT');
    title.classList.toggle('fell', cause !== 'erased');
    const taunts = t(cause === 'erased' ? 'over.taunts' : 'over.tauntsFell').split('|');
    document.getElementById('over-taunt').textContent = U.pick(taunts);
    document.getElementById('over-record').hidden = !(res.newBest || res.newBestM);

    const dc = G.diffCfg();
    const rows = [
      ['over.dist', m + 'm'], ['over.score', G.score],
      ['over.stars', '★ ' + G.stars], ['over.stomps', G.stomps],
      ['over.maxCombo', '×' + G.maxCombo],
      ['over.diff', dc.ico + ' ' + t('diff.' + G.effDiff)],
      ['over.deaths', '📍 ' + G.deaths],
      ['over.best', S.bestM + 'm · ' + S.best]
    ];
    document.getElementById('over-stats').innerHTML = rows.map(r =>
      `<div class="st-l">${t(r[0])}</div><div class="st-v"><b>${r[1]}</b></div>`).join('');

    // desbloqueios: comemoração (canetas e pets) ou barras de progresso
    const uEl = document.getElementById('over-unlock');
    let html = '';
    if (res.unlocked.length) {
      html += res.unlocked.map(p =>
        `<div class="u-new">${t('over.unlockNew', { name: p.ico + ' ' + t('pen.' + p.id + '.name') })}</div>`).join('');
      U.showToast(t('toast.penUnlock', { name: t('pen.' + res.unlocked[0].id + '.name') }));
    }
    if (res.unlockedPets.length) {
      html += res.unlockedPets.map(p =>
        `<div class="u-new">${t('over.unlockPet', { name: p.ico + ' ' + t('pet.' + p.id + '.name') })}</div>`).join('');
      U.showToast(t('toast.petUnlock', { name: t('pet.' + res.unlockedPets[0].id + '.name') }));
    }
    if (!html) {
      const next = C.PENS.find(p => p.unlock > S.totalM);
      if (next) {
        const frac = Math.min(1, S.totalM / next.unlock);
        html +=
          `<div>${t('over.unlockNext', { name: p2name(next) })}</div>` +
          `<div class="u-bar"><i style="width:${Math.round(frac * 100)}%"></i></div>` +
          `<div class="u-txt">${t('over.unlockLeft', { m: Math.max(0, Math.ceil(next.unlock - S.totalM)) })}</div>`;
      } else {
        html += `<div class="u-txt">${t('over.allUnlocked')}</div>`;
      }
      const nextPet = C.PETS.find(p => p.unlock > S.totalStars);
      if (nextPet) {
        html += `<div class="u-txt">${t('over.nextPet', {
          name: nextPet.ico + ' ' + t('pet.' + nextPet.id + '.name'),
          n: nextPet.unlock - S.totalStars
        })}</div>`;
      }
    }
    uEl.innerHTML = html;
    document.getElementById('btn-again').textContent =
      t('over.again', { k: UD.input.isTouch ? '↻' : 'R' });
    if (UD.main) UD.main.refreshMenu();
  }

  function p2name(p) { return p.ico + ' ' + UD.t('pen.' + p.id + '.name'); }

  // ---------------------------------------------------------------- passo (60Hz, tempo de jogo)
  G.step = function () {
    const active = (G.state === 'running') || (G.state === 'over') || (G.state === 'menu' && G.attract);
    if (!active) return;
    const dt = 1 / 60;
    G.time += C.STEP;

    const P = UD.player, W = UD.world, E = UD.eraser;
    const cam = UD.render.cam;
    const camRight = cam.x + (innerWidth / 2) / cam.zoom;

    W.update(dt, cam.x, camRight, E.frontX());
    UD.ink.update(dt);
    P.update(dt);
    UD.enemies.update(dt);
    E.update(dt);
    UD.pet.update(dt);

    if (G.state === 'running' || G.attract) collectPickups(P);

    // distância / pontuação (hardcore paga bônus)
    if (P.state !== 'dead') G.meters = Math.max(G.meters, P.x / C.M2PX);
    G.score = Math.round((G.bonus + Math.floor(G.meters) * 10) * G.diffCfg().scoreMult);

    if (G.state === 'running') {
      if (P.y > C.VOID_Y) { G.over('fell'); return; }
      milestones();
      checkpoints();
      nearMiss();
    }

    // vitrine: se o piloto se enrolar, reposiciona discretamente
    if (G.attract) {
      const gy = W.groundYAt(P.x + 260);
      if (P.y > C.VOID_Y - 60 || P.x - E.frontX() < 320) {
        P.reset(P.x + 300, (gy === null ? -60 : gy - 40));
      }
    }

    // combo expira
    if (G.combo > 1 && G.time > G.comboUntil) G.combo = 1;

    // decaimentos
    G.hitFlash = Math.max(0, G.hitFlash - 0.04);
    G.shakeAmt *= 0.88;
    updateParts();
    updateFloats();
    for (const who of ['player', 'eraser'])
      if (G.bubbles[who] && G.time > G.bubbles[who].until) G.bubbles[who] = null;
    if (G.banner && G.time - G.banner.born > 2200) G.banner = null;
  };

  // ---------------------------------------------------------------- coleta
  function collectPickups(P) {
    for (const k of UD.world.pickups) {
      if (k.taken) continue;
      if (U.dist(P.x, P.y, k.x, k.y) > 36) continue;
      k.taken = true;
      if (k.type === 'ink') {
        UD.ink.meter = Math.min(C.INK.max, UD.ink.meter + 30);
        UD.audio.play('ink');
        G.spawnCrumbs(k.x, k.y, 4, 'ink');
        G.float(k.x, k.y - 20, '+💧');
      } else {
        G.stars++;
        UD.audio.play('star');
        G.addScore(25);
        G.bumpCombo(1);
        G.spawnCrumbs(k.x, k.y, 5, 'star');
        G.float(k.x, k.y - 20, '+' + 25 * G.combo + ' ★');
      }
    }
  }

  // ---------------------------------------------------------------- marcos & near-miss
  function milestones() {
    if (G.meters >= G.nextMilestone) {
      G.banner = { text: UD.t('banner.m', { m: G.nextMilestone }), born: G.time };
      UD.audio.play('milestone');
      G.addScore(100);
      if (Math.random() < 0.5) G.say('player', U.pick(UD.t('bub.milestone').split('|')));
      G.nextMilestone += 500;
    }
    const b = UD.world.biomeIdx(UD.player.x);
    if (b !== G.lastBiome) {
      G.lastBiome = b;
      const name = UD.t('biome.' + C.PALETTES[b].id);
      G.banner = { text: UD.t('banner.biome', { b: name }), born: G.time };
      UD.audio.play('milestone');
    }
  }

  function nearMiss() {
    if (G.meters < 15) return;   // parado na largada não vale coragem
    const gap = UD.player.x - UD.eraser.frontX();
    const NM = C.NEARMISS;
    if (gap < NM.gap && gap > 0) {
      if (!G.nearSince) G.nearSince = G.time;
      if (G.time - G.nearSince > NM.holdMs && G.time > G.nearCooldownUntil) {
        const pts = NM.bonus * G.combo;
        G.addScore(NM.bonus);
        G.bumpCombo(1);
        G.banner = { text: UD.t('banner.near', { n: pts }), born: G.time };
        G.say('player', U.pick(UD.t('bub.near').split('|')));
        UD.audio.play('near');
        G.nearCooldownUntil = G.time + NM.cooldownMs;
        G.nearSince = G.time;
      }
    } else G.nearSince = 0;
  }

  // ---------------------------------------------------------------- eventos de jogo
  G.addScore = function (n) { G.bonus += n * G.combo; };

  G.bumpCombo = function (inc) {
    if (inc > 0) {
      G.combo = Math.min(C.COMBO.max, G.combo + inc);
      G.maxCombo = Math.max(G.maxCombo, G.combo);
    }
    G.comboUntil = G.time + C.COMBO.windowMs;
  };

  G.onStomp = function (e) {
    UD.player.vy = -8.8;
    UD.player.stretch = 1;
    G.stomps++;
    G.addScore(50);
    G.bumpCombo(1);
    G.spawnPop(e.x, e.y);
    G.float(e.x, e.y - 24, '+' + 50 * G.combo);
    UD.audio.play('stomp');
  };

  G.onEnemyFried = function (e) {
    G.addScore(75);
    G.bumpCombo(1);
    G.spawnPop(e.x, e.y);
    G.float(e.x, e.y - 24, '+' + 75 * G.combo + ' 🔥');
    UD.audio.play('pop');
  };

  G.onPlayerHit = function () {
    G.combo = 1;
    G.hitFlash = 1;
    G.shake(4);
    G.spawnCrumbs(UD.player.x, UD.player.y, 6, 'ink');
  };

  G.noInkFx = function () {
    if (G.time - G._noInkAt < 1400) return;
    G._noInkAt = G.time;
    UD.audio.play('noink');
    G.say('player', UD.t('bub.noInk'));
  };

  G.setPen = function (idx) {
    const p = C.PENS[idx];
    if (!p) return;
    if (!UD.save.penUnlocked(p.id)) {
      U.showToast(UD.t('pen.locked', { m: Math.max(0, Math.ceil(p.unlock - UD.save.data.totalM)) }) + ' — ' + p.ico);
      return;
    }
    if (UD.ink.pen === p.id) return;
    UD.ink.pen = p.id;
    UD.save.data.pen = p.id;
    UD.save.commit();
    UD.audio.play('pen');
    U.showToast(UD.t('toast.pen', { name: p.ico + ' ' + UD.t('pen.' + p.id + '.name') }));
    if (UD.main) UD.main.refreshMenu();
  };

  G.setPet = function (id) {
    if (id !== 'none' && !UD.save.petUnlocked(id)) {
      const p = C.PETS.find(p => p.id === id);
      if (p) U.showToast(UD.t('toast.petLocked', {
        name: UD.t('pet.' + id + '.name'), n: Math.max(0, p.unlock - UD.save.data.totalStars)
      }));
      return;
    }
    UD.save.data.pet = id;
    UD.save.commit();
    UD.pet.reset();
    UD.audio.play('pen');
    if (id !== 'none') {
      const p = C.PETS.find(p => p.id === id);
      U.showToast(UD.t('toast.pet', { name: (p ? p.ico + ' ' : '') + UD.t('pet.' + id + '.name') }));
    }
    if (UD.main) UD.main.refreshMenu();
  };

  G.setDiff = function (id) {
    if (!C.DIFF[id]) return;
    UD.save.data.diff = id;
    UD.save.commit();
    UD.audio.play('ui');
    if (UD.main) UD.main.refreshMenu();
  };

  G.toggleSound = function () {
    const S = UD.save.data;
    S.sound = !S.sound;
    UD.save.commit();
    UD.audio.setSound(S.sound);
    if (UD.main) UD.main.refreshMenu();
  };
  G.toggleMusic = function () {
    const S = UD.save.data;
    S.music = !S.music;
    UD.save.commit();
    UD.audio.setMusic(S.music);
    if (S.music && G.state === 'running') UD.audio.musicStart(); else if (!S.music) UD.audio.musicStop();
    if (UD.main) UD.main.refreshMenu();
  };

  G.say = function (who, text) {
    G.bubbles[who] = { text, until: G.time + 2400 + text.length * 40 };
  };

  G.shake = function (a) { G.shakeAmt = Math.max(G.shakeAmt, a); };

  // ---------------------------------------------------------------- partículas
  G.spawnCrumbs = function (x, y, n, type) {
    for (let i = 0; i < n; i++) {
      G.parts.push({
        x, y, vx: (Math.random() - 0.5) * 5, vy: -Math.random() * 4 - 1,
        g: 0.28, life: 0, max: 30 + Math.random() * 26,
        type, ang: Math.random() * 7, spin: (Math.random() - 0.5) * 0.4,
        len: 2.5 + Math.random() * 4
      });
    }
    trimParts();
  };
  G.spawnDust = function (x, y, n) {
    for (let i = 0; i < n; i++) {
      G.parts.push({
        x: x + (Math.random() - 0.5) * 16, y,
        vx: (Math.random() - 0.5) * 2.4, vy: -Math.random() * 1.6,
        g: 0.02, life: 0, max: 16 + Math.random() * 12,
        type: 'dust', ang: 0, spin: 0, len: 2 + Math.random() * 3
      });
    }
    trimParts();
  };
  G.spawnPop = function (x, y) {
    for (let i = 0; i < 9; i++) {
      const a = (i / 9) * Math.PI * 2;
      G.parts.push({
        x, y, vx: Math.cos(a) * (2 + Math.random() * 3), vy: Math.sin(a) * 3 - 2,
        g: 0.24, life: 0, max: 24 + Math.random() * 16,
        type: 'ink', ang: a, spin: (Math.random() - 0.5) * 0.5,
        len: 3 + Math.random() * 5
      });
    }
    trimParts();
  };
  function trimParts() {
    if (G.parts.length > 380) G.parts.splice(0, G.parts.length - 380);
  }
  function updateParts() {
    for (let i = G.parts.length - 1; i >= 0; i--) {
      const p = G.parts[i];
      p.life++;
      p.vy += p.g;
      p.x += p.vx; p.y += p.vy; p.ang += p.spin;
      if (p.life > p.max) G.parts.splice(i, 1);
    }
  }

  G.float = function (x, y, txt) {
    G.floats.push({ x, y, txt, born: G.time });
    if (G.floats.length > 12) G.floats.shift();
  };
  function updateFloats() {
    for (let i = G.floats.length - 1; i >= 0; i--)
      if (G.time - G.floats[i].born > 950) G.floats.splice(i, 1);
  }

  UD.game = G;
})();

// UNDRAWN — boot, loop principal (passo fixo + slow-mo) e fiação do menu
(function () {
  const U = UD.U, C = UD.C;
  const M = {};

  // ---------------------------------------------------------------- menu
  M.refreshMenu = function () {
    const S = UD.save.data, t = UD.t;

    const bl = document.getElementById('best-line');
    bl.innerHTML = S.plays > 0
      ? t('menu.best', { m: S.bestM, pts: S.best, total: Math.floor(S.totalM), stars: S.totalStars })
      : t('menu.firstRun');

    const seed = UD.game.dateSeed();
    const dBest = S.daily[seed] || 0;
    document.getElementById('btn-daily').innerHTML =
      t('menu.daily') + (dBest ? ' <small>· ' + t('menu.dailyBest', { m: dBest }) + '</small>' : '');

    // dificuldade
    const dBox = document.getElementById('diffs');
    dBox.innerHTML = '';
    for (const id of ['easy', 'standard', 'hardcore']) {
      const d = C.DIFF[id];
      const el = document.createElement('button');
      el.className = 'pen-card' + ((S.diff || 'standard') === id ? ' sel' : '');
      el.innerHTML = `<span class="p-ico">${d.ico}</span><span class="p-name">${t('diff.' + id)}</span><span class="p-desc">${t('diff.' + id + '.d')}</span>`;
      el.onclick = () => UD.game.setDiff(id);
      dBox.appendChild(el);
    }

    // pets
    const petBox = document.getElementById('pets');
    petBox.innerHTML = '';
    const pets = [{ id: 'none', ico: '—', unlock: 0 }].concat(C.PETS);
    for (const p of pets) {
      const unlocked = UD.save.petUnlocked(p.id);
      const sel = ((S.pet || 'none') === p.id) && unlocked;
      const el = document.createElement('button');
      el.className = 'pen-card' + (sel ? ' sel' : '') + (unlocked ? '' : ' locked');
      if (unlocked) {
        el.innerHTML = `<span class="p-ico">${p.ico}</span><span class="p-name">${t('pet.' + p.id + '.name')}</span><span class="p-desc">${t('pet.' + p.id + '.desc')}</span>`;
        el.onclick = () => UD.game.setPet(p.id);
      } else {
        const frac = Math.min(1, S.totalStars / p.unlock);
        el.innerHTML = `<span class="p-ico">🔒</span><span class="p-name">${t('pet.' + p.id + '.name')}</span>` +
          `<span class="p-lock">${t('pet.lockedStars', { n: Math.max(0, p.unlock - S.totalStars) })}</span>` +
          `<div class="p-bar"><i style="width:${Math.round(frac * 100)}%"></i></div>`;
      }
      petBox.appendChild(el);
    }

    // cartas de caneta
    const box = document.getElementById('pens');
    box.innerHTML = '';
    C.PENS.forEach((p, i) => {
      const unlocked = UD.save.penUnlocked(p.id);
      const sel = (UD.save.data.pen === p.id) && unlocked;
      const el = document.createElement('button');
      el.className = 'pen-card' + (sel ? ' sel' : '') + (unlocked ? '' : ' locked');
      if (unlocked) {
        el.innerHTML = `<span class="p-ico">${p.ico}</span><span class="p-name">${t('pen.' + p.id + '.name')}</span><span class="p-desc">${t('pen.' + p.id + '.desc')}</span>`;
        el.onclick = () => { UD.game.setPen(i); M.refreshMenu(); };
      } else {
        const frac = Math.min(1, S.totalM / p.unlock);
        el.innerHTML = `<span class="p-ico">🔒</span><span class="p-name">${t('pen.' + p.id + '.name')}</span>` +
          `<span class="p-lock">${t('pen.locked', { m: Math.ceil(p.unlock - S.totalM) })}</span>` +
          `<div class="p-bar"><i style="width:${Math.round(frac * 100)}%"></i></div>`;
      }
      box.appendChild(el);
    });

    const sb = document.getElementById('btn-sound');
    sb.textContent = (S.sound ? '🔊 ' : '🔇 ') + t('menu.sound');
    sb.classList.toggle('off', !S.sound);
    const mb = document.getElementById('btn-music');
    mb.textContent = (S.music ? '🎵 ' : '🎵 ') + t('menu.music');
    mb.classList.toggle('off', !S.music);
  };

  function wire() {
    const $ = (id) => document.getElementById(id);
    $('btn-play').onclick = () => { UD.audio.play('ui'); UD.game.start('endless'); };
    $('btn-daily').onclick = () => { UD.audio.play('ui'); UD.game.start('daily'); };
    $('btn-resume').onclick = () => UD.game.pauseToggle();
    $('btn-p-restart').onclick = () => UD.game.start(UD.game.mode);
    $('btn-p-menu').onclick = () => UD.game.toMenu();
    $('btn-again').onclick = () => UD.game.start(UD.game.mode);
    $('btn-o-menu').onclick = () => UD.game.toMenu();
    $('btn-sound').onclick = () => UD.game.toggleSound();
    $('btn-music').onclick = () => UD.game.toggleMusic();

    $('btn-copy').onclick = () => {
      const G = UD.game;
      const txt = UD.t('over.copyText', { m: Math.floor(G.meters), pts: G.score });
      const done = () => U.showToast(UD.t('over.copied'));
      if (navigator.clipboard && navigator.clipboard.writeText)
        navigator.clipboard.writeText(txt).then(done, done);
      else {
        const ta = document.createElement('textarea');
        ta.value = txt; document.body.appendChild(ta);
        ta.select(); try { document.execCommand('copy'); } catch (e) {}
        ta.remove(); done();
      }
    };

    document.querySelectorAll('.lang-opt').forEach(b => {
      b.onclick = () => {
        UD.i18n.setLang(b.dataset.lang);
        UD.save.data.lang = b.dataset.lang;
        UD.save.commit();
        M.refreshMenu();
      };
    });

    // primeira interação em qualquer lugar libera o áudio
    addEventListener('pointerdown', () => UD.audio.unlock(), { once: true });
    addEventListener('keydown', () => UD.audio.unlock(), { once: true });
  }

  // ---------------------------------------------------------------- loop
  let last = performance.now(), acc = 0;
  function frame(now) {
    const G = UD.game;
    const dtReal = Math.min(50, now - last);
    last = now;

    // alvo do slow-mo: desenhar desacelera o mundo; a morte também
    let target = 1;
    if (G.state === 'running' && UD.ink.drawing) target = C.INK.slowmo;
    if (G.state === 'over') target = 0.45;
    G.timescale += (target - G.timescale) * (1 - Math.exp(-dtReal * 0.012));

    if (G.state !== 'paused') {
      acc += dtReal * G.timescale;
      let n = 0;
      while (acc >= C.STEP && n < 5) { G.step(); acc -= C.STEP; n++; }
      if (n === 5) acc = 0;    // trava de segurança (aba dormiu)
    }
    UD.render.draw(dtReal / 1000);
    requestAnimationFrame(frame);
  }

  // ---------------------------------------------------------------- boot
  function boot() {
    UD.save.load();
    const S = UD.save.data;
    const lang = S.lang || ((navigator.language || 'pt').toLowerCase().startsWith('pt') ? 'pt' : 'en');
    UD.i18n.setLang(lang);
    UD.audio.sound = S.sound;
    UD.audio.music = S.music;

    const cv = document.getElementById('game');
    UD.render.init(cv);
    UD.input.init(cv);
    UD.game.initAttract();
    wire();
    M.refreshMenu();
    requestAnimationFrame((t) => { last = t; requestAnimationFrame(frame); });
  }

  UD.main = M;
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
  else boot();
})();

</script>
</body>
</html>

Game Source: UNDRAWN

Creator: PlasmaRanger47

Libraries: none

Complexity: complex (4317 lines, 154.0 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: undrawn-plasmaranger47" to link back to the original. Then publish at arcadelab.ai/publish.