Загрузка данных


<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Капсулы — игра и редактор</title><style>
*{box-sizing:border-box}body{margin:0;background:#111a26;color:#edf4fc;font-family:system-ui,Arial,sans-serif}header{padding:17px 22px;display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;border-bottom:1px solid #304054}h1{font-size:21px;margin:0}h1 span{color:#ffd448}.tag{font-size:12px;color:#9cb0c7;margin-top:4px}button,select{font:inherit;color:#e9f1fb;background:#253448;border:1px solid #3b506a;border-radius:9px;padding:10px 14px;cursor:pointer}button:hover{background:#344963}button.active{background:#ffd448;color:#20262e;border-color:#ffd448}button.primary{background:#83e0bf;color:#142c28;border-color:#83e0bf}.actions{display:flex;gap:8px;flex-wrap:wrap}.layout{display:grid;grid-template-columns:240px 1fr;gap:18px;padding:18px;max-width:1450px;margin:auto}aside{background:#1a2737;border:1px solid #304157;border-radius:14px;padding:16px}h2{font-size:14px;margin:0 0 12px;color:#d1dfef}.tools{display:grid;gap:7px}.tools button{text-align:left;font-size:13px;padding:10px}.dot{display:inline-block;width:14px;height:14px;border-radius:4px;vertical-align:-2px;margin-right:9px}.hint{font-size:12px;color:#9bb0c9;line-height:1.7;margin:15px 0}.rule{border-top:1px solid #344356;margin:17px 0}.stage{position:relative;border:1px solid #3d5267;border-radius:14px;overflow:hidden;background:#92ccdf;box-shadow:0 15px 45px #0003}canvas{display:block;width:100%;aspect-ratio:16/10;touch-action:none}.hud{position:absolute;top:14px;left:14px;right:14px;display:flex;justify-content:space-between;pointer-events:none;gap:8px}.pill{background:#12293be6;padding:8px 12px;border:1px solid #ffffff25;border-radius:9px;font-size:12px;box-shadow:0 3px 9px #0001}.below{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-top:12px;font-size:12px;color:#9eafc4;flex-wrap:wrap}.touch{display:flex;gap:8px}.touch button{min-width:45px;user-select:none;touch-action:none}.status{margin-top:14px;color:#afc2d8;font-size:12px;min-height:35px;line-height:1.5}.win{display:none;position:absolute;left:50%;top:45%;transform:translate(-50%,-50%);text-align:center;background:#14293ef5;border:1px solid #6d9baf;border-radius:18px;padding:25px;width:min(85%,360px);box-shadow:0 16px 60px #0004}.win h2{font-size:24px;color:#ffe273}.win p{font-size:14px;color:#b8cbdd}kbd{background:#304157;border-radius:4px;padding:2px 5px;font-size:11px}.footer{font-size:11px;opacity:.65;margin-top:12px}@media(max-width:760px){.layout{grid-template-columns:1fr;padding:10px;gap:10px}aside{order:2}.tools{grid-template-columns:repeat(3,1fr)}.tools button{padding:9px 5px;font-size:11px}.hint{margin:8px 0}.rule{margin:10px 0}header{padding:12px}h1{font-size:18px}button{padding:8px 10px}canvas{aspect-ratio:16/11}.tag{font-size:11px}.dot{margin-right:4px;width:11px;height:11px}}
</style></head><body><header><div><h1>КАПСУЛЫ<span> / PLAYGROUND</span></h1><div class="tag">Жёлтая команда. Маленький мир. Большие прыжки.</div></div><div class="actions"><button id="play" class="primary">▶ Играть</button><button id="edit">✎ Редактор</button><button id="restart" title="Начать уровень заново">↻ Заново</button></div></header><main class="layout"><aside><h2>КОНСТРУКТОР УРОВНЯ</h2><div class="tools" id="tools"></div><p class="hint" id="modehint">Переключись в редактор, чтобы построить свой маршрут.</p><div class="rule"></div><h2>УПРАВЛЕНИЕ</h2><p class="hint"><kbd>A</kbd> <kbd>D</kbd> или <kbd>←</kbd> <kbd>→</kbd> — бег<br><kbd>W</kbd> <kbd>↑</kbd> <kbd>Пробел</kbd> — прыжок<br><kbd>Shift</kbd> — ускорение<br><kbd>R</kbd> — начать заново</p><div class="rule"></div><div class="actions"><button id="save">Сохранить</button><button id="load">Загрузить</button><button id="export">↓ JSON</button><button id="import">↑ JSON</button><button id="clear">Очистить</button><button id="demo">Пример</button></div><input type="file" id="file" accept="application/json,.json" hidden><div id="status" class="status" role="status">Доберись до флага и собери звёзды. Остальные капсулы бегают сами!</div><div class="footer">Уровень: 32 × 20 клеток • всё работает без сети</div></aside><section><div class="stage"><canvas id="game" width="960" height="600" tabindex="0" aria-label="Игра с жёлтыми человечками. Управление стрелками, прыжок пробелом."></canvas><div class="hud"><div class="pill" id="badge">▶ Режим игры</div><div class="pill" id="score">★ 0 / 0</div></div><div class="win" id="win"><h2>Финиш!</h2><button id="again" class="primary">Ещё раз</button><button id="build">Строить</button></div></div><div class="below"><span id="bottom">Прыгай по платформам и найди зелёный флаг.</span><div class="touch"><button data-key="ArrowLeft" aria-label="Влево">◀</button><button data-key="ArrowRight" aria-label="Вправо">▶</button><button data-key="Space" aria-label="Прыжок">Прыжок ↑</button></div></div></section></main><script>
'use strict';
function wsLog(...a){try{console.log('[WS]', new Date().toISOString().slice(11,19), ...a)}catch(e){}}
const initialHTML='<!doctype html>\n'+document.documentElement.outerHTML;
const canvas=document.getElementById('game'),ctx=canvas.getContext('2d'),S=30,$=id=>document.getElementById(id);let W=960,H=600,C=32,R=20;
const palette=[['block','Платформа','#5a7980'],['star','Звезда','#ffc947'],['npc','Человечек','#ffde45'],['spawn','Старт героя','#6d9cff'],['flag','Финиш','#72e8b2'],['erase','Ластик','#dd8796']];
let level,mode='play',tool='block',actors=[],stars=[],hero,won=false,collected=0,total=0,keys={},last=0,acc=0,paint=false,hover=null,visited=new Set(),time=0,jumpWas=false;
function base(){return {version:1,tiles:Array.from({length:R},()=>Array(C).fill(0)),spawn:{x:3,y:17},npcs:[{x:8,y:17},{x:14,y:17},{x:24,y:17}],stars:[{x:7,y:14},{x:12,y:11},{x:18,y:13},{x:24,y:10},{x:28,y:7}],flag:{x:29,y:17}}}
function demo(){level=base();for(let x=0;x<C;x++){level.tiles[18][x]=1;level.tiles[19][x]=1}[[5,15,5],[10,12,5],[16,14,5],[22,11,5],[27,8,4]].forEach(([x,y,n])=>{for(let i=0;i<n;i++)level.tiles[y][x+i]=1});reset()}
function actor(p,n){return {x:p.x*S+5,y:(p.y+1)*S-42,w:20,h:42,vx:0,vy:0,ground:false,dir:n%2?1:-1,phase:n*2,npc:n>0,spawn:{...p}}}
function reset(){keys={};jumpWas=false;collected=0;total=level.stars.length;stars=level.stars.map(p=>({...p,taken:false}));actors=[actor(level.spawn,0),...level.npcs.map((p,i)=>actor(p,i+1))];hero=actors[0];won=false;$('win').style.display='none';updateScore()}
function updateScore(){$('score').textContent='★ '+collected+' / '+total}
function setMode(m){mode=m;reset();$('play').classList.toggle('active',m==='play');$('edit').classList.toggle('active',m==='edit');$('badge').textContent=m==='play'?'▶ Режим игры':'✎ Редактор';$('modehint').textContent=m==='edit'?'Выбери объект и рисуй мышью или пальцем. Правая кнопка — стереть. Старт и финиш переносятся одним нажатием.':'Переключись в редактор, чтобы построить свой маршрут.';$('bottom').textContent=m==='edit'?'Клетка 30 px • изменения применятся при нажатии «Играть».':'Прыгай по платформам и найди зелёный флаг.';if(m==='play')canvas.focus({preventScroll:true})}
for(const [id,label,color] of palette){let b=document.createElement('button');b.innerHTML='<span class="dot" style="background:'+color+'"></span>'+label;b.className=id===tool?'active':'';b.onclick=()=>{tool=id;if(mode!=='edit')setMode('edit');[...$('tools').children].forEach((el,i)=>el.classList.toggle('active',palette[i][0]===tool))};$('tools').appendChild(b)}
$('play').onclick=()=>setMode('play');$('edit').onclick=()=>setMode('edit');$('restart').onclick=reset;$('again').onclick=reset;$('build').onclick=()=>setMode('edit');
function status(s){$('status').textContent=s}
$('save').onclick=()=>{try{localStorage.setItem('capsule-level-v1',JSON.stringify(level));status('Уровень сохранён в этом браузере.')}catch(e){status('Хранилище недоступно. Скачай уровень кнопкой ↓ JSON.')}};
function validate(o){const point=p=>p&&Number.isInteger(p.x)&&Number.isInteger(p.y)&&p.x>=0&&p.x<C&&p.y>=0&&p.y<R;if(!o||!Array.isArray(o.tiles)||o.tiles.length!==R||!o.tiles.every(r=>Array.isArray(r)&&r.length===C&&r.every(v=>v===0||v===1))||!point(o.spawn)||!point(o.flag)||!Array.isArray(o.npcs)||o.npcs.length>100||!o.npcs.every(point)||!Array.isArray(o.stars)||o.stars.length>640||!o.stars.every(point))throw Error('Некорректный формат уровня');return {version:1,tiles:o.tiles,spawn:o.spawn,flag:o.flag,npcs:o.npcs,stars:o.stars}}
$('load').onclick=()=>{try{let raw=localStorage.getItem('capsule-level-v1');if(!raw){status('Сохранения пока нет.');return}level=validate(JSON.parse(raw));reset();status('Сохранённый уровень загружен.')}catch(e){status('Не удалось загрузить уровень. Используй ↑ JSON.')}};
$('export').onclick=()=>{let u=URL.createObjectURL(new Blob([JSON.stringify(level,null,2)],{type:'application/json'})),a=document.createElement('a');a.href=u;a.download='capsule-level.json';a.click();setTimeout(()=>URL.revokeObjectURL(u),1500);status('Файл уровня подготовлен для скачивания.')};$('import').onclick=()=>$('file').click();$('file').onchange=async e=>{try{let f=e.target.files[0];if(!f)return;if(f.size>200000)throw Error();level=validate(JSON.parse(await f.text()));setMode('edit');status('Уровень импортирован. Нажми «Играть».')}catch(err){status('Ошибка: нужен JSON уровня размером 32 × 20.')}e.target.value=''};
$('clear').onclick=()=>{if(!confirm('Очистить уровень? Несохранённые изменения пропадут.'))return;level=base();level.npcs=[];level.stars=[];for(let x=0;x<C;x++)level.tiles[18][x]=level.tiles[19][x]=1;setMode('edit');status('Чистая площадка готова. Нижний пол оставлен для удобства.')};$('demo').onclick=()=>{if(confirm('Вернуть пример уровня?')){demo();status('Пример восстановлен.')}};
function tile(x,y){if(x<0||x>=C||y<0)return 1;if(y>=R)return 0;return level.tiles[y][x]}
function collide(a){let out=[];for(let y=Math.floor(a.y/S);y<=Math.floor((a.y+a.h-.01)/S);y++)for(let x=Math.floor(a.x/S);x<=Math.floor((a.x+a.w-.01)/S);x++)if(tile(x,y))out.push({x:x*S,y:y*S});return out}
function move(a,dt){a.x+=a.vx*dt;let hits=collide(a);if(hits.length){if(a.vx>0)a.x=Math.min(...hits.map(t=>t.x))-a.w;else if(a.vx<0)a.x=Math.max(...hits.map(t=>t.x+S));if(a.npc)a.dir*=-1;a.vx=0}a.vy+=1150*dt;a.y+=a.vy*dt;a.ground=false;hits=collide(a);if(hits.length){if(a.vy>0){a.y=Math.min(...hits.map(t=>t.y))-a.h;a.ground=true}else if(a.vy<0)a.y=Math.max(...hits.map(t=>t.y+S));a.vy=0}if(a.y>H+120){let b=actor(a.spawn,a.npc?1:0);Object.assign(a,b);if(!a.npc)status('Упс! Герой вернуля на старт.')}if(Math.abs(a.vx)>5){a.phase+=dt*Math.abs(a.vx)/20;a.dir=Math.sign(a.vx)}}
function tick(dt){time+=dt;if(mode!=='play'||won)return;let j=!!(keys.Space||keys.ArrowUp||keys.KeyW);for(const a of actors){if(a.npc){let front=a.dir>0?a.x+a.w+7:a.x-7;if(a.ground&&!tile(Math.floor(front/S),Math.floor((a.y+a.h+5)/S)))a.dir*=-1;a.vx=a.dir*75}else{let d=(keys.ArrowRight||keys.KeyD?1:0)-(keys.ArrowLeft||keys.KeyA?1:0);a.vx=d*((keys.ShiftLeft||keys.ShiftRight)?280:185);if(j&&!jumpWas&&a.ground){a.vy=-480;a.ground=false}}move(a,dt)}jumpWas=j;for(const s of stars)if(!s.taken&&Math.abs(hero.x+10-(s.x+.5)*S)<24&&Math.abs(hero.y+21-(s.y+.5)*S)<33){s.taken=true;collected++;updateScore()}let f=level.flag;if(Math.abs(hero.x+10-(f.x+.5)*S)<24&&Math.abs(hero.y+21-(f.y*S-2))<45){won=true;$('result').textContent='Звёзд собрано: '+collected+' из '+total+'. '+(collected===total?'Идеальное прохождение!':'Попробуй собрать их все!');$('win').style.display='block'}}
function rr(x,y,w,h,r,fill){ctx.fillStyle=fill;ctx.beginPath();ctx.roundRect(x,y,w,h,r);ctx.fill()}
function cloud(x,y,k){ctx.fillStyle='#ffffff77';ctx.beginPath();ctx.ellipse(x,y,44*k,14*k,0,0,7);ctx.ellipse(x-17*k,y-9*k,19*k,18*k,0,0,7);ctx.ellipse(x+10*k,y-15*k,24*k,24*k,0,0,7);ctx.fill()}
function star(x,y){ctx.save();ctx.translate(x,y);ctx.rotate(Math.sin(time*2+x)*.09);ctx.fillStyle='#ffda49';ctx.strokeStyle='#bd8c25';ctx.lineWidth=2;ctx.beginPath();for(let i=0;i<10;i++){let a=i*Math.PI/5-Math.PI/2,r=i%2?6:13;ctx.lineTo(Math.cos(a)*r,Math.sin(a)*r)}ctx.closePath();ctx.fill();ctx.stroke();ctx.restore()}
function person(a,ghost=false){let x=a.x+10,y=a.y,run=Math.abs(a.vx)>5&&a.ground,swing=run?Math.sin(a.phase)*8:Math.sin(time*2+a.phase)*1.4;ctx.save();if(ghost)ctx.globalAlpha=.8;ctx.fillStyle='#1c526d20';ctx.beginPath();ctx.ellipse(x,a.y+44,14,4,0,0,7);ctx.fill();ctx.lineCap='round';ctx.lineJoin='round';const limb=(p,c,w)=>{ctx.strokeStyle=c;ctx.lineWidth=w;ctx.beginPath();ctx.moveTo(...p[0]);p.slice(1).forEach(v=>ctx.lineTo(...v));ctx.stroke()};limb([[x-5,y+28],[x-6-swing*.4,y+35],[x-6-swing,y+40]],'#cda42e',5);limb([[x+5,y+28],[x+6+swing*.4,y+35],[x+6+swing,y+40]],'#f7c832',5);limb([[x-9,y+15],[x-14,y+23+swing*.3],[x-15-swing*.5,y+28+swing*.2]],'#e6b62d',5);limb([[x+9,y+15],[x+14,y+22-swing*.3],[x+15+swing*.5,y+27-swing*.2]],'#ffdc4e',5);rr(x-10,y,20,32,10,'#ffda43');rr(x-7,y+3,5,17,3,'#ffed8c');ctx.fillStyle='#2a3540';ctx.beginPath();ctx.arc(x-3+a.dir*1.5,y+11,1.7,0,7);ctx.arc(x+4+a.dir*1.5,y+11,1.7,0,7);ctx.fill();ctx.strokeStyle='#765321';ctx.lineWidth=1.5;ctx.beginPath();ctx.arc(x+1,y+16,3,0,Math.PI);ctx.stroke();if(!a.npc){rr(x-10,y+23,20,5,2,'#497cc8');ctx.fillStyle='#426cab';ctx.beginPath();ctx.moveTo(x,y-7);ctx.lineTo(x-5,y-14);ctx.lineTo(x+5,y-14);ctx.fill()}ctx.restore()}
function flag(p){let x=p.x*S+15,y=(p.y+1)*S;rr(x-2,y-63,4,63,2,'#eaf8f7');ctx.fillStyle='#46be8e';ctx.beginPath();ctx.moveTo(x+2,y-62);ctx.quadraticCurveTo(x+15,y-67+Math.sin(time*3)*3,x+28,y-58);ctx.lineTo(x+28,y-37);ctx.quadraticCurveTo(x+15,y-46+Math.sin(time*3)*3,x+2,y-40);ctx.fill();rr(x-9,y-4,18,5,2,'#397b70')}
function draw(){let g=ctx.createLinearGradient(0,0,0,H);g.addColorStop(0,'#8dc5e0');g.addColorStop(1,'#d8efe0');ctx.fillStyle=g;ctx.fillRect(0,0,W,H);ctx.fillStyle='#fff0ad';ctx.beginPath();ctx.arc(810,88,34,0,7);ctx.fill();cloud(150,102,1.1);cloud(465,68,.9);cloud(685,153,.7);ctx.fillStyle='#81b9ba55';ctx.beginPath();ctx.moveTo(0,480);for(let x=0;x<=W;x+=10)ctx.lineTo(x,410+Math.sin(x/160)*50+Math.cos(x/82)*20);ctx.lineTo(W,H);ctx.lineTo(0,H);ctx.fill();for(let y=0;y<R;y++)for(let x=0;x<C;x++)if(level.tiles[y][x]){let px=x*S,py=y*S;ctx.fillStyle='#546e78';ctx.fillRect(px,py,S,S);ctx.fillStyle='#617f86';ctx.fillRect(px+1,py+2,S-2,S-3);if(!tile(x,y-1)){ctx.fillStyle='#63b994';ctx.fillRect(px,py,S,6);ctx.fillStyle='#91dab0';ctx.fillRect(px,py,S,2)}ctx.fillStyle='#46616d';ctx.fillRect(px+8,py+19,6,2)}for(const s of(mode==='edit'?level.stars:stars))if(!s.taken)star(s.x*S+15,s.y*S+15+(mode==='play'?Math.sin(time*3+s.x)*3:0));flag(level.flag);if(mode==='edit'){person(actor(level.spawn,0),true);level.npcs.forEach((p,i)=>person(actor(p,i+1),true));ctx.strokeStyle='#26495c24';ctx.lineWidth=1;ctx.beginPath();for(let x=0;x<=W;x+=S){ctx.moveTo(x,0);ctx.lineTo(x,H)}for(let y=0;y<=H;y+=S){ctx.moveTo(0,y);ctx.lineTo(W,y)}ctx.stroke();if(hover){ctx.fillStyle=tool==='erase'?'#ef6a8355':'#ffffff66';ctx.fillRect(hover.x*S,hover.y*S,S,S);ctx.strokeStyle=tool==='erase'?'#c3405a':'#fff';ctx.strokeRect(hover.x*S+1,hover.y*S+1,S-2,S-2)}}else actors.slice().reverse().forEach(a=>person(a))}
function cell(e){let r=canvas.getBoundingClientRect();return {x:Math.floor((e.clientX-r.left)/r.width*C),y:Math.floor((e.clientY-r.top)/r.height*R)}}
function apply(e){const p=cell(e);if(p.x<0||p.x>=C||p.y<0||p.y>=R)return;let t=e.buttons===2?'erase':tool,k=p.x+','+p.y;if(visited.has(k))return;visited.add(k);const same=q=>q.x===p.x&&q.y===p.y;if(t==='erase'){level.tiles[p.y][p.x]=0;if(level.tileRot)delete level.tileRot[p.x+','+p.y];if(level.tileRot)delete level.tileRot[p.x+','+p.y];level.stars=level.stars.filter(q=>!same(q));level.npcs=level.npcs.filter(q=>!same(q))}else if(t==='block'){if(same(level.spawn)||same(level.flag))return;level.tiles[p.y][p.x]=1;level.stars=level.stars.filter(q=>!same(q));level.npcs=level.npcs.filter(q=>!same(q))}else{level.tiles[p.y][p.x]=0;if(t==='spawn'||t==='flag'){level[t]={...p};if(p.y>0)level.tiles[p.y-1][p.x]=0}else{let arr=t==='npc'?level.npcs:level.stars;if(!arr.some(same)&&arr.length<(t==='npc'?100:640))arr.push({...p});if(t==='npc'&&p.y>0)level.tiles[p.y-1][p.x]=0}}reset()}
canvas.oncontextmenu=e=>e.preventDefault();canvas.onpointerdown=e=>{canvas.focus({preventScroll:true});if(mode!=='edit')return;e.preventDefault();paint=true;visited.clear();canvas.setPointerCapture(e.pointerId);apply(e)};canvas.onpointermove=e=>{hover=cell(e);if(paint&&mode==='edit')apply(e)};canvas.onpointerup=()=>{paint=false;visited.clear()};canvas.onpointercancel=()=>paint=false;canvas.onpointerleave=()=>hover=null;
window.addEventListener('keydown',e=>{if(['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName))return;if(['Space','ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code))e.preventDefault();keys[e.code]=true;if(e.code==='KeyR'&&!e.repeat)reset()});window.addEventListener('keyup',e=>keys[e.code]=false);window.addEventListener('blur',()=>{keys={};paint=false});document.addEventListener('visibilitychange',()=>{keys={};last=0;acc=0});document.querySelectorAll('[data-key]').forEach(b=>{b.onpointerdown=e=>{e.preventDefault();b.setPointerCapture(e.pointerId);keys[b.dataset.key]=true};b.onpointerup=b.onpointercancel=()=>keys[b.dataset.key]=false});
// Playground 2: campaign, persistent workshop and equipment.
const clone=o=>JSON.parse(JSON.stringify(o));
const defs=[['block','▦ Камень',1],['spikes','▲ Шипы',2],['lava','≋ Лава',3],['spring','↟ Пружина',4],['crumble','▧ Хрупкий',5],['ice','❄ Лёд',6],['belt','» Конвейер',7],['moving','↔ Платформа',8],['lift','↕ Лифт',9],['saw','✹ Пила',10],['ramp','◤ Склон',15],['star','★ Звезда',0],['enemy','● Враг',0],['npc','☻ Друг',0],['checkpoint','⚑ Чекпоинт',0],['spawn','◎ Старт',0],['flag','⚐ Финиш',0],['erase','⌫ Ластик',0]];
const items=[{id:'double',name:'Двойной прыжок',icon:'↟',price:100,desc:'Второй прыжок в воздухе. Нажми прыжок ещё раз.',kind:'power'},{id:'dash',name:'Рывок',icon:'➜',price:140,desc:'E — рывок вперёд. Перезарядка 1,4 секунды.',kind:'power'},{id:'shield',name:'Защитная сфера',icon:'◉',price:180,desc:'Спасает от одного удара за жизнь. Не спасает от падения.',kind:'power'},{id:'magnet',name:'Магнит звёзд',icon:'✦',price:100,desc:'Собирает звёзды в радиусе трёх клеток.',kind:'power'},{id:'classic',name:'Классика',icon:'☻',price:0,desc:'Жёлтая капсула с синим поясом.',kind:'outfit'},{id:'hoodie',name:'Мятное худи',icon:'♧',price:70,desc:'Мятная куртка и белые кроссовки.',kind:'outfit'},{id:'ninja',name:'Ночной бегун',icon:'◆',price:110,desc:'Тёмный костюм и коралловая повязка.',kind:'outfit'},{id:'space',name:'Космокапсула',icon:'◎',price:150,desc:'Белый скафандр с прозрачным шлемом.',kind:'outfit'},{id:'crown',name:'Королевский',icon:'♛',price:200,desc:'Золотая корона и фиолетовый костюм.',kind:'outfit'}];
let profile={coins:120,owned:['classic'],outfit:'classic',powers:[],done:{},claims:{},custom:[],draft:null};let storageOK=true;
try{const raw=JSON.parse(localStorage.getItem('capsules-world-v2'));if(raw&&Array.isArray(raw.custom)&&Array.isArray(raw.owned))profile={...profile,...raw};}catch(e){storageOK=false}
function persist(){try{localStorage.setItem('capsules-world-v2',JSON.stringify(profile));storageOK=true}catch(e){storageOK=false}wallet();return storageOK}
function wallet(){$('wallet').textContent='★ '+profile.coins;$('storageNote').textContent=storageOK?'Прогресс, покупки и уровни сохраняются в этом браузере.':'Хранилище недоступно! Скачивай свои уровни в JSON.'}
const style=document.createElement('style');style.textContent=`body{background:#101925}header{background:#152131}.layout{max-width:1500px;grid-template-columns:275px 1fr}.layout.hidden,.hidden{display:none!important}header h1{letter-spacing:1px}.menu{max-width:1240px;margin:0 auto;padding:28px 22px 45px}.eyebrow{font-size:11px;letter-spacing:3px;color:#7cdbbb;font-weight:700}.herohead{display:flex;align-items:center;justify-content:space-between;gap:20px;margin:18px 0 25px}.herohead h2{font-size:clamp(28px,4vw,48px);color:#fff;line-height:1.1;margin:12px 0}.herohead p{color:#9cb0c7;line-height:1.7;max-width:540px}.mascot{width:160px;min-width:130px;height:175px;position:relative;background:radial-gradient(ellipse,#ffd44818,transparent 70%)}.caps{position:absolute;left:48px;top:14px;width:68px;height:113px;border-radius:38px;background:linear-gradient(100deg,#ffed8c,#ffce37);transform:rotate(8deg);box-shadow:0 15px 40px #0004}.caps:before{content:'••';font-size:28px;letter-spacing:10px;position:absolute;top:10px;left:18px;color:#283444}.caps:after{content:'';position:absolute;left:0;top:82px;width:68px;height:15px;background:#75d7bb}.mascot i{position:absolute;width:12px;height:49px;background:#ffd448;border-radius:12px;top:113px;left:63px;transform:rotate(20deg)}.mascot i:last-child{left:102px;transform:rotate(-24deg)}.tabs{display:flex;gap:8px;flex-wrap:wrap;margin:22px 0}.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:14px}.card{border:1px solid #344357;background:#1a2737;border-radius:16px;padding:18px;position:relative;overflow:hidden}.card h3{margin:12px 0 8px;font-size:17px}.card p{font-size:12px;color:#9eb2c8;line-height:1.65;min-height:40px}.card .num{font-size:32px;font-weight:800;color:#ffd448}.card .meta{font-size:11px;color:#7cdbbb}.card button{font-size:12px}.card .actions{margin-top:15px}.card .mini{height:42px;border-bottom:7px solid #638991;display:flex;align-items:flex-end;gap:9px;margin:15px 0}.mini b{width:18%;background:#77c9a2;height:8px}.mini b:nth-child(2){height:24px}.mini b:nth-child(3){height:17px}.sectiontitle{font-size:22px;color:#eff5fc;margin:26px 0 8px}.subtext{font-size:13px;color:#9fb0c6;line-height:1.7}.shopicon{font-size:40px;color:#ffd448;height:54px}.shopcard.equipped{border-color:#75d7bb;background:#203836}.shopcard button{width:100%}button:disabled{opacity:.45;cursor:not-allowed}.balance{background:#ffd44815!important;color:#ffdf67!important;border-color:#ffd44844!important;font-weight:700}.tools{grid-template-columns:1fr 1fr;gap:5px}.tools button{font-size:11px;padding:9px 6px}.editorline{display:flex;gap:6px;margin-bottom:8px}.editorline input,.editorline select{min-width:0;width:100%;background:#111d2c;border:1px solid #3b506a;color:#edf4fc;border-radius:8px;padding:9px;font:inherit;font-size:12px}.compact button{font-size:11px;padding:8px}.statbox{padding:14px;border-radius:12px;background:#111e2d;font-size:13px;line-height:1.9;color:#adbed1;margin-bottom:12px}.touch{flex-wrap:wrap}.notice{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#ecf8ed;color:#19372d;border-radius:12px;padding:12px 20px;box-shadow:0 10px 45px #0005;z-index:100;max-width:90%;font-size:13px;pointer-events:none}.hud{top:10px;left:10px;right:10px}.pill{font-size:11px}.levelstrip{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;gap:8px}.levelstrip strong{font-size:14px}.win{z-index:3}.win .actions{justify-content:center}.stage{border-radius:18px}#editpanel h2{margin-top:14px}#status{min-height:24px}.touch button{font-size:12px}.menufooter{border-top:1px solid #2e3e50;padding-top:16px;margin-top:25px;font-size:12px;color:#8ba2b9}@media(max-width:760px){.layout{grid-template-columns:1fr}.menu{padding:20px 12px}.herohead{gap:0}.mascot{transform:scale(.8);min-width:110px;width:110px}.cards{grid-template-columns:repeat(auto-fill,minmax(155px,1fr))}.card{padding:14px}.tools{grid-template-columns:repeat(3,1fr)}.herohead p{font-size:12px}.layout aside{order:2}.hud{gap:4px}.pill{padding:6px}.herohead h2{font-size:30px}}`;
document.head.appendChild(style);
document.querySelector('header').innerHTML='<div><h1>КАПСУЛЫ<span> / WORLD</span></h1><div class="tag">Бегай. Создавай. Становись круче.</div></div><div class="actions"><button id="home">⌂ Меню</button><button id="shopnav">Магазин</button><button id="editnav">✎ Редактор</button><button id="wallet" class="balance"></button></div>';
const menu=document.createElement('section');menu.className='menu';menu.id='menu';document.querySelector('main').before(menu);
const layout=document.querySelector('main');layout.classList.add('hidden');
document.querySelector('aside').innerHTML='<div id="playpanel"><h2>ТВОЯ КАПСУЛА</h2><div id="loadout" class="statbox"></div><button id="quickshop">Магазин и гардероб</button><div class="rule"></div><h2>УПРАВЛЕНИЕ</h2><p class="hint">A / D или ← / → — бег<br>W / ↑ / пробел — прыжок<br>Shift — ускорение • E — рывок<br>R — заново • Esc — меню<br><br>Прыгай врагам на голову. Избегай шипов, лавы и пил. Зелёные флажки сохраняют точку возвращения.</p><div class="statbox">★ Звезда = 5 монет<br>⚐ Первый финиш = 40 монет<br><span class="hint">Награда за каждую звезду и финиш выдаётся один раз на уровень. В своих уровнях — игра без наград.</span></div></div><div id="editpanel" class="hidden"><h2>МАСТЕРСКАЯ</h2><div class="editorline"><input id="levelname" maxlength="48" placeholder="Название уровня" aria-label="Название уровня"></div><div class="editorline"><select id="brush" aria-label="Режим кисти"><option value="pen">Кисть</option><option value="rect">Прямоугольник</option><option value="fill">Заливка блоков</option></select></div><div class="actions compact"><button id="undo">↶ Отмена</button><button id="redo">↷ Повтор</button><button id="grid">▦ Сетка</button><button id="rotate" title="Повернуть склон">↻ Повернуть</button></div><h2>БЛОКИ И ОБЪЕКТЫ</h2><div class="tools" id="tools2"></div><div class="rule"></div><div class="actions compact"><button id="save2" class="primary">Сохранить</button><button id="savecopy">+ Копия</button><button id="test">▶ Тест</button><button id="newlevel">Новый</button><button id="export2">↓ JSON</button><button id="import2">↑ JSON</button><button id="clear2">Очистить</button></div><input id="file2" type="file" accept=".json,application/json" hidden><p class="hint">Рисуй мышью или пальцем. ПКМ — ластик. Ctrl+Z / Ctrl+Y — история. Черновик сохраняется автоматически. Размер: 32 × 20 клеток.</p></div><div id="status" class="status" role="status"></div><div id="storageNote" class="footer"></div>';
const strip=document.createElement('div');strip.className='levelstrip';strip.innerHTML='<strong id="leveltitle"></strong><div class="actions compact"><button id="backedit" class="hidden">✎ К редактору</button><button id="restart2">↻ Заново</button></div>';document.querySelector('.stage').before(strip);
$('win').innerHTML='<h2>Финиш!</h2><div class="actions"><button id="next" class="primary">Следующий →</button><button id="again">↻ Ещё раз</button><button id="finishmenu">Меню</button></div>';
document.querySelector('.touch').innerHTML='<button data-key="ArrowLeft">◀</button><button data-key="ArrowRight">▶</button><button data-key="ShiftLeft">Бег</button><button data-key="Space">Прыжок ↑</button><button data-key="KeyE">Рывок E</button>';
let toastTimer;function toast(text){let el=$('toast');if(!el){el=document.createElement('div');el.id='toast';el.className='notice';document.body.appendChild(el)}el.textContent=text;el.hidden=false;clearTimeout(toastTimer);toastTimer=setTimeout(()=>el.hidden=true,3000)}
function blank(name='Мой уровень'){return {version:2,name,tiles:Array.from({length:R},(_,y)=>Array(C).fill(y>=18?1:0)),spawn:{x:2,y:17},flag:{x:29,y:17},npcs:[],stars:[],enemies:[],checkpoints:[]}}
const names=['Первые шаги','Острые ощущения','Пружинный парк','Ледяной разбег','Лента приключений','Мост над лавой','Хрупкий маршрут','Лифты и высоты','Зубастый патруль','Большой забег'];
const subtitles=['Разминка, друзья и первые звёзды','Шипы и первый патруль','Пружины подбросят к облакам','Скользкие мостики','Конвейеры не дадут заскучать','Движущиеся платформы и лава','Пол исчезает прямо под ногами','Вертикальные платформы','Пилы, враги и безопасные островки','Все механики на одном маршруте'];
function campaign(n){let l=blank(names[n]);const row=(x,y,len,t=1)=>{for(let i=x;i<Math.min(C,x+len);i++)l.tiles[y][i]=t};const ss=(x,y)=>l.stars.push({x,y});l.npcs=[{x:4,y:17}];
if(n===0){row(7,15,4);row(14,13,4);row(21,15,4);[[6,17],[8,14],[15,12],[22,14],[27,17]].forEach(p=>ss(...p))}
if(n===1){row(8,17,2,2);row(16,17,2,2);row(24,17,2,2);row(11,14,3);l.enemies=[{x:20,y:17}];[[6,16],[10,16],[12,13],[18,16],[26,16]].forEach(p=>ss(...p))}
if(n===2){row(6,18,1,4);row(11,11,4);row(18,18,1,4);row(23,10,4);[[6,12],[12,10],[18,12],[24,9],[28,17]].forEach(p=>ss(...p))}
if(n===3){row(5,18,10,6);row(19,18,10,6);row(11,17,2,2);row(22,17,2,2);row(15,14,4,6);[[7,17],[10,15],[16,13],[21,15],[26,17]].forEach(p=>ss(...p))}
if(n===4){row(5,18,8,7);row(17,18,10,7);row(11,17,1,2);row(23,17,2,2);row(14,14,4);l.enemies=[{x:19,y:17}];[[6,17],[12,15],[15,13],[21,16],[26,17]].forEach(p=>ss(...p))}
if(n===5){row(8,18,13,3);row(8,19,13,3);row(7,15,3);row(11,15,2,8);row(16,15,2,8);row(21,15,3);l.checkpoints=[{x:7,y:14}];[[7,14],[11,13],[16,13],[22,14],[28,17]].forEach(p=>ss(...p))}
if(n===6){row(7,18,17,3);row(7,19,17,3);row(6,15,4,5);row(12,15,4,5);row(18,15,4,5);row(24,15,3);l.checkpoints=[{x:5,y:17}];[[7,14],[13,14],[19,14],[25,14],[28,17]].forEach(p=>ss(...p))}
if(n===7){row(6,16,3,9);row(11,11,4);row(17,13,3,9);row(22,8,4);row(26,13,3);l.checkpoints=[{x:12,y:10}];[[7,13],[12,10],[18,10],[23,7],[27,12]].forEach(p=>ss(...p));l.flag={x:24,y:7}}
if(n===8){row(8,17,1,10);row(15,17,1,10);row(23,17,1,10);row(10,14,3);row(18,14,3);l.enemies=[{x:6,y:17},{x:19,y:17},{x:27,y:17}];l.checkpoints=[{x:17,y:17}];[[5,16],[11,13],[17,16],[19,13],[27,16]].forEach(p=>ss(...p))}
if(n===9){row(5,18,1,4);row(8,13,3,6);row(11,18,11,3);row(11,19,11,3);row(13,13,2,8);row(18,13,3,5);row(23,15,4,7);row(25,14,1,10);row(28,18,2,2);l.checkpoints=[{x:9,y:12},{x:23,y:14}];l.enemies=[{x:26,y:17}];[[5,12],[9,12],[13,11],[19,12],[23,14],[29,15]].forEach(p=>ss(...p))}return l}
const campaignLevels=Array.from({length:10},(_,n)=>campaign(n));
validate=function(o){const point=p=>p&&Number.isInteger(p.x)&&Number.isInteger(p.y)&&p.x>=0&&p.x<C&&p.y>=0&&p.y<R;if(!o||!Array.isArray(o.tiles)||o.tiles.length!==R||!o.tiles.every(r=>Array.isArray(r)&&r.length===C&&r.every(v=>Number.isInteger(v)&&v>=0&&v<=10))||!point(o.spawn)||!point(o.flag))throw Error('Нужен уровень 32 × 20 с корректными стартом и финишем.');let l={version:2,name:String(o.name||'Импортированный уровень').slice(0,48),tiles:clone(o.tiles),spawn:{...o.spawn},flag:{...o.flag}};for(const k of ['stars','npcs','enemies','checkpoints']){const a=o[k]||[];if(!Array.isArray(a)||a.length>(['npcs','enemies'].includes(k)?80:640)||!a.every(point))throw Error('Некорректные объекты уровня.');l[k]=a.map(p=>({x:p.x,y:p.y}))}return l};
let page='campaign',source={kind:'campaign',index:0},testing=false,editId=null,undoStack=[],redoStack=[],gridOn=true,strokeStart=null,strokeLast=null,strokeErase=false,dirty=false;
let platforms=[],crumbs={},enemies=[],checkpoint=null,activeCheckpoint=-1,runTime=0,deaths=0,shieldReady=false,invulnerable=0,airJumps=0,coyote=0,dashTimer=0,dashCooldown=0,dashWas=false,currentGain=0;
function has(id){return profile.powers.includes(id)&&profile.owned.includes(id)}
function makeCard(title,desc){const c=document.createElement('article');c.className='card';const h=document.createElement('h3');h.textContent=title;c.appendChild(h);const p=document.createElement('p');p.textContent=desc;c.appendChild(p);return c}
function btn(text,fn,cls=''){const b=document.createElement('button');b.textContent=text;b.onclick=fn;b.className=cls;return b}
function showMenu(tab='campaign'){if(mode==='edit')autosave();mode='menu';keys={};page=tab;layout.classList.add('hidden');menu.classList.remove('hidden');menu.innerHTML='<div class="herohead"><div><div class="eyebrow">CAPSULE ADVENTURES · 02</div><h2>Маленький герой.<br>Большие возможности.</h2><p>10 приключений, суперсилы и твоя собственная вселенная. Собирай звёзды, меняй образ и строй уровни с характером.</p></div><div class="mascot" aria-hidden="true"><div class="caps"></div><i></i><i></i></div></div><nav class="tabs" id="tabs"></nav><div id="menubody"></div><div class="menufooter" id="menustorage"></div>';
[['campaign','01 / Приключения'],['shop','02 / Магазин'],['custom','03 / Мои уровни']].forEach(([id,label])=>$('tabs').appendChild(btn(label,()=>showMenu(id),tab===id?'active':'')));const body=$('menubody');
if(tab==='campaign'){body.innerHTML='<h2 class="sectiontitle">Десять миров — один герой</h2><p class="subtext">Все уровни доступны сразу. Суперсилы помогают, но не обязательны. Пройдено: '+Object.keys(profile.done).filter(k=>k.startsWith('c')).length+' / 10.</p><div class="cards" id="cards"></div>';campaignLevels.forEach((l,i)=>{const d=profile.done['c'+i];const c=makeCard(l.name,subtitles[i]);const num=document.createElement('div');num.className='num';num.textContent=String(i+1).padStart(2,'0');c.prepend(num);const mini=document.createElement('div');mini.className='mini';mini.innerHTML='<b></b><b></b><b></b><b></b>';c.appendChild(mini);const meta=document.createElement('div');meta.className='meta';meta.textContent=d?'✓ Пройдено · ★ '+d.stars+' / '+l.stars.length:'★ '+l.stars.length+' звёзд · +40 за финиш';c.appendChild(meta);const a=document.createElement('div');a.className='actions';a.append(btn(d?'Играть снова':'▶ Играть',()=>startCampaign(i),'primary'),btn('✎ Копия',()=>openEditor(l,null)));c.appendChild(a);$('cards').appendChild(c)})}
if(tab==='shop'){body.innerHTML='<h2 class="sectiontitle">Суперсилы и гардероб</h2><p class="subtext">Стартовый подарок — 120 монет. Покупки навсегда. Силы можно включать вместе; костюм — один. Изменения применяются при следующем запуске уровня.</p><div class="tabs" id="shopfilters"></div><div class="cards" id="cards"></div>';items.filter(item=>!item.caseOnly).forEach(item=>{const owned=profile.owned.includes(item.id),equipped=item.kind==='power'?has(item.id):profile.outfit===item.id;const c=makeCard(item.name,item.desc);c.classList.add('shopcard');c.classList.toggle('equipped',equipped);const icon=document.createElement('div');icon.className='shopicon';icon.textContent=item.icon;c.prepend(icon);const b=btn(!owned?'Купить · ★ '+item.price:equipped?'✓ Надето / активно':item.kind==='power'?'Включить':'Надеть',()=>{if(!profile.owned.includes(item.id)){if(profile.coins<item.price){toast('Не хватает монет. Собери звёзды в приключениях.');return}profile.coins-=item.price;profile.owned.push(item.id);if(item.kind==='power')profile.powers.push(item.id);else profile.outfit=item.id;toast('Куплено: '+item.name)}else if(item.kind==='power'){profile.powers=has(item.id)?profile.powers.filter(x=>x!==item.id):[...profile.powers,item.id]}else profile.outfit=item.id;persist();showMenu('shop')},equipped?'active':owned?'':'primary');b.disabled=!owned&&profile.coins<item.price;c.appendChild(b);$('cards').appendChild(c)})}
if(tab==='custom'){body.innerHTML='<h2 class="sectiontitle">Твоя мастерская</h2><p class="subtext">Создавай сколько угодно уровней в пределах памяти браузера. Сохранения локальные; JSON позволяет перенести уровень на другое устройство.</p><div class="actions" id="customactions" style="margin:16px 0"></div><div class="cards" id="cards"></div>';$('customactions').append(btn('+ Создать уровень',()=>openEditor(blank(),null),'primary'),btn('↑ Импорт JSON',()=>$('file2').click()));if(profile.draft)$('customactions').appendChild(btn('Продолжить черновик',()=>{try{openEditor(validate(profile.draft.level),profile.draft.id)}catch(e){toast('Не удалось открыть черновик')}}));profile.custom.forEach(entry=>{const c=makeCard(entry.level.name,'★ '+entry.level.stars.length+' · Врагов: '+(entry.level.enemies||[]).length+' · '+new Date(entry.updated).toLocaleDateString('ru'));const a=document.createElement('div');a.className='actions';a.append(btn('▶ Играть',()=>startCustom(entry),'primary'),btn('✎ Изменить',()=>openEditor(entry.level,entry.id)),btn('Удалить',()=>{if(confirm('Удалить «'+entry.level.name+'» из моих уровней?')){profile.custom=profile.custom.filter(x=>x.id!==entry.id);if(profile.draft?.id===entry.id)profile.draft=null;persist();showMenu('custom')}}));c.appendChild(a);$('cards').appendChild(c)});if(!profile.custom.length){const c=makeCard('Здесь будет твой мир','Начни с чистой площадки или открой копию любого обычного уровня.');$('cards').appendChild(c)}}
wallet();$('menustorage').textContent=storageOK?'● Автосохранение включено · всё работает без сети':'⚠ Хранилище недоступно. Используй экспорт JSON.'}
function showStage(editing){menu.classList.add('hidden');layout.classList.remove('hidden');$('editpanel').classList.toggle('hidden',!editing);$('playpanel').classList.toggle('hidden',editing);$('backedit').classList.toggle('hidden',!testing||editing);$('restart2').classList.toggle('hidden',editing);$('leveltitle').textContent=(editing?'✎ ':testing?'Тест · ':source.kind==='campaign'?String(source.index+1).padStart(2,'0')+' / ':'')+level.name;$('badge').textContent=editing?'✎ Редактор':testing?'▶ Тест · без наград':'▶ Приключение';$('bottom').textContent=editing?'Прямоугольник — протяни от угла до угла. ПКМ стирает.':'Зелёный флаг — финиш. Маленькие флажки — чекпоинты.';wallet();loadout()}
function loadout(){$('loadout').textContent='Образ: '+(items.find(x=>x.id===profile.outfit)?.name||'Классика')+' · Силы: '+(items.filter(x=>has(x.id)).map(x=>x.name).join(', ')||'не включены')}
function startCampaign(i){source={kind:'campaign',index:i};level=clone(campaignLevels[i]);testing=false;mode='play';reset();showStage(false);canvas.focus({preventScroll:true})}
function startCustom(entry){source={kind:'custom',id:entry.id};level=validate(entry.level);testing=false;mode='play';reset();showStage(false);canvas.focus({preventScroll:true})}
function openEditor(l,id){if(mode==='edit')autosave();try{level=validate(l)}catch(e){toast(e.message);return}editId=id;testing=false;mode='edit';undoStack=[];redoStack=[];dirty=false;reset();$('levelname').value=level.name;showStage(true);updateHistory();autosave();toolHelp()}
function autosave(){if(!level)return;profile.draft={id:editId,level:clone(level)};persist();status(storageOK?'Черновик сохранён автоматически. Нажми «Сохранить», чтобы добавить в «Мои уровни».':'Автосохранение недоступно. Скачай JSON.');dirty=false}
function saveCustom(copy=false){if(mode!=='edit')return;level.name=$('levelname').value.trim()||'Мой уровень';if(copy||!editId)editId='u'+Date.now().toString(36)+Math.random().toString(36).slice(2,6);const entry={id:editId,level:clone(level),updated:Date.now()},i=profile.custom.findIndex(x=>x.id===editId);if(i<0)profile.custom.push(entry);else profile.custom[i]=entry;autosave();$('leveltitle').textContent='✎ '+level.name;toast(storageOK?'Уровень сохранён в «Мои уровни»!':'В памяти сохранён. Для надёжности скачай JSON.')}
function updateHistory(){$('undo').disabled=!undoStack.length;$('redo').disabled=!redoStack.length}
function remember(){undoStack.push(clone(level));if(undoStack.length>60)undoStack.shift();redoStack=[];updateHistory()}
function historyStep(back){if(mode!=='edit')return;const from=back?undoStack:redoStack,to=back?redoStack:undoStack;if(!from.length)return;to.push(clone(level));level=from.pop();$('levelname').value=level.name;reset();updateHistory();autosave()}
function toolHelp(){const help={block:'Камень: надёжная неподвижная опора.',spikes:'Шипы: опасны при касании. Перепрыгни их.',lava:'Лава: смертельный горячий блок.',spring:'Пружина: подбрасывает выше обычного прыжка.',crumble:'Хрупкий блок: исчезает через 0,65 с под ногами, возвращается через 3 с.',ice:'Лёд: скользит и медленно тормозит.',belt:'Конвейер: сдвигает вправо.',moving:'Платформа: ходит на 2 клетки влево и вправо. Оставь свободный путь.',lift:'Лифт: ходит на 3 клетки вверх и вниз. Оставь свободную шахту.',saw:'Пила: вращающаяся опасность.',enemy:'Враг: патрулирует край платформы. Прыгни сверху, чтобы победить.',npc:'Друг: безобидный жёлтый бегун.',checkpoint:'Чекпоинт: точка возрождения после касания.',spawn:'Старт: место появления героя. Над ним нужно 2 свободные клетки.',flag:'Финиш: коснись флага, чтобы завершить уровень.',star:'Звезда: коллекционный предмет.',erase:'Ластик: удаляет блоки и объекты, кроме старта и финиша.'};$('toolhelp').textContent=help[tool]||help.block;[...$('tools2').children].forEach((b,i)=>b.classList.toggle('active',defs[i][0]===tool))}
defs.forEach(([id,label])=>$('tools2').appendChild(btn(label,()=>{tool=id;toolHelp()})));
function paintCell(p,t){if(p.x<0||p.x>=C||p.y<0||p.y>=R)return;const same=q=>q.x===p.x&&q.y===p.y;let value=defs.find(d=>d[0]===t)?.[2]||0;if(value||t==='erase'){if(value&&(same(level.spawn)||same(level.flag)))return;level.tiles[p.y][p.x]=value;for(const k of ['stars','npcs','enemies','checkpoints'])level[k]=level[k].filter(q=>!same(q))}else{level.tiles[p.y][p.x]=0;for(const k of ['stars','npcs','enemies','checkpoints'])level[k]=level[k].filter(q=>!same(q));if(t==='spawn'||t==='flag'){level[t]={...p};if(p.y>0)level.tiles[p.y-1][p.x]=0}else if(t==='worm'){if(level.worms.length<50)level.worms.push({x:p.x,y:p.y,tx:p.x+3,ty:p.y,len:4,duration:3})}else{const k={star:'stars',npc:'npcs',enemy:'enemies',checkpoint:'checkpoints'}[t];if(k&&level[k].length<(['npcs','enemies'].includes(k)?80:640))level[k].push({...p});if((t==='npc'||t==='enemy')&&p.y>0)level.tiles[p.y-1][p.x]=0}}dirty=true}
function linePaint(a,b,t){const steps=Math.max(Math.abs(b.x-a.x),Math.abs(b.y-a.y));for(let i=0;i<=steps;i++)paintCell({x:Math.round(a.x+(b.x-a.x)*(steps?i/steps:0)),y:Math.round(a.y+(b.y-a.y)*(steps?i/steps:0))},t)}
function fillPaint(p,t){if(!defs.find(d=>d[0]===t)?.[2]&&t!=='erase'){toast('Заливка работает с блоками и ластиком.');return}const old=level.tiles[p.y][p.x],seen=new Set(),queue=[p];while(queue.length){const q=queue.pop(),k=q.x+','+q.y;if(q.x<0||q.x>=C||q.y<0||q.y>=R||seen.has(k)||level.tiles[q.y][q.x]!==old)continue;seen.add(k);paintCell(q,t);queue.push({x:q.x+1,y:q.y},{x:q.x-1,y:q.y},{x:q.x,y:q.y+1},{x:q.x,y:q.y-1})}}
canvas.onpointerdown=e=>{canvas.focus({preventScroll:true});if(mode!=='edit')return;e.preventDefault();const p=cell(e);if(p.x<0||p.x>=C||p.y<0||p.y>=R)return;remember();paint=true;strokeStart=strokeLast=p;hover=p;strokeErase=e.button===2;canvas.setPointerCapture(e.pointerId);if($('brush').value==='fill'){fillPaint(p,strokeErase?'erase':tool);paint=false;reset();autosave()}else if($('brush').value==='replace'){replacePaint05(p,strokeErase?'erase':tool);paint=false;reset();autosave()}else if($('brush').value==='pen')paintCell(p,strokeErase?'erase':tool)};
canvas.onpointermove=e=>{hover=cell(e);if(!paint||mode!=='edit')return;if($('brush').value==='pen')linePaint(strokeLast,hover,strokeErase?'erase':tool);strokeLast=hover};
function endStroke(){if(paint&&mode==='edit'){if($('brush').value==='rect'&&hover){for(let y=Math.max(0,Math.min(strokeStart.y,hover.y));y<=Math.min(R-1,Math.max(strokeStart.y,hover.y));y++)for(let x=Math.max(0,Math.min(strokeStart.x,hover.x));x<=Math.min(C-1,Math.max(strokeStart.x,hover.x));x++)paintCell({x,y},strokeErase?'erase':tool)}else if($('brush').value==='line'&&hover&&strokeStart){linePaint(strokeStart,hover,strokeErase?'erase':tool)}paint=false;strokeStart=null;reset();autosave()}}
canvas.onpointerup=endStroke;canvas.onpointercancel=endStroke;canvas.onpointerleave=()=>{if(!paint)hover=null};
$('home').onclick=()=>showMenu();$('shopnav').onclick=$('quickshop').onclick=()=>showMenu('shop');$('wallet').onclick=()=>showMenu('shop');$('editnav').onclick=()=>{if(testing){mode='edit';testing=false;reset();showStage(true);return}if(profile.draft){try{openEditor(validate(profile.draft.level),profile.draft.id)}catch(e){openEditor(blank(),null)}}else openEditor(blank(),null)};
$('save2').onclick=()=>saveCustom();$('savecopy').onclick=()=>saveCustom(true);$('undo').onclick=()=>historyStep(true);$('redo').onclick=()=>historyStep(false);$('grid').onclick=()=>{gridOn=!gridOn;$('grid').classList.toggle('active',gridOn)};$('grid').classList.add('active');
if($('rotate'))$('rotate').onclick=()=>{
  const p=hover||strokeStart;
  if(!p){toast('Наведи на клетку и кликни.');return}
  if(rawTile(p.x,p.y)!==15){toast('Сначала поставь склон.');return}
  remember();level.tileRot=level.tileRot||{};
  const k=p.x+','+p.y;
  level.tileRot[k]=((level.tileRot[k]||0)+1)%4;
  reset();autosave();
  toast('Поворот: '+level.tileRot[k]*90+'°');
};
$('rotate').onclick=()=>{
  const p=hover||strokeStart;
  if(!p){toast('Наведи на клетку и кликни.');return}
  if(rawTile(p.x,p.y)!==15){toast('Сначала поставь треугольник кистью.');return}
  remember();level.tileRot=level.tileRot||{};
  const k=p.x+','+p.y;
  level.tileRot[k]=((level.tileRot[k]||0)+1)%4;
  reset();autosave();
  toast('Поворот: '+level.tileRot[k]*90+'°');
};
$('levelname').onchange=()=>{remember();level.name=$('levelname').value.trim()||'Мой уровень';$('leveltitle').textContent='✎ '+level.name;autosave()};
$('newlevel').onclick=()=>openEditor(blank(),null);$('clear2').onclick=()=>{if(confirm('Очистить площадку? Действие можно отменить.')){remember();level=blank(level.name);reset();autosave()}};
$('test').onclick=()=>{autosave();testing=true;mode='play';reset();showStage(false);canvas.focus({preventScroll:true});toast('Тест уровня. Вернуться можно кнопкой «К редактору».')};$('backedit').onclick=()=>{testing=false;mode='edit';reset();showStage(true)};
$('export2').onclick=()=>{const u=URL.createObjectURL(new Blob([JSON.stringify(level,null,2)],{type:'application/json'}));const a=document.createElement('a');a.href=u;a.download=(level.name||'capsule-level').replace(/[^a-zа-яё0-9 _-]/gi,'')+'.json';a.click();setTimeout(()=>URL.revokeObjectURL(u),1500);toast('JSON подготовлен к скачиванию.')};$('import2').onclick=()=>$('file2').click();$('file2').onchange=async e=>{const f=e.target.files[0];if(!f)return;try{if(f.size>250000)throw Error('Файл слишком большой. Максимум 250 КБ.');const l=validate(JSON.parse(await f.text()));openEditor(l,null);toast('Импортировано. Сохрани уровень в мастерской.')}catch(err){toast(err.message||'Некорректный JSON')}e.target.value=''};
$('restart2').onclick=$('again').onclick=()=>{reset();canvas.focus({preventScroll:true})};$('finishmenu').onclick=()=>showMenu(testing||source.kind==='custom'?'custom':'campaign');$('next').onclick=()=>{if(testing){testing=false;mode='edit';reset();showStage(true)}else if(source.kind==='campaign'&&source.index<campaignLevels.length-1)startCampaign(source.index+1);else showMenu(source.kind==='custom'?'custom':'campaign')};
reset=function(){keys={};jumpWas=false;dashWas=false;collected=0;total=level.stars.length;stars=level.stars.map(p=>({...p,taken:false}));actors=[actor(level.spawn,0),...level.npcs.map((p,i)=>actor(p,i+1))];hero=actors[0];enemies=(level.enemies||[]).map((p,i)=>({...actor(p,i+1),enemy:true,dead:false}));won=false;runTime=0;deaths=0;currentGain=0;crumbs={};checkpoint={...level.spawn};activeCheckpoint=-1;shieldReady=has('shield');invulnerable=0;airJumps=0;coyote=0;dashTimer=0;dashCooldown=0;platforms=[];for(let y=0;y<R;y++)for(let x=0;x<C;x++)if(level.tiles[y][x]===8||level.tiles[y][x]===9)platforms.push({x:x*S,y:y*S,ox:x*S,oy:y*S,w:S,h:12,type:level.tiles[y][x],dx:0,dy:0});$('win').style.display='none';updateScore()};
updateScore=function(){$('score').textContent='★ '+collected+' / '+total+(mode==='play'?' · ↺ '+deaths+(has('shield')?' · '+(shieldReady?'◉':'○'):''):'')};
function rawTile(x,y){return x>=0&&x<C&&y>=0&&y<R?level.tiles[y][x]:0}
function solid(v,x,y){return [1,4,5,6,7].includes(v)&&!(v===5&&crumbs[x+','+y]?.gone)}
tile=function(x,y){if(x<0||x>=C||y<0)return 1;if(y>=R)return 0;return solid(level.tiles[y][x],x,y)?1:0};
function overlap(a,b){return a.x<b.x+b.w&&a.x+a.w>b.x&&a.y<b.y+b.h&&a.y+a.h>b.y}
function updatePlatforms(dt){for(const p of platforms){const oldx=p.x,oldy=p.y;p.x=p.ox+(p.type===8?Math.sin(runTime*1.4)*60:0);p.y=p.oy+(p.type===9?Math.sin(runTime*1.1)*90:0);p.x=Math.max(0,Math.min(W-p.w,p.x));p.y=Math.max(40,Math.min(H-35,p.y));p.dx=p.x-oldx;p.dy=p.y-oldy}for(const k in crumbs){const c=crumbs[k];c.t-=dt;if(c.t<=0){if(c.gone)delete crumbs[k];else{c.gone=true;c.t=3}}}}
function physics(a,dt){if(a.platform){a.x+=a.platform.dx;a.y+=a.platform.dy}a.x+=a.vx*dt;let hits=collide(a);if(hits.length){if(a.vx>0)a.x=Math.min(...hits.map(t=>t.x))-a.w;else if(a.vx<0)a.x=Math.max(...hits.map(t=>t.x+S));if(a.npc)a.dir*=-1;a.vx=0}const bottom=a.y+a.h;a.vy=Math.min(800,a.vy+1150*dt);a.y+=a.vy*dt;a.ground=false;a.platform=null;hits=collide(a);if(hits.length){if(a.vy>0){a.y=Math.min(...hits.map(t=>t.y))-a.h;a.ground=true}else if(a.vy<0)a.y=Math.max(...hits.map(t=>t.y+S));a.vy=0}if(!a.ground&&a.vy>=0)for(const p of platforms){const oldTop=p.y-p.dy;if(a.x+a.w>p.x&&a.x<p.x+p.w&&bottom<=oldTop+3&&a.y+a.h>=p.y&&a.y<p.y){a.y=p.y-a.h;a.vy=0;a.ground=true;a.platform=p;break}}
const tx=Math.floor((a.x+a.w/2)/S),ty=Math.floor((a.y+a.h+1)/S),v=rawTile(tx,ty);a.surface=a.ground&&!a.platform?v:0;if(a.ground&&!a.platform){if(v===4){a.vy=-710;a.ground=false}else if(v===5&&!crumbs[tx+','+ty])crumbs[tx+','+ty]={t:.65,gone:false};if(v===7){a.x+=95*dt;const hs=collide(a);if(hs.length)a.x=Math.min(...hs.map(t=>t.x))-a.w}}if(Math.abs(a.vx)>5){a.phase+=dt*Math.abs(a.vx)/20;a.dir=Math.sign(a.vx)}a.x=Math.max(0,Math.min(W-a.w,a.x));
// Склоны WORLD 05
resolveSlope05(a,dt);
}
function respawn(){deaths++;const p=actor(checkpoint,0);Object.assign(hero,p);hero.platform=null;hero.surface=0;shieldReady=has('shield');invulnerable=1.1;airJumps=0;dashTimer=0;coyote=0;enemies=level.enemies.map((p,i)=>({...actor(p,i+1),enemy:true,dead:false}));updateScore();status('Возврат '+(activeCheckpoint>=0?'к чекпоинту':'на старт')+'. Собранные звёзды остались с тобой.')}
function damage(fall=false){if(!fall&&invulnerable>0)return;if(!fall&&shieldReady){shieldReady=false;invulnerable=1.8;hero.vy=-420;dashTimer=0;toast('Сфера поглотила удар!');updateScore();return}respawn()}
function dangerous(a){const small={x:a.x+3,y:a.y+3,w:a.w-6,h:a.h-4};for(let y=Math.max(0,Math.floor(a.y/S));y<=Math.min(R-1,Math.floor((a.y+a.h)/S));y++)for(let x=Math.max(0,Math.floor(a.x/S));x<=Math.min(C-1,Math.floor((a.x+a.w)/S));x++){const v=rawTile(x,y);if(v===2&&overlap(small,{x:x*S+3,y:y*S+9,w:24,h:21})||v===3&&overlap(small,{x:x*S,y:y*S+4,w:S,h:26}))return true;if(v===10){const cx=x*S+15,cy=y*S+15,nx=Math.max(small.x,Math.min(cx,small.x+small.w)),ny=Math.max(small.y,Math.min(cy,small.y+small.h));if((nx-cx)**2+(ny-cy)**2<17**2)return true}}return false}
function claim(key,amount){if(testing||source.kind!=='campaign')return;const id='c'+source.index+':'+key;if(!profile.claims[id]){profile.claims[id]=true;profile.coins+=amount;currentGain+=amount;persist()}}
function finish(){won=true;claim('finish',40);if(!testing&&source.kind==='campaign'){const id='c'+source.index,old=profile.done[id];profile.done[id]={stars:Math.max(old?.stars||0,collected),time:Math.min(old?.time||Infinity,runTime)};persist()}$('result').textContent='★ '+collected+' из '+total+' · '+Math.floor(runTime)+' с · Возвратов: '+deaths+'. '+(testing?'Тест завершён. Можно продолжать строить.':source.kind==='custom'?'Твой мир пройден!':'Получено монет: '+currentGain+'.');$('next').textContent=testing?'✎ К редактору':source.kind==='campaign'&&source.index<campaignLevels.length-1?'Следующий →':'К уровням';$('win').style.display='block'}
tick=function(dt){time+=dt;if(mode!=='play'||won)return;runTime+=dt;invulnerable=Math.max(0,invulnerable-dt);dashCooldown=Math.max(0,dashCooldown-dt);dashTimer=Math.max(0,dashTimer-dt);updatePlatforms(dt);const j=!!(keys.Space||keys.ArrowUp||keys.KeyW),e=!!keys.KeyE;const d=(keys.ArrowRight||keys.KeyD?1:0)-(keys.ArrowLeft||keys.KeyA?1:0);if(hero.ground){coyote=.1;airJumps=0}else coyote=Math.max(0,coyote-dt);if(j&&!jumpWas){if(hero.ground||coyote>0){hero.vy=-480;hero.ground=false;hero.platform=null;coyote=0;airJumps=1}else if((has('double')||has('triple'))&&airJumps<(has('triple')?3:2)){hero.vy=-470;hero.platform=null;airJumps++}}if(e&&!dashWas&&has('dash')&&dashCooldown<=0){dashTimer=.16;dashCooldown=1.4;if(d)hero.dir=d}if(dashTimer>0){hero.vx=hero.dir*570;hero.vy=-10}else{const target=d*((keys.ShiftLeft||keys.ShiftRight)?280:200);hero.vx+=Math.max(-1,Math.min(1,target-hero.vx))*Math.min(Math.abs(target-hero.vx),(hero.surface===6?360:2300)*dt)}const oldBottom=hero.y+hero.h;physics(hero,dt);jumpWas=j;dashWas=e;if(hero.y>H+75){damage(true);return}if(dangerous(hero))damage();
for(const a of [...actors.slice(1),...enemies]){if(a.dead)continue;const front=a.dir>0?a.x+a.w+5:a.x-5;if(a.ground&&!a.platform&&(!tile(Math.floor(front/S),Math.floor((a.y+a.h+5)/S))||[2,3,10].includes(rawTile(Math.floor(front/S),Math.floor((a.y+a.h-7)/S)))))a.dir*=-1;a.vx=a.dir*(a.enemy?90:65);physics(a,dt);if(a.y>H+80||dangerous(a)){if(a.enemy)a.dead=true;else Object.assign(a,actor(a.spawn,1));continue}if(a.enemy&&overlap(hero,a)){if(hero.vy>0&&oldBottom<=a.y+14){a.dead=true;hero.vy=-350;airJumps=1}else damage()}}
for(let i=0;i<stars.length;i++){const s=stars[i];if(!s.taken&&Math.hypot(hero.x+10-(s.x+.5)*S,(hero.y+22)-(s.y+.5)*S)<(has('magnet')?94:29)){s.taken=true;collected++;claim('star'+i,5);updateScore()}}
level.checkpoints.forEach((p,i)=>{if(i!==activeCheckpoint&&overlap(hero,{x:p.x*S,y:p.y*S-25,w:S,h:55})){activeCheckpoint=i;checkpoint={...p};status('⚑ Чекпоинт активирован!')}});if(overlap(hero,{x:level.flag.x*S+5,y:level.flag.y*S-30,w:27,h:60}))finish();
};
const originalPerson=person;
person=function(a,ghost=false){if(a.enemy){const x=a.x+10,y=a.y;ctx.save();ctx.lineWidth=5;ctx.lineCap='round';ctx.strokeStyle='#8f354f';const sw=Math.sin(a.phase)*6;ctx.beginPath();ctx.moveTo(x-5,y+27);ctx.lineTo(x-8-sw,y+40);ctx.moveTo(x+5,y+27);ctx.lineTo(x+8+sw,y+40);ctx.moveTo(x-8,y+15);ctx.lineTo(x-15,y+26);ctx.moveTo(x+8,y+15);ctx.lineTo(x+15,y+26);ctx.stroke();rr(x-10,y,20,32,10,'#ed6d80');ctx.fillStyle='#fff4e7';ctx.fillRect(x-7,y+9,5,5);ctx.fillRect(x+2,y+9,5,5);ctx.fillStyle='#422b48';ctx.fillRect(x-5,y+10,2,3);ctx.fillRect(x+3,y+10,2,3);ctx.strokeStyle='#752b45';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(x-7,y+6);ctx.lineTo(x-2,y+8);ctx.moveTo(x+7,y+6);ctx.lineTo(x+2,y+8);ctx.stroke();ctx.restore();return}ctx.save();if(!a.npc&&invulnerable>0&&mode==='play')ctx.globalAlpha=.5+Math.sin(time*35)*.25;originalPerson(a,ghost);if(!a.npc){const x=a.x+10,y=a.y,o=profile.outfit;if(o==='hoodie'){rr(x-10,y+20,20,13,4,'#76daba');rr(x-8,y+37,8,5,2,'#f0f7f1');rr(x+2,y+37,8,5,2,'#f0f7f1');ctx.fillStyle='#244d4c';ctx.fillRect(x-3,y+25,6,4)}if(o==='ninja'){rr(x-10,y+19,20,14,4,'#2c354b');rr(x-10,y+1,20,6,3,'#f07c85');ctx.fillStyle='#ed6d80';ctx.beginPath();ctx.moveTo(x-9,y+3);ctx.lineTo(x-24,y+8+Math.sin(time*10)*3);ctx.lineTo(x-10,y+8);ctx.fill()}if(o==='space'){rr(x-11,y+21,22,13,5,'#f3f7f5');rr(x-5,y+23,10,7,2,'#589dba');ctx.strokeStyle='#eafaff';ctx.lineWidth=2;ctx.fillStyle='#c6f6ff30';ctx.beginPath();ctx.arc(x,y+11,16,0,7);ctx.fill();ctx.stroke()}if(o==='crown'){rr(x-10,y+20,20,13,4,'#a886e4');ctx.fillStyle='#ffce38';ctx.beginPath();ctx.moveTo(x-11,y+3);ctx.lineTo(x-13,y-10);ctx.lineTo(x-5,y-5);ctx.lineTo(x,y-15);ctx.lineTo(x+5,y-5);ctx.lineTo(x+13,y-10);ctx.lineTo(x+11,y+3);ctx.fill()}if(has('shield')&&(shieldReady||mode==='edit')){ctx.strokeStyle='#8be7ea99';ctx.lineWidth=2;ctx.beginPath();ctx.ellipse(x,y+19,24,31,0,0,7);ctx.stroke()}if(dashTimer>0&&mode==='play'){ctx.strokeStyle='#f9ee9688';ctx.lineWidth=4;for(let i=0;i<3;i++){ctx.beginPath();ctx.moveTo(x-a.dir*18,y+7+i*10);ctx.lineTo(x-a.dir*(36+i*6),y+7+i*10);ctx.stroke()}}}ctx.restore()};
function drawTile(v,x,y,editing){const px=x*S,py=y*S,k=x+','+y;if(v===5&&crumbs[k]?.gone&&!editing){ctx.strokeStyle='#a6c3c644';ctx.setLineDash([3,4]);ctx.strokeRect(px+2,py+2,S-4,S-4);ctx.setLineDash([]);return}if([1,4,5,6,7].includes(v)){const colors={1:'#607e89',4:'#527276',5:'#c79670',6:'#8ed9e8',7:'#687b96'};rr(px+1,py+1,S-2,S-1,3,colors[v]);ctx.fillStyle=v===1?'#81c8a4':v===6?'#d0f6ff':v===5?'#e6b789':'#9cacbb';ctx.fillRect(px+1,py+1,S-2,4);if(v===1){ctx.fillStyle='#4d6b78';ctx.fillRect(px+7,py+19,7,2)}if(v===4){ctx.strokeStyle='#b8efbb';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(px+6,py+24);for(let i=0;i<5;i++)ctx.lineTo(px+(i%2?7:23),py+22-i*4);ctx.stroke();rr(px+2,py+2,26,5,2,'#98efb9')}if(v===5){ctx.strokeStyle=crumbs[k]&&!editing?'#ffead5':'#8d5d4c';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(px+12,py+5);ctx.lineTo(px+17,py+13);ctx.lineTo(px+11,py+19);ctx.lineTo(px+18,py+29);ctx.stroke()}if(v===6){ctx.strokeStyle='#e9ffffaa';ctx.beginPath();ctx.moveTo(px+6,py+23);ctx.lineTo(px+20,py+9);ctx.moveTo(px+16,py+26);ctx.lineTo(px+25,py+17);ctx.stroke()}if(v===7){ctx.save();ctx.beginPath();ctx.rect(px+2,py+5,26,21);ctx.clip();ctx.fillStyle='#ffe09b';ctx.font='bold 19px system-ui';ctx.fillText('»»',px-10+(editing?0:time*35%18),py+24);ctx.restore()}}else if(v===2){ctx.fillStyle='#e7eaf3';ctx.strokeStyle='#ad657b';ctx.lineWidth=2;for(let i=0;i<3;i++){ctx.beginPath();ctx.moveTo(px+i*10,py+29);ctx.lineTo(px+i*10+5,py+6);ctx.lineTo(px+i*10+10,py+29);ctx.closePath();ctx.fill();ctx.stroke()}}else if(v===3){ctx.fillStyle='#e46c56';ctx.fillRect(px,py+4,S,S-4);ctx.fillStyle='#ffb95d';ctx.beginPath();ctx.moveTo(px,py+7);for(let i=0;i<=30;i+=3)ctx.lineTo(px+i,py+5+Math.sin(time*4+x+i*.2)*3);ctx.lineTo(px+30,py+13);ctx.lineTo(px,py+13);ctx.fill();ctx.fillStyle='#ffe097';ctx.fillRect(px+9,py+18,5,3)}else if(v===10){ctx.save();ctx.translate(px+15,py+15);ctx.rotate(time*4);ctx.fillStyle='#e6e9f4';ctx.strokeStyle='#af6278';ctx.lineWidth=2;ctx.beginPath();for(let i=0;i<24;i++){const r=i%2?12:18;ctx.lineTo(Math.cos(i*Math.PI/12)*r,Math.sin(i*Math.PI/12)*r)}ctx.closePath();ctx.fill();ctx.stroke();ctx.fillStyle='#cf6178';ctx.beginPath();ctx.arc(0,0,6,0,7);ctx.fill();ctx.restore()}else if((v===8||v===9)&&editing){const dx=v===8?60:0,dy=v===9?90:0;ctx.strokeStyle='#367a8c55';ctx.setLineDash([4,5]);ctx.beginPath();ctx.moveTo(px+15-dx,py+6-dy);ctx.lineTo(px+15+dx,py+6+dy);ctx.stroke();ctx.setLineDash([]);drawPlatform({x:px,y:py,w:S,type:v})}}
function drawPlatform(p){rr(p.x,p.y,p.w,12,4,p.type===8?'#629dae':'#9a87ca');rr(p.x+2,p.y+1,p.w-4,3,2,p.type===8?'#b5eef0':'#d6c3ff');ctx.fillStyle='#eafaff';ctx.font='12px system-ui';ctx.textAlign='center';ctx.fillText(p.type===8?'↔':'↕',p.x+p.w/2,p.y+11);ctx.textAlign='left'}
draw=function(){if(mode==='menu')return;const editing=mode==='edit';const g=ctx.createLinearGradient(0,0,0,H);g.addColorStop(0,'#89bfd9');g.addColorStop(1,'#e0efe0');ctx.fillStyle=g;ctx.fillRect(0,0,W,H);ctx.fillStyle='#fff0b1';ctx.beginPath();ctx.arc(815,88,34,0,7);ctx.fill();cloud(150,102,1.1);cloud(455,68,.9);cloud(685,153,.7);ctx.fillStyle='#7db4b44d';ctx.beginPath();ctx.moveTo(0,H);for(let x=0;x<=W;x+=12)ctx.lineTo(x,410+Math.sin(x/160)*50+Math.cos(x/82)*20);ctx.lineTo(W,H);ctx.fill();(level.decor||[]).slice().sort((a,b)=>a.y-b.y).forEach(drawDecor);for(let y=0;y<R;y++)for(let x=0;x<C;x++){const v=level.tiles[y][x];if(v)drawTile(v,x,y,editing)}if(!editing)platforms.forEach(drawPlatform);for(const s of(editing?level.stars:stars))if(!s.taken)star(s.x*S+15,s.y*S+15+(editing?0:Math.sin(time*3+s.x)*3));level.checkpoints.forEach((p,i)=>{const x=p.x*S+15,y=p.y*S+30;rr(x-2,y-42,3,42,1,'#ecfff0');ctx.fillStyle=i===activeCheckpoint&&!editing?'#ffe071':'#63c8c3';ctx.beginPath();ctx.moveTo(x,y-42);ctx.lineTo(x+21,y-34);ctx.lineTo(x,y-25);ctx.fill();ctx.fillStyle='#325764';ctx.font='10px system-ui';ctx.fillText('⚑',x-6,y+9)});flag(level.flag);if(editing){person(actor(level.spawn,0),true);level.npcs.forEach((p,i)=>person(actor(p,i+1),true));level.enemies.forEach((p,i)=>person({...actor(p,i+1),enemy:true},true));if(gridOn){ctx.strokeStyle='#26495c24';ctx.lineWidth=1;ctx.beginPath();for(let x=0;x<=W;x+=S){ctx.moveTo(x,0);ctx.lineTo(x,H)}for(let y=0;y<=H;y+=S){ctx.moveTo(0,y);ctx.lineTo(W,y)}ctx.stroke()}if(hover){let x=hover.x,y=hover.y,w=1,h=1;if(paint&&strokeStart&&$('brush').value==='rect'){x=Math.min(strokeStart.x,hover.x);y=Math.min(strokeStart.y,hover.y);w=Math.abs(strokeStart.x-hover.x)+1;h=Math.abs(strokeStart.y-hover.y)+1}ctx.fillStyle=tool==='erase'||strokeErase?'#ef6a8355':'#ffffff55';ctx.fillRect(x*S,y*S,w*S,h*S);ctx.strokeStyle='#fff';ctx.lineWidth=2;ctx.strokeRect(x*S+1,y*S+1,w*S-2,h*S-2)}}else{actors.slice(1).forEach(a=>person(a));enemies.filter(a=>!a.dead).forEach(a=>person(a));person(hero);if(has('dash')){rr(hero.x-4,hero.y-21,28,3,2,'#2f526e55');rr(hero.x-4,hero.y-21,28*(1-dashCooldown/1.4),3,2,'#fff5b5')}}};
window.addEventListener('keydown',e=>{if(['INPUT','SELECT','TEXTAREA'].includes(document.activeElement.tagName))return;if(e.code==='Escape'){e.preventDefault();showMenu(testing?'custom':'campaign')}if(mode==='edit'&&(e.ctrlKey||e.metaKey)){if(e.code==='KeyZ'){e.preventDefault();historyStep(!e.shiftKey)}if(e.code==='KeyY'){e.preventDefault();historyStep(false)}if(e.code==='KeyS'){e.preventDefault();saveCustom()}}});
document.querySelectorAll('[data-key]').forEach(b=>{b.onpointerdown=e=>{e.preventDefault();b.setPointerCapture(e.pointerId);keys[b.dataset.key]=true};b.onpointerup=b.onpointercancel=()=>keys[b.dataset.key]=false});
window.addEventListener('pagehide',()=>{if(mode==='edit')autosave()});
try{if(!profile.draft&&!profile.custom.length){const legacy=localStorage.getItem('capsule-level-v1');if(legacy){const l=validate(JSON.parse(legacy));l.name='Уровень из прошлой версии';profile.custom.push({id:'legacy',level:l,updated:Date.now()});persist()}}}catch(e){}
// WORLD 03 — articulated wardrobe, landscaping and direct LAN rooms.
const v3css=document.createElement('style');v3css.textContent=`:root{--mint:#99edcb;--ink:#101b25}body{background:radial-gradient(ellipse at 70% 0,#253c46 0,transparent 55%),#101923}header{background:#111e29ed;border-bottom:1px solid #ffffff12;padding:19px 26px}header h1{letter-spacing:2px;font-size:19px}header h1 span{color:#a1efd0}.tag{letter-spacing:.5px}button{transition:background .18s,transform .18s;border-color:#ffffff18;border-radius:12px;background:#263746}button:hover{transform:translateY(-1px);background:#36515b}button.primary,button.active{background:#a1efd0;border-color:#a1efd0;color:#142d2b}button.balance{color:#ffdf87!important}.menu{max-width:1320px}.herohead{padding:25px 30px;background:linear-gradient(120deg,#234442,#263447);border:1px solid #ffffff15;border-radius:26px}.herohead h2{letter-spacing:-1.8px}.herohead .eyebrow{color:#c9efd8}.card{background:linear-gradient(145deg,#223443,#1a2834);border:1px solid #ffffff12;box-shadow:0 10px 22px #0001;border-radius:20px}.card:hover{border-color:#9be5c04d}.card .num{color:#a1efd0;font-size:38px}.tabs{gap:9px}.tabs button{font-size:13px;padding:12px 18px}.stage{border:5px solid #293e49;border-radius:24px;box-shadow:0 24px 65px #0004}aside{background:#1a2a36;border-color:#ffffff12;border-radius:20px}.pill{backdrop-filter:blur(8px);background:#142b38c9;border-radius:30px;padding:9px 14px}.wardrobe{display:grid;grid-template-columns:minmax(280px,1.2fr) minmax(250px,1fr);gap:22px;margin:20px 0}.previewbox{background:radial-gradient(ellipse at 50% 35%,#3b6463,#192936 80%);border:1px solid #ffffff18;border-radius:24px;overflow:hidden;position:relative}.previewbox canvas{width:100%;aspect-ratio:6/5;cursor:grab}.previewbox canvas:active{cursor:grabbing}.previewbar{display:flex;gap:8px;flex-wrap:wrap;padding:14px;align-items:center}.previewbar button{font-size:12px}.previewlabel{position:absolute;top:18px;left:22px;pointer-events:none;font-size:11px;letter-spacing:2px;color:#b8e7d5}.fitchoices{display:grid;grid-template-columns:repeat(2,1fr);gap:10px}.fitchoice{min-height:86px;text-align:left}.fitchoice small{display:block;color:#99b5c3;margin-top:7px}.fitchoice.active small{color:#31594e}.wardcontrols{margin:15px 0;padding:15px;background:#172633;border-radius:16px}.wardcontrols label{display:flex;align-items:center;justify-content:space-between;margin:10px 0;font-size:13px}.wardcontrols input[type=color]{width:44px;height:30px;border:0;background:transparent;cursor:pointer}.lanpanel{max-width:900px;margin:22px auto;padding:25px;border:1px solid #ffffff18;border-radius:22px;background:#1b2c38}.lanpanel textarea{width:100%;height:100px;background:#101e28;border:1px solid #ffffff25;border-radius:12px;color:#b4d3d7;padding:12px;font:11px monospace;resize:vertical;margin:9px 0}.lanpanel select,.lanpanel input{max-width:100%;background:#263b48;color:white;border:1px solid #ffffff20;border-radius:9px;padding:10px}.lanstate{display:block;padding:13px 16px;background:#102a2b;color:#a1efd0;border-radius:12px;margin:14px 0;font-size:13px}.lansteps{font-size:13px;line-height:1.8;color:#b3c9d3}.decorhead{margin:14px 0 8px;color:#91dec0;font-size:11px;letter-spacing:2px}.tools button.active{background:#a1efd0}.filtertools{display:flex;gap:5px;margin:12px 0}.filtertools button{padding:7px;font-size:11px}.skinbadge{font-size:12px;color:#a1efd0;margin:10px 0}.levelstrip strong{color:#c4e7db}.menufooter{border-color:#ffffff12}@media(max-width:760px){.wardrobe{grid-template-columns:1fr}.herohead{padding:20px}.herohead h2{font-size:29px}.tabs button{padding:10px}.lanpanel{padding:16px}.previewbox canvas{max-height:380px}}`;document.head.appendChild(v3css);
profile.trim=/^#[0-9a-f]{6}$/i.test(profile.trim||'')?profile.trim:'#91edce';profile.glasses=!!profile.glasses;
let fitting=profile.outfit,orbit=.35,previewPose='run',previewZoom=1,previewDrag=null,previewCanvas=null;
function pose(a,t){const moving=Math.abs(a.vx)>5&&a.ground,sw=moving?Math.sin(a.phase)*.85:0,jump=!a.ground;const bob=moving?Math.abs(Math.cos(a.phase))*1.2:Math.sin(t*2)*.6;const limb=(side,leg)=>{const s=side,angle=(leg?sw:-sw)*s;const root=leg?[s*5,28+bob,0]:[s*10,16+bob,0];return [root,[s*(leg?6:13),root[1]+(leg?6:7)+(jump&&!leg?-5:0),Math.sin(angle)*7],[s*(leg?7:15),root[1]+(leg?12:12)+(jump?(leg?-3:-12):0),Math.sin(angle)*14+(jump?(leg?6:-4):0)]]};return {bob,limbs:[{p:limb(-1,true),leg:true},{p:limb(1,true),leg:true},{p:limb(-1,false),leg:false},{p:limb(1,false),leg:false}]}}
function renderCapsule(c,a,opt={}){const outfit=opt.outfit||profile.outfit,trim=opt.trim||profile.trim,glasses=opt.glasses??profile.glasses,scale=opt.scale||1,yaw=opt.yaw??(a.dir*.25),ox=opt.x??a.x+10,oy=opt.y??a.y,t=opt.time??time;const rig=pose(a,t),b=rig.bob,cy=Math.cos(yaw),sy=Math.sin(yaw),tilt=opt.preview?.17:0;const project=p=>{const z=-p[0]*sy+p[2]*cy;return [ox+(p[0]*cy+p[2]*sy)*scale,oy+(p[1]-z*tilt)*scale,z]};const line=(ps,color,w)=>{c.strokeStyle=color;c.lineWidth=w*scale;c.lineCap='round';c.lineJoin='round';c.beginPath();ps.forEach((p,i)=>{const q=project(p);i?c.lineTo(q[0],q[1]):c.moveTo(q[0],q[1])});c.stroke()};const ball=(p,r,color)=>{const q=project(p);c.fillStyle=color;c.beginPath();c.arc(q[0],q[1],r*scale,0,7);c.fill()};const poly=(ps,color)=>{c.fillStyle=color;c.beginPath();ps.forEach((p,i)=>{const q=project(p);i?c.lineTo(q[0],q[1]):c.moveTo(q[0],q[1])});c.closePath();c.fill()};const suit={classic:'#527dc3',hoodie:trim,ninja:'#30394e',space:'#e5f4ef',crown:'#9975d5'}[outfit]||'#527dc3';c.save();if(opt.ghost)c.globalAlpha=.75;c.fillStyle='#0c29352b';c.beginPath();c.ellipse(ox,oy+44*scale,16*scale,4*scale,0,0,7);c.fill();const limbDraw=l=>{line(l.p,'#edc13b',5);if(outfit!=='classic'){if(l.leg){line(l.p,suit,6);const f=l.p[2];line([f,[f[0],f[1],f[2]+3]],outfit==='ninja'?'#182533':'#f2fcf4',6)}else{line([l.p[0],l.p[1]],suit,6.5);ball(l.p[2],2.7,'#ffdc59')}}};const sorted=rig.limbs.map(l=>({...l,z:project(l.p[2])[2]})).sort((u,v)=>u.z-v.z);sorted.filter(l=>l.z<=0).forEach(limbDraw);line([[0,10+b,0],[0,22+b,0]],'#f6ca3c',20);line([[-4,8+b,5],[-4,19+b,5]],'#ffe684',4);line([[0,24+b,0],[0,28+b,0]],suit,20);if(cy>0){line([[0,23+b,9],[0,29+b,9]],outfit==='classic'?'#789bd0':trim,1.2);if(outfit==='hoodie'){line([[-5,25+b,9],[5,25+b,9]],'#23564a',2);line([[-3,20+b,9],[-3,23+b,9]],'#eef9eb',1);line([[3,20+b,9],[3,23+b,9]],'#eef9eb',1)}if(outfit==='space'){line([[-3,25+b,10],[3,25+b,10]],'#537d94',4);ball([2,25+b,12],1,trim)}ball([-3,11+b,9],1.6,'#22343d');ball([4,11+b,9],1.6,'#22343d');line([[-1,16+b,10],[1,17+b,10],[3,16+b,10]],'#997137',1);if(glasses){line([[-6,11+b,11],[-1,11+b,11]],'#172d3a',4);line([[2,11+b,11],[7,11+b,11]],'#172d3a',4);line([[-1,11+b,11],[2,11+b,11]],trim,1)}}if(outfit==='ninja'){line([[-8,4+b,4],[8,4+b,4]],trim,4);const wave=Math.sin(t*9)*3;poly([[-7,5+b,-3],[-20,8+b+wave,-7],[-12,11+b,-3]],trim)}if(outfit==='crown'){poly([[-10,2+b,2],[-11,-8+b,2],[-5,-4+b,2],[0,-13+b,2],[5,-4+b,2],[11,-8+b,2],[10,2+b,2]],'#ffdd66');ball([0,-1+b,4],2,trim)}sorted.filter(l=>l.z>0).forEach(limbDraw);if(outfit==='space'){const q=project([0,11+b,0]);c.strokeStyle='#d7ffffaa';c.fillStyle='#e1ffff12';c.lineWidth=1.5*scale;c.beginPath();c.arc(q[0],q[1],16*scale,0,7);c.fill();c.stroke()}c.restore()}
const previousPerson=person;
person=function(a,ghost=false){if(a.enemy){previousPerson(a,ghost);return}ctx.save();if(!a.npc&&invulnerable>0&&mode==='play'&&!a.remote)ctx.globalAlpha=.5+Math.sin(time*35)*.25;renderCapsule(ctx,a,{outfit:a.remote?a.outfit:a.npc?'classic':profile.outfit,trim:a.remote?a.trim:profile.trim,glasses:a.remote?a.glasses:!a.npc&&profile.glasses,ghost});if(!a.npc&&!a.remote&&has('shield')&&(shieldReady||mode==='edit')){ctx.strokeStyle='#a3fff2aa';ctx.lineWidth=2;ctx.beginPath();ctx.ellipse(a.x+10,a.y+19,25,32,0,0,7);ctx.stroke()}if(!a.remote&&!a.npc&&dashTimer>0){ctx.strokeStyle='#fff5b7aa';ctx.lineWidth=3;for(let i=0;i<3;i++){ctx.beginPath();ctx.moveTo(a.x+10-a.dir*18,a.y+8+i*9);ctx.lineTo(a.x+10-a.dir*40,a.y+8+i*9);ctx.stroke()}}ctx.restore()};
function wardrobe(){const body=$('menubody');body.innerHTML='<h2 class="sectiontitle">Твой герой. Со всех сторон.</h2><p class="subtext">Вращай персонажа мышью или пальцем. Примеряй до покупки: рукава, штанины и обувь двигаются вместе с телом. Это объёмный процедурный просмотр, без внешних моделей.</p><div class="wardrobe"><div class="previewbox"><div class="previewlabel">AVATAR STUDIO / 360°</div><canvas id="avatar" width="600" height="500" aria-label="Вращающийся предпросмотр персонажа"></canvas><div class="previewbar" id="posebuttons"></div></div><div><div class="fitchoices" id="fits"></div><div class="wardcontrols"><label>Акцент одежды<input type="color" id="trimcolor"></label><label>Очки<input type="checkbox" id="glasses"></label><label>Масштаб<input type="range" id="avatarzoom" min="70" max="135" value="100"></label></div><div class="skinbadge" id="fitstate"></div><button class="primary" id="equipfit"></button><p class="subtext">Цвет и очки бесплатны и сохраняются сразу. Костюмы покупаются за монеты приключений.</p></div></div>';previewCanvas=$('avatar');$('trimcolor').value=profile.trim;$('glasses').checked=profile.glasses;$('avatarzoom').value=previewZoom*100;$('trimcolor').oninput=e=>{profile.trim=e.target.value;persist()};$('glasses').onchange=e=>{profile.glasses=e.target.checked;persist()};$('avatarzoom').oninput=e=>previewZoom=Number(e.target.value)/100;[['idle','Стоять'],['run','Бежать'],['jump','Прыжок']].forEach(([id,label])=>$('posebuttons').appendChild(btn(label,()=>{previewPose=id;[...$('posebuttons').children].forEach(b=>b.classList.toggle('active',b.textContent===label))},previewPose===id?'active':'')));$('posebuttons').appendChild(btn('↺ Вид',()=>{orbit=.35;previewZoom=1;$('avatarzoom').value=100}));items.filter(i=>i.kind==='outfit'&&(!i.caseOnly||profile.owned.includes(i.id))).forEach(i=>{const b=btn(i.icon+' '+i.name,()=>{fitting=i.id;wardrobe()},'fitchoice'+(fitting===i.id?' active':''));const s=document.createElement('small');s.textContent=profile.owned.includes(i.id)?(profile.outfit===i.id?'✓ Надето':'В гардеробе'):'★ '+i.price+' · примерить';b.appendChild(s);$('fits').appendChild(b)});const it=items.find(i=>i.id===fitting)||items[4],owned=profile.owned.includes(it.id);$('fitstate').textContent=owned?'В твоём гардеробе':'Сейчас только примерка';$('equipfit').textContent=owned?(profile.outfit===it.id?'✓ Образ надет':'Надеть образ'):'Купить и надеть · ★ '+it.price;$('equipfit').disabled=!owned&&profile.coins<it.price;$('equipfit').onclick=()=>{if(!profile.owned.includes(it.id)){if(profile.coins<it.price)return;profile.coins-=it.price;profile.owned.push(it.id)}profile.outfit=it.id;persist();wardrobe();toast('Образ сохранён!')};previewCanvas.onpointerdown=e=>{previewDrag=e.clientX;previewCanvas.setPointerCapture(e.pointerId)};previewCanvas.onpointermove=e=>{if(previewDrag===null)return;orbit+=(e.clientX-previewDrag)*.014;previewDrag=e.clientX};previewCanvas.onpointerup=previewCanvas.onpointercancel=()=>previewDrag=null;previewCanvas.onwheel=e=>{e.preventDefault();previewZoom=Math.max(.7,Math.min(1.35,previewZoom-e.deltaY*.001));$('avatarzoom').value=previewZoom*100}}
function drawPreview(){if(mode!=='menu'||page!=='avatar'||!previewCanvas?.isConnected)return;const c=previewCanvas.getContext('2d');c.clearRect(0,0,600,500);c.strokeStyle='#a9dec51b';c.lineWidth=1;for(let i=-5;i<=5;i++){c.beginPath();c.moveTo(300+i*35,350);c.lineTo(300+i*140,500);c.stroke()}for(let i=0;i<6;i++){c.beginPath();c.moveTo(0,360+i*i*5);c.lineTo(600,360+i*i*5);c.stroke()}c.fillStyle='#9ee6c915';c.beginPath();c.ellipse(300,408,132,27,0,0,7);c.fill();const a={x:0,y:0,dir:1,phase:time*9,vx:previewPose==='run'?180:0,ground:previewPose!=='jump'};renderCapsule(c,a,{outfit:fitting,scale:6.4*previewZoom,x:300,y:125-(previewPose==='jump'?Math.abs(Math.sin(time*2))*35:0),yaw:orbit,preview:true});c.fillStyle='#a8c7ce';c.font='12px system-ui';c.textAlign='center';c.fillText('Перетаскивай для вращения · колесо для масштаба',300,478)}
// Decorative objects occupy their own, non-solid layer.
const decorTypes=[['tree','Дерево'],['bush','Куст'],['flowers','Цветы'],['mushroom','Грибы'],['crystal','Кристалл'],['lamp','Фонарь'],['sign','Указатель'],['ruins','Арка']];
const validateBeforeDecor=validate;validate=function(o){const l=validateBeforeDecor(o);l.version=3;l.decor=[];if(o.decor!==undefined){if(!Array.isArray(o.decor)||o.decor.length>640)throw Error('Слишком много декора.');for(const p of o.decor){if(!p||!Number.isInteger(p.x)||!Number.isInteger(p.y)||p.x<0||p.x>=C||p.y<0||p.y>=R||!decorTypes.some(d=>d[0]===p.type))throw Error('Некорректный декор.');l.decor.push({x:p.x,y:p.y,type:p.type})}}return l};
const oldBlank=blank;blank=function(name){return {...oldBlank(name),version:3,decor:[]}};
campaignLevels.forEach((l,i)=>{l.decor=[{x:1,y:17,type:'tree'},{x:30,y:17,type:i%2?'lamp':'tree'},{x:3,y:17,type:'flowers'},{x:27,y:17,type:'bush'},{x:4,y:17,type:'sign'}];l.version=3});
const filters=document.createElement('div');filters.className='filtertools';$('tools2').before(filters);for(const [id,label] of [['all','Все'],['blocks','Блоки'],['objects','Объекты'],['decor','Декор']])filters.appendChild(btn(label,()=>{[...$('tools2').children].forEach((b,i)=>{const d=defs[i];b.hidden=id==='all'?false:id==='decor'?!d[0].startsWith('decor:'):id==='blocks'?!d[2]:!!d[2]||d[0].startsWith('decor:')});[...filters.children].forEach(b=>b.classList.toggle('active',b.textContent===label))},id==='all'?'active':''));
decorTypes.forEach(([id,label])=>{defs.push(['decor:'+id,'✿ '+label,0]);$('tools2').appendChild(btn('✿ '+label,()=>{tool='decor:'+id;toolHelp()}))});defs.push(['decorErase','⌫ Только декор',0]);$('tools2').appendChild(btn('⌫ Только декор',()=>{tool='decorErase';toolHelp()}));
const prevToolHelp=toolHelp;toolHelp=function(){prevToolHelp();if(tool.startsWith('decor:'))$('toolhelp').textContent='Фоновый декор: не мешает бегу и не заменяет блоки. Клетка — основание объекта. Можно рисовать кистью или прямоугольником.';if(tool==='decorErase')$('toolhelp').textContent='Удаляет только декор. Платформы и игровые объекты остаются.'};
const prevPaint=paintCell;paintCell=function(p,t){if(p.x<0||p.x>=C||p.y<0||p.y>=R)return;level.decor??=[];if(t.startsWith('decor:')||t==='decorErase'||t==='erase')level.decor=level.decor.filter(q=>q.x!==p.x||q.y!==p.y);if(t.startsWith('decor:')){level.decor.push({...p,type:t.slice(6)});dirty=true;return}if(t==='decorErase'){dirty=true;return}prevPaint(p,t)};
function drawDecor(p){const x=p.x*S+15,y=(p.y+1)*S,sway=Math.sin(time*1.7+p.x)*2;ctx.save();ctx.lineCap='round';if(p.type==='tree'){rr(x-5,y-65,10,65,4,'#8b7662');ctx.strokeStyle='#8b7662';ctx.lineWidth=5;ctx.beginPath();ctx.moveTo(x,y-28);ctx.lineTo(x-16,y-48);ctx.stroke();for(const [dx,dy,r,col] of [[-18,-68,23,'#437f75'],[16,-76,27,'#529887'],[-2,-95,25,'#71b798']]){ctx.fillStyle=col;ctx.beginPath();ctx.arc(x+dx+sway,y+dy,r,0,7);ctx.fill()}}if(p.type==='bush'){for(let i=0;i<3;i++){ctx.fillStyle=i===1?'#70b293':'#4b9281';ctx.beginPath();ctx.ellipse(x-14+i*14,y-9-i%2*5,15,14,0,0,7);ctx.fill()}}if(p.type==='flowers'){for(let i=0;i<3;i++){const xx=x-10+i*10;ctx.strokeStyle='#4c9479';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(xx,y);ctx.lineTo(xx+sway,y-13-i%2*7);ctx.stroke();ctx.fillStyle=i%2?'#ffe4a1':'#f4abbe';for(let j=0;j<5;j++){ctx.beginPath();ctx.arc(xx+sway+Math.cos(j*1.26)*4,y-13-i%2*7+Math.sin(j*1.26)*4,3,0,7);ctx.fill()}ctx.fillStyle='#fff5c9';ctx.fillRect(xx-1+sway,y-15-i%2*7,3,3)}}if(p.type==='mushroom'){rr(x-3,y-18,6,18,2,'#f6dfbd');ctx.fillStyle='#dc8590';ctx.beginPath();ctx.ellipse(x,y-17,14,10,0,Math.PI,Math.PI*2);ctx.fill();ctx.fillStyle='#ffe8d1';ctx.fillRect(x-6,y-23,4,3);ctx.fillRect(x+4,y-21,3,3)}if(p.type==='crystal'){ctx.fillStyle='#92d7df';ctx.beginPath();ctx.moveTo(x-10,y);ctx.lineTo(x-15,y-22);ctx.lineTo(x,y-43);ctx.lineTo(x+12,y-17);ctx.lineTo(x+8,y);ctx.fill();ctx.fillStyle='#d7fcf5';ctx.beginPath();ctx.moveTo(x,y-43);ctx.lineTo(x-3,y-15);ctx.lineTo(x+8,y);ctx.lineTo(x+12,y-17);ctx.fill()}if(p.type==='lamp'){rr(x-2,y-59,4,59,2,'#4b6372');const glow=ctx.createRadialGradient(x,y-61,2,x,y-61,35);glow.addColorStop(0,'#ffeab969');glow.addColorStop(1,'#ffeab900');ctx.fillStyle=glow;ctx.fillRect(x-35,y-96,70,70);rr(x-8,y-71,16,19,4,'#ffe4a5');rr(x-11,y-74,22,5,2,'#506879')}if(p.type==='sign'){rr(x-2,y-33,4,33,1,'#8d7560');rr(x-17,y-38,34,19,4,'#ddc398');ctx.strokeStyle='#76654f';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(x-8,y-29);ctx.lineTo(x+9,y-29);ctx.lineTo(x+4,y-34);ctx.moveTo(x+9,y-29);ctx.lineTo(x+4,y-24);ctx.stroke()}if(p.type==='ruins'){ctx.strokeStyle='#a0b3a1';ctx.lineWidth=12;ctx.beginPath();ctx.moveTo(x-22,y);ctx.lineTo(x-22,y-44);ctx.arc(x,y-44,22,Math.PI,0);ctx.lineTo(x+22,y);ctx.stroke();rr(x-31,y-6,18,6,2,'#819b8f');rr(x+13,y-6,18,6,2,'#819b8f')}ctx.restore()}
const oldTileDraw=drawTile;drawTile=function(v,x,y,editing){if(![1,4,5,6,7,3].includes(v)||v===5&&crumbs[x+','+y]?.gone&&!editing){oldTileDraw(v,x,y,editing);return}const px=x*S,py=y*S,same=(dx,dy)=>rawTile(x+dx,y+dy)===v&&!(v===5&&!editing&&crumbs[(x+dx)+','+(y+dy)]?.gone),colors={1:'#587b80',4:'#527a76',5:'#bb9276',6:'#8bcfdc',7:'#61778e',3:'#e87558'},edge={1:'#8dd3a7',4:'#b0e9b1',5:'#e7bd91',6:'#d5f9f9',7:'#b4c4c5',3:'#ffcf7c'};ctx.fillStyle=colors[v];ctx.fillRect(px,py,S,S);if(!same(0,-1)){ctx.fillStyle=edge[v];ctx.fillRect(px,py,S,v===1?6:4)}ctx.fillStyle='#102d3a25';if(!same(-1,0))ctx.fillRect(px,py,2,S);if(!same(1,0))ctx.fillRect(px+28,py,2,S);if(!same(0,1))ctx.fillRect(px,py+27,S,3);if(v===1&&((x*7+y*11)%5===0)){ctx.fillStyle='#b3d4b722';ctx.fillRect(px+10,py+17,5,3)}if(v===4){ctx.strokeStyle='#b3edc0';ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(px+7,py+24);for(let i=0;i<5;i++)ctx.lineTo(px+(i%2?7:23),py+23-i*4);ctx.stroke()}if(v===5){ctx.strokeStyle=crumbs[x+','+y]?'#ffebce':'#886b5c';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(px+10,py+7);ctx.lineTo(px+17,py+15);ctx.lineTo(px+12,py+24);ctx.stroke()}if(v===6&&(x+y)%2===0){ctx.strokeStyle='#f4ffff66';ctx.beginPath();ctx.moveTo(px+6,py+24);ctx.lineTo(px+23,py+7);ctx.stroke()}if(v===7){ctx.fillStyle='#fce1a2';ctx.font='17px system-ui';ctx.fillText('»',px+8+(editing?0:Math.sin(time*4)*3),py+23)}if(v===3&&!same(0,-1)){ctx.strokeStyle='#ffcf7c';ctx.lineWidth=3;ctx.beginPath();for(let i=0;i<=30;i+=2){const yy=py+4+Math.sin((px+i)*.12+time*4)*2;i?ctx.lineTo(px+i,yy):ctx.moveTo(px+i,yy)}ctx.stroke()}};
const beforeMergeReset=reset;reset=function(){beforeMergeReset();const merged=[];for(const p of platforms){const last=merged[merged.length-1];if(last&&last.type===p.type&&last.oy===p.oy&&last.ox+last.w===p.ox)last.w+=p.w;else merged.push({...p})}platforms=merged};
// LAN: manual SDP exchange, no external signaling server or STUN.
let peer=null,channel=null,netRole='',netStatus='Нет соединения',remoteHero=null,netPlaying=false,netSendTime=0,netEpoch='',guestStars=new Set();
function netState(s){netStatus=s;if($('lanstatus'))$('lanstatus').textContent=s}
function sendNet(o){if(channel?.readyState==='open'&&channel.bufferedAmount<262144)channel.send(JSON.stringify(o))}
function disconnect(){if(channel)channel.close();if(peer)peer.close();peer=null;channel=null;remoteHero=null;netPlaying=false;netRole='';netState('Соединение закрыто')}
function lanPage(){const b=$('menubody');b.innerHTML='<div class="lanpanel"><div class="eyebrow">LOCAL PLAY / 2 PLAYERS</div><h2 class="sectiontitle">Рядом — веселее.</h2><p class="subtext">Прямое соединение WebRTC для двух игроков в одной Wi-Fi / локальной сети. Внешний сервер не нужен: коды приглашения и ответа передаются вручную. Это совместный забег: общие звёзды и мир, отдельные жизни и финиши.</p><div class="lanstate" id="lanstatus"></div><div class="actions"><button id="makeoffer" class="primary">1. Создать комнату</button><button id="makeanswer">2. Присоединиться</button><button id="closepeer">Отключиться</button></div><p class="lansteps">Хозяин: создай комнату и передай исходящий код другу. Гость: вставь приглашение во входящее поле и нажми «Присоединиться», затем передай свой исходящий код хозяину. Хозяин: вставь ответ и нажми «Принять ответ». Дождитесь статуса «Подключено».</p><label class="subtext" for="netin">Входящий код</label><textarea id="netin" spellcheck="false" placeholder="Вставь код другого игрока"></textarea><button id="acceptanswer">3. Принять ответ</button><p class="subtext">Исходящий код — передай целиком только своему другу</p><textarea id="netout" readonly aria-label="Исходящий код"></textarea><div class="actions"><button id="copycode">Копировать код</button><button id="savecode">↓ Код в TXT</button></div><div class="rule"></div><div class="actions"><select id="lanlevel" aria-label="Уровень для комнаты"></select><button id="lanstart" class="primary">▶ Запустить для двоих</button><button id="lanresume">Вернуться в забег</button></div><p class="subtext">Уровень выбирает хозяин. В сетевой игре нет монетных наград. При запуске свой уровень автоматически передаётся гостю. Не меняйте уровень обычными кнопками во время общего забега.</p><details><summary>Если браузер или встроенное окно блокирует сеть</summary><p class="lansteps">Приложение внутри чата может запрещать прямые сетевые соединения. Скачай HTML кнопкой ниже и открой его в обычном браузере на обоих устройствах. Если файл не даёт соединиться, размести его в локальной папке как index.html, запусти там <code>python -m http.server 8000 --bind 0.0.0.0</code> и открой на обоих устройствах <code>http://IP-компьютера:8000</code>. Разреши Python доступ только к частной сети. Нужны доступная WebRTC и связь устройств друг с другом; гостевая Wi-Fi сеть с изоляцией клиентов, VPN, брандмауэр или ограничения mDNS могут мешать. Сервер раздаёт только HTML; игровой трафик идёт напрямую. Коды могут содержать локальные сетевые адреса — не публикуй их. Без обмена кодами автоматического поиска комнат нет.</p></details><div class="actions" style="margin-top:14px"><button id="downloadgame">↓ Скачать игру HTML</button></div></div>';$('lanstatus').textContent=netStatus;campaignLevels.forEach((l,i)=>{const o=document.createElement('option');o.value='c'+i;o.textContent=(i+1)+'. '+l.name;$('lanlevel').appendChild(o)});profile.custom.forEach((l,i)=>{const o=document.createElement('option');o.value='u'+i;o.textContent='Мой: '+l.level.name;$('lanlevel').appendChild(o)});if(profile.draft){const o=document.createElement('option');o.value='draft';o.textContent='Черновик: '+profile.draft.level.name;$('lanlevel').appendChild(o)}$('makeoffer').onclick=()=>signalAction('offer');$('makeanswer').onclick=()=>signalAction('answer');$('acceptanswer').onclick=()=>signalAction('accept');$('closepeer').onclick=disconnect;$('copycode').onclick=async()=>{try{await navigator.clipboard.writeText($('netout').value);toast('Код скопирован')}catch(e){$('netout').select();toast('Поле выделено. Скопируй вручную.')}};$('savecode').onclick=()=>downloadText($('netout').value,'capsules-connection.txt','text/plain');$('downloadgame').onclick=()=>downloadText(exportGameSource,'capsules-world.html','text/html');$('lanstart').onclick=()=>{if(netRole!=='host'||channel?.readyState!=='open'){toast('Запустить может хозяин после подключения гостя.');return}const v=$('lanlevel').value,l=v==='draft'?profile.draft.level:v[0]==='c'?campaignLevels[Number(v.slice(1))]:profile.custom[Number(v.slice(1))].level;netEpoch=Date.now().toString(36);launchNet(l);sendNet({type:'start',level:validate(l),epoch:netEpoch})};$('lanresume').onclick=()=>{if(!netPlaying){toast('Сначала хозяин должен запустить уровень.');return}mode='play';showStage(false);canvas.focus()}}
function downloadText(text,name,type){const u=URL.createObjectURL(new Blob([text],{type})),a=document.createElement('a');a.href=u;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(u),2000)}
function hookChannel(ch){channel=ch;ch.onopen=()=>{netState('Подключено · '+(netRole==='host'?'ты хозяин, выбери уровень':'жди запуска от хозяина'));toast('Друг подключён!')};ch.onclose=()=>{netPlaying=false;remoteHero=null;netState('Игрок отключился. Можно продолжить одному.');toast('Сетевое соединение закрыто')};ch.onerror=()=>netState('Ошибка канала. Пересоздай комнату.');ch.onmessage=e=>{try{if(typeof e.data!=='string'||e.data.length>150000)return;receiveNet(JSON.parse(e.data))}catch(err){netState('Некорректный пакет: '+err.message)}}}
function makePeer(role){disconnect();if(typeof RTCPeerConnection==='undefined')throw Error('WebRTC недоступна в этом окне. Скачай HTML и открой в браузере.');netRole=role;peer=new RTCPeerConnection({iceServers:[]});peer.ondatachannel=e=>hookChannel(e.channel);peer.onconnectionstatechange=()=>{if(!peer)return;if(peer.connectionState==='failed')netState('Соединение не удалось. Проверь локальную сеть и ограничения браузера.');if(peer.connectionState==='disconnected')netState('Связь прервалась. Проверь сеть.')};return peer}
async function iceReady(p){if(p.iceGatheringState==='complete')return;await new Promise((resolve,reject)=>{const timeout=setTimeout(()=>{p.removeEventListener('icegatheringstatechange',check);reject(Error('Сбор адресов занял слишком долго. Попробуй в обычном браузере.'))},15000);function check(){if(p.iceGatheringState==='complete'){clearTimeout(timeout);p.removeEventListener('icegatheringstatechange',check);resolve()}}p.addEventListener('icegatheringstatechange',check)})}
async function signalAction(action){try{const input=$('netin').value.trim();if(action==='accept'){if(netRole!=='host'||!peer)throw Error('Сначала создай комнату.');const data=JSON.parse(input);if(data.type!=='answer'||typeof data.sdp!=='string')throw Error('Нужен код ответа гостя.');await peer.setRemoteDescription(data);netState('Ответ принят. Устанавливаем прямое соединение…');return}let offer;if(action==='answer'){offer=JSON.parse(input);if(offer.type!=='offer'||typeof offer.sdp!=='string')throw Error('Нужен код приглашения хозяина.')}const p=makePeer(action==='offer'?'host':'guest');netState('Собираем локальные сетевые адреса…');if(action==='offer'){hookChannel(p.createDataChannel('capsules'));await p.setLocalDescription(await p.createOffer())}else{await p.setRemoteDescription(offer);await p.setLocalDescription(await p.createAnswer())}await iceReady(p);if(p!==peer)return;const code=JSON.stringify(p.localDescription);if($('netout'))$('netout').value=code;netState(action==='offer'?'Приглашение готово. Передай его другу и прими ответ.':'Ответ готов. Передай его хозяину.')}catch(e){netState('Не удалось: '+e.message)}}
function launchNet(l){level=validate(l);source={kind:'lan'};testing=false;mode='play';netPlaying=true;remoteHero=null;guestStars.clear();reset();showStage(false);$('badge').textContent='● LAN · '+(netRole==='host'?'Хозяин':'Гость');canvas.focus({preventScroll:true});toast('Общий забег начался!')}
function receiveNet(m){if(m.type==='start'&&netRole==='guest'&&typeof m.epoch==='string'){netEpoch=m.epoch;launchNet(m.level);return}if(!netPlaying||m.epoch!==netEpoch)return;if(m.type==='state'){const a=m.actor;if(!a||!['x','y','vx','phase'].every(k=>Number.isFinite(a[k]))||Math.abs(a.x)>2000||Math.abs(a.y)>4000)return;remoteHero={...actor(level.spawn,0),x:a.x,y:a.y,vx:a.vx,phase:a.phase,ground:!!a.ground,dir:a.dir===-1?-1:1,outfit:items.some(i=>i.kind==='outfit'&&i.id===a.outfit)?a.outfit:'classic',trim:/^#[0-9a-f]{6}$/i.test(a.trim||'')?a.trim:'#91edce',glasses:!!a.glasses,remote:true,finished:!!a.finished};if(Array.isArray(m.taken))for(const i of m.taken)if(Number.isInteger(i)&&stars[i])stars[i].taken=true;collected=stars.filter(s=>s.taken).length;updateScore();if(netRole==='guest'&&Number.isFinite(m.clock)&&Array.isArray(m.enemies)){runTime=m.clock;for(let i=0;i<Math.min(enemies.length,m.enemies.length);i++){const e=m.enemies[i];if(e&&Number.isFinite(e.x)&&Number.isFinite(e.y))Object.assign(enemies[i],{x:e.x,y:e.y,dead:!!e.dead})}if(m.crumbs&&typeof m.crumbs==='object'){const next={};for(const k of Object.keys(m.crumbs).slice(0,640)){const v=m.crumbs[k];if(/^\d{1,2},\d{1,2}$/.test(k)&&v&&Number.isFinite(v.t))next[k]={t:v.t,gone:!!v.gone}}crumbs=next}}if(netRole==='host'&&Array.isArray(m.defeated))for(const i of m.defeated)if(Number.isInteger(i)&&enemies[i])enemies[i].dead=true}}
const beforeNetTick=tick;tick=function(dt){beforeNetTick(dt);if(!netPlaying||channel?.readyState!=='open')return;netSendTime+=dt;if(netSendTime<.06)return;netSendTime=0;const packet={type:'state',epoch:netEpoch,actor:{x:hero.x,y:hero.y,vx:hero.vx,phase:hero.phase,ground:hero.ground,dir:hero.dir,outfit:profile.outfit,trim:profile.trim,glasses:profile.glasses,finished:won},taken:stars.flatMap((s,i)=>s.taken?[i]:[]),defeated:enemies.flatMap((a,i)=>a.dead?[i]:[])};if(netRole==='host'){packet.clock=runTime;packet.enemies=enemies.map(a=>({x:a.x,y:a.y,dead:a.dead}));packet.crumbs=crumbs}sendNet(packet)};
const v3showMenu=showMenu;showMenu=function(tab='campaign'){previewCanvas=null;v3showMenu(tab);const eye=menu.querySelector('.eyebrow');if(eye)eye.textContent='CAPSULE WORLD · 03';menu.querySelector('.herohead h2').innerHTML='Твой мир.<br>Твои правила.';menu.querySelector('.herohead p').textContent='Исследуй острова, собирай свой образ и строй новые миры. Теперь с живой одеждой, декором и игрой вдвоём.';$('tabs').append(btn('04 / Персонаж',()=>showMenu('avatar'),tab==='avatar'?'active':''),btn('05 / Локальная сеть',()=>showMenu('lan'),tab==='lan'?'active':''));if(tab==='avatar')wardrobe();if(tab==='lan')lanPage();if(tab==='shop')$('shopfilters').appendChild(btn('✦ Открыть 360° примерочную',()=>showMenu('avatar'),'primary'))};
const avatarNav=btn('✦ Персонаж',()=>showMenu('avatar'));$('shopnav').after(avatarNav);$('quickshop').after(btn('360° Примерочная',()=>showMenu('avatar')));
const baseStartCampaign=startCampaign;startCampaign=function(i){netPlaying=false;remoteHero=null;baseStartCampaign(i)};const baseStartCustom=startCustom;startCustom=function(e){netPlaying=false;remoteHero=null;baseStartCustom(e)};const baseOpenEditor=openEditor;openEditor=function(l,id){netPlaying=false;remoteHero=null;baseOpenEditor(l,id)};
const resetButtonAction=()=>{if(netPlaying){if(netRole!=='host'){toast('Общий уровень перезапускает хозяин.');return}netEpoch=Date.now().toString(36);const l=clone(level);launchNet(l);sendNet({type:'start',level:l,epoch:netEpoch})}else reset();canvas.focus()};$('restart2').onclick=$('again').onclick=resetButtonAction;
window.addEventListener('keydown',e=>{if(e.code==='KeyR'&&netPlaying&&!['INPUT','SELECT','TEXTAREA'].includes(document.activeElement.tagName)){e.preventDefault();e.stopImmediatePropagation();resetButtonAction()}},true);
const beforeLanStage=showStage;showStage=function(editing){beforeLanStage(editing);if(netPlaying&&!editing){$('badge').textContent='● LAN · '+(netRole==='host'?'Хозяин':'Гость');$('bottom').textContent='Общие звёзды · отдельные жизни · друг отмечен мятной подписью';$('backedit').classList.add('hidden')}};
// WORLD 04: server accounts, published worlds and scrolling editor.
const V4STYLE=document.createElement('style');V4STYLE.textContent=`.worldsettings{background:#11252e;border-radius:14px;padding:12px;margin:12px 0}.worldsettings label{display:flex;justify-content:space-between;align-items:center;font-size:12px;margin:9px 0;gap:8px}.worldsettings input,.worldsettings select{width:110px;min-width:0;background:#233e49;color:#efffed;border:1px solid #47616b;border-radius:6px;padding:6px}.onlineform{max-width:500px;display:grid;gap:12px}.onlineform input{font:inherit;background:#18333f;border:1px solid #45636a;color:white;padding:13px;border-radius:10px}.serverstate{font-size:12px;color:#9becce}.worldbanner{display:flex;justify-content:space-between;gap:10px;padding:14px;border-radius:14px;background:#112d35;margin-bottom:18px}.published .meta{overflow-wrap:anywhere}canvas{aspect-ratio:16/10!important}#game{cursor:crosshair}.touch{user-select:none}`;document.head.appendChild(V4STYLE);
let cam={x:0,y:0,z:1},pan=null,routeSelection=null,remotePlayers=new Map(),socket=null,onlineUser=null,serverRevision=0,cloudBusy=false,cloudDirty=false,cloudTimer=null,roomId=null,roomWanted=null,networkAccumulator=0,serverOffset=0,cloudState='Гостевой режим',reconnectTimer=null,networkGeneration=0;
const defaultSettings={gravity:1150,jump:480,speed:200,theme:'day',camera:'smooth',zoom:1,firePeriod:3,fireDuty:.55,requireStars:false,allowPowers:true};
const settings=()=>({...defaultSettings,...level?.settings});
function dimensions(l){R=l.tiles.length;C=l.tiles[0].length;W=C*S;H=R*S}
validate=function(o){if(!o||!Array.isArray(o.tiles)||o.tiles.length<20||o.tiles.length>128)throw Error('Высота: 20–128 клеток.');const rows=o.tiles.length,cols=o.tiles[0]?.length;if(!Number.isInteger(cols)||cols<32||cols>512||!o.tiles.every(r=>Array.isArray(r)&&r.length===cols&&r.every(v=>Number.isInteger(v)&&v>=0&&v<=15)))throw Error('Ширина: 32–512 клеток; некорректные блоки.');const point=p=>p&&Number.isInteger(p.x)&&Number.isInteger(p.y)&&p.x>=0&&p.y>=0&&p.x<cols&&p.y<rows;if(!point(o.spawn)||!point(o.flag))throw Error('Старт или финиш вне уровня.');const l={version:4,name:String(o.name||'Мой мир').slice(0,48),tiles:clone(o.tiles),spawn:{x:o.spawn.x,y:o.spawn.y},flag:{x:o.flag.x,y:o.flag.y}};for(const k of ['stars','npcs','enemies','checkpoints']){const a=o[k]||[];if(!Array.isArray(a)||a.length>(k==='npcs'||k==='enemies'?80:2000)||!a.every(point))throw Error('Некорректные объекты.');l[k]=a.map(p=>({x:p.x,y:p.y}))}l.tileRot={};for(const [k,v] of Object.entries(o.tileRot||{})){if(/^\d{1,3},\d{1,3}$/.test(k)&&Number.isInteger(v)&&v>=0&&v<=3)l.tileRot[k]=v}
const dec=o.decor||[];if(!Array.isArray(dec)||dec.length>4000||!dec.every(p=>point(p)&&decorTypes.some(d=>d[0]===p.type)))throw Error('Некорректный декор.');l.decor=dec.map(p=>({x:p.x,y:p.y,type:p.type}));l.settings={...defaultSettings};const s=o.settings||{};for(const [k,min,max] of [['gravity',300,2200],['jump',250,850],['speed',100,400],['zoom',.5,1.5],['firePeriod',1,12],['fireDuty',.1,.9]]){if(s[k]!==undefined){if(!Number.isFinite(s[k])||s[k]<min||s[k]>max)throw Error('Настройка вне диапазона: '+k);l.settings[k]=s[k]}}if(['day','night','cave'].includes(s.theme))l.settings.theme=s.theme;if(['smooth','direct'].includes(s.camera))l.settings.camera=s.camera;for(const k of ['allowPowers','requireStars'])if(typeof s[k]==='boolean')l.settings[k]=s[k];l.routes={};for(const [key,r] of Object.entries(o.routes||{})){if(!/^\d{1,3},\d{1,3}$/.test(key))throw Error('Некорректный маршрут.');const [x,y]=key.split(',').map(Number);if(!point({x,y})||![8,9].includes(l.tiles[y][x])||!r||!point(r)||!Number.isFinite(r.duration)||r.duration<.5||r.duration>30)continue;l.routes[key]={x:r.x,y:r.y,duration:r.duration,ease:r.ease!==false}}return l};
blank=function(name='Мой мир'){return {version:4,name,tiles:Array.from({length:32},(_,y)=>Array(80).fill(y>=30?1:0)),spawn:{x:2,y:29},flag:{x:76,y:29},npcs:[],stars:[],enemies:[],checkpoints:[],decor:[],routes:{},tileRot:{},settings:{...defaultSettings}}};
for(const d of [['pine','Ель'],['rock','Валун'],['grass','Трава'],['torch','Факел'],['waterfall','Водопад'],['banner','Вымпел'],['bones','Кости'],['vines','Лианы']]){decorTypes.push(d);defs.push(['decor:'+d[0],'✿ '+d[1],0]);$('tools2').appendChild(btn('✿ '+d[1],()=>{tool='decor:'+d[0];toolHelp()}))}
for(const d of [['fire','♨ Огонь',11],['jet','♨ Огнемёт',12],['water','≋ Вода',13],['bounce','↟ Батут',14]]){defs.push(d);$('tools2').appendChild(btn(d[1],()=>{tool=d[0];toolHelp()}))}defs.push(['route','↝ Маршрут',0]);$('tools2').appendChild(btn('↝ Маршрут',()=>{tool='route';toolHelp()}));
const toolHelp4=toolHelp;toolHelp=function(){toolHelp4();const h={fire:'Постоянное пламя. Опасно внутри клетки.',jet:'Огнемёт: бьёт на 3 клетки вверх. Период и доля активности — в настройках мира.',water:'Вода замедляет движение и падение. Можно выпрыгнуть.',bounce:'Батут: сильный автоматический прыжок.',route:'Два клика: выбери клетку движущейся платформы или лифта, затем точку назначения. Путь прямой, туда и обратно. Проверь, что он не проходит сквозь блоки.'};if(h[tool])$('toolhelp').textContent=h[tool]};
const controlbox=document.createElement('details');controlbox.className='worldsettings';controlbox.innerHTML='<summary>⚙ Размеры и правила мира</summary><label>Ширина<input id="worldcols" type="number" min="32" max="512"></label><label>Высота<input id="worldrows" type="number" min="20" max="128"></label><button id="resizeworld">Применить размер</button><p class="hint">Мир расширяется вправо и вниз. При уменьшении внешние объекты удаляются. Ctrl+Z отменяет.</p><label>Гравитация<input id="s_gravity" type="number" min="300" max="2200"></label><label>Прыжок<input id="s_jump" type="number" min="250" max="850"></label><label>Бег<input id="s_speed" type="number" min="100" max="400"></label><label>Масштаб игры<input id="s_zoom" type="number" min="0.5" max="1.5" step="0.1"></label><label>Тема<select id="s_theme"><option value="day">День</option><option value="night">Ночь</option><option value="cave">Пещера</option></select></label><label>Камера<select id="s_camera"><option value="smooth">Плавная</option><option value="direct">Точная</option></select></label><label>Период огня, с<input id="s_firePeriod" type="number" min="1" max="12" step="0.5"></label><label>Доля активности<input id="s_fireDuty" type="number" min="0.1" max="0.9" step="0.1"></label><label>Нужны все звёзды<input id="s_requireStars" type="checkbox"></label><label>Разрешить суперсилы<input id="s_allowPowers" type="checkbox"></label><label>Путь в одну сторону, с<input id="routeDuration" type="number" min="0.5" max="30" step="0.5" value="3"></label><label>Плавный разворот<input id="routeEase" type="checkbox" checked></label><button id="applyrules">Сохранить правила</button><p class="hint">Редактор: средняя кнопка или Alt+перетаскивание — перемещение; стрелки — камера; колесо — масштаб. На телефоне используй стрелки и кнопки ± ниже.</p><div class="actions" id="cambuttons"></div>';$('levelname').parentElement.after(controlbox);
function syncSettings(){if(!level)return;$('worldcols').value=C;$('worldrows').value=R;const s=settings();for(const k in defaultSettings){const el=$('s_'+k);if(typeof s[k]==='boolean')el.checked=s[k];else el.value=s[k]}}
for(const [txt,fn] of [['←',()=>cam.x-=240/cam.z],['→',()=>cam.x+=240/cam.z],['↑',()=>cam.y-=180/cam.z],['↓',()=>cam.y+=180/cam.z],['−',()=>cam.z=Math.max(.25,cam.z-.15)],['+',()=>cam.z=Math.min(2,cam.z+.15)],['◎',()=>focusSpawn()]])$('cambuttons').appendChild(btn(txt,()=>{fn();clampCamera()}));
function clampCamera(){cam.x=Math.max(0,Math.min(Math.max(0,W-960/cam.z),cam.x));cam.y=Math.max(0,Math.min(Math.max(0,H-600/cam.z),cam.y))}
function focusSpawn(){cam.x=(level.spawn.x+.5)*S-480/cam.z;cam.y=level.spawn.y*S-330/cam.z;clampCamera()}
$('resizeworld').onclick=()=>{if(mode!=='edit')return;const nc=Number($('worldcols').value),nr=Number($('worldrows').value);if(!Number.isInteger(nc)||!Number.isInteger(nr)||nc<32||nc>512||nr<20||nr>128){toast('Размер: 32–512 × 20–128.');return}if((nc<C||nr<R)&&!confirm('Уменьшить мир? Объекты за границами будут удалены.'))return;remember();level.tiles=Array.from({length:nr},(_,y)=>Array.from({length:nc},(_,x)=>level.tiles[y]?.[x]||0));const inside=p=>p.x<nc&&p.y<nr;for(const k of ['stars','npcs','enemies','checkpoints','decor'])level[k]=(level[k]||[]).filter(inside);for(const k of ['spawn','flag']){level[k].x=Math.min(nc-1,level[k].x);level[k].y=Math.min(nr-1,level[k].y)}level.routes=Object.fromEntries(Object.entries(level.routes||{}).filter(([k,r])=>{const [x,y]=k.split(',').map(Number);return x<nc&&y<nr&&inside(r)}));reset();syncSettings();autosave();toast('Размер мира обновлён. Новая область пустая.')};
$('applyrules').onclick=()=>{if(mode!=='edit')return;try{const s={};for(const k in defaultSettings){const e=$('s_'+k);s[k]=typeof defaultSettings[k]==='boolean'?e.checked:typeof defaultSettings[k]==='number'?Number(e.value):e.value}const next=validate({...level,settings:s});remember();level=next;autosave();toast('Правила сохранены')}catch(e){toast(e.message)}};
const old4reset=reset;reset=function(){dimensions(level);level.settings={...defaultSettings,...level.settings};level.routes??={};level.tileRot=level.tileRot||{};level.tileRot=level.tileRot||{};old4reset();platforms=[];for(let y=0;y<R;y++)for(let x=0;x<C;x++)if([8,9].includes(level.tiles[y][x])){const type=level.tiles[y][x],r=level.routes[x+','+y],last=platforms[platforms.length-1];if(!r&&last&&!last.explicit&&last.type===type&&last.oy===y*S&&last.ox+last.w===x*S)last.w+=S;else platforms.push({x:x*S,y:y*S,ox:x*S,oy:y*S,w:S,h:12,type,dx:0,dy:0,explicit:!!r,tx:r?r.x*S:Math.min(W-S,x*S+(type===8?120:0)),ty:r?r.y*S:Math.max(0,y*S-(type===9?180:0)),duration:r?.duration||3,ease:r?.ease!==false})}if(mode==='play'){cam.z=settings().zoom;focusSpawn()}};
function worldClock(){return roomId?(Date.now()+serverOffset)/1000:runTime}
updatePlatforms=function(dt){const clock=worldClock();for(const p of platforms){let t=(clock/(p.duration||3))%2;t=t>1?2-t:t;if(p.ease)t=(1-Math.cos(t*Math.PI))/2;const nx=p.ox+(p.tx-p.ox)*t,ny=p.oy+(p.ty-p.oy)*t;p.dx=p.initialized?nx-p.x:0;p.dy=p.initialized?ny-p.y:0;p.initialized=true;p.x=nx;p.y=ny}for(const k in crumbs){const c=crumbs[k];c.t-=dt;if(c.t<=0){if(c.gone)delete crumbs[k];else{c.gone=true;c.t=3}}}};
const has4=has;has=function(id){return settings().allowPowers&&has4(id)};
const solid4=solid;solid=function(v,x,y){return v===14||solid4(v,x,y)};
const physics4=physics;physics=function(a,dt){a.vy+=(settings().gravity-1150)*dt;const v=rawTile(Math.floor((a.x+10)/S),Math.floor((a.y+20)/S));if(v===13){a.vx*=Math.exp(-dt*4);a.vy=Math.min(130,a.vy);if(a===hero&&(keys.Space||keys.KeyW||keys.ArrowUp))a.vy=-200}physics4(a,dt);if(a.ground&&a.surface===14){a.vy=-820;a.ground=false}};
const danger4=dangerous;function flameActive(){return worldClock()%settings().firePeriod<settings().firePeriod*settings().fireDuty}
dangerous=function(a){if(danger4(a))return true;const box={x:a.x+3,y:a.y+4,w:a.w-6,h:a.h-5};for(let y=Math.max(0,Math.floor(a.y/S));y<=Math.min(R-1,Math.floor((a.y+a.h)/S)+3);y++)for(let x=Math.max(0,Math.floor(a.x/S));x<=Math.min(C-1,Math.floor((a.x+a.w)/S));x++){let v=rawTile(x,y);if(v===11&&overlap(box,{x:x*S+4,y:y*S+3,w:22,h:27})||v===12&&flameActive()&&overlap(box,{x:x*S+5,y:(y-3)*S,w:20,h:4*S}))return true}return false};
const finish4=finish;finish=function(){if(settings().requireStars&&collected<total){status('Для финиша нужны все звёзды: '+collected+' / '+total);return}finish4();if(!testing&&source.kind!=='campaign'){const id=source.kind==='public'?'p'+source.id:source.id||'custom';profile.done[id]={stars:Math.max(profile.done[id]?.stars||0,collected),time:Math.min(profile.done[id]?.time||Infinity,runTime)};persist()}};
const old4paint=paintCell;paintCell=function(p,t){if(t==='route')return;old4paint(p,t);if(level.routes&&![8,9].includes(rawTile(p.x,p.y)))delete level.routes[p.x+','+p.y]};
cell=function(e){const r=canvas.getBoundingClientRect();return {x:Math.floor((cam.x+(e.clientX-r.left)/r.width*960/cam.z)/S),y:Math.floor((cam.y+(e.clientY-r.top)/r.height*600/cam.z)/S)}};
const down4=canvas.onpointerdown,move4=canvas.onpointermove,up4=canvas.onpointerup;
canvas.onpointerdown=e=>{if(mode==='edit'&&(e.button===1||e.altKey)){e.preventDefault();pan={x:e.clientX,y:e.clientY};canvas.setPointerCapture(e.pointerId);return}if(mode==='edit'&&tool==='route'){const p=cell(e);if(p.x<0||p.x>=C||p.y<0||p.y>=R)return;if(!routeSelection){if(![8,9].includes(rawTile(p.x,p.y))){toast('Сначала выбери платформу или лифт.');return}routeSelection=p;toast('Теперь укажи конец маршрута.')}else{remember();const k=routeSelection.x+','+routeSelection.y;level.routes??={};level.tileRot=level.tileRot||{};level.tileRot=level.tileRot||{};level.routes[k]={...p,duration:Math.max(.5,Math.min(30,Number($('routeDuration').value)||3)),ease:$('routeEase').checked};routeSelection=null;reset();autosave();toast('Маршрут задан. Нажми «Тест».')}return}down4(e)};
canvas.onpointermove=e=>{if(pan){const rect=canvas.getBoundingClientRect();cam.x-=(e.clientX-pan.x)*960/rect.width/cam.z;cam.y-=(e.clientY-pan.y)*600/rect.height/cam.z;pan={x:e.clientX,y:e.clientY};clampCamera();return}move4(e)};canvas.onpointerup=e=>{pan=null;up4(e)};canvas.onpointercancel=()=>{pan=null;endStroke()};canvas.addEventListener('wheel',e=>{if(mode!=='edit')return;e.preventDefault();const r=canvas.getBoundingClientRect(),sx=(e.clientX-r.left)/r.width*960,sy=(e.clientY-r.top)/r.height*600,wx=cam.x+sx/cam.z,wy=cam.y+sy/cam.z;cam.z=Math.max(.25,Math.min(2,cam.z*Math.exp(-e.deltaY*.001)));cam.x=wx-sx/cam.z;cam.y=wy-sy/cam.z;clampCamera()},{passive:false});
const decor4=drawDecor;drawDecor=function(p){if(!['pine','rock','grass','torch','waterfall','banner','bones','vines'].includes(p.type)){decor4(p);return}const x=p.x*S+15,y=(p.y+1)*S;ctx.save();if(p.type==='pine'){rr(x-4,y-75,8,75,2,'#84664d');for(let i=0;i<3;i++){ctx.fillStyle=['#32766b','#408d78','#58a489'][i];ctx.beginPath();ctx.moveTo(x,y-110+i*24);ctx.lineTo(x-33+i*5,y-48+i*20);ctx.lineTo(x+33-i*5,y-48+i*20);ctx.fill()}}if(p.type==='rock'){ctx.fillStyle='#789297';ctx.beginPath();ctx.moveTo(x-23,y);ctx.lineTo(x-27,y-20);ctx.lineTo(x-6,y-37);ctx.lineTo(x+20,y-27);ctx.lineTo(x+29,y);ctx.fill()}if(p.type==='grass'||p.type==='vines'){ctx.strokeStyle=p.type==='grass'?'#80ba80':'#5b9b76';ctx.lineWidth=3;for(let i=0;i<6;i++){ctx.beginPath();ctx.moveTo(x-15+i*6,y);ctx.quadraticCurveTo(x+i*4-8,y-20,x+i*6-20+Math.sin(time+i)*4,y-(p.type==='grass'?18:90));ctx.stroke()}}if(p.type==='torch'){rr(x-3,y-33,6,33,2,'#8b7155');drawFlame(x,y-33,24)}if(p.type==='waterfall'){ctx.fillStyle='#a5e8f455';ctx.fillRect(x-16,y-100,32,100);ctx.strokeStyle='#e1ffff77';for(let i=0;i<4;i++){ctx.beginPath();const yy=y-100+(time*40+i*27)%100;ctx.moveTo(x-11+i*8,yy);ctx.lineTo(x-11+i*8,Math.min(y,yy+20));ctx.stroke()}}if(p.type==='banner'){rr(x-2,y-70,3,70,1,'#ccd9c0');ctx.fillStyle='#eeb789';ctx.beginPath();ctx.moveTo(x,y-70);ctx.lineTo(x+25,y-64+Math.sin(time*3)*3);ctx.lineTo(x+23,y-32);ctx.lineTo(x+12,y-39);ctx.lineTo(x,y-32);ctx.fill()}if(p.type==='bones'){ctx.strokeStyle='#e9dfc4';ctx.lineWidth=5;ctx.lineCap='round';ctx.beginPath();ctx.moveTo(x-15,y-5);ctx.lineTo(x+12,y-15);ctx.moveTo(x-11,y-18);ctx.lineTo(x+15,y-4);ctx.stroke()}ctx.restore()};
function drawFlame(x,y,h){for(let i=0;i<3;i++){ctx.fillStyle=['#ef664a','#ffb74e','#fff1a6'][i];ctx.beginPath();ctx.moveTo(x-12+i*3,y);ctx.quadraticCurveTo(x-17+i*4,y-h*.4,x+Math.sin(time*9+x)*4,y-h*(1-i*.22));ctx.quadraticCurveTo(x+19-i*5,y-h*.4,x+12-i*3,y);ctx.fill()}}
const tile4=drawTile;drawTile=function(v,x,y,edit){if(v<=10){tile4(v,x,y,edit);return}const px=x*S,py=y*S;if(v===11)drawFlame(px+15,py+30,29);if(v===12){rr(px+3,py+18,24,12,3,'#6b5862');if(edit||flameActive())drawFlame(px+15,py+19,108);else{ctx.fillStyle='#ffb36a';ctx.fillRect(px+8,py+17,14,3)}}if(v===13){ctx.fillStyle='#5ab2d17a';ctx.fillRect(px,py,S,S);if(rawTile(x,y-1)!==13){ctx.strokeStyle='#c0f5ff';ctx.beginPath();for(let i=0;i<=30;i+=3){const yy=py+3+Math.sin(time*3+(px+i)*.12)*2;i?ctx.lineTo(px+i,yy):ctx.moveTo(px+i,yy)}ctx.stroke()}}if(v===15){
      const rot=(level.tileRot&&level.tileRot[x+','+y])||0;
      ctx.fillStyle='#607e89';ctx.fillRect(px,py,S,S);
      ctx.fillStyle='#81c8a4';
      ctx.beginPath();
      if(rot===0){ctx.moveTo(px,py+S);ctx.lineTo(px+S/2,py);ctx.lineTo(px+S,py+S);}
      else if(rot===1){ctx.moveTo(px,py);ctx.lineTo(px+S,py+S/2);ctx.lineTo(px,py+S);}
      else if(rot===2){ctx.moveTo(px,py);ctx.lineTo(px+S,py);ctx.lineTo(px+S/2,py+S);}
      else{ctx.moveTo(px+S,py);ctx.lineTo(px+S,py+S);ctx.lineTo(px,py+S/2);}
      ctx.closePath();ctx.fill();
      ctx.fillStyle='#4d6b78';
      ctx.beginPath();
      if(rot===0){ctx.moveTo(px+8,py+19);ctx.lineTo(px+12,py+15);ctx.lineTo(px+16,py+19);}
      else if(rot===1){ctx.moveTo(px+13,py+14);ctx.lineTo(px+17,py+18);ctx.lineTo(px+13,py+22);}
      else if(rot===2){ctx.moveTo(px+10,py+17);ctx.lineTo(px+14,py+13);ctx.lineTo(px+18,py+17);}
      else{ctx.moveTo(px+13,py+14);ctx.lineTo(px+17,py+18);ctx.lineTo(px+13,py+22);}
      ctx.closePath();ctx.fill();
    }
    if(v===15){
    const rot=(level.tileRot&&level.tileRot[x+','+y])||0;
    const tri=()=>{
      ctx.beginPath();
      if(rot===0){ctx.moveTo(px,py+S);ctx.lineTo(px+S,py);ctx.lineTo(px+S,py+S);}
      else if(rot===1){ctx.moveTo(px,py);ctx.lineTo(px+S,py);ctx.lineTo(px,py+S);}
      else if(rot===2){ctx.moveTo(px,py);ctx.lineTo(px+S,py+S);ctx.lineTo(px+S,py);}
      else{ctx.moveTo(px,py);ctx.lineTo(px+S,py+S);ctx.lineTo(px,py+S);}
      ctx.closePath();
    };
    ctx.save();
    tri();ctx.clip();
    ctx.fillStyle='#587b80';ctx.fillRect(px,py,S,S);
    ctx.fillStyle='#8dd3a7';ctx.fillRect(px,py,S,6);
    ctx.fillStyle='#b3d4b722';ctx.fillRect(px+10,py+17,5,3);
    ctx.fillStyle='#102d3a25';
    ctx.fillRect(px,py,2,S);ctx.fillRect(px+28,py,2,S);ctx.fillRect(px,py+27,S,3);
    ctx.restore();
    ctx.strokeStyle='#3e5760cc';ctx.lineWidth=1.5;
    tri();ctx.stroke();
  }if(v===14){rr(px,py+4,30,26,2,'#66537c');rr(px,py,30,6,2,'#dfaff7');ctx.strokeStyle='#ceadea';ctx.beginPath();ctx.moveTo(px+4,py+25);ctx.lineTo(px+14,py+10);ctx.lineTo(px+25,py+25);ctx.stroke()}};
draw=function(){if(mode==='menu')return;const editing=mode==='edit',s=settings();ctx.setTransform(1,0,0,1,0,0);const g=ctx.createLinearGradient(0,0,0,600);g.addColorStop(0,s.theme==='day'?'#89c2d0':s.theme==='night'?'#17273f':'#182e35');g.addColorStop(1,s.theme==='day'?'#e6eed3':s.theme==='night'?'#405168':'#446163');ctx.fillStyle=g;ctx.fillRect(0,0,960,600);ctx.fillStyle=s.theme==='day'?'#ffedb7':'#d4ede7';ctx.beginPath();ctx.arc(800-cam.x*.015,85-cam.y*.01,32,0,7);ctx.fill();cloud(150-cam.x*.06,110-cam.y*.03,1);cloud(620-cam.x*.035,145-cam.y*.015,.8);ctx.fillStyle=s.theme==='day'?'#5f9d9750':'#101f334d';ctx.beginPath();ctx.moveTo(0,600);for(let x=0;x<=960;x+=15)ctx.lineTo(x,460+Math.sin((x+cam.x*.15)/180)*60-cam.y*.03);ctx.lineTo(960,600);ctx.fill();ctx.save();ctx.scale(cam.z,cam.z);ctx.translate(-cam.x,-cam.y);const x0=Math.max(0,Math.floor(cam.x/S)-3),x1=Math.min(C,Math.ceil((cam.x+960/cam.z)/S)+3),y0=Math.max(0,Math.floor(cam.y/S)-4),y1=Math.min(R,Math.ceil((cam.y+600/cam.z)/S)+5),visible=p=>p.x>=x0&&p.x<x1&&p.y>=y0&&p.y<y1;

(level.decor||[]).filter(visible).forEach(drawDecor);for(let y=y0;y<y1;y++)for(let x=x0;x<x1;x++)if(level.tiles[y][x]){const v=level.tiles[y][x];if(editing&&[8,9].includes(v)){drawPlatform({x:x*S,y:y*S,w:S,type:v});const r=level.routes?.[x+','+y];if(r){ctx.strokeStyle='#ebd799';ctx.lineWidth=2/cam.z;ctx.setLineDash([7,5]);ctx.beginPath();ctx.moveTo(x*S+15,y*S+6);ctx.lineTo(r.x*S+15,r.y*S+6);ctx.stroke();ctx.setLineDash([]);ctx.fillStyle='#ebd799';ctx.beginPath();ctx.arc(r.x*S+15,r.y*S+6,5/cam.z,0,7);ctx.fill()}}else drawTile(v,x,y,editing)}if(!editing)platforms.forEach(drawPlatform);for(const p of editing?level.stars:stars)if(!p.taken&&visible(p))star(p.x*S+15,p.y*S+15);flag(level.flag);level.checkpoints.filter(visible).forEach(p=>{rr(p.x*S+14,p.y*S-12,3,42,1,'#d7efe4');rr(p.x*S+17,p.y*S-12,17,13,2,'#74dbc5')});if(editing){person(actor(level.spawn,0),true);level.npcs.forEach((p,i)=>person(actor(p,i+1),true));level.enemies.forEach((p,i)=>person({...actor(p,i+1),enemy:true},true));if(gridOn){ctx.strokeStyle='#43666a35';ctx.lineWidth=1/cam.z;ctx.beginPath();for(let x=x0;x<=x1;x++){ctx.moveTo(x*S,y0*S);ctx.lineTo(x*S,y1*S)}for(let y=y0;y<=y1;y++){ctx.moveTo(x0*S,y*S);ctx.lineTo(x1*S,y*S)}ctx.stroke()}if(hover){const a=paint&&strokeStart&&$('brush').value==='rect'?strokeStart:hover;ctx.fillStyle='#fff8';ctx.fillRect(Math.min(a.x,hover.x)*S,Math.min(a.y,hover.y)*S,(Math.abs(a.x-hover.x)+1)*S,(Math.abs(a.y-hover.y)+1)*S)}if(routeSelection){ctx.strokeStyle='#ffe49b';ctx.lineWidth=3/cam.z;ctx.strokeRect(routeSelection.x*S,routeSelection.y*S,S,S)}}else{actors.slice(1).forEach(a=>person(a));enemies.filter(a=>!a.dead).forEach(a=>person(a));for(const a of remotePlayers.values()){a.x+=(a.tx-a.x)*.25;a.y+=(a.ty-a.y)*.25;person(a);ctx.fillStyle=s.theme==='day'?'#1c5049':'#d5f9e4';ctx.font='bold 12px system-ui';ctx.textAlign='center';ctx.fillText(a.name,a.x+10,a.y-14);ctx.textAlign='left'}person(hero)}ctx.restore();if(editing){ctx.fillStyle='#163139df';ctx.fillRect(12,556,320,30);ctx.fillStyle='#dbf6df';ctx.font='12px system-ui';ctx.fillText(C+' × '+R+' · '+Math.round(cam.z*100)+'% · '+(hover?hover.x+', '+hover.y:'Alt + перетаскивание'),24,576)}};
async function api(path,options={}){const r=await fetch('/api'+path,{credentials:'same-origin',...options,headers:{'Content-Type':'application/json',...(options.headers||{})}});const data=await r.json().catch(()=>({error:'Сервер не вернул JSON'}));if(!r.ok){const e=Error(data.error||'Ошибка сервера '+r.status);e.code=r.status;e.data=data;throw e}return data}
function setCloudState(s){cloudState=s;const e=$('onlineBadge');if(e)e.textContent=onlineUser?onlineUser.name+' · '+s:s;const n=$('storageNote');if(n)n.textContent=onlineUser?'Сервер: '+s:'Гостевой прогресс хранится в этом браузере.'}
function publicProfile(){return {coins:profile.coins,owned:profile.owned,outfit:profile.outfit,powers:profile.powers,done:profile.done,claims:profile.claims,custom:profile.custom,draft:profile.draft,trim:profile.trim,glasses:profile.glasses}}
const persistLocal=persist;persist=function(){const ok=persistLocal();if(onlineUser){try{localStorage.setItem('capsules-account-'+onlineUser.id,JSON.stringify({profile:publicProfile(),revision:serverRevision,dirty:true}))}catch(e){}cloudDirty=true;clearTimeout(cloudTimer);cloudTimer=setTimeout(flushCloud,900);setCloudState('изменения ждут сохранения')}return ok};
async function flushCloud(){if(!onlineUser||cloudBusy||!cloudDirty)return;cloudBusy=true;cloudDirty=false;const snapshot=clone(publicProfile());try{const d=await api('/profile',{method:'PUT',body:JSON.stringify({profile:snapshot,revision:serverRevision})});serverRevision=d.revision;try{localStorage.setItem('capsules-account-'+onlineUser.id,JSON.stringify({profile:cloudDirty?publicProfile():snapshot,revision:serverRevision,dirty:cloudDirty}))}catch(e){}setCloudState(cloudDirty?'есть новые изменения':'сохранено')}catch(e){cloudDirty=true;if(e.code===409){downloadText(JSON.stringify(snapshot,null,2),'capsules-conflict-backup.json','application/json');cloudDirty=false;profile={...profile,...e.data.profile};serverRevision=e.data.revision;persistLocal();toast('Профиль изменён на другом устройстве. Загружена серверная версия; местная копия скачана.');setCloudState('конфликт разрешён серверной версией')}else{setCloudState('ошибка сохранения — копия в браузере');toast(e.message)}}finally{cloudBusy=false;if(cloudDirty)cloudTimer=setTimeout(flushCloud,5000)}}
async function loadAccount(d){onlineUser=d.user;serverRevision=d.revision;window.__isAdmin=!!(d.user&&d.user.isAdmin);const defaults={coins:120,owned:['classic'],outfit:'classic',powers:[],done:{},claims:{},custom:[],draft:null,trim:'#91edce',glasses:false};profile={...defaults,...d.profile};let pending;try{pending=JSON.parse(localStorage.getItem('capsules-account-'+onlineUser.id))}catch(e){}cloudDirty=false;if(pending?.dirty){if(pending.revision===serverRevision){profile={...defaults,...pending.profile};cloudDirty=true}else{downloadText(JSON.stringify(pending.profile,null,2),'capsules-pending-backup.json','application/json');toast('Несохранённая местная версия скачана отдельно. На сервере более новый профиль.')}}persistLocal();setCloudState('подключено');showMenu('online');if(cloudDirty)flushCloud()}
function sendWS(o){if(socket?.readyState===1)socket.send(JSON.stringify(o))}
function leaveRoom(){roomWanted=null;roomId=null;wsLastRoom=null;remotePlayers.clear();sendWS({type:'leave'})}
function closeOnlineSocket(){networkGeneration++;clearTimeout(reconnectTimer);if(socket){socket.onclose=null;socket.close()}socket=null;roomId=null;remotePlayers.clear()}
function joinOnline(id){leaveRoom();if(!onlineUser||testing||mode!=='play')return;roomWanted=id;connectSocket();sendWS({type:'join',id})}

let wsRetry=0,wsLastRoom=null;
function connectSocket(){
  if(!onlineUser) { wsLog('skip: no user'); return; }
  if(socket && (socket.readyState===0 || socket.readyState===1)) { wsLog('skip: already', socket.readyState); return; }
  const gen = ++networkGeneration;
  const url = (location.protocol==='https:'?'wss:':'ws:')+'//'+location.host+'/ws';
  wsLog('connecting', url, 'gen='+gen);
  socket = new WebSocket(url);
  let pingTimer = null;
  let aliveTimeout = null;
  const armAlive = () => {
    clearTimeout(aliveTimeout);
    aliveTimeout = setTimeout(() => {
      wsLog('no pong in 25s, forcing close');
      try { socket.close(); } catch(e){}
    }, 25000);
  };
  socket.onopen = () => {
    if (gen !== networkGeneration) { wsLog('onopen stale'); return; }
    wsLog('open');
    wsRetry = 0;
    armAlive();
    const target = roomWanted || wsLastRoom;
    if (target) { wsLog('join', target); sendWS({type:'join', id:target}); }
    clearInterval(pingTimer);
    pingTimer = setInterval(() => {
      if (socket && socket.readyState === 1) {
        try { socket.send(JSON.stringify({type:'ping', t: Date.now()})); wsLog('ping'); } catch(e){ wsLog('ping err', e.message); }
      }
    }, 10000);
  };
  socket.onmessage = e => {
    if (gen !== networkGeneration) { wsLog('msg stale'); return; }
    try {
      const m = JSON.parse(e.data);
      if (m.type === 'pong') { armAlive(); wsLog('pong'); return; }
      if (m.type === 'ping') { try { socket.send(JSON.stringify({type:'pong'})); } catch(e){} return; }
      if (m.type === 'error') { wsLog('srv-err:', m.error); toast(m.error); return; }
      if (m.type === 'joined') {
        wsLog('joined', m.id);
        const expected = roomWanted || wsLastRoom;
        if (expected && m.id !== expected) { sendWS({type:'leave'}); return; }
        const sameRoom = (roomId === m.id);
        roomId = m.id; wsLastRoom = m.id; wsRetry = 0;
        serverOffset = m.serverTime - Date.now();
        if (!sameRoom) {
          remotePlayers.clear();
          if (m.level) { level = validate(m.level); source = {kind:'public', id:m.id.slice(1), revision:m.version}; reset(); showStage(false); }
        }
        $('badge').textContent = '● Онлайн · личный прогресс';
        const cp = $('chatpanel'); if (cp) cp.classList.add('on');
      }
      if (m.type === 'players' && roomId === m.id) {
        const ids = new Set();
        for (const a of m.players) {
          if (a.userId === onlineUser.id) continue;
          ids.add(a.userId);
          const old = remotePlayers.get(a.userId);
          remotePlayers.set(a.userId, {...actor(level.spawn,0), ...a, x: old?.x??a.x, y: old?.y??a.y, tx:a.x, ty:a.y, remote:true});
        }
        for (const id of remotePlayers.keys()) if (!ids.has(id)) remotePlayers.delete(id);
        $('badge').textContent = '● Онлайн · ' + (remotePlayers.size+1) + ' игроков';
      }
      if (m.type === 'chat') { addChat(m.name||'игрок', m.text); }
    } catch(e) { wsLog('parse err', e.message); }
  };
  socket.onclose = (ev) => {
    if (gen !== networkGeneration) { wsLog('onclose stale'); return; }
    wsLog('close code=' + ev.code + ' reason=' + (ev.reason||''));
    clearInterval(pingTimer);
    clearTimeout(aliveTimeout);
    socket = null;
    const cp = $('chatpanel'); if (cp) cp.classList.remove('on');
    if (onlineUser && (roomWanted || wsLastRoom)) {
      const delay = Math.min(15000, 500 * Math.pow(1.5, wsRetry++));
      wsLog('reconnect in', Math.round(delay), 'ms attempt', wsRetry);
      setCloudState('переподключение через ' + Math.round(delay/1000) + 'с…');
      reconnectTimer = setTimeout(connectSocket, delay);
    }
  };
  socket.onerror = (e) => { wsLog('error', e.type||e); setCloudState('сеть недоступна'); };
}
const stage4=showStage;showStage=function(editing){stage4(editing);if(editing){syncSettings();$('bottom').textContent='Мир '+C+' × '+R+' · Alt + перетаскивание · колесо: масштаб';$('editpanel').querySelectorAll('p.hint').forEach(p=>{if(p.textContent.includes('Размер: 32'))p.textContent='Кисть, заливка, прямоугольник. Ctrl+Z / Y — история. Размер мира и маршруты настраиваются выше.'})}else if(roomId)$('badge').textContent='● Онлайн';setCloudState(cloudState)};
const editor4=openEditor;openEditor=function(l,id){leaveRoom();routeSelection=null;editor4(l,id);if(mode==='edit'){cam.z=1;focusSpawn();syncSettings()}};
const campaign4=startCampaign;startCampaign=function(i){leaveRoom();campaign4(i);joinOnline('c'+i)};
const custom4=startCustom;startCustom=function(e){leaveRoom();custom4(e)};
const menu4=showMenu;showMenu=function(tab='campaign'){leaveRoom();menu4(tab==='lan'?'online':tab);if(tab==='lan')tab='online';page=tab;const tabs=$('tabs');[...tabs.children].filter(b=>b.textContent.includes('Локальная')).forEach(b=>b.remove());tabs.append(btn('05 / Онлайн',()=>showMenu('online'),tab==='online'?'active':''),btn('06 / Миры игроков',()=>showMenu('community'),tab==='community'?'active':''));if(window.__isAdmin&&!tabs.querySelector('[data-admin]')){const ab=btn('⚙ Админ',()=>showMenu('admin'),tab==='admin'?'active':'');ab.dataset.admin='1';tabs.append(ab)}menu.querySelector('.eyebrow').textContent='CAPSULE WORLD · 05 / CASES & LAYERS';menu.querySelector('.herohead p').textContent='Большие миры, живые персонажи и твоя мастерская. На своём сервере — аккаунты, публикации и общие комнаты.';$('menustorage').textContent=onlineUser?'● '+onlineUser.name+' · '+cloudState:'● Гостевой режим · локальное сохранение';if(tab==='online')onlinePage();if(tab==='community')communityPage();if(tab==='admin')adminPage();if(tab==='custom'){const p=$('menubody').querySelector('.subtext');p.textContent=onlineUser?'Мастерская сохраняется в аккаунте на сервере. Чтобы другие могли зайти, опубликуй уровень.':'Сохраняй миры здесь. Для публикации войди в аккаунт.';[...$('cards').children].forEach((c,i)=>{const entry=profile.custom[i];if(entry)c.appendChild(btn('↑ Опубликовать',()=>publishLevel(entry)))})}setCloudState(cloudState)};
function onlinePage(){const body=$('menubody');body.innerHTML='<h2 class="sectiontitle">Один аккаунт. Все твои миры.</h2><p class="subtext">Прогресс, покупки, одежда и мастерская хранятся на сервере. Игроки на одном уровне видят друг друга. Звёзды, враги, жизни и чекпоинты индивидуальные; игроки не толкают друг друга. Маршруты платформ и огонь используют общие часы комнаты.</p><div class="worldbanner"><span id="onlineBadge" class="serverstate"></span><span>HTTP API + WebSocket</span></div><div id="accountActions" class="onlineform"></div><h2 class="sectiontitle">Админ-панель</h2><div class="worldbanner"><span id="admStats" class="serverstate"></span><span>Admin</span></div><div class="actions" style="margin:14px 0"><button id="admCleanup" class="primary">Очистить дубликаты</button><button id="admRefresh">Обновить</button></div><h3 class="sectiontitle" style="font-size:16px">Уровни</h3><div class="cards" id="admLevels"></div><h3 class="sectiontitle" style="font-size:16px">Пользователи</h3><div class="cards" id="admUsers"></div>';
  async function refresh(){
    try{
      const s=await api('/admin/stats');
      $('admStats').textContent='Пользователей: '+s.users+' · Уровней: '+s.levels+' · Сессий: '+s.sessions;
      const L=await api('/admin/levels');
      const lc=$('admLevels');lc.replaceChildren();
      for(const e of L.levels){
        const c=makeCard(e.name,'от '+e.author+' · v'+e.version+' · '+new Date(e.updated).toLocaleString('ru'));
        c.appendChild(btn('Удалить',async()=>{if(!confirm('Удалить уровень «'+e.name+'»?'))return;await api('/levels/'+e.id,{method:'DELETE'});refresh()}));
        lc.appendChild(c);
      }
      const U=await api('/admin/users');
      const uc=$('admUsers');uc.replaceChildren();
      for(const u of U.users){
        const c=makeCard(u.name,'ID '+u.id+' · уровней: '+u.levels+(u.name===ADMIN_NAME?' · АДМИН':''));
        if(u.name!==ADMIN_NAME)c.appendChild(btn('Удалить всё',async()=>{if(!confirm('Удалить пользователя '+u.name+' и все его уровни?'))return;await api('/admin/users/'+u.id,{method:'DELETE'});refresh()}));
        uc.appendChild(c);
      }
    }catch(e){toast(e.message)}
  }
  $('admCleanup').onclick=async()=>{try{const d=await api('/admin/cleanup',{method:'POST'});toast('Удалено дубликатов: '+d.removed);refresh()}catch(e){toast(e.message)}};
  $('admRefresh').onclick=refresh;
  refresh();
}

async function publishLevel(entry){if(!onlineUser){toast('Войди в аккаунт на своём сайте.');showMenu('online');return}try{const d=await api('/levels',{method:'POST',body:JSON.stringify({localId:entry.id,level:validate(entry.level)})});entry.publicId=d.id;persist();toast('Опубликовано! Мир доступен в каталоге.')}catch(e){toast(e.message)}}
let catalogueSerial=0;async function communityPage(offset=0){const serial=++catalogueSerial,body=$('menubody');body.innerHTML='<h2 class="sectiontitle">Миры сообщества</h2><p class="subtext">Опубликованные уровни. Войди, чтобы присоединяться к общим комнатам. Разные версии мира играются в отдельных комнатах.</p><div class="actions" id="catalogtools"></div><p class="subtext" id="catalogstate">Загрузка…</p><div class="cards published" id="publiccards"></div>';const q=document.createElement('input');q.placeholder='Название мира';q.style='padding:10px;border-radius:9px';$('catalogtools').append(q,btn('Найти',()=>load(q.value,0)),btn('Обновить',()=>load(q.value,offset)));async function load(search,skip){try{const d=await api('/levels?q='+encodeURIComponent(search)+'&offset='+skip);if(serial!==catalogueSerial||page!=='community')return;const cards=$('publiccards');cards.replaceChildren();$('catalogstate').textContent=d.levels.length?'Миры '+(skip+1)+'–'+(skip+d.levels.length):'Пока нет опубликованных миров.';for(const e of d.levels){const c=makeCard(e.name,e.author+' · '+e.width+' × '+e.height+' · версия '+e.version);c.append(btn('▶ Войти в мир',async()=>{try{const d=await api('/levels/'+e.id);leaveRoom();startCustom({id:e.id,level:d.level});source={kind:'public',id:e.id,revision:d.version};if(onlineUser)joinOnline('p'+e.id);else toast('Гостевой забег без других игроков.')}catch(err){toast(err.message)}},'primary'),btn('✎ Ремикс',async()=>{try{const d=await api('/levels/'+e.id);openEditor(d.level,null)}catch(err){toast(err.message)}}));if(onlineUser?.id===e.owner)c.appendChild(btn('Снять публикацию',async()=>{if(!confirm('Снять мир с публикации? Локальный оригинал останется.'))return;try{await api('/levels/'+e.id,{method:'DELETE'});load(search,skip)}catch(err){toast(err.message)}}));cards.appendChild(c)}if(skip>0)cards.appendChild(btn('← Назад',()=>load(search,Math.max(0,skip-24))));if(d.levels.length===24)cards.appendChild(btn('Дальше →',()=>load(search,skip+24)))}catch(e){if(serial===catalogueSerial&&$('catalogstate'))$('catalogstate').textContent='Каталог недоступен. Разверни сервер и открой его сайт. '+e.message}}load('',offset)}
const publishButton=btn('↑ Опубликовать',()=>{saveCustom();const e=profile.custom.find(x=>x.id===editId);if(e)publishLevel(e)});$('save2').after(publishButton);$('file2').onchange=async e=>{const f=e.target.files[0];if(!f)return;try{if(f.size>2000000)throw Error('Максимум 2 МБ.');openEditor(validate(JSON.parse(await f.text())),null)}catch(err){toast(err.message)}e.target.value=''};
const baseOfflineTick=beforeNetTick;tick=function(dt){if(mode==='edit'&&!['INPUT','SELECT','TEXTAREA'].includes(document.activeElement.tagName)){cam.x+=((keys.ArrowRight?1:0)-(keys.ArrowLeft?1:0))*650*dt/cam.z;cam.y+=((keys.ArrowDown?1:0)-(keys.ArrowUp?1:0))*650*dt/cam.z;clampCamera()}const prevVy=hero?.vy;const wantJump=!!(keys.Space||keys.ArrowUp||keys.KeyW)&&!jumpWas;const canJump=hero&&(hero.ground||coyote>0);baseOfflineTick(dt);if(mode==='play'&&!won){if(wantJump&&canJump&&hero.vy<0&&hero.vy>-600)hero.vy+=480-settings().jump;const d=(keys.ArrowRight||keys.KeyD?1:0)-(keys.ArrowLeft||keys.KeyA?1:0);if(d&&dashTimer<=0)hero.vx+=d*(settings().speed-200)*dt*8;const tx=hero.x+10+hero.vx*.35-480/cam.z,ty=hero.y+20-330/cam.z,f=settings().camera==='direct'?1:1-Math.exp(-dt*7);cam.x+=(tx-cam.x)*f;cam.y+=(ty-cam.y)*f;clampCamera()}if(roomId&&mode==='play'){networkAccumulator+=dt;if(networkAccumulator>=.066){networkAccumulator=0;sendWS({type:'state',x:hero.x,y:hero.y,vx:hero.vx,phase:hero.phase,ground:hero.ground,dir:hero.dir,outfit:profile.outfit,trim:profile.trim,glasses:profile.glasses})}}};
document.addEventListener('visibilitychange',()=>{if(document.hidden&&onlineUser)flushCloud();if(!document.hidden&&onlineUser&&(!socket||socket.readyState>=2)){wsLog('tab visible: reconnect');connectSocket();}}); /* visibilitychange-ws */window.addEventListener('online',()=>{if(onlineUser){flushCloud();connectSocket()}});window.addEventListener('beforeunload',e=>{if(onlineUser&&(cloudDirty||cloudBusy)){e.preventDefault();e.returnValue=''}});
setTimeout(async()=>{try{const d=await api('/me');if(d.user)await loadAccount(d);else setCloudState('Гостевой режим')}catch(e){setCloudState('Превью: без сервера')}},0);

// CAPSULES_WORLD_05_PATCH
const WORLD05_VERSION=5;
const world05Style=document.createElement('style');
world05Style.textContent=`
.casegrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:18px;margin-top:18px}
.casebox{padding:22px;min-height:275px;border-radius:22px;border:1px solid #ffffff20;background:radial-gradient(circle at 85% 5%,#ffe58b25,transparent 38%),linear-gradient(145deg,#263e49,#192a36)}
.casebox.power{background:radial-gradient(circle at 85% 5%,#8fefff25,transparent 38%),linear-gradient(145deg,#293650,#192a36)}
.caseicon{font-size:58px;filter:drop-shadow(0 10px 16px #0005)}
.casebox h3{font-size:22px;margin:12px 0 8px}
.casebox p{color:#aec3ce;min-height:60px;font-size:13px;line-height:1.65}
.caseodds{color:#9ee5ca;font-size:11px;margin:13px 0}
.caseout{margin-top:18px;padding:16px;min-height:58px;border-radius:14px;background:#10252d;color:#ffe38b}
.caseout.pop{animation:world05Pop .4s ease 2}
@keyframes world05Pop{50%{transform:scale(1.025);box-shadow:0 0 30px #ffe38b55}}
.new05{display:inline-block;padding:3px 8px;margin-left:8px;border-radius:14px;background:#9ce7ca;color:#17332d;font-size:10px;font-weight:800}
`;
document.head.appendChild(world05Style);

items.push(
  {id:'aurora',name:'Северное сияние',icon:'✧',price:190,desc:'Переливающийся костюм исследователя.',kind:'outfit',caseOnly:true},
  {id:'mech',name:'Меха-капсула',icon:'⬡',price:220,desc:'Бронепластины и светящееся ядро.',kind:'outfit',caseOnly:true},
  {id:'pirate',name:'Звёздный корсар',icon:'☠',price:210,desc:'Камзол капитана звёздного корабля.',kind:'outfit',caseOnly:true},
  {id:'crystalSuit',name:'Кристальный страж',icon:'♦',price:240,desc:'Сияющий кристальный панцирь.',kind:'outfit',caseOnly:true},
  {id:'dragon',name:'Драконья искра',icon:'♨',price:260,desc:'Крылья, рога и огненный акцент.',kind:'outfit',caseOnly:true},
  {id:'triple',name:'Тройной прыжок',icon:'⇈',price:210,desc:'Позволяет совершить ещё один прыжок в воздухе.',kind:'power',caseOnly:true},
  {id:'glide',name:'Планер',icon:'⌁',price:180,desc:'Удерживай прыжок во время падения.',kind:'power',caseOnly:true},
  {id:'aqua',name:'Аква-двигатель',icon:'≋',price:170,desc:'Ускоряет движение и гребок в воде.',kind:'power',caseOnly:true},
  {id:'pulse',name:'Звёздный импульс',icon:'⊛',price:230,desc:'Клавиша Q обезвреживает ближайших врагов.',kind:'power',caseOnly:true}
);
for(const [id,label] of [['picker','◉ Пипетка'],['objectErase','⌫ Только объекты'],['waterErase','⌫ Только вода']]){
  defs.push([id,label,0]);
  $('tools2').appendChild(btn(label,()=>{tool=id;toolHelp()}));
}
if(!$('brush').querySelector('[value="line"]')){
  $('brush').insertAdjacentHTML('beforeend','<option value="line">Прямая линия</option><option value="replace">Заменить одинаковые блоки</option>');
}
const world05OldToolHelp=toolHelp;
toolHelp=function(){
  world05OldToolHelp();
  const messages={picker:'Пипетка выбирает блок, воду, декор или игровой объект из клетки.',objectErase:'Удаляет звёзды, друзей, врагов и чекпоинты, сохраняя воду и блоки.',waterErase:'Удаляет только воду.'};
  if(messages[tool])$('toolhelp').textContent=messages[tool];
};
const world05OldPaintCell=paintCell;
paintCell=function(p,t){
  if(p.x<0||p.x>=C||p.y<0||p.y>=R)return;
  if(t==='objectErase'){for(const key of ['stars','npcs','enemies','checkpoints'])level[key]=(level[key]||[]).filter(q=>q.x!==p.x||q.y!==p.y);dirty=true;return}
  if(t==='waterErase'){if(rawTile(p.x,p.y)===13)level.tiles[p.y][p.x]=0;dirty=true;return}
  const hadWater=rawTile(p.x,p.y)===13;
  const canExistInWater=['star','npc','enemy','checkpoint','spawn','flag'].includes(t);
  world05OldPaintCell(p,t);
  if(hadWater&&canExistInWater)level.tiles[p.y][p.x]=13;
};
function replacePaint05(p,t){
  const oldValue=rawTile(p.x,p.y);
  const newValue=defs.find(d=>d[0]===t)?.[2]||0;
  if(!newValue&&t!=='erase'){toast('Замена работает с блоками, водой и ластиком.');return}
  if(oldValue===newValue)return;
  const cells=[];
  for(let y=0;y<R;y++)for(let x=0;x<C;x++)if(rawTile(x,y)===oldValue)cells.push({x,y});
  for(const cell of cells)paintCell(cell,t);
  toast('Заменено клеток: '+cells.length);
}
const world05OldPointerDown=canvas.onpointerdown;
canvas.onpointerdown=function(e){
  if(mode==='edit'&&tool==='picker'){
    e.preventDefault();
    const p=cell(e);
    if(p.x<0||p.x>=C||p.y<0||p.y>=R)return;
    let selected=null;
    for(const [arrayName,id] of [['stars','star'],['npcs','npc'],['enemies','enemy'],['checkpoints','checkpoint']]){
      if((level[arrayName]||[]).some(q=>q.x===p.x&&q.y===p.y)){selected=id;break}
    }
    if(!selected){
      const decoration=(level.decor||[]).find(q=>q.x===p.x&&q.y===p.y);
      if(decoration)selected='decor:'+decoration.type;
    }
    if(!selected){
      const value=rawTile(p.x,p.y);
      selected=(defs.find(d=>d[2]===value&&d[2]>0)||['erase'])[0];
    }
    tool=selected;
    toolHelp();
    toast('Пипетка выбрала: '+selected);
    return;
  }
  world05OldPointerDown(e);
};
function resolveSlope05(actorObject,dt){
  if(!actorObject||actorObject.vy<0)return;
  const foot=actorObject.y+actorObject.h;
  const centerX=actorObject.x+actorObject.w/2;
  const tileX=Math.floor(centerX/S);
  const localX=Math.max(0,Math.min(1,(centerX-tileX*S)/S));
  let bestSurface=null;
  for(let tileY=Math.floor(foot/S)-1;tileY<=Math.floor(foot/S)+1;tileY++){
    if(rawTile(tileX,tileY)!==15)continue;
    const rotation=level.tileRot?.[tileX+','+tileY]||0;
    const risesRight=rotation===0||rotation===3;
    const surfaceY=(tileY+(risesRight?1-localX:localX))*S;
    const penetration=foot-surfaceY;
    const allowance=Math.max(10,Math.abs(actorObject.vx)*dt+Math.max(0,actorObject.vy)*dt+5);
    if(penetration>=-allowance&&penetration<=20&&(bestSurface===null||surfaceY<bestSurface))bestSurface=surfaceY;
  }
  if(bestSurface!==null){actorObject.y=bestSurface-actorObject.h;actorObject.vy=0;actorObject.ground=true;actorObject.platform=null;actorObject.surface=15}
}
const world05OldSolid=solid;
solid=function(value,x,y){if(value===15)return false;return world05OldSolid(value,x,y)};
const world05OldDrawTile=drawTile;
drawTile=function(value,x,y,editing){
  if(value!==15){world05OldDrawTile(value,x,y,editing);return}
  const px=x*S,py=y*S;
  const rotation=level.tileRot?.[x+','+y]||0;
  const risesRight=rotation===0||rotation===3;
  const triangle=()=>{ctx.beginPath();if(risesRight){ctx.moveTo(px,py+S);ctx.lineTo(px+S,py);ctx.lineTo(px+S,py+S)}else{ctx.moveTo(px,py);ctx.lineTo(px,py+S);ctx.lineTo(px+S,py+S)}ctx.closePath()};
  ctx.save();triangle();ctx.clip();
  ctx.fillStyle='#587b80';ctx.fillRect(px,py,S,S);
  ctx.strokeStyle='#8dd3a7';ctx.lineWidth=5;
  ctx.beginPath();
  if(risesRight){ctx.moveTo(px,py+S);ctx.lineTo(px+S,py)}else{ctx.moveTo(px,py);ctx.lineTo(px+S,py+S)}
  ctx.stroke();ctx.restore();
  ctx.strokeStyle='#3e5760';ctx.lineWidth=1.4;triangle();ctx.stroke();
};
let world05PulseCooldown=0,world05PulseEffect=0;
const world05OldReset=reset;
reset=function(){world05OldReset();world05PulseCooldown=0;world05PulseEffect=0};
const world05OldPhysics=physics;
physics=function(actorObject,dt){
  world05OldPhysics(actorObject,dt);
  if(actorObject===hero&&has('aqua')&&rawTile(Math.floor((actorObject.x+actorObject.w/2)/S),Math.floor((actorObject.y+actorObject.h/2)/S))===13){
    const direction=(keys.ArrowRight||keys.KeyD?1:0)-(keys.ArrowLeft||keys.KeyA?1:0);
    actorObject.vx=Math.max(-280,Math.min(280,actorObject.vx+direction*1500*dt));
    if(keys.Space||keys.KeyW||keys.ArrowUp)actorObject.vy=Math.min(actorObject.vy,-255);
  }
};
const world05OldTick=tick;
tick=function(dt){
  world05OldTick(dt);
  world05PulseCooldown=Math.max(0,world05PulseCooldown-dt);
  world05PulseEffect=Math.max(0,world05PulseEffect-dt);
  if(mode==='play'&&!won&&hero&&has('glide')&&(keys.Space||keys.KeyW||keys.ArrowUp)&&hero.vy>125)hero.vy=125;
};
window.addEventListener('keydown',e=>{
  if(e.code!=='KeyQ'||e.repeat||mode!=='play'||won||!has('pulse')||world05PulseCooldown>0)return;
  world05PulseCooldown=4.5;world05PulseEffect=.45;
  let defeated=0;
  for(const enemy of enemies)if(!enemy.dead&&Math.hypot(enemy.x-hero.x,enemy.y-hero.y)<S*4){enemy.dead=true;defeated++}
  toast(defeated?'Импульс обезвредил врагов: '+defeated:'Рядом нет врагов');
});
const world05OldDraw=draw;
draw=function(){
  world05OldDraw();
  if(mode==='play'&&world05PulseEffect>0&&hero){
    ctx.save();ctx.scale(cam.z,cam.z);ctx.translate(-cam.x,-cam.y);
    ctx.strokeStyle='#b9fff0aa';ctx.lineWidth=4/cam.z;
    ctx.beginPath();ctx.arc(hero.x+10,hero.y+20,(1-world05PulseEffect/.45)*S*4,0,Math.PI*2);ctx.stroke();
    ctx.restore();
  }
};
const world05OldRenderCapsule=renderCapsule;
renderCapsule=function(context,actorObject,options={}){
  world05OldRenderCapsule(context,actorObject,options);
  const outfit=options.outfit||profile.outfit;
  if(!['aurora','mech','pirate','crystalSuit','dragon'].includes(outfit))return;
  const scale=options.scale||1;
  const x=options.x??actorObject.x+10;
  const y=options.y??actorObject.y;
  context.save();
  if(outfit==='aurora'){
    const gradient=context.createLinearGradient(x-12*scale,y,x+12*scale,y+34*scale);
    gradient.addColorStop(0,'#65ebca');gradient.addColorStop(.5,'#789fff');gradient.addColorStop(1,'#ed8de0');
    context.fillStyle=gradient;context.fillRect(x-10*scale,y+20*scale,20*scale,13*scale);
  }
  if(outfit==='mech'){
    context.fillStyle='#718997';context.fillRect(x-13*scale,y+19*scale,26*scale,15*scale);
    context.fillStyle='#8ff4df';context.fillRect(x-3*scale,y+23*scale,6*scale,5*scale);
  }
  if(outfit==='pirate'){
    context.fillStyle='#55304d';
    context.beginPath();context.ellipse(x,y,14*scale,5*scale,0,0,Math.PI*2);context.fill();
    context.beginPath();context.moveTo(x-10*scale,y);context.lineTo(x,y-12*scale);context.lineTo(x+11*scale,y);context.closePath();context.fill();
  }
  if(outfit==='crystalSuit'){
    context.fillStyle='#9be5f0';
    for(const offset of [-9,0,9]){
      context.beginPath();context.moveTo(x+offset*scale,y+23*scale);context.lineTo(x+(offset+4)*scale,y+10*scale);context.lineTo(x+(offset+7)*scale,y+25*scale);context.closePath();context.fill();
    }
  }
  if(outfit==='dragon'){
    context.fillStyle='#e86955';
    for(const direction of [-1,1]){
      context.beginPath();context.moveTo(x+direction*8*scale,y+20*scale);context.lineTo(x+direction*27*scale,y+9*scale);context.lineTo(x+direction*20*scale,y+29*scale);context.closePath();context.fill();
    }
    context.fillStyle='#ffd36a';context.fillRect(x-7*scale,y-6*scale,14*scale,7*scale);
  }
  context.restore();
};
function createOfficialWorld05(index){
  const levelNames=['Школа склонов','Звёзды под водой','Огненный ритм','Экспресс-платформы','Ледяная луна','Кристальная глубина','Аква-башня','Фабрика импульсов','Руины ветра','Марафон WORLD 05'];
  const descriptions=['Подъёмы, спуски и плавные стыки','Звёзды находятся прямо внутри воды','Огнемёты работают по общему таймеру','Маршруты движущихся платформ','Лёд, ночь и точные прыжки','Пещера с кристаллами и огнём','Вода, батуты и вертикальный маршрут','Конвейеры, пилы и враги','Длинные мосты и новые склоны','Все механики WORLD 05'];
  const cols=96,rows=32;
  const tiles=Array.from({length:rows},()=>Array(cols).fill(0));
  for(let x=0;x<cols;x++){tiles[30][x]=1;tiles[31][x]=1}
  const newLevel={version:5,name:levelNames[index],tiles,spawn:{x:2,y:29},flag:{x:92,y:29},npcs:[],stars:[],enemies:[],checkpoints:[{x:47,y:29}],decor:[{x:8,y:29,type:'grass'},{x:48,y:29,type:'banner'},{x:90,y:29,type:'pine'}],routes:{},tileRot:{},settings:{...defaultSettings}};
  const platform=(x,y,length,value=1)=>{for(let offset=0;offset<length;offset++)if(x+offset<cols)tiles[y][x+offset]=value};
  const addStar=(x,y)=>{newLevel.stars.push({x,y})};
  const addSlope=(x,y,rotation)=>{tiles[y][x]=15;newLevel.tileRot[x+','+y]=rotation};
  for(const [x,y] of [[10,26],[25,24],[40,22],[55,25],[70,23],[84,26]]){platform(x,y,5);addStar(x+2,y-1)}
  if(index%3===0){addSlope(15,29,0);platform(16,28,8);addSlope(24,29,1);addSlope(62,29,0);platform(63,28,8);addSlope(71,29,1)}
  if(index%3===1){for(let x=15;x<27;x++){tiles[28][x]=13;tiles[29][x]=13}addStar(19,28);addStar(24,29);platform(52,29,16,6);tiles[29][75]=14}
  if(index%3===2){for(const x of [17,35,53,72])tiles[29][x]=12;newLevel.enemies.push({x:31,y:29},{x:68,y:29});platform(76,29,10,7)}
  if([3,6,9].includes(index)){tiles[23][45]=8;newLevel.routes['45,23']={x:54,y:18,duration:3,ease:true};addStar(53,17)}
  if([4,9].includes(index))newLevel.settings.theme='night';
  if([5,6].includes(index))newLevel.settings.theme='cave';
  if(index===9)newLevel.settings.requireStars=true;
  return {level:newLevel,description:descriptions[index]};
}
if(campaignLevels.length<20){
  for(let index=0;index<10;index++){
    const official=createOfficialWorld05(index);
    campaignLevels.push(official.level);
    names.push(official.level.name);
    subtitles.push(official.description);
  }
}
const WORLD05_SKINS=['aurora','mech','pirate','crystalSuit','dragon'];
const WORLD05_POWERS=['triple','glide','aqua','pulse'];
function randomWorld05(maximum){
  if(maximum<=1)return 0;
  if(typeof crypto!=='undefined'&&crypto.getRandomValues){const values=new Uint32Array(1);crypto.getRandomValues(values);return values[0]%maximum}
  return Math.floor(Math.random()*maximum);
}
function openWorld05Case(kind){return openCase06(kind)}
function casesWorld05(){
  const body=$('menubody');
  body.innerHTML='<h2 class="sectiontitle">Кейсы за монеты</h2><p class="subtext">До полной коллекции повторов нет.</p><div class="casegrid"><article class="casebox"><div class="caseicon">▣</div><h3>Кейс новых образов</h3><p>Северное сияние, меха-капсула, звёздный корсар, кристальный страж или драконья искра.</p><div class="caseodds">Гарантирован новый костюм · ★ 85</div><button id="world05SkinCase" class="primary">Открыть · ★ 85</button></article><article class="casebox power"><div class="caseicon">✦</div><h3>Кейс суперсил</h3><p>Тройной прыжок, планер, аква-двигатель или звёздный импульс.</p><div class="caseodds">Гарантирована новая сила · ★ 120</div><button id="world05PowerCase" class="primary">Открыть · ★ 120</button></article></div><div id="world05CaseOutput" class="caseout">Текущий баланс: ★ '+profile.coins+'</div>';
  $('world05SkinCase').disabled=profile.coins<85;
  $('world05PowerCase').disabled=profile.coins<120;
  $('world05SkinCase').onclick=()=>openWorld05Case('skin');
  $('world05PowerCase').onclick=()=>openWorld05Case('power');
  wallet();
}
const world05OldShowMenu=showMenu;
showMenu=function(tab='campaign'){
  const requestedTab=tab;
  world05OldShowMenu(tab==='cases'?'shop':tab);
  const tabs=$('tabs');
  if(!tabs)return;
  const caseButton=btn('03 / Кейсы',()=>showMenu('cases'),requestedTab==='cases'?'active':'');
  tabs.insertBefore(caseButton,tabs.children[2]||null);
  for(const button of tabs.children){
    if(button.textContent.includes('Мои уровни'))button.textContent='04 / Мои уровни';
    if(button.textContent.includes('Персонаж'))button.textContent='05 / Персонаж';
    if(button.textContent.includes('Онлайн'))button.textContent='06 / Онлайн';
    if(button.textContent.includes('Миры игроков'))button.textContent='07 / Миры игроков';
  }
  if(requestedTab==='cases'){page='cases';for(const button of tabs.children)button.classList.toggle('active',button===caseButton);casesWorld05()}
  if(requestedTab==='campaign'){
    const title=$('menubody')?.querySelector('.sectiontitle');
    if(title)title.textContent='Двадцать миров — один герой';
    const description=$('menubody')?.querySelector('.subtext');
    if(description)description.textContent=description.textContent.replace(/\/ 10\.?/,'/ '+campaignLevels.length+'.');
  }
};


// CAPSULES_WORLD_06_BEGIN
function normalize06(p = profile) {
  p.owned = [...new Set(Array.isArray(p.owned) ? p.owned : [])];
  p.powers = [...new Set(Array.isArray(p.powers) ? p.powers : [])]
    .filter(id => p.owned.includes(id) && items.some(i => i.id === id && i.kind === 'power'))
    .slice(0, 2);
}
try {
  const key = 'capsules-before-world06';
  if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(profile));
} catch (e) {}
normalize06();

const saveBefore06 = persist;
const walletBefore06 = wallet;
persist = function () { normalize06(); return saveBefore06(); };
wallet = function () { normalize06(); walletBefore06(); };
has = function (id) {
  return settings().allowPowers !== false && profile.owned.includes(id) &&
    profile.powers.filter((v, i, a) =>
      a.indexOf(v) === i && profile.owned.includes(v) &&
      items.some(q => q.id === v && q.kind === 'power')
    ).slice(0, 2).includes(id);
};
function equip06(id) {
  normalize06();
  if (!profile.owned.includes(id) || !items.some(i => i.id === id && i.kind === 'power')) return false;
  if (profile.powers.includes(id)) profile.powers = profile.powers.filter(x => x !== id);
  else {
    if (profile.powers.length >= 2) { toast('Оба слота заняты. Сначала сними одну силу.'); return false; }
    profile.powers.push(id);
  }
  persist(); return true;
}
loadout = function () {
  normalize06();
  const powers = profile.powers.map(id => items.find(i => i.id === id)?.name || id);
  $('loadout').textContent =
    'Образ: ' + (items.find(i => i.id === profile.outfit)?.name || 'Классика') +
    ' · Слоты ' + profile.powers.length + '/2: ' + (powers.join(' + ') || 'пусто') +
    (settings().allowPowers === false ? ' · В этом мире силы отключены' : '');
};

const style06 = document.createElement('style');
style06.textContent = `
.slots06{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:20px 0}
.slot06{border:1px dashed #9bdbc7;border-radius:18px;background:#153238;padding:18px}
.slot06 strong{display:block;margin:12px 0;color:#b2f8dc}
.fitchoice canvas{width:100%;height:110px;aspect-ratio:auto!important;pointer-events:none}
.fitchoice.active{background:#315950;color:white}
.fitchoice.active small{color:#c4ebda}
.map06{width:100%;height:88px;aspect-ratio:auto!important;margin:12px 0;border-radius:10px}
.caseout{white-space:pre-line}
.collection06{font-size:12px;line-height:1.9;color:#b6d4cf;margin:12px 0}
.tools06{display:flex;gap:6px;flex-wrap:wrap;margin:10px 0}
.tools06 button{font-size:11px;padding:7px}
`;
document.head.appendChild(style06);

function inventory06() {
  normalize06();
  $('menubody').innerHTML =
    '<h2 class="sectiontitle">Инвентарь суперсил</h2>' +
    '<p class="subtext">Одновременно — максимум две силы. Снятые способности остаются в коллекции.</p>' +
    '<div class="slots06" id="slots06"></div><div class="cards" id="powers06"></div>';
  for (let n = 0; n < 2; n++) {
    const id = profile.powers[n];
    const item = items.find(i => i.id === id);
    const box = document.createElement('article');
    box.className = 'slot06';
    box.textContent = 'СЛОТ 0' + (n + 1);
    const title = document.createElement('strong');
    title.textContent = item ? item.icon + ' ' + item.name : 'Свободно';
    box.appendChild(title);
    if (item) box.appendChild(btn('Снять', () => { equip06(id); inventory06(); }));
    $('slots06').appendChild(box);
  }
  const owned = items.filter(i => i.kind === 'power' && profile.owned.includes(i.id));
  if (!owned.length) $('powers06').appendChild(makeCard('Пока пусто', 'Получи способности в магазине или кейсах.'));
  for (const item of owned) {
    const equipped = profile.powers.includes(item.id);
    const card = makeCard(item.icon + ' ' + item.name, item.desc);
    const button = btn(equipped ? '✓ Снять' : 'Экипировать',
      () => { if (equip06(item.id)) inventory06(); },
      equipped ? 'active' : 'primary');
    button.disabled = !equipped && profile.powers.length >= 2;
    card.appendChild(button);
    $('powers06').appendChild(card);
  }
  $('menubody').append(btn('Магазин', () => showMenu('shop')), btn('Кейсы', () => showMenu('cases')));
}

function shop06() {
  normalize06();
  $('menubody').innerHTML =
    '<h2 class="sectiontitle">Магазин</h2>' +
    '<p class="subtext">Два слота суперсил. Если оба заняты, новая покупка останется в инвентаре.</p>' +
    '<div class="actions" id="shopfilters"></div><div class="cards" id="cards" style="margin-top:18px"></div>';
  $('shopfilters').append(
    btn('Инвентарь · ' + profile.powers.length + '/2', () => showMenu('inventory'), 'primary'),
    btn('Примерочная', () => showMenu('avatar'))
  );
  for (const item of items.filter(i => !i.caseOnly)) {
    const owned = profile.owned.includes(item.id);
    const equipped = item.kind === 'power' ? profile.powers.includes(item.id) : profile.outfit === item.id;
    const card = makeCard(item.icon + ' ' + item.name, item.desc);
    const button = btn(
      !owned ? 'Купить · ★ ' + item.price :
      equipped ? (item.kind === 'power' ? '✓ Снять' : '✓ Надето') :
      item.kind === 'power' ? 'Экипировать' : 'Надеть',
      () => {
        normalize06();
        if (!profile.owned.includes(item.id)) {
          if (profile.coins < item.price) { toast('Не хватает монет.'); return; }
          profile.coins -= item.price;
          profile.owned.push(item.id);
          if (item.kind === 'outfit') profile.outfit = item.id;
          else if (profile.powers.length < 2) profile.powers.push(item.id);
          else toast('Куплено. Сила ждёт в инвентаре.');
          persist();
        } else if (item.kind === 'power') equip06(item.id);
        else { profile.outfit = item.id; persist(); }
        shop06();
      },
      equipped ? 'active' : 'primary'
    );
    button.disabled = !owned && profile.coins < item.price;
    card.appendChild(button);
    $('cards').appendChild(card);
  }
  wallet();
}

let caseBusy06 = false;
let caseMessage06 = 'Награда без повторов.';
async function openCase06(kind) {
  if (!['skin', 'power'].includes(kind) || caseBusy06) return false;
  caseBusy06 = true;
  const identity = onlineUser?.id || 'guest';
  if (page === 'cases') casesWorld05();
  const operation = () => {
    if ((onlineUser?.id || 'guest') !== identity) throw Error('Аккаунт изменился.');
    const key = onlineUser ? 'capsules-account-' + onlineUser.id : 'capsules-world-v2';
    const stored = JSON.parse(localStorage.getItem(key) || 'null');
    const fresh = onlineUser ? stored?.profile : stored;
    if (fresh && Array.isArray(fresh.owned) && Array.isArray(fresh.custom)) profile = {...profile, ...fresh};
    normalize06();
    const pool = kind === 'skin' ? WORLD05_SKINS : WORLD05_POWERS;
    const cost = kind === 'skin' ? 85 : 120;
    const missing = pool.filter(id => !profile.owned.includes(id));
    if (!missing.length) { caseMessage06 = '✓ Коллекция собрана. Кейс закрыт. Баланс не изменён.'; return false; }
    if (!Number.isFinite(profile.coins) || profile.coins < cost) { caseMessage06 = 'Не хватает монет. Нужно ★ ' + cost + '.'; return false; }
    const previous = clone(profile);
    const id = missing[randomWorld05(missing.length)];
    const item = items.find(i => i.id === id);
    profile.coins -= cost;
    profile.owned.push(id);
    if (!persist()) { profile = previous; persist(); throw Error('Сохранение недоступно.'); }
    caseMessage06 = 'НОВОЕ · ' + item.icon + ' ' + item.name + '\n' + item.desc + '\n' +
      (item.kind === 'power' ? 'Добавлено в инвентарь. Выбери свободный слот.' : 'Костюм доступен в примерочной.');
    return true;
  };
  try {
    if (navigator.locks?.request) return await navigator.locks.request('capsules-case06-' + identity, operation);
    return operation();
  } catch (error) {
    caseMessage06 = error.message || 'Не удалось открыть кейс.';
    toast(caseMessage06);
    return false;
  } finally {
    caseBusy06 = false;
    if (page === 'cases' && mode === 'menu') casesWorld05();
    wallet();
  }
}
openWorld05Case = function (kind) { return openCase06(kind); };
casesWorld05 = function () {
  normalize06();
  $('menubody').innerHTML =
    '<h2 class="sectiontitle">Кейсы · только новые предметы</h2>' +
    '<p class="subtext">При полной коллекции кейс закрыт: ни списаний, ни компенсаций.</p>' +
    '<div class="casegrid" id="cases06"></div><div id="world05CaseOutput" class="caseout" role="status"></div>';
  const cases = [['skin', 'Кейс костюмов', WORLD05_SKINS, 85], ['power', 'Кейс суперсил', WORLD05_POWERS, 120]];
  for (const [kind, title, pool, cost] of cases) {
    const missing = pool.filter(id => !profile.owned.includes(id));
    const card = makeCard(title, missing.length
      ? 'Осталось ' + missing.length + ' из ' + pool.length + '. Шанс каждого: 1/' + missing.length + '.'
      : 'Коллекция полностью собрана.');
    card.className = 'casebox ' + (kind === 'power' ? 'power' : '');
    const collection = document.createElement('div');
    collection.className = 'collection06';
    for (const id of pool) {
      const line = document.createElement('div');
      line.textContent = (profile.owned.includes(id) ? '✓ ' : '◇ ') + items.find(i => i.id === id).name;
      collection.appendChild(line);
    }
    card.appendChild(collection);
    const button = btn(
      !missing.length ? '✓ Коллекция собрана' : caseBusy06 ? 'Сохранение…' : 'Открыть · ★ ' + cost,
      () => openWorld05Case(kind), 'primary'
    );
    button.disabled = caseBusy06 || !missing.length || profile.coins < cost;
    card.appendChild(button);
    $('cases06').appendChild(card);
  }
  $('world05CaseOutput').textContent = caseMessage06 + '\nБаланс: ★ ' + profile.coins;
  $('menubody').append(btn('Инвентарь', () => showMenu('inventory')), btn('Костюмы', () => showMenu('avatar')));
  wallet();
};

const rightSlope06 = (x, y) => [0, 3].includes(Number(level.tileRot?.[x + ',' + y]) || 0);
function slopeSurface06(a, x, y) {
  const left = Math.max(x * S, a.x + 1);
  const right = Math.min((x + 1) * S, a.x + a.w - 1);
  if (left > right) return null;
  return y * S + (rightSlope06(x, y) ? S - (right - x * S) : left - x * S);
}
resolveSlope05 = function () {};

physics = function (a, dt) {
  if (!(dt > 0) || !Number.isFinite(dt)) return;
  const steps = Math.max(1, Math.ceil(dt / .00834));
  const step = dt / steps;
  for (let sub = 0; sub < steps; sub++) {
    const wasGround = a.ground;
    const oldX = a.x, oldY = a.y, oldBottom = a.y + a.h;
    if (sub === 0 && a.platform) { a.x += a.platform.dx || 0; a.y += a.platform.dy || 0; }
    const water = rawTile(Math.floor((a.x + a.w / 2) / S), Math.floor((a.y + a.h / 2) / S)) === 13;
    const swimming = a === hero && (keys.Space || keys.KeyW || keys.ArrowUp);
    const aqua = a === hero && has('aqua');
    let gravity = settings().gravity;
    if (water) {
      a.vx *= Math.exp(-step * (aqua ? 1.5 : 4));
      gravity *= .2;
      if (swimming) a.vy = aqua ? -270 : -205;
      else a.vy = Math.min(125, a.vy);
      if (aqua) {
        const dir = (keys.ArrowRight || keys.KeyD ? 1 : 0) - (keys.ArrowLeft || keys.KeyA ? 1 : 0);
        a.vx = Math.max(-290, Math.min(290, a.vx + dir * 1050 * step));
      }
    }
    const dx = (a.vx + (wasGround && a.surface === 7 ? 95 : 0)) * step;
    a.x += dx;
    let hits = collide(a);
    if (hits.length) {
      const stepUp = a.y + a.h - Math.min(...hits.map(t => t.y));
      let stepped = false;
      if (wasGround && a.vy >= 0 && stepUp > 0 && stepUp <= Math.abs(dx) + 2.1) {
        const savedY = a.y; a.y -= stepUp + .001;
        if (!collide(a).length) stepped = true; else a.y = savedY;
      }
      if (!stepped) {
        if (dx > 0) a.x = Math.min(...hits.map(t => t.x)) - a.w;
        else if (dx < 0) a.x = Math.max(...hits.map(t => t.x + S));
        if (a.npc) a.dir *= -1;
        a.vx = 0;
      }
    }
    for (let y = Math.max(0, Math.floor(a.y / S)); y <= Math.min(R - 1, Math.floor((a.y + a.h) / S)); y++) {
      for (let x = Math.max(0, Math.floor(a.x / S) - 1); x <= Math.min(C - 1, Math.floor((a.x + a.w) / S) + 1); x++) {
        if (rawTile(x, y) !== 15 || a.y >= (y + 1) * S || a.y + a.h <= y * S + 1) continue;
        if (rightSlope06(x, y) && dx < 0 && oldX >= (x + 1) * S - .05 && a.x < (x + 1) * S) {
          a.x = (x + 1) * S; a.vx = 0; if (a.npc) a.dir = 1;
        }
        if (!rightSlope06(x, y) && dx > 0 && oldX + a.w <= x * S + .05 && a.x + a.w > x * S) {
          a.x = x * S - a.w; a.vx = 0; if (a.npc) a.dir = -1;
        }
      }
    }
    const beforeFall = a.y + a.h;
    a.vy = Math.min(water ? 180 : 900, a.vy + gravity * step);
    a.y += a.vy * step;
    a.ground = false; a.platform = null;
    hits = collide(a);
    if (hits.length) {
      if (a.vy >= 0) { a.y = Math.min(...hits.map(t => t.y)) - a.h; a.ground = true; }
      else a.y = Math.max(...hits.map(t => t.y + S));
      a.vy = 0;
    }
    let top = null;
    const allowance = Math.abs(dx) + 2.2;
    for (let y = Math.max(0, Math.floor(Math.min(oldY, a.y) / S) - 1);
         y <= Math.min(R - 1, Math.floor((Math.max(oldBottom, a.y + a.h) + allowance) / S)); y++) {
      for (let x = Math.max(0, Math.floor(a.x / S)); x <= Math.min(C - 1, Math.floor((a.x + a.w - .01) / S)); x++) {
        if (rawTile(x, y) !== 15) continue;
        const surface = slopeSurface06(a, x, y);
        if (surface === null) continue;
        const fromAbove = beforeFall <= surface + .25 || (wasGround && beforeFall <= surface + allowance);
        if (a.vy >= 0 && fromAbove && a.y + a.h >= surface - (wasGround ? allowance : .001)) {
          if (top === null || surface < top) top = surface;
        }
        if (a.vy < 0 && oldY >= (y + 1) * S - .1 && a.y < (y + 1) * S) {
          a.y = (y + 1) * S; a.vy = 0;
        }
      }
    }
    if (top !== null) { a.y = top - a.h; a.vy = 0; a.ground = true; }
    if (!a.ground && a.vy >= 0) {
      for (const p of platforms) {
        if (a.x + a.w > p.x && a.x < p.x + p.w &&
            beforeFall <= p.y - (p.dy || 0) + 3 && a.y + a.h >= p.y && a.y < p.y) {
          a.y = p.y - a.h; a.vy = 0; a.ground = true; a.platform = p; break;
        }
      }
    }
    const tx = Math.floor((a.x + a.w / 2) / S);
    const ty = Math.floor((a.y + a.h + 1) / S);
    const v = rawTile(tx, ty);
    a.surface = top !== null ? 15 : a.ground && !a.platform ? v : 0;
    if (a.ground && !a.platform) {
      if (v === 4 || v === 14) { a.vy = v === 4 ? -710 : -820; a.ground = false; }
      else if (v === 5 && !crumbs[tx + ',' + ty]) crumbs[tx + ',' + ty] = {t: .65, gone: false};
    }
    a.x = Math.max(0, Math.min(W - a.w, a.x));
  }
  if (Math.abs(a.vx) > 5) { a.phase += dt * Math.abs(a.vx) / 20; a.dir = Math.sign(a.vx); }
};

const paintBefore06 = paintCell;
let rotation06 = 0;
let lastCell06 = null;
let selection06 = null;
let selectionStart06 = null;
let clipboard06 = null;

paintCell = function (p, t) {
  if (p.x < 0 || p.x >= C || p.y < 0 || p.y >= R) return;
  const same = q => q.x === p.x && q.y === p.y;
  const key = p.x + ',' + p.y;
  if (t === 'water') {
    if (![0, 13].includes(rawTile(p.x, p.y))) return;
    level.tiles[p.y][p.x] = 13; dirty = true; return;
  }
  if (t === 'terrainErase') {
    if (rawTile(p.x, p.y) !== 13) level.tiles[p.y][p.x] = 0;
    delete level.routes?.[key]; delete level.tileRot?.[key];
    dirty = true; return;
  }
  const arrayName = {star: 'stars', npc: 'npcs', enemy: 'enemies', checkpoint: 'checkpoints'}[t];
  if (arrayName) {
    level[arrayName] ??= [];
    const cap = ['npcs', 'enemies'].includes(arrayName) ? 80 : 2000;
    if (!level[arrayName].some(same) && level[arrayName].length < cap)
      level[arrayName].push({...p});
    dirty = true; return;
  }
  if (t === 'spawn' || t === 'flag') {
    level[t] = {...p};
    for (const y of [p.y, p.y - 1]) if (y >= 0 && level.tiles[y][p.x] !== 13) level.tiles[y][p.x] = 0;
    dirty = true; return;
  }
  if (t === 'select06' || t === 'paste06') return;
  paintBefore06(p, t);
  if (t === 'ramp' && rawTile(p.x, p.y) === 15) {
    level.tileRot ??= {};
    level.tileRot[key] = rotation06;
  }
};

function box06(a, b) {
  return {
    x: Math.max(0, Math.min(C - 1, Math.min(a.x, b.x))),
    y: Math.max(0, Math.min(R - 1, Math.min(a.y, b.y))),
    x2: Math.max(0, Math.min(C - 1, Math.max(a.x, b.x))),
    y2: Math.max(0, Math.min(R - 1, Math.max(a.y, b.y)))
  };
}
function copySelection06() {
  if (!selection06) { toast('Протяни рамку инструментом «Выделение».'); return; }
  const r = selection06;
  clipboard06 = {
    w: r.x2 - r.x + 1, h: r.y2 - r.y + 1,
    tiles: level.tiles.slice(r.y, r.y2 + 1).map(row => row.slice(r.x, r.x2 + 1)),
    tileRot: {}, routes: {}
  };
  for (const key of ['stars', 'npcs', 'enemies', 'checkpoints', 'decor']) {
    clipboard06[key] = (level[key] || []).filter(p => p.x >= r.x && p.x <= r.x2 && p.y >= r.y && p.y <= r.y2)
      .map(p => ({...p, x: p.x - r.x, y: p.y - r.y}));
  }
  for (let y = r.y; y <= r.y2; y++) for (let x = r.x; x <= r.x2; x++) {
    const key = x + ',' + y; const target = (x - r.x) + ',' + (y - r.y);
    if (level.tileRot?.[key] !== undefined) clipboard06.tileRot[target] = level.tileRot[key];
    const route = level.routes?.[key];
    if (route && route.x >= r.x && route.x <= r.x2 && route.y >= r.y && route.y <= r.y2)
      clipboard06.routes[target] = {...route, x: route.x - r.x, y: route.y - r.y};
  }
  toast('Скопировано ' + clipboard06.w + ' × ' + clipboard06.h + '.');
}
function pasteSelection06(p) {
  if (!clipboard06) return toast('Сначала скопируй участок.');
  const b = clipboard06;
  if (p.x < 0 || p.y < 0 || p.x + b.w > C || p.y + b.h > R) return toast('Участок не помещается.');
  const inside = q => q.x >= p.x && q.x < p.x + b.w && q.y >= p.y && q.y < p.y + b.h;
  if (inside(level.spawn) || inside(level.flag)) return toast('Нельзя перекрывать старт или финиш.');
  remember();
  for (let y = 0; y < b.h; y++) for (let x = 0; x < b.w; x++) {
    const key = (p.x + x) + ',' + (p.y + y); const source = x + ',' + y;
    level.tiles[p.y + y][p.x + x] = b.tiles[y][x];
    delete level.tileRot?.[key]; delete level.routes?.[key];
    if (b.tileRot[source] !== undefined) { level.tileRot ??= {}; level.tileRot[key] = b.tileRot[source]; }
    if (b.routes[source]) { level.routes ??= {}; level.routes[key] = {...b.routes[source], x: b.routes[source].x + p.x, y: b.routes[source].y + p.y}; }
  }
  for (const key of ['stars', 'npcs', 'enemies', 'checkpoints', 'decor']) {
    level[key] = [...(level[key] || []).filter(q => !inside(q)), ...b[key].map(q => ({...q, x: q.x + p.x, y: q.y + p.y}))];
  }
  reset(); autosave();
}
for (const [id, label] of [['terrainErase', '⌫ Только рельеф'], ['select06', '▱ Выделение'], ['paste06', '▣ Вставить']]) {
  defs.push([id, label, 0]);
  $('tools2').appendChild(btn(label, () => { tool = id; toolHelp(); }));
}
$('brush').insertAdjacentHTML('beforeend',
  '<option value="outline06">Контур прямоугольника</option><option value="ellipse06">Эллипс</option>');
const controls06 = document.createElement('div');
controls06.className = 'tools06';
controls06.append(btn('Копировать участок', copySelection06), btn('Вставить участок', () => { tool = 'paste06'; toolHelp(); }));
$('brush').parentElement.after(controls06);
const helpBefore06 = toolHelp;
toolHelp = function () {
  helpBefore06();
  const help = {
    ramp: 'Склон 45°. Поворот меняет кисть и последний выбранный склон.',
    water: 'Сохраняет объекты. Плавание — удерживай прыжок.',
    terrainErase: 'Удаляет рельеф и маршруты. Вода и объекты остаются.',
    select06: 'Протяни рамку, затем «Копировать участок».',
    paste06: 'Клик — верхний левый угол вставки. Ctrl+Z отменяет.'
  };
  if (help[tool]) $('toolhelp').textContent = help[tool];
};
$('rotate').onclick = () => {
  rotation06 = rotation06 === 0 ? 1 : 0;
  const p = lastCell06;
  if (mode === 'edit' && p && rawTile(p.x, p.y) === 15) {
    remember();
    level.tileRot ??= {};
    level.tileRot[p.x + ',' + p.y] = rotation06;
    reset(); autosave();
  }
  toast('Подъём ' + (rotation06 === 0 ? 'вправо ◢' : 'влево ◣'));
};
const downBefore06 = canvas.onpointerdown;
const moveBefore06 = canvas.onpointermove;
canvas.onpointerdown = e => {
  if (mode === 'edit' && !e.altKey && e.button !== 1) {
    lastCell06 = cell(e);
    if (tool === 'select06') {
      e.preventDefault(); selectionStart06 = cell(e);
      selection06 = box06(selectionStart06, selectionStart06);
      canvas.setPointerCapture(e.pointerId); return;
    }
    if (tool === 'paste06') { e.preventDefault(); pasteSelection06(cell(e)); return; }
  }
  downBefore06(e);
};
canvas.onpointermove = e => {
  if (selectionStart06) { hover = cell(e); selection06 = box06(selectionStart06, hover); return; }
  moveBefore06(e);
};
const endBefore06 = endStroke;
endStroke = function () {
  if (paint && mode === 'edit' && strokeStart && hover && ['outline06', 'ellipse06'].includes($('brush').value)) {
    const r = box06(strokeStart, hover);
    const t = strokeErase ? 'erase' : tool;
    for (let y = r.y; y <= r.y2; y++) for (let x = r.x; x <= r.x2; x++) {
      const nx = (x - (r.x + r.x2) / 2) / ((r.x2 - r.x + 1) / 2);
      const ny = (y - (r.y + r.y2) / 2) / ((r.y2 - r.y + 1) / 2);
      const use = $('brush').value === 'outline06'
        ? x === r.x || x === r.x2 || y === r.y || y === r.y2
        : nx * nx + ny * ny <= 1;
      if (use) paintCell({x, y}, t);
    }
    paint = false; strokeStart = null; reset(); autosave(); return;
  }
  endBefore06();
};
canvas.onpointerup = () => {
  pan = null;
  if (selectionStart06) { selectionStart06 = null; return; }
  endStroke();
};
canvas.onpointercancel = () => { selectionStart06 = null; pan = null; endStroke(); };
const drawBefore06 = draw;
draw = function () {
  drawBefore06();
  if (mode === 'edit' && selection06) {
    ctx.save(); ctx.scale(cam.z, cam.z); ctx.translate(-cam.x, -cam.y);
    ctx.strokeStyle = '#ffe6a0'; ctx.lineWidth = 2 / cam.z;
    ctx.setLineDash([7 / cam.z, 5 / cam.z]);
    ctx.strokeRect(selection06.x * S, selection06.y * S,
      (selection06.x2 - selection06.x + 1) * S, (selection06.y2 - selection06.y + 1) * S);
    ctx.restore();
  }
};
const editorBefore06 = openEditor;
openEditor = function (l, id) {
  selection06 = null; selectionStart06 = null; lastCell06 = null;
  editorBefore06(l, id);
};

const newSuits06 = ['aurora', 'mech', 'pirate', 'crystalSuit', 'dragon'];
renderCapsule = function (c, a, opt = {}) {
  const outfit = opt.outfit || profile.outfit;
  if (!newSuits06.includes(outfit)) { world05OldRenderCapsule(c, a, opt); return; }
  const scale = opt.scale || 1;
  const ox = opt.x ?? a.x + 10;
  const oy = opt.y ?? a.y;
  const t = opt.time ?? time;
  const yaw = opt.yaw ?? a.dir * .25;
  const cy = Math.cos(yaw); const sy = Math.sin(yaw);
  const rig = pose(a, t); const bob = rig.bob;
  const palettes = {
    aurora: ['#29436d', '#7dfce0', '#ce8fff'],
    mech: ['#334953', '#7df5de', '#e9b477'],
    pirate: ['#3c304a', '#efcc83', '#d56573'],
    crystalSuit: ['#425a8f', '#9cf6ff', '#c49aff'],
    dragon: ['#74384d', '#ffe398', '#f18b68']
  };
  const [baseColor, light, accent] = palettes[outfit];
  const project = p => {
    const depth = -p[0] * sy + p[2] * cy;
    return [ox + (p[0] * cy + p[2] * sy) * scale,
            oy + (p[1] - (opt.preview ? .16 : 0) * depth) * scale, depth];
  };
  const line = (points, color, width) => {
    c.strokeStyle = color; c.lineWidth = width * scale;
    c.lineCap = 'round'; c.lineJoin = 'round'; c.beginPath();
    points.forEach((p, i) => { const q = project(p); i ? c.lineTo(q[0], q[1]) : c.moveTo(q[0], q[1]); });
    c.stroke();
  };
  const polygon = (points, color, border) => {
    c.beginPath();
    points.forEach((p, i) => { const q = project(p); i ? c.lineTo(q[0], q[1]) : c.moveTo(q[0], q[1]); });
    c.closePath(); c.fillStyle = color; c.fill();
    if (border) { c.strokeStyle = border; c.lineWidth = .65 * scale; c.stroke(); }
  };
  const ball = (p, radius, color) => {
    const q = project(p); c.fillStyle = color;
    c.beginPath(); c.arc(q[0], q[1], radius * scale, 0, Math.PI * 2); c.fill();
  };
  const gem = (x, y, z, size) => {
    polygon([[x, y - size, z], [x + size * .65, y, z], [x, y + size, z], [x - size * .65, y, z]], light, baseColor);
    polygon([[x, y - size, z], [x, y + size, z], [x - size * .65, y, z]], accent);
  };
  c.save();
  if (opt.ghost) c.globalAlpha *= .75;
  c.fillStyle = '#061a2d35';
  c.beginPath(); c.ellipse(ox, oy + 44 * scale, 17 * scale, 3.5 * scale, 0, 0, Math.PI * 2); c.fill();
  if (outfit === 'aurora') {
    const wave = Math.sin(t * 4) * 3;
    polygon([[-9, 17 + bob, -8], [9, 17 + bob, -8], [13, 37 + bob + wave, -12], [0, 32 + bob, -15], [-13, 37 + bob - wave, -12]], '#6679b8');
    line([[-9, 19 + bob, -9], [-12, 34 + bob - wave, -12]], light, 1.5);
    line([[9, 19 + bob, -9], [12, 34 + bob + wave, -12]], accent, 1.5);
  }
  if (outfit === 'mech') {
    for (const d of [-1, 1]) {
      line([[d * 7, 17 + bob, -8], [d * 7, 27 + bob, -8]], '#1e303b', 7);
      ball([d * 7, 29 + bob, -8], 2.5, light);
    }
  }
  if (outfit === 'dragon') {
    for (const d of [-1, 1]) {
      const flap = Math.sin(t * 5) * 3;
      polygon([[d * 8, 19 + bob, -7], [d * 28, 5 + bob + flap, -11], [d * 25, 22 + bob, -9], [d * 18, 20 + bob, -9], [d * 16, 30 + bob, -8]], accent, baseColor);
      line([[d * 8, 19 + bob, -7], [d * 28, 5 + bob + flap, -11], [d * 25, 22 + bob, -9]], baseColor, 1.7);
      line([[d * 8, 19 + bob, -7], [d * 18, 20 + bob, -9]], light, .8);
    }
    line([[0, 27 + bob, -7], [8, 34 + bob, -17], [15, 30 + bob, -22]], baseColor, 4);
    gem(15, 29 + bob, -22, 3);
  }
  const drawLimb = limb => {
    const p = limb.p;
    const metal = outfit === 'mech';
    line(p, baseColor, metal ? 7 : 5.5);
    line([p[0], p[1]], limb.leg ? baseColor : accent, metal ? 8 : 6.5);
    ball(p[1], metal ? 3 : 2, metal ? accent : light);
    line([p[1], p[2]], baseColor, metal ? 7 : 5);
    if (limb.leg) {
      line([p[2], [p[2][0], p[2][1], p[2][2] + 3]], metal ? '#b7cbd0' : light, metal ? 7 : 5.5);
      if (outfit === 'crystalSuit') gem(p[1][0], p[1][1] + 2, p[1][2] + 3, 3.5);
    } else ball(p[2], metal ? 3.6 : 2.7, metal ? '#b5c8cf' : '#ffe096');
  };
  const limbs = rig.limbs.map(l => ({...l, depth: project(l.p[2])[2]})).sort((a, b) => a.depth - b.depth);
  limbs.filter(l => l.depth <= 0).forEach(drawLimb);
  const gradient = c.createLinearGradient(ox - 12 * scale, oy, ox + 12 * scale, oy + 32 * scale);
  gradient.addColorStop(0, light); gradient.addColorStop(.2, baseColor); gradient.addColorStop(1, '#172736');
  line([[0, 10 + bob, 0], [0, 24 + bob, 0]], gradient, 22);
  line([[0, 24 + bob, 0], [0, 29 + bob, 0]], baseColor, 21);
  if (cy > 0) {
    if (outfit === 'mech') {
      line([[-6, 9 + bob, 9], [6, 9 + bob, 9]], '#142a37', 7);
      line([[-5, 9 + bob, 12], [5, 9 + bob, 12]], light, 2);
      polygon([[-7, 16 + bob, 8], [0, 20 + bob, 11], [7, 16 + bob, 8]], '#9eb3be');
    } else {
      line([[-5, 10 + bob, 8], [5, 10 + bob, 8]], '#ffdf84', 10);
      ball([-3, 10 + bob, 12], 1.3, '#18313c');
      ball([4, 10 + bob, 12], 1.3, '#18313c');
      line([[-1, 14 + bob, 12], [1, 15 + bob, 12], [3, 14 + bob, 12]], '#9b713e', .9);
    }
    if (outfit === 'aurora') {
      line([[-7, 19 + bob, 10], [0, 26 + bob, 12], [7, 19 + bob, 10]], light, 1.7);
      gem(0, 25 + bob, 12, 3);
      line([[-7, 5 + bob, 7], [0, 1 + bob, 10], [7, 5 + bob, 7]], accent, 3);
    }
    if (outfit === 'mech') {
      polygon([[-9, 20 + bob, 8], [0, 18 + bob, 11], [9, 20 + bob, 8], [7, 30 + bob, 9], [-7, 30 + bob, 9]], '#637e8c', '#accbd0');
      ball([0, 24 + bob, 12], 4.5, '#193943');
      ball([0, 24 + bob, 13], 2.8, light);
      line([[-8, 29 + bob, 10], [-4, 29 + bob, 11]], accent, 1);
      line([[4, 29 + bob, 11], [8, 29 + bob, 10]], accent, 1);
    }
    if (outfit === 'pirate') {
      for (const d of [-1, 1]) {
        polygon([[d * 10, 18 + bob, 6], [d, 21 + bob, 11], [d * 4, 32 + bob, 9], [d * 12, 35 + bob, 2]], baseColor, light);
        for (const y of [23, 27, 31]) ball([d * 6, y + bob, 11], .9, light);
      }
      line([[-7, 19 + bob, 11], [6, 29 + bob, 12]], accent, 3);
      line([[-6, 9 + bob, 13], [-1, 9 + bob, 13]], '#202937', 4);
      line([[-9, 5 + bob, 9], [9, 11 + bob, 10]], '#202937', .8);
    }
    if (outfit === 'crystalSuit') {
      polygon([[-9, 20 + bob, 8], [0, 16 + bob, 12], [9, 20 + bob, 8], [6, 31 + bob, 10], [0, 28 + bob, 13], [-6, 31 + bob, 10]], baseColor, light);
      gem(0, 24 + bob, 13, 7);
      for (const d of [-1, 1]) { gem(d * 9, 21 + bob, 8, 4); gem(d * 6, bob, 4, 5); }
      gem(0, -3 + bob, 6, 7);
    }
    if (outfit === 'dragon') {
      polygon([[-8, 18 + bob, 8], [0, 17 + bob, 12], [8, 18 + bob, 8], [6, 30 + bob, 10], [-6, 30 + bob, 10]], accent, baseColor);
      for (const y of [21, 25, 29]) line([[-5, y + bob, 11], [0, y + 2 + bob, 12], [5, y + bob, 11]], light, 1);
    }
    if (opt.glasses ?? profile.glasses) {
      line([[-6, 10 + bob, 14], [-1, 10 + bob, 14]], '#203042', 3);
      line([[2, 10 + bob, 14], [7, 10 + bob, 14]], '#203042', 3);
      line([[-1, 10 + bob, 14], [2, 10 + bob, 14]], light, 1);
    }
  } else {
    line([[-5, 14 + bob, -10], [5, 14 + bob, -10]], accent, 1.5);
    if (outfit === 'mech') gem(0, 23 + bob, -12, 4);
    if (outfit === 'crystalSuit') gem(0, 16 + bob, -12, 7);
  }
  if (outfit === 'pirate') {
    polygon([[-17, 2 + bob, 2], [-9, -9 + bob, 0], [0, -4 + bob, 10], [9, -9 + bob, 0], [17, 2 + bob, 2], [0, bob, 11]], '#33293f', light);
    if (cy > 0) {
      gem(0, -3 + bob, 12, 2.5);
      line([[10, -6 + bob, 1], [15, -16 + bob, -1], [19, -18 + bob, -2]], accent, 2);
    }
  }
  if (outfit === 'dragon') {
    for (const d of [-1, 1]) polygon([[d * 5, 2 + bob, 2], [d * 7, -10 + bob, 0], [d * 11, -3 + bob, 0], [d * 9, 5 + bob, 3]], light, baseColor);
  }
  if (outfit === 'mech') {
    for (const d of [-1, 1]) polygon([[d * 8, 16 + bob, 0], [d * 15, 17 + bob, 0], [d * 14, 23 + bob, 2], [d * 9, 21 + bob, 5]], '#98afb9', baseColor);
  }
  limbs.filter(l => l.depth > 0).forEach(drawLimb);
  if (outfit === 'aurora') {
    for (let i = 0; i < 3; i++) ball([Math.sin(t + i * 2) * 16, 8 + bob + Math.cos(t * 1.7 + i) * 8, Math.cos(t + i * 2) * 12], .7, i % 2 ? accent : light);
  }
  c.restore();
};

const wardrobeBefore06 = wardrobe;
wardrobe = function () {
  wardrobeBefore06();
  const fits = $('fits');
  fits.replaceChildren();
  for (const item of items.filter(i => i.kind === 'outfit')) {
    const owned = profile.owned.includes(item.id);
    const button = btn(item.name, () => { fitting = item.id; wardrobe(); }, 'fitchoice' + (fitting === item.id ? ' active' : ''));
    const thumbnail = document.createElement('canvas');
    thumbnail.width = 210; thumbnail.height = 110;
    renderCapsule(thumbnail.getContext('2d'), {x: 0, y: 0, vx: 0, ground: true, phase: 0, dir: 1},
      {outfit: item.id, x: 105, y: 24, scale: 1.7, yaw: .3, glasses: false, time: 0});
    button.prepend(thumbnail);
    const label = document.createElement('small');
    label.textContent = owned ? (profile.outfit === item.id ? '✓ Надето' : 'В гардеробе')
      : item.caseOnly ? 'Из кейса · можно примерить' : '★ ' + item.price;
    button.appendChild(label);
    fits.appendChild(button);
  }
  const item = items.find(i => i.id === fitting && i.kind === 'outfit') || items.find(i => i.id === 'classic');
  const owned = profile.owned.includes(item.id);
  const locked = item.caseOnly && !owned;
  $('fitstate').textContent = locked ? 'Примерка. Получи этот костюм из кейса.'
    : owned ? 'Костюм в гардеробе' : 'Примерка перед покупкой';
  $('equipfit').textContent = locked ? 'Открыть кейсы →' :
    owned ? (profile.outfit === item.id ? '✓ Надето' : 'Надеть') :
    'Купить и надеть · ★ ' + item.price;
  $('equipfit').disabled = !locked && !owned && profile.coins < item.price;
  $('equipfit').onclick = () => {
    if (locked) { showMenu('cases'); return; }
    if (!profile.owned.includes(item.id)) {
      if (profile.coins < item.price) return;
      profile.coins -= item.price;
      profile.owned.push(item.id);
    }
    profile.outfit = item.id;
    persist(); wardrobe();
  };
};

function official06(n) {
  const sizes = [[92,32],[76,32],[84,32],[76,32],[80,36],[64,46],[52,56],[96,32],[100,36],[112,38]];
  const titles = ['Террасы рассвета','Затонувшая обсерватория','Сердце вулкана','Небесная почта','Орбита льда','Кристальный спуск','Башня прилива','Медный конвейер','Сады над бездной','Последняя экспедиция'];
  const descriptions = [
    'Два настоящих холма, террасы и плавные спуски',
    'Ныряй под арки и всплывай к затонувшим площадкам',
    'Огненные камеры с безопасными местами ожидания',
    'Пересадки между воздушными паромами',
    'Ледяная лестница к верхней обсерватории',
    'Зигзагообразный спуск через шесть пещерных залов',
    'Высокая водяная шахта с площадками отдыха',
    'Конвейеры, обходы пил и патрули',
    'Садовые острова, хрупкие мосты и паромы',
    'Склоны, вода, огонь, лёд и финальная переправа'
  ];
  const [w, h] = sizes[n];
  const l = {
    version: 6, name: titles[n],
    tiles: Array.from({length:h}, () => Array(w).fill(0)),
    spawn: {x:2,y:h-3}, flag: {x:w-4,y:h-3},
    npcs: [], stars: [], enemies: [], checkpoints: [], decor: [],
    routes: {}, tileRot: {}, settings: {...defaultSettings, allowPowers:true}
  };
  const T = l.tiles;
  const rect = (x,y,ww,hh,v=1) => {
    for (let yy=Math.max(0,y); yy<Math.min(h,y+hh); yy++)
      for (let xx=Math.max(0,x); xx<Math.min(w,x+ww); xx++) T[yy][xx]=v;
  };
  const row = (x,y,len,v=1) => rect(x,y,len,1,v);
  const floor = (x,y,len) => rect(x,y,len,h-y);
  const water = (x,y,ww,hh) => rect(x,y,ww,hh,13);
  const star = (x,y) => l.stars.push({x,y});
  const cp = (x,y) => l.checkpoints.push({x,y});
  const decor = (x,y,type) => l.decor.push({x,y,type});
  const slope = (x,y,right) => { floor(x,y+1,1); T[y][x]=15; l.tileRot[x+','+y]=right?0:1; };
  const ferry = (x,y,tx,ty,len=3,duration=4) => {
    row(x,y,len,8);
    for(let i=0;i<len;i++) l.routes[(x+i)+','+y]={x:tx+i,y:ty,duration,ease:true};
  };
  if (n===0) {
    floor(0,30,w);
    for(let x=10;x<17;x++) slope(x,39-x,true);
    floor(17,23,12);
    for(let x=29;x<36;x++) slope(x,x-6,false);
    for(let x=47;x<53;x++) slope(x,76-x,true);
    floor(53,24,13);
    for(let x=66;x<72;x++) slope(x,x-42,false);
    [[6,28],[14,23],[22,21],[33,25],[43,28],[50,24],[60,22],[69,25],[83,28]].forEach(p=>star(...p));
    cp(24,22); cp(62,23);
    l.flag={x:87,y:29};
    decor(20,22,'tree'); decor(58,23,'pine'); decor(77,29,'ruins');
    l.npcs=[{x:6,y:29}];
  }
  if (n===1) {
    floor(0,30,w);
    water(11,22,52,8);
    floor(0,26,7);
    for(let x=7;x<11;x++) floor(x,19+x,1);
    floor(63,26,13);
    l.spawn={x:2,y:25}; l.flag={x:71,y:25};
    rect(22,22,2,5); rect(37,28,2,2); rect(48,22,2,5);
    row(28,24,6); row(54,25,4);
    [[14,25],[20,28],[26,28],[31,22],[35,26],[42,26],[51,28],[58,23]].forEach(p=>star(...p));
    cp(31,23); cp(57,24);
    decor(3,25,'lamp'); decor(67,25,'ruins'); decor(42,29,'crystal');
    l.settings.theme='night';
  }
  if (n===2) {
    floor(0,30,w);
    for(const x of [13,29,47,64]) {
      row(x,29,1,12);
      row(x-5,26,3); row(x+3,26,3);
      star(x-4,25); star(x+4,25);
      decor(x-7,29,'sign');
    }
    cp(24,29); cp(42,29); cp(59,29);
    row(74,28,4); star(76,27);
    l.flag={x:80,y:29};
    l.settings.firePeriod=4.5; l.settings.fireDuty=.4; l.settings.theme='cave';
    decor(7,29,'torch');
  }
  if (n===3) {
    floor(0,28,15); floor(30,25,13); floor(61,22,15);
    rect(15,31,15,1,3); rect(43,31,18,1,3);
    l.spawn={x:3,y:27}; l.flag={x:71,y:21};
    ferry(13,27,29,24,3,4);
    ferry(41,24,60,21,3,4.5);
    cp(35,24); cp(64,21);
    row(6,25,4); row(34,22,4); row(66,19,4);
    [[8,24],[20,25],[32,24],[36,21],[50,21],[65,20],[68,18]].forEach(p=>star(...p));
    decor(10,27,'banner'); decor(39,24,'lamp'); decor(73,21,'pine');
  }
  if (n===4) {
    floor(0,34,w);
    row(5,34,18,6); row(28,34,20,6); row(54,34,22,6);
    row(19,33,2,2); row(44,33,2,2);
    for(const p of [[12,31,7],[21,28,7],[30,25,8],[40,22,8],[49,19,8],[59,16,8],[67,13,10]]) row(...p,6);
    l.flag={x:73,y:12};
    cp(32,24); cp(60,15);
    [[9,33],[14,30],[24,27],[33,24],[43,21],[52,18],[62,15],[72,12]].forEach(p=>star(...p));
    decor(4,33,'pine'); decor(34,24,'crystal'); decor(72,12,'ruins');
    l.settings.theme='night';
  }
  if (n===5) {
    l.spawn={x:4,y:6}; l.flag={x:8,y:42};
    row(1,7,24); row(29,13,30); row(5,19,24);
    row(29,25,30); row(5,31,24); row(29,37,30);
    floor(0,43,w);
    rect(0,0,1,43); rect(63,0,1,43);
    [[18,6],[46,12],[12,18],[48,24],[14,30],[48,36],[13,42]].forEach(p=>star(...p));
    cp(50,12); cp(12,30);
    for(const [x,y] of [[7,6],[35,12],[9,18],[54,24],[8,30],[56,36]]) decor(x,y,'crystal');
    l.settings.theme='cave';
  }
  if (n===6) {
    floor(0,54,w);
    water(17,7,18,47);
    rect(15,9,2,45); rect(35,9,2,45);
    rect(15,49,2,4,0); rect(35,7,2,4,0);
    l.spawn={x:7,y:53}; l.flag={x:45,y:7};
    row(35,8,16); row(17,41,6); row(28,29,7); row(17,18,6);
    cp(20,40); cp(30,28); cp(20,17);
    [[21,48],[29,43],[24,37],[20,33],[26,26],[31,22],[25,15],[30,9],[42,7]].forEach(p=>star(...p));
    decor(9,53,'lamp'); decor(42,7,'banner');
    l.settings.theme='cave';
  }
  if (n===7) {
    floor(0,30,w);
    row(8,30,20,7); row(36,30,18,7); row(64,30,20,7);
    for(const x of [20,47,74]) { row(x,29,1,10); row(x-4,26,9); star(x,25); }
    for(const x of [32,60,88]) { l.enemies.push({x,y:29}); row(x-2,27,4); star(x,26); }
    cp(39,29); cp(67,29);
    l.flag={x:91,y:29};
    star(10,28); star(83,28);
    decor(4,29,'lamp'); decor(57,29,'sign'); decor(89,26,'banner');
    l.settings.theme='night';
  }
  if (n===8) {
    floor(0,32,18); floor(31,29,18); floor(61,27,17); floor(87,30,13);
    l.spawn={x:3,y:31}; l.flag={x:95,y:29};
    row(19,30,4); row(25,28,4);
    row(50,28,4,5); row(56,27,3,5); row(79,28,5);
    ferry(18,32,29,29,3,4); ferry(49,30,60,27,3,4);
    cp(36,28); cp(66,26);
    [[10,30],[20,29],[27,27],[40,27],[52,27],[57,26],[69,25],[81,27],[92,28]].forEach(p=>star(...p));
    decor(9,31,'tree'); decor(38,28,'ruins'); decor(71,26,'tree'); decor(94,29,'flowers');
  }
  if (n===9) {
    floor(0,36,81); floor(96,33,16);
    l.spawn={x:3,y:35}; l.flag={x:107,y:32};
    for(let x=9;x<14;x++) slope(x,44-x,true);
    floor(14,31,6);
    for(let x=20;x<25;x++) slope(x,x+11,false);
    water(30,29,18,7); rect(39,29,2,4);
    row(56,35,1,12); row(52,32,3); row(59,32,3);
    row(64,36,12,6); row(71,35,2,2); row(75,33,5);
    ferry(80,34,95,32,3,3.5);
    rect(81,37,15,1,3);
    cp(27,35); cp(50,35); cp(77,32); cp(100,32);
    [[6,34],[16,30],[27,34],[34,32],[43,34],[53,31],[60,31],[68,34],[77,32],[87,31],[101,32]].forEach(p=>star(...p));
    decor(17,30,'banner'); decor(50,35,'torch'); decor(104,32,'ruins');
    l.settings.theme='night'; l.settings.firePeriod=4.5; l.settings.fireDuty=.35; l.settings.requireStars=false;
  }
  return {level:l, description:descriptions[n]};
}
for(let n=0;n<10;n++) {
  const entry=official06(n);
  campaignLevels[n+10]=entry.level;
  names[n+10]=entry.level.name;
  subtitles[n+10]=entry.description;
}

function mapThumbnail06(card, l) {
  const canvas = document.createElement('canvas');
  canvas.className='map06'; canvas.width=360; canvas.height=100;
  const g=canvas.getContext('2d');
  const sx=canvas.width/l.tiles[0].length;
  const sy=canvas.height/l.tiles.length;
  g.fillStyle='#152b39'; g.fillRect(0,0,canvas.width,canvas.height);
  for(let y=0;y<l.tiles.length;y++) for(let x=0;x<l.tiles[y].length;x++) {
    const value=l.tiles[y][x];
    if(!value) continue;
    g.fillStyle=value===13?'#559ebc':[3,10,11,12].includes(value)?'#d68a73':value===6?'#b4e9f3':'#85b5a0';
    g.fillRect(x*sx,y*sy,Math.max(1,sx),Math.max(1,sy));
  }
  g.fillStyle='#ffe5a0';
  for(const p of l.stars) g.fillRect(p.x*sx,p.y*sy,2,2);
  g.fillStyle='#b2ffdc';
  g.fillRect(l.flag.x*sx,(l.flag.y-2)*sy,3,6);
  card.querySelector('.mini')?.replaceWith(canvas);
}
const menuBefore06=showMenu;
showMenu=function(tab='campaign') {
  normalize06();
  menuBefore06(tab);
  const tabs=$('tabs');
  if(tabs&&!tabs.querySelector('[data-inventory06]')) {
    const button=btn('Инвентарь · '+profile.powers.length+'/2',()=>showMenu('inventory'),tab==='inventory'?'active':'');
    button.dataset.inventory06='1';
    tabs.appendChild(button);
  }
  if(tab==='inventory') { page='inventory'; inventory06(); }
  if(tab==='shop') shop06();
  const eye=menu.querySelector('.eyebrow');
  if(eye) eye.textContent='CAPSULE WORLD · 06 / EXPEDITION';
  const intro=menu.querySelector('.herohead p');
  if(intro) intro.textContent='20 приключений. Новые карты 11–20, обновлённые костюмы и две суперсилы на выбор.';
  if(tab==='campaign') {
    const cards=$('cards');
    if(cards) [...cards.children].forEach((card,i)=>{ if(campaignLevels[i]) mapThumbnail06(card,campaignLevels[i]); });
    const description=$('menubody').querySelector('.subtext');
    if(description) description.textContent='Все уровни открыты. Прогресс прошлой версии сохранён. Пройдено: '+campaignLevels.filter((_,i)=>profile.done['c'+i]).length+' / '+campaignLevels.length+'.';
  }
};
$('quickshop').after(btn('Инвентарь · 2 слота',()=>showMenu('inventory')));
document.querySelector('.touch').appendChild(btn('Импульс Q',()=>{
  if(mode==='play') window.dispatchEvent(new KeyboardEvent('keydown',{code:'KeyQ',bubbles:true}));
}));
const resetBefore06=reset;
reset=function(){ normalize06(); resetBefore06(); };
const accountBefore06=loadAccount;
loadAccount=async function(data){
  if(data?.profile) normalize06(data.profile);
  await accountBefore06(data);
  normalize06();
};
// CAPSULES_WORLD_06_END

const exportGameSource=initialHTML;
function frame(t){if(!last)last=t;acc+=Math.min((t-last)/1000,.05);last=t;while(acc>=1/120){tick(1/120);acc-=1/120}draw();drawPreview();if(netPlaying&&remoteHero&&mode==='play'){person(remoteHero);ctx.fillStyle='#164741';ctx.font='bold 11px system-ui';ctx.textAlign='center';ctx.fillText(remoteHero.finished?'✓ Друг на финише':'● Друг',remoteHero.x+10,remoteHero.y-14);ctx.textAlign='left'}requestAnimationFrame(frame)}level=clone(campaignLevels[0]);reset();persist();showMenu('campaign');requestAnimationFrame(frame);
</script></body></html>