UNDRAWN
by PlasmaRanger474317 lines154.0 KB
<!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 · 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 · 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.