🎮ArcadeLab

Костмикс-2598: Клятва брата

by PrismMeteor75
1838 lines88.2 KB
▶ Play
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Костмикс-2598: Клятва брата</title>
<style>
  html,body{margin:0;height:100%;background:#04050a;display:flex;align-items:center;
    justify-content:center;font-family:'Trebuchet MS',system-ui,sans-serif;overflow:hidden;}
  canvas{display:block;width:min(100vw,160vh);height:auto;border-radius:10px;
    box-shadow:0 0 80px rgba(90,130,255,.28);background:#05060c;}
</style>
</head>
<body>
<canvas id="game" width="960" height="600"></canvas>
<script>
/* ============ БАЗА ============ */
const cv=document.getElementById('game'), ctx=cv.getContext('2d');
const W=cv.width, H=cv.height;
const keys={}, pressed=new Set();
const BLOCK=['Space','Enter','KeyJ','KeyK','ArrowUp','ArrowDown','ArrowLeft','ArrowRight',
  'KeyW','KeyA','KeyS','KeyD','ShiftLeft','ShiftRight','KeyM'];
addEventListener('keydown',e=>{
  if(BLOCK.includes(e.code)) e.preventDefault();
  if(!keys[e.code]) pressed.add(e.code);
  keys[e.code]=true;
});
addEventListener('keyup',e=>{keys[e.code]=false;});
const press=c=>pressed.has(c);
const rnd=(a,b)=>a+Math.random()*(b-a);
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));

function roundRect(x,y,w,h,r){ctx.beginPath();ctx.moveTo(x+r,y);
  ctx.arcTo(x+w,y,x+w,y+h,r);ctx.arcTo(x+w,y+h,x,y+h,r);
  ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();}

function wrapText(text,maxW,font){
  ctx.font=font; const words=text.split(' '); const lines=[]; let line='';
  for(const w of words){const t=line?line+' '+w:w;
    if(ctx.measureText(t).width>maxW&&line){lines.push(line);line=w;}else line=t;}
  if(line) lines.push(line); return lines;
}

function circle(x,y,r,color,glow){
  if(glow){ctx.shadowBlur=glow; ctx.shadowColor=color;}
  ctx.beginPath(); ctx.arc(x,y,r,0,7);
  ctx.fillStyle=color; ctx.fill();
  ctx.shadowBlur=0;
}

function bar(x,y,w,h,ratio,c1,c2,stroke){
  ctx.fillStyle='rgba(0,0,0,.55)'; roundRect(x,y,w,h,h/3); ctx.fill();
  const g=ctx.createLinearGradient(x,0,x+w,0);
  g.addColorStop(0,c1); g.addColorStop(1,c2||c1);
  ctx.fillStyle=g;
  roundRect(x+2,y+2,Math.max(0,(w-4)*clamp(ratio,0,1)),h-4,h/3-1); ctx.fill();
  ctx.strokeStyle=stroke||'rgba(150,190,255,.5)'; ctx.lineWidth=1.5;
  roundRect(x,y,w,h,h/3); ctx.stroke();
}

/* ============ ОЗВУЧКА ============ */
let voiceEnabled=true;
const voiceCache={};
if(window.speechSynthesis){
  speechSynthesis.getVoices();
  speechSynthesis.addEventListener('voiceschanged',()=>{for(const k in voiceCache) delete voiceCache[k];});
}
function pickVoice(id){
  if(voiceCache[id]) return voiceCache[id];
  const vs=speechSynthesis.getVoices(); if(!vs.length) return null;
  const ru=vs.filter(v=>v.lang&&v.lang.toLowerCase().startsWith('ru'));
  const pool=ru.length?ru:vs;
  const idx={hero:0,brother:1,god:2,companion:3,guard:4,radio:5,narrator:6,portal:7,father:8}[id]||0;
  return (voiceCache[id]=pool[idx%pool.length]);
}
const VOICE_CFG={
  hero:{pitch:1,rate:.92,volume:1}, brother:{pitch:.55,rate:.82,volume:1},
  god:{pitch:.45,rate:.78,volume:1}, companion:{pitch:1.15,rate:.95,volume:1},
  guard:{pitch:.7,rate:.88,volume:1}, radio:{pitch:.35,rate:.85,volume:.9},
  portal:{pitch:.3,rate:.78,volume:1}, father:{pitch:.52,rate:.8,volume:1},
  narrator:{pitch:.95,rate:.95,volume:.95}
};

const STRESS={
  'замок':'за́мок','замка':'за́мка','замке':'за́мке','замков':'за́мков','замком':'за́мком',
  'отец':'оте́ц','отца':'отца́','отцу':'отцу́','отцом':'отцо́м','отце':'отце́',
  'портал':'по́ртал','портала':'порта́ла','портале':'порта́ле','порталу':'порта́лу',
  'тебя':'тебя́','меня':'меня́','себя':'себя́',
  'хочу':'хочу́','приди':'приди́','приду':'приду́',
  'душа':'душа́','души':'души́','душу':'ду́шу','душой':'душо́й',
  'зло':'зло́','зла':'зла́','злом':'зло́м','злее':'зле́е',
  'брат':'бра́т','брата':'бра́та','брату':'бра́ту','братом':'бра́том','братец':'бра́тец',
  'война':'война́','войны':'войны́','войну':'войну́',
  'солдат':'солда́т','солдаты':'солда́ты','сыновья':'сыновья́'
};
function stressText(t){
  if(!t) return t;
  return t.replace(/[А-Яа-яЁё]+/g, w=>{
    const s=STRESS[w.toLowerCase()];
    if(!s) return w;
    return (w[0]>='А'&&w[0]<='Я') ? s[0].toUpperCase()+s.slice(1) : s;
  });
}

let currentUtter=null;
function speak(text,charId,onEnd){
  if(!voiceEnabled||!window.speechSynthesis||!text){
    const delay = text ? Math.min(6000, text.length*50+800) : 30;
    if(onEnd) setTimeout(onEnd, delay); return;
  }
  try{ speechSynthesis.cancel(); }catch(e){}
  const u=new SpeechSynthesisUtterance(stressText(text));
  currentUtter=u;
  const v=pickVoice(charId); if(v) u.voice=v;
  const c=VOICE_CFG[charId]||VOICE_CFG.narrator;
  u.pitch=c.pitch; u.rate=c.rate; u.volume=c.volume;
  u.lang=v&&v.lang?v.lang:'ru-RU';
  if(onEnd){u.onend=onEnd; u.onerror=onEnd;}
  try{speechSynthesis.speak(u);}catch(e){if(onEnd) onEnd();}
}
function stopSpeech(){try{speechSynthesis&&speechSynthesis.cancel();}catch(e){}}
function isSynthSpeaking(){return window.speechSynthesis&&speechSynthesis.speaking;}

/* ============ ФОН ============ */
const bgStars=Array.from({length:150},()=>({x:Math.random()*W,y:Math.random()*H*.5,
  r:Math.random()*1.4+.3,p:Math.random()*6.28}));
const bgBuildings=Array.from({length:36},(_,i)=>({x:i*28+Math.random()*6,
  w:16+Math.random()*22,h:40+Math.random()*140}));

function drawArenaBg(kind,t){
  const g=ctx.createLinearGradient(0,0,0,H);
  const G={
    castle:['#140417','#280a26','#3a0a16'], temple:['#06101f','#0e2340','#0a2a3a'],
    road:['#0a0e24','#1a1530','#251218'], village:['#0f1224','#221a30','#2a1a1a'],
    void:['#0a0020','#180030','#22002a'], city:['#080b1e','#170f2e','#280e22'],
    tengrad:['#0a0418','#20082a','#380a18']
  }[kind]||['#080b1e','#170f2e','#280e22'];
  g.addColorStop(0,G[0]); g.addColorStop(.5,G[1]); g.addColorStop(1,G[2]);
  ctx.fillStyle=g; ctx.fillRect(0,0,W,H);

  const sc=kind==='void'?'#c080ff':(kind==='tengrad'?'#ff8a8a':'#cfe4ff');
  for(const s of bgStars){
    ctx.globalAlpha=.25+.55*Math.abs(Math.sin(t*1.6+s.p));
    ctx.fillStyle=sc; ctx.fillRect(s.x,s.y,s.r,s.r);
  }
  ctx.globalAlpha=1;

  const mx=W-150,my=95;
  const mg=ctx.createRadialGradient(mx-25,my-25,8,mx,my,58);
  if(kind==='castle'){mg.addColorStop(0,'#ff9a9a');mg.addColorStop(1,'#6b1f33');}
  else if(kind==='temple'){mg.addColorStop(0,'#bff0ff');mg.addColorStop(1,'#1f4b6b');}
  else if(kind==='void'){mg.addColorStop(0,'#d0a0ff');mg.addColorStop(1,'#3a1060');}
  else if(kind==='tengrad'){mg.addColorStop(0,'#ff5050');mg.addColorStop(1,'#4a0a1a');}
  else{mg.addColorStop(0,'#ffd9a0');mg.addColorStop(1,'#8a4a2a');}
  ctx.globalAlpha=.16; circle(mx,my,86,kind==='castle'?'#ff7a8a':kind==='void'?'#a060ff':'#ffb070');
  ctx.globalAlpha=1; circle(mx,my,52,mg);

  const horizon=H*.46;
  if(kind!=='void'){
    ctx.fillStyle=kind==='castle'?'#1d0715':'#0c0918';
    for(const b of bgBuildings) ctx.fillRect(b.x,horizon-b.h,b.w,b.h+4);
    ctx.fillStyle=kind==='castle'?'rgba(255,60,90,.35)':'rgba(120,170,255,.3)';
    for(const b of bgBuildings) if(b.w>24){
      ctx.fillRect(b.x+6,horizon-b.h+12,3,3);
      ctx.fillRect(b.x+6,horizon-b.h+30,3,3);
    }
  } else {
    for(let i=0;i<8;i++){
      const px=(i*180+Math.sin(t*.3+i)*60)%W, py=horizon-120+Math.sin(t*.5+i*1.3)*40;
      ctx.fillStyle='#2a0a40';
      ctx.beginPath();
      ctx.ellipse(px,py,20+Math.sin(i)*8,10+Math.sin(i)*4,Math.sin(t*.2+i),0,7); ctx.fill();
    }
  }

  const gg=ctx.createLinearGradient(0,horizon,0,H);
  if(kind==='castle'){gg.addColorStop(0,'#1a0612');gg.addColorStop(1,'#2c0a16');}
  else if(kind==='temple'){gg.addColorStop(0,'#07131f');gg.addColorStop(1,'#0d2233');}
  else if(kind==='road'){gg.addColorStop(0,'#15101e');gg.addColorStop(1,'#221722');}
  else if(kind==='village'){gg.addColorStop(0,'#1a1424');gg.addColorStop(1,'#2a1a1a');}
  else if(kind==='void'){gg.addColorStop(0,'#150025');gg.addColorStop(1,'#220030');}
  else if(kind==='tengrad'){gg.addColorStop(0,'#180418');gg.addColorStop(1,'#280620');}
  else{gg.addColorStop(0,'#100a1c');gg.addColorStop(1,'#1c0e22');}
  ctx.fillStyle=gg; ctx.fillRect(0,horizon,W,H-horizon);

  ctx.strokeStyle=kind==='void'?'rgba(160,80,255,.15)':kind==='tengrad'?'rgba(255,60,80,.12)':'rgba(110,160,255,.10)';
  ctx.lineWidth=1;
  for(let i=0;i<=14;i++){
    const y=horizon+(H-horizon)*Math.pow(i/14,1.9);
    ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke();
  }
  for(let i=-10;i<=10;i++){
    ctx.beginPath(); ctx.moveTo(W/2+i*22,horizon); ctx.lineTo(W/2+i*180,H); ctx.stroke();
  }
}

/* ============ АКТЁРЫ ============ */
const heroActor=(x,y,e={})=>({id:'hero',x,y,color:'#5fb2ff',weapon:true,seed:1,pants:'#1a2a4a',emotion:'neutral',...e});
const brotherActor=(x,y,e={})=>({id:'brother',x,y,color:'#e0464b',weapon:true,seed:2,pants:'#3a0a1a',emotion:'evil',...e});
const godActor=(x,y,e={})=>({id:'god',x,y,color:'#ffd76a',glow:'#ffe9a0',seed:3,pants:'#c8a04a',emotion:'calm',...e});
const fatherActor=(x,y,e={})=>({id:'father',x,y,color:'#ffd76a',glow:'#ffe9a0',seed:3,pants:'#c8a04a',emotion:'calm',...e});
const companionActor=(x,y,e={})=>({id:'companion',x,y,color:'#4ad6a0',weapon:true,seed:9,pants:'#1a4a3a',emotion:'neutral',...e});
const guardActor=(x,y,e={})=>({id:'guard',x,y,color:'#8a1030',seed:10,pants:'#4a0a1a',emotion:'angry',...e});

function drawFace(hx,hy,a,speaking,t){
  const R=12;
  circle(hx,hy,R,a.color||'#5fb2ff');
  ctx.strokeStyle='rgba(255,255,255,.45)'; ctx.lineWidth=1.5;
  ctx.beginPath(); ctx.arc(hx,hy,R,0,7); ctx.stroke();

  const eyeY=hy-1, eyeL=hx-4, eyeR=hx+4;
  ctx.fillStyle='#f0f2ff';
  ctx.beginPath(); ctx.ellipse(eyeL,eyeY,3.2,3.6,0,0,7); ctx.fill();
  ctx.beginPath(); ctx.ellipse(eyeR,eyeY,3.2,3.6,0,0,7); ctx.fill();

  const lx=(a.fx||0)*1.2, ly=(a.fy||0)*1.2;
  ctx.fillStyle='#0a0a1a';
  ctx.beginPath(); ctx.arc(eyeL+lx,eyeY+ly,1.7,0,7); ctx.fill();
  ctx.beginPath(); ctx.arc(eyeR+lx,eyeY+ly,1.7,0,7); ctx.fill();

  ctx.fillStyle='rgba(255,255,255,.85)';
  ctx.beginPath(); ctx.arc(eyeL+lx-.6,eyeY+ly-.6,.6,0,7); ctx.fill();
  ctx.beginPath(); ctx.arc(eyeR+lx-.6,eyeY+ly-.6,.6,0,7); ctx.fill();

  const emo=a.emotion||'neutral', browY=hy-6;
  let Li=0,Lo=0,Ri=0,Ro=0;
  if(emo==='angry'||emo==='evil'){Li=2.2;Lo=-.8;Ri=2.2;Ro=-.8;}
  else if(emo==='surprised'){Li=Lo=Ri=Ro=-2.8;}
  else if(emo==='sad'){Li=-1.5;Lo=2;Ri=-1.5;Ro=2;}
  ctx.strokeStyle='#0a0a1a'; ctx.lineWidth=2; ctx.lineCap='round';
  ctx.beginPath(); ctx.moveTo(hx-7.5,browY+Lo); ctx.lineTo(hx-1.5,browY+Li); ctx.stroke();
  ctx.beginPath(); ctx.moveTo(hx+1.5,browY+Ri); ctx.lineTo(hx+7.5,browY+Ro); ctx.stroke();
  ctx.lineCap='butt';

  const mouthY=hy+5;
  if(speaking&&t!==undefined){
    const open=Math.abs(Math.sin(t*16))*2.2+.7;
    ctx.fillStyle='#3a0a1a';
    ctx.beginPath(); ctx.ellipse(hx,mouthY,2.8,open,0,0,7); ctx.fill();
    if(open>1.4){ctx.fillStyle='rgba(255,255,255,.7)'; ctx.fillRect(hx-2,mouthY-open+.5,4,.8);}
  } else {
    ctx.strokeStyle='#3a0a1a'; ctx.lineWidth=1.6;
    ctx.beginPath();
    if(emo==='evil'){
      ctx.moveTo(hx-3,mouthY); ctx.quadraticCurveTo(hx,mouthY-1.5,hx+3.5,mouthY+1);
    } else if(emo==='angry'){
      ctx.moveTo(hx-3,mouthY+1.2); ctx.lineTo(hx+3,mouthY+1.2);
    } else if(emo==='sad'){
      ctx.moveTo(hx-3,mouthY+1); ctx.quadraticCurveTo(hx,mouthY-1,hx+3,mouthY+1);
    } else if(emo==='surprised'){
      ctx.beginPath(); ctx.ellipse(hx,mouthY,1.8,2,0,0,7);
      ctx.fillStyle='#3a0a1a'; ctx.fill();
    } else {
      ctx.moveTo(hx-2,mouthY); ctx.lineTo(hx+2,mouthY);
    }
    ctx.stroke();
  }
}

function drawCutActor(a,t,beatT,speaking){
  let x=a.x,y=a.y;
  if(a.from){
    const k=clamp(beatT/(a.moveDur||1.6),0,1);
    const e=k<.5?2*k*k:1-Math.pow(-2*k+2,2)/2;
    x=a.from.x+(a.x-a.from.x)*e;
    y=a.from.y+(a.y-a.from.y)*e;
  }
  const bob=Math.sin(t*2.5+(a.seed||0))*2+(speaking?Math.sin(t*10)*1.2:0);
  const py=y+bob, col=a.color||'#5fb2ff';

  if(a.glow){
    ctx.globalAlpha=.18+.08*Math.sin(t*5+(a.seed||0));
    circle(x,py,50,a.glow);
    ctx.globalAlpha=1;
  }
  ctx.fillStyle='rgba(0,0,0,.4)';
  ctx.beginPath(); ctx.ellipse(x,py+34,20,7,0,0,7); ctx.fill();

  ctx.fillStyle=a.pants||'#2a2a40';
  ctx.fillRect(x-9,py+12,7,24); ctx.fillRect(x+2,py+12,7,24);

  ctx.fillStyle=col; roundRect(x-14,py-12,28,28,6); ctx.fill();
  ctx.strokeStyle='rgba(255,255,255,.35)'; ctx.lineWidth=1.5;
  roundRect(x-14,py-12,28,28,6); ctx.stroke();

  drawFace(x,py-26,a,speaking,t);

  if(a.crown){
    ctx.fillStyle='#ffd76a';
    ctx.beginPath();
    ctx.moveTo(x-12,py-38); ctx.lineTo(x-8,py-50); ctx.lineTo(x-3,py-40);
    ctx.lineTo(x,py-52); ctx.lineTo(x+3,py-40); ctx.lineTo(x+8,py-50); ctx.lineTo(x+12,py-38);
    ctx.closePath(); ctx.fill();
    ctx.strokeStyle='#b8860b'; ctx.lineWidth=1.2; ctx.stroke();
    circle(x-4,py-44,1.5,'#ff3b6b'); circle(x+4,py-44,1.5,'#ff3b6b');
    circle(x,py-46,1.8,'#4ad6a0');
  }
  if(a.weapon){
    ctx.strokeStyle='#c0d0e0'; ctx.lineWidth=3;
    ctx.beginPath(); ctx.moveTo(x+14,py+4); ctx.lineTo(x+32,py-16); ctx.stroke();
    ctx.strokeStyle='#8a6030'; ctx.lineWidth=4;
    ctx.beginPath(); ctx.moveTo(x+11,py+7); ctx.lineTo(x+16,py+2); ctx.stroke();
  }
  if(a.swing){
    ctx.beginPath(); ctx.arc(x+18,py-4,30,-.9,.9);
    ctx.strokeStyle='rgba(200,230,255,.85)'; ctx.lineWidth=5;
    ctx.shadowBlur=18; ctx.shadowColor='#aaddff'; ctx.stroke(); ctx.shadowBlur=0;
  }
}

function drawSpeechBubble(x,y,text,name,t){
  const font='18px "Trebuchet MS",sans-serif';
  ctx.font=font;
  const lines=wrapText(text,440,font);
  const bw=480, bh=lines.length*26+58;
  const bx=clamp(x-bw/2,20,W-bw-20);
  const by=clamp(y-bh-40,20,H-bh-20);
  const ax=clamp(x,bx+30,bx+bw-30), ay=y-30;
  ctx.beginPath();
  ctx.moveTo(ax-12,by+bh); ctx.lineTo(ax,ay); ctx.lineTo(ax+12,by+bh);
  ctx.closePath(); ctx.fillStyle='rgba(10,15,35,.94)'; ctx.fill();
  roundRect(bx,by,bw,bh,14);
  ctx.fillStyle='rgba(10,15,35,.94)'; ctx.fill();
  ctx.strokeStyle='rgba(120,160,255,.7)'; ctx.lineWidth=2; ctx.stroke();
  if(name){
    ctx.font='bold 16px "Trebuchet MS"'; ctx.fillStyle='#7fb2ff';
    ctx.fillText(name,bx+20,by+28);
  }
  ctx.font=font; ctx.fillStyle='#e8eefc';
  lines.forEach((l,i)=>ctx.fillText(l,bx+20,by+58+i*26));
}

/* ============ КАТ-СЦЕНА ============ */
function CinematicScene(beats,onDone){
  let bi=0, t=0, total=0, spoken=false, beatEnded=false, done=false;
  let token=0, advanceTimer=null;
  const MIN_SKIP=1.5;

  function clearTimer(){ if(advanceTimer){ clearTimeout(advanceTimer); advanceTimer=null; } }

  function speakBeat(){
    if(done) return;
    const myToken=++token;
    beatEnded=false; clearTimer();
    const b=beats[bi];
    if(!b){ beatEnded=true; return; }
    const onEnd=()=>{
      if(myToken!==token || done) return;
      beatEnded=true;
      advanceTimer=setTimeout(()=>{
        if(myToken===token && !done) advance();
      },1500);
    };
    if(b.speech) speak(b.speech.text,b.speech.actor,onEnd);
    else if(b.text) speak(b.text,'narrator',onEnd);
    else onEnd();
  }

  function advance(){
    if(done) return;
    clearTimer();
    bi++; t=0; beatEnded=false;
    if(bi>=beats.length){
      done=true;
      token++;
      stopSpeech();
      onDone();
    }
    else speakBeat();
  }

  return {
    update(dt){
      if(done) return;
      if(!spoken){ spoken=true; speakBeat(); }
      if(press('KeyM')){
        voiceEnabled=!voiceEnabled;
        if(!voiceEnabled){ stopSpeech(); beatEnded=true; clearTimer(); }
        else speakBeat();
      }
      t+=dt; total+=dt;
      const b=beats[bi]; if(!b) return;
      if(t>MIN_SKIP && (press('Space')||press('Enter'))){ advance(); return; }
      if(!beatEnded && t>20) advance();
      if(!beatEnded && b.speech && t>6 && !isSynthSpeaking()) advance();
    },
    draw(){
      const b=beats[bi]; if(!b) return;
      drawArenaBg(b.bg||'city',total);

      if(b.effect==='flash'&&t<.6){
        ctx.fillStyle=`rgba(255,255,255,${.75*(1-t/.6)})`; ctx.fillRect(0,0,W,H);
      }
      if(b.effect==='redflash'&&t<.8){
        ctx.fillStyle=`rgba(255,60,60,${.6*(1-t/.8)})`; ctx.fillRect(0,0,W,H);
      }
      if(b.effect==='divine'){
        const k=clamp(t/1.5,0,1);
        const g=ctx.createRadialGradient(W/2,-60,10,W/2,H/2,H*1.1);
        g.addColorStop(0,`rgba(255,225,130,${.5*k+.15*Math.sin(total*4)})`);
        g.addColorStop(.5,`rgba(255,200,90,${.18*k})`);
        g.addColorStop(1,'rgba(255,200,90,0)');
        ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
        for(let i=0;i<8;i++){
          const a=total*.5+i*Math.PI/4;
          ctx.globalAlpha=.08*k+.03*Math.sin(total*3+i);
          ctx.beginPath();
          ctx.moveTo(W/2+Math.cos(a)*30,120); ctx.lineTo(W/2+Math.cos(a)*400,H);
          ctx.lineTo(W/2+Math.cos(a+.1)*400,H); ctx.lineTo(W/2+Math.cos(a+.1)*30,120);
          ctx.fillStyle='#ffe9a0'; ctx.fill();
        }
        ctx.globalAlpha=1;
      }
      if(b.effect==='embers'){
        for(let i=0;i<8;i++){
          const px=(Math.sin(total*1.3+i*2.1)*.5+.5)*W;
          const py=H-((total*60+i*97)%H);
          ctx.globalAlpha=.5*Math.sin(total*3+i);
          ctx.fillStyle='#ff9a5a'; ctx.fillRect(px,py,2,2);
        }
        ctx.globalAlpha=1;
      }
      if(b.effect==='void'){
        for(let i=0;i<14;i++){
          const px=W/2+Math.cos(total*.6+i)*250, py=H/2+Math.sin(total*.4+i*1.3)*120;
          ctx.globalAlpha=.4*Math.sin(total*2+i)+.3;
          ctx.fillStyle='#a060ff'; ctx.fillRect(px,py,3,3);
        }
        ctx.globalAlpha=1;
      }
      if(b.effect==='fadeout'){
        ctx.fillStyle=`rgba(0,0,0,${clamp(t/1.6,0,1)})`; ctx.fillRect(0,0,W,H);
      }

      const speakingId=b.speech?b.speech.actor:null;
      for(const a of b.actors||[]) drawCutActor(a,total,t,a.id===speakingId);

      if(b.speech){
        const a=(b.actors||[]).find(x=>x.id===b.speech.actor);
        drawSpeechBubble(a?a.x:W/2,a?a.y-80:H/2-80,b.speech.text,b.speech.name,total);
      }
      if(b.text){
        ctx.fillStyle='rgba(3,4,12,.82)'; ctx.fillRect(0,H-140,W,140);
        ctx.font='21px "Trebuchet MS"'; ctx.fillStyle='#e8eefc';
        wrapText(b.text,W-100,'21px "Trebuchet MS"')
          .forEach((l,i)=>ctx.fillText(l,50,H-105+i*30));
      }
      if(b.label){
        ctx.font='bold 14px "Trebuchet MS"'; ctx.fillStyle='rgba(255,215,120,.75)';
        ctx.fillText(b.label,30,36);
      }

      ctx.font='13px "Trebuchet MS"';
      ctx.fillStyle=voiceEnabled?'rgba(120,220,160,.85)':'rgba(200,120,120,.6)';
      ctx.textAlign='right';
      ctx.fillText(voiceEnabled?'🔊 ОЗВУЧКА [M]':'🔇 БЕЗ ЗВУКА [M]',W-30,28);
      ctx.textAlign='left';

      if(t>MIN_SKIP){
        ctx.font='14px "Trebuchet MS"';
        ctx.fillStyle=`rgba(200,220,255,${.5+.4*Math.abs(Math.sin(total*4))})`;
        ctx.textAlign='right'; ctx.fillText('ПРОБЕЛ ▸',W-30,H-18); ctx.textAlign='left';
      } else {
        ctx.font='14px "Trebuchet MS"'; ctx.fillStyle='rgba(200,220,255,.35)';
        ctx.textAlign='right'; ctx.fillText('читайте...',W-30,H-18); ctx.textAlign='left';
      }

      if(beatEnded){ ctx.fillStyle='rgba(120,220,160,.8)'; ctx.fillRect(0,H-4,W,4); }
    }
  };
}

/* ============ ТИТУЛ ============ */
function TitleScene(onStart){
  let t=0;
  return{
    update(dt){t+=dt; if(t>.5&&(press('Space')||press('Enter')||press('KeyJ'))) onStart();},
    draw(){
      drawArenaBg('city',t);
      ctx.fillStyle='rgba(3,4,12,.55)'; ctx.fillRect(0,0,W,H);
      ctx.textAlign='center';
      ctx.font='bold 62px "Trebuchet MS"';
      const g=ctx.createLinearGradient(0,150,0,230);
      g.addColorStop(0,'#9fd6ff'); g.addColorStop(1,'#4a6bff');
      ctx.fillStyle=g; ctx.shadowBlur=30; ctx.shadowColor='#4a7bff';
      ctx.fillText('КОСТМИКС-2598',W/2,205); ctx.shadowBlur=0;
      ctx.font='bold 24px "Trebuchet MS"'; ctx.fillStyle='#ff8a9c';
      ctx.fillText('КЛЯТВА БРАТА',W/2,250);
      ctx.font='15px "Trebuchet MS"'; ctx.fillStyle='rgba(200,215,255,.75)';
      ctx.fillText('WASD / стрелки — движение     J / Space — удар     Shift — рывок',W/2,390);
      ctx.fillStyle='rgba(200,215,255,.6)';
      ctx.fillText('🔊 Озвучка + мимика + ударения (M — вкл/выкл)',W/2,415);
      ctx.fillText('Сюжет • Напарник • Порталы • Тенеград • Засада',W/2,440);
      ctx.font='bold 20px "Trebuchet MS"';
      ctx.fillStyle=`rgba(160,200,255,${.4+.5*Math.abs(Math.sin(t*3))})`;
      ctx.fillText('НАЖМИ ПРОБЕЛ',W/2,505);
      ctx.textAlign='left';
    }
  };
}

/* ============ УТИЛИТЫ БОЯ ============ */
const ENEMY_TYPES={
  grunt:{r:14,hp:40,speed:98,dmg:8,color:'#e0464b',glow:'#ff6b6b'},
  knight:{r:17,hp:80,speed:128,dmg:12,color:'#e0a03a',glow:'#ffcc55'},
  brute:{r:23,hp:160,speed:62,dmg:17,color:'#a0308f',glow:'#e060d0'}
};
const makeEnemy=(type,x,y)=>{const t=ENEMY_TYPES[type];return{type,x,y,r:t.r,hp:t.hp,maxHp:t.hp,
  speed:t.speed,dmg:t.dmg,color:t.color,glow:t.glow,atkCd:rnd(0,1),flash:0,dead:false};};
const makePlayer=sup=>({x:W/2,y:H/2+60,r:15,
  maxHp:sup?420:100,hp:sup?420:100,speed:sup?275:215,dmg:sup?130:34,range:sup?125:78,
  isSuper:sup,fx:0,fy:-1,atkCd:0,swing:0,invuln:0,dashCd:0,dashT:0,dashX:0,dashY:0});
const makeCompanion=(x,y)=>({x,y,r:14,hp:280,maxHp:280,speed:240,dmg:42,range:88,
  atkCd:0,swing:0,flash:0,invuln:0});
const makeBoss=()=>({x:W/2,y:170,r:36,hp:3200,maxHp:3200,speed:95,dmg:22,
  atkCd:0,flash:0,shootCd:1.8,chargeCd:5,chargeT:0,chargeX:0,chargeY:0,dead:false});

/* ============ БОЕВАЯ СЦЕНА ============ */
function PlayScene(cfg){
  const S={};
  S.init=function(){
    S.p=makePlayer(!!cfg.superHero);
    S.enemies=[]; S.projectiles=[]; S.particles=[]; S.queue=[];
    for(const w of cfg.waves) for(let i=0;i<w.count;i++) S.queue.push(w.type);
    S.total=S.queue.length; S.killed=0;
    S.spawnT=1.2; S.maxAlive=cfg.maxAlive||14;
    S.boss=null; S.bossSpawned=false; S.pickup=null;
    S.companion=cfg.withCompanion?makeCompanion(S.p.x-80,S.p.y+40):null;
    S.shake=0; S.flash=0; S.time=0; S.state='play'; S.endT=0;
    S.msg=cfg.title||''; S.msgT=3.5;
    S.exitCalled=false;
    stopSpeech();
  };
  S.init();

  const burst=(x,y,n,color,spd)=>{
    for(let i=0;i<n;i++){
      const a=Math.random()*Math.PI*2, v=rnd(spd*.25,spd);
      S.particles.push({x,y,vx:Math.cos(a)*v,vy:Math.sin(a)*v,
        life:rnd(.3,.75),max:.75,color,size:rnd(2,4.5)});
    }
  };

  function hurtPlayer(dmg){
    const p=S.p;
    if(p.invuln>0||S.state!=='play') return;
    p.hp-=dmg; p.invuln=.7; S.shake=Math.min(16,S.shake+7);
    S.flash=.3; burst(p.x,p.y,9,'#ff5566',180);
    if(p.hp<=0){p.hp=0;S.state='dead';S.endT=0;
      burst(p.x,p.y,34,'#5fb2ff',280);S.shake=22;}
  }

  function damageEnemy(e,dmg,nx,ny){
    e.hp-=dmg; e.flash=.16; e.x+=nx*16; e.y+=ny*16;
    burst(e.x,e.y,5,e.glow,150);
    if(e.hp<=0&&!e.dead){e.dead=true;S.killed++;burst(e.x,e.y,14,e.glow,240);}
  }

  function playerAttack(){
    const p=S.p;
    if(p.atkCd>0) return;
    p.atkCd=p.isSuper?.26:.42; p.swing=.16;
    const ang=Math.atan2(p.fy,p.fx), half=Math.PI/2.1;
    let hit=false;
    const tryHit=(obj,apply)=>{
      const dx=obj.x-p.x, dy=obj.y-p.y, d=Math.hypot(dx,dy)||1;
      if(d>p.range+obj.r) return;
      let da=Math.atan2(dy,dx)-ang;
      while(da>Math.PI) da-=Math.PI*2;
      while(da<-Math.PI) da+=Math.PI*2;
      if(Math.abs(da)>half) return;
      apply(dx/d,dy/d); hit=true;
    };
    for(const e of S.enemies) tryHit(e,(nx,ny)=>damageEnemy(e,p.dmg,nx,ny));
    if(S.boss&&!S.boss.dead){
      tryHit(S.boss,(nx,ny)=>{
        const b=S.boss; b.hp-=p.dmg; b.flash=.14;
        b.x+=nx*5; b.y+=ny*5;
        burst(b.x+rnd(-20,20),b.y+rnd(-20,20),6,'#ffcc55',200);
        if(b.hp<=0&&!b.dead){b.dead=true;burst(b.x,b.y,60,'#ff5566',380);
          burst(b.x,b.y,40,'#ffd76a',300);S.shake=26;
          if(cfg.boss){S.state='win';S.endT=0;}}
      });
    }
    if(hit) S.shake=Math.min(11,S.shake+3);
  }

  function spawnEnemy(type){
    const m=34, side=Math.floor(Math.random()*4);
    let x,y;
    if(side===0){x=rnd(m,W-m);y=80;}
    else if(side===1){x=rnd(m,W-m);y=H-m;}
    else if(side===2){x=m;y=rnd(150,H-m);}
    else{x=W-m;y=rnd(150,H-m);}
    S.enemies.push(makeEnemy(type,x,y));
    burst(x,y,8,'#ff5566',130);
  }

  S.update=function(dt){
    S.time+=dt;
    S.shake=Math.max(0,S.shake-dt*34);
    S.flash=Math.max(0,S.flash-dt*1.8);
    if(S.msgT>0) S.msgT-=dt;
    for(const pt of S.particles){pt.x+=pt.vx*dt;pt.y+=pt.vy*dt;
      pt.vx*=.93;pt.vy*=.93;pt.life-=dt;}
    S.particles=S.particles.filter(pt=>pt.life>0);

    if(S.state==='dead'){
      S.endT+=dt;
      if(S.endT>.7&&(press('KeyR')||press('Enter')||press('Space'))) S.init();
      return;
    }
    if(S.state==='win'){
      S.endT+=dt;
      if(S.endT>.9 && !S.exitCalled && (press('Space')||press('Enter')||press('KeyJ'))){
        S.exitCalled=true;
        cfg.onWin();
      }
      return;
    }

    const p=S.p;
    p.atkCd=Math.max(0,p.atkCd-dt);
    p.swing=Math.max(0,p.swing-dt);
    p.invuln=Math.max(0,p.invuln-dt);
    p.dashCd=Math.max(0,p.dashCd-dt);
    if(p.isSuper) p.hp=Math.min(p.maxHp,p.hp+9*dt);

    let mx=0,my=0;
    if(keys['KeyW']||keys['ArrowUp']) my--;
    if(keys['KeyS']||keys['ArrowDown']) my++;
    if(keys['KeyA']||keys['ArrowLeft']) mx--;
    if(keys['KeyD']||keys['ArrowRight']) mx++;
    const mag=Math.hypot(mx,my);
    if(mag>0){mx/=mag;my/=mag;p.fx=mx;p.fy=my;}

    if(p.dashT>0){
      p.dashT-=dt;
      p.x+=p.dashX*880*dt; p.y+=p.dashY*880*dt;
      if(Math.random()<.7) burst(p.x,p.y,1,p.isSuper?'#ffd76a':'#7fd4ff',40);
    } else { p.x+=mx*p.speed*dt; p.y+=my*p.speed*dt; }
    if((press('ShiftLeft')||press('ShiftRight'))&&p.dashCd<=0&&p.dashT<=0){
      p.dashCd=1.1; p.dashT=.15; p.invuln=Math.max(p.invuln,.32);
      if(mag>0){p.dashX=mx;p.dashY=my;}
      else{const a=Math.atan2(p.fy,p.fx);p.dashX=Math.cos(a);p.dashY=Math.sin(a);}
      burst(p.x,p.y,10,p.isSuper?'#ffe9a0':'#a0e0ff',200);
    }
    p.x=clamp(p.x,26,W-26); p.y=clamp(p.y,96,H-26);
    if(press('KeyJ')||press('Space')||press('KeyK')) playerAttack();

    if(S.companion){
      const c=S.companion;
      c.atkCd=Math.max(0,c.atkCd-dt); c.swing=Math.max(0,c.swing-dt);
      let target=null, tD=Infinity;
      for(const e of S.enemies){
        const d=Math.hypot(e.x-c.x,e.y-c.y);
        if(d<tD){tD=d;target=e;}
      }
      if(S.boss&&!S.boss.dead){
        const d=Math.hypot(S.boss.x-c.x,S.boss.y-c.y);
        if(d<tD){tD=d;target=S.boss;}
      }
      if(target && tD<c.range+target.r){
        if(c.atkCd<=0){
          c.atkCd=.55; c.swing=.15;
          const dx=target.x-c.x, dy=target.y-c.y, d=Math.hypot(dx,dy)||1;
          target.hp-=c.dmg; target.flash=.14;
          target.x+=(dx/d)*8; target.y+=(dy/d)*8;
          burst(target.x,target.y,4,'#4ad6a0',120);
          if(target.hp<=0 && target!==S.boss && !target.dead){
            target.dead=true; S.killed++; burst(target.x,target.y,12,target.glow,220);
          }
          if(target===S.boss && S.boss.hp<=0 && !S.boss.dead){
            S.boss.dead=true; burst(S.boss.x,S.boss.y,60,'#ff5566',380);
            burst(S.boss.x,S.boss.y,40,'#ffd76a',300); S.shake=26;
            if(cfg.boss){S.state='win';S.endT=0;}
          }
        }
      } else if(target && tD>c.range*.7){
        const dx=target.x-c.x, dy=target.y-c.y, d=Math.hypot(dx,dy)||1;
        c.x+=(dx/d)*c.speed*dt; c.y+=(dy/d)*c.speed*dt;
        c.x=clamp(c.x,26,W-26); c.y=clamp(c.y,96,H-26);
      } else {
        const dx=p.x-c.x-50, dy=p.y-c.y, d=Math.hypot(dx,dy);
        if(d>50){c.x+=(dx/d)*c.speed*dt; c.y+=(dy/d)*c.speed*dt;
          c.x=clamp(c.x,26,W-26); c.y=clamp(c.y,96,H-26);}
      }
    }

    for(const e of S.enemies){
      e.flash=Math.max(0,e.flash-dt); e.atkCd=Math.max(0,e.atkCd-dt);
      const dx=p.x-e.x, dy=p.y-e.y, d=Math.hypot(dx,dy)||1;
      if(d>e.r+p.r+2){e.x+=(dx/d)*e.speed*dt; e.y+=(dy/d)*e.speed*dt;}
      else if(e.atkCd<=0){e.atkCd=1; hurtPlayer(e.dmg);}
    }
    for(let i=0;i<S.enemies.length;i++) for(let j=i+1;j<S.enemies.length;j++){
      const a=S.enemies[i],b=S.enemies[j];
      const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy),mn=a.r+b.r;
      if(d>0&&d<mn){const push=(mn-d)/2/d;a.x-=dx*push;a.y-=dy*push;b.x+=dx*push;b.y+=dy*push;}
    }
    S.enemies=S.enemies.filter(e=>!e.dead);

    if(S.boss&&!S.boss.dead){
      const b=S.boss;
      b.flash=Math.max(0,b.flash-dt); b.atkCd=Math.max(0,b.atkCd-dt);
      const dx=p.x-b.x, dy=p.y-b.y, d=Math.hypot(dx,dy)||1;
      if(b.chargeT>0){
        b.chargeT-=dt;
        b.x+=b.chargeX*540*dt; b.y+=b.chargeY*540*dt;
        if(Math.random()<.8) burst(b.x,b.y,1,'#ff5566',90);
      } else {
        if(d>150){b.x+=(dx/d)*b.speed*dt; b.y+=(dy/d)*b.speed*dt;}
        b.chargeCd-=dt;
        if(b.chargeCd<=0){b.chargeCd=6;b.chargeT=.55;b.chargeX=dx/d;b.chargeY=dy/d;}
      }
      b.shootCd-=dt;
      if(b.shootCd<=0){
        b.shootCd=2.6;
        const n=7, base=Math.atan2(dy,dx);
        for(let i=0;i<n;i++){
          const a=base+(i-(n-1)/2)*.22;
          S.projectiles.push({x:b.x,y:b.y,vx:Math.cos(a)*270,vy:Math.sin(a)*270,
            r:7,dmg:12,life:3.5});
        }
      }
      if(d<b.r+p.r&&b.atkCd<=0){b.atkCd=.9;hurtPlayer(b.dmg);}
      b.x=clamp(b.x,60,W-60); b.y=clamp(b.y,140,H-60);
    }

    for(const pr of S.projectiles){
      pr.x+=pr.vx*dt; pr.y+=pr.vy*dt; pr.life-=dt;
      if(Math.hypot(pr.x-p.x,pr.y-p.y)<pr.r+p.r){pr.life=0;hurtPlayer(pr.dmg);}
      if(pr.x<-40||pr.x>W+40||pr.y<-40||pr.y>H+40) pr.life=0;
    }
    S.projectiles=S.projectiles.filter(pr=>pr.life>0);

    if(S.queue.length>0&&S.enemies.length<S.maxAlive){
      S.spawnT-=dt;
      if(S.spawnT<=0){
        S.spawnT=.5; spawnEnemy(S.queue.shift());
        if(S.queue.length&&S.enemies.length<S.maxAlive) spawnEnemy(S.queue.shift());
      }
    }

    if(cfg.pickup && !S.pickup && !cfg.boss && S.queue.length===0 && S.enemies.length===0){
      S.pickup={x:W/2,y:H/2,r:30,bob:0};
      S.msg='КУСОЧЕК КОРОНЫ'; S.msgT=3.5;
    }
    if(S.pickup){
      S.pickup.bob+=dt*2.5;
      if(Math.hypot(p.x-S.pickup.x,p.y-S.pickup.y)<p.r+S.pickup.r+15){
        S.state='win'; S.endT=0;
      }
    }

    if(!S.bossSpawned&&!cfg.pickup&&S.queue.length===0&&S.enemies.length===0){
      if(cfg.boss){
        S.bossSpawned=true; S.boss=makeBoss();
        if(cfg.bossHp){S.boss.hp=cfg.bossHp; S.boss.maxHp=cfg.bossHp;}
        S.msg=cfg.bossName||'КОРОЛЬ ЗЛА'; S.msgT=3.5; S.shake=24;
        burst(S.boss.x,S.boss.y,50,'#ff5566',320);
      } else {S.state='win';S.endT=0;}
    }
  };

  function drawEnemy(e){
    ctx.save(); ctx.translate(e.x,e.y);
    ctx.beginPath(); ctx.ellipse(0,e.r*.85,e.r*.9,e.r*.4,0,0,7);
    ctx.fillStyle='rgba(0,0,0,.35)'; ctx.fill();
    if(e.flash>0){ctx.shadowBlur=26;ctx.shadowColor='#fff';}
    else{ctx.shadowBlur=14;ctx.shadowColor=e.glow;}
    ctx.beginPath();
    if(e.type==='brute'){ctx.moveTo(0,-e.r);ctx.lineTo(e.r,0);ctx.lineTo(0,e.r);ctx.lineTo(-e.r,0);}
    else{ctx.arc(0,0,e.r,0,7);}
    ctx.closePath(); ctx.fillStyle=e.flash>0?'#fff':e.color; ctx.fill();
    ctx.shadowBlur=0; ctx.lineWidth=2;
    ctx.strokeStyle=e.flash>0?'#fff':'rgba(255,255,255,.35)'; ctx.stroke();
    circle(-e.r*.35,-e.r*.15,e.r*.18,'#fff'); circle(e.r*.35,-e.r*.15,e.r*.18,'#fff');
    circle(-e.r*.35,-e.r*.15,e.r*.1,'#0a0a1a'); circle(e.r*.35,-e.r*.15,e.r*.1,'#0a0a1a');
    if(e.hp<e.maxHp){
      const w=e.r*2;
      ctx.fillStyle='rgba(0,0,0,.6)'; ctx.fillRect(-w/2,-e.r-10,w,4);
      ctx.fillStyle='#ff6b7d'; ctx.fillRect(-w/2,-e.r-10,w*(e.hp/e.maxHp),4);
    }
    ctx.restore();
  }

  function drawBoss(b){
    ctx.save(); ctx.translate(b.x,b.y);
    ctx.beginPath(); ctx.ellipse(0,b.r*.85,b.r,b.r*.4,0,0,7);
    ctx.fillStyle='rgba(0,0,0,.4)'; ctx.fill();
    ctx.globalAlpha=.18+.08*Math.sin(S.time*5);
    circle(0,0,b.r+22,'#ff3b6b'); ctx.globalAlpha=1;
    ctx.shadowBlur=b.flash>0?40:22;
    ctx.shadowColor=b.flash>0?'#fff':'#ff3b6b';
    ctx.beginPath();
    ctx.moveTo(0,-b.r); ctx.lineTo(b.r*.86,-b.r*.5); ctx.lineTo(b.r*.86,b.r*.5);
    ctx.lineTo(0,b.r); ctx.lineTo(-b.r*.86,b.r*.5); ctx.lineTo(-b.r*.86,-b.r*.5);
    ctx.closePath(); ctx.fillStyle=b.flash>0?'#fff':'#7a1030'; ctx.fill();
    ctx.shadowBlur=0; ctx.lineWidth=3; ctx.strokeStyle='#ff6b8d'; ctx.stroke();
    ctx.fillStyle='#fff';
    ctx.beginPath(); ctx.ellipse(-10,-4,5,6,0,0,7); ctx.fill();
    ctx.beginPath(); ctx.ellipse(10,-4,5,6,0,0,7); ctx.fill();
    circle(-10,-4,2.5,'#ff1a1a'); circle(10,-4,2.5,'#ff1a1a');
    ctx.fillStyle='#1a0008';
    ctx.beginPath(); ctx.ellipse(0,10,10,5,0,0,7); ctx.fill();
    ctx.fillStyle='#e0e0e0';
    for(let i=-2;i<=2;i++){
      ctx.fillRect(i*3.5-1.2,5,2.4,3);
      ctx.fillRect(i*3.5-1.2,12,2.4,3);
    }
    ctx.beginPath();
    ctx.moveTo(-20,-b.r-6); ctx.lineTo(-14,-b.r-26); ctx.lineTo(-6,-b.r-10);
    ctx.lineTo(0,-b.r-30); ctx.lineTo(6,-b.r-10); ctx.lineTo(14,-b.r-26); ctx.lineTo(20,-b.r-6);
    ctx.closePath(); ctx.fillStyle='#ffd76a'; ctx.fill();
    ctx.strokeStyle='#b8860b'; ctx.lineWidth=1.5; ctx.stroke();
    ctx.restore();
  }

  function drawPlayer(p){
    ctx.save(); ctx.translate(p.x,p.y);
    ctx.beginPath(); ctx.ellipse(0,p.r*.9,p.r*.9,p.r*.4,0,0,7);
    ctx.fillStyle='rgba(0,0,0,.35)'; ctx.fill();
    const ang=Math.atan2(p.fy,p.fx);
    if(p.isSuper){
      ctx.globalAlpha=.18+.07*Math.sin(S.time*6);
      circle(0,0,p.r+18,'#ffd76a'); ctx.globalAlpha=1;
    }
    if(p.swing>0){
      const k=p.swing/.16;
      ctx.beginPath(); ctx.arc(0,0,p.range*.9,ang-.9,ang+.9);
      ctx.strokeStyle=p.isSuper?`rgba(255,228,140,${k})`:`rgba(160,220,255,${k})`;
      ctx.lineWidth=12*k+3; ctx.lineCap='round';
      ctx.shadowBlur=22; ctx.shadowColor=p.isSuper?'#ffd76a':'#7fd4ff';
      ctx.stroke(); ctx.shadowBlur=0; ctx.lineCap='butt';
    }
    const blink=p.invuln>0&&Math.floor(p.invuln*22)%2===0;
    ctx.shadowBlur=18; ctx.shadowColor=p.isSuper?'#ffd76a':'#5fb2ff';
    circle(0,0,p.r,blink?'#fff':(p.isSuper?'#ffd76a':'#5fb2ff'));
    ctx.shadowBlur=0; ctx.lineWidth=3;
    ctx.strokeStyle=p.isSuper?'#fff6d0':'#b6e2ff'; ctx.stroke();
    circle(-3,-2,1.8,'#fff'); circle(3,-2,1.8,'#fff');
    circle(-3,-2,.8,'#0a0a1a'); circle(3,-2,.8,'#0a0a1a');
    ctx.beginPath();
    ctx.moveTo(Math.cos(ang)*p.r*.35,Math.sin(ang)*p.r*.35);
    ctx.lineTo(Math.cos(ang)*(p.r+7),Math.sin(ang)*(p.r+7));
    ctx.strokeStyle='rgba(255,255,255,.85)'; ctx.lineWidth=3; ctx.stroke();
    ctx.restore();
  }

  function drawCompanion(c){
    ctx.save(); ctx.translate(c.x,c.y);
    ctx.beginPath(); ctx.ellipse(0,c.r*.9,c.r*.9,c.r*.4,0,0,7);
    ctx.fillStyle='rgba(0,0,0,.35)'; ctx.fill();
    if(c.swing>0){
      const k=c.swing/.15;
      ctx.beginPath(); ctx.arc(0,0,c.range*.85,0,Math.PI*2);
      ctx.strokeStyle=`rgba(74,214,160,${k})`;
      ctx.lineWidth=8*k+2; ctx.shadowBlur=16; ctx.shadowColor='#4ad6a0';
      ctx.stroke(); ctx.shadowBlur=0;
    }
    circle(0,0,c.r,'#4ad6a0',16);
    ctx.lineWidth=3; ctx.strokeStyle='#a0ffd0'; ctx.stroke();
    circle(-3,-1,1.8,'#fff'); circle(3,-1,1.8,'#fff');
    circle(-3,-1,.8,'#0a1a1a'); circle(3,-1,.8,'#0a1a1a');
    ctx.strokeStyle='#1a4a3a'; ctx.lineWidth=1.2;
    ctx.beginPath(); ctx.arc(0,3,2.5,0,Math.PI); ctx.stroke();
    ctx.restore();
  }

  function drawPickup(pk){
    const py=pk.y+Math.sin(pk.bob)*10;
    ctx.save(); ctx.translate(pk.x,py);
    ctx.globalAlpha=.3+.15*Math.sin(S.time*4);
    circle(0,0,pk.r+18,'#ffd76a'); ctx.globalAlpha=1;
    ctx.shadowBlur=30; ctx.shadowColor='#ffd76a';
    ctx.fillStyle='#ffd76a';
    ctx.beginPath();
    ctx.moveTo(-18,6); ctx.lineTo(-12,-16); ctx.lineTo(-4,0);
    ctx.lineTo(0,-22); ctx.lineTo(4,0); ctx.lineTo(12,-16); ctx.lineTo(18,6);
    ctx.closePath(); ctx.fill();
    ctx.shadowBlur=0;
    ctx.strokeStyle='#b8860b'; ctx.lineWidth=2; ctx.stroke();
    ctx.beginPath(); ctx.moveTo(-18,6); ctx.lineTo(18,6); ctx.stroke();
    circle(-6,-8,3,'rgba(255,255,255,.6)');
    ctx.restore();
  }

  function drawUI(){
    const p=S.p;
    const bw=300,bh=22,bx=26,by=22;
    bar(bx,by,bw,bh,p.hp/p.maxHp,p.isSuper?'#ffd76a':'#4ad6a0',p.isSuper?'#ff9a3c':'#2aa8ff');
    ctx.font='bold 14px "Trebuchet MS"'; ctx.fillStyle='#dbe7ff';
    ctx.fillText(`${Math.ceil(p.hp)} / ${p.maxHp}`,bx+10,by+16);

    if(S.companion){
      const cx=bx, cy=by+32, cw=200, ch=16;
      bar(cx,cy,cw,ch,S.companion.hp/S.companion.maxHp,'#4ad6a0',null,'rgba(74,214,160,.6)');
      ctx.font='bold 11px "Trebuchet MS"'; ctx.fillStyle='#1a4a3a';
      ctx.fillText('РЕЙН',cx+8,cy+12);
    }

    ctx.textAlign='right'; ctx.font='bold 16px "Trebuchet MS"';
    ctx.fillStyle='rgba(200,220,255,.85)';
    if(S.bossSpawned) ctx.fillText(cfg.bossName||'КОРОЛЬ ЗЛА',W-26,38);
    else if(S.pickup) ctx.fillText('НАЙДИТЕ КУСОЧЕК КОРОНЫ',W-26,38);
    else ctx.fillText(`ПОВЕРЖЕНО: ${S.killed} / ${S.total}`,W-26,38);
    ctx.textAlign='left';

    if(S.boss&&!S.boss.dead){
      bar((W-520)/2,62,520,16,S.boss.hp/S.boss.maxHp,'#ff3b6b',null,'rgba(255,120,140,.7)');
    }

    if(S.msgT>0&&S.msg){
      ctx.globalAlpha=Math.min(1,S.msgT);
      ctx.textAlign='center'; ctx.font='bold 44px "Trebuchet MS"';
      ctx.fillStyle='#ffd76a'; ctx.shadowBlur=24; ctx.shadowColor='#ff8a3c';
      ctx.fillText(S.msg,W/2,H*.32); ctx.shadowBlur=0; ctx.globalAlpha=1;
      ctx.textAlign='left';
    }

    const overlay=(title,col,sub)=>{
      ctx.fillStyle='rgba(20,0,8,.65)'; ctx.fillRect(0,0,W,H);
      ctx.textAlign='center'; ctx.font='bold 54px "Trebuchet MS"';
      ctx.fillStyle=col; ctx.fillText(title,W/2,H/2-20);
      ctx.font='20px "Trebuchet MS"'; ctx.fillStyle='rgba(220,230,255,.85)';
      ctx.fillText(sub,W/2,H/2+30);
      ctx.textAlign='left';
    };
    if(S.state==='dead') overlay('ВЫ ПОГИБЛИ','#ff5566','нажмите R, чтобы попробовать снова');
    if(S.state==='win') overlay('ПОБЕДА','#ffd76a','нажмите ПРОБЕЛ');
  }

  S.draw=function(){
    ctx.save();
    if(S.shake>.5) ctx.translate(rnd(-S.shake,S.shake)*.45,rnd(-S.shake,S.shake)*.45);
    drawArenaBg(cfg.bg||'city',S.time);
    for(const pt of S.particles){
      ctx.globalAlpha=pt.life/pt.max;
      ctx.fillStyle=pt.color;
      ctx.fillRect(pt.x-pt.size/2,pt.y-pt.size/2,pt.size,pt.size);
    }
    ctx.globalAlpha=1;
    if(S.pickup) drawPickup(S.pickup);
    for(const pr of S.projectiles) circle(pr.x,pr.y,pr.r,'#ff3b6b',18);
    for(const e of S.enemies) drawEnemy(e);
    if(S.boss&&!S.boss.dead) drawBoss(S.boss);
    if(S.companion) drawCompanion(S.companion);
    if(S.state!=='dead') drawPlayer(S.p);
    ctx.restore();
    drawUI();
    if(S.flash>0){ctx.fillStyle=`rgba(255,60,80,${S.flash*.45})`; ctx.fillRect(0,0,W,H);}
  };
  return S;
}

/* ============ СТЕЛС ============ */
function InfiltrationScene(onWin){
  const S={};
  const WORLD_W=3200, WORLD_H=720;
  S.init=function(){
    S.p={x:90,y:WORLD_H/2,r:14,speed:230,hp:130,maxHp:130,fx:1,fy:0,atkCd:0,swing:0,invuln:0};
    S.camX=0; S.time=0; S.state='play'; S.endT=0;
    S.particles=[]; S.msg='ПУТЬ В ЗАМОК'; S.msgT=3.5;
    S.exitCalled=false;
    S.crates=[
      {x:270,y:150,w:70,h:90},{x:420,y:420,w:80,h:70},{x:560,y:240,w:60,h:110},
      {x:720,y:520,w:90,h:60},{x:880,y:170,w:70,h:90},{x:1020,y:400,w:80,h:90},
      {x:1180,y:560,w:90,h:70},{x:1320,y:200,w:60,h:100},{x:1470,y:420,w:80,h:80},
      {x:1620,y:250,w:70,h:90},{x:1780,y:520,w:90,h:70},{x:1920,y:180,w:70,h:100},
      {x:2080,y:380,w:80,h:90},{x:2240,y:560,w:90,h:70},{x:2380,y:220,w:70,h:100},
      {x:2540,y:440,w:80,h:80},{x:2700,y:280,w:70,h:90},{x:2840,y:540,w:80,h:70}
    ];
    const mkGuard=(x,y,ba,pat)=>({x,y,baseAngle:ba,angle:ba,pat,points:pat==='patrol'?
      [{x:x-80,y},{x:x+100,y}]:null,patIdx:0,viewDist:210+Math.random()*20,viewAngle:.65,
      speed:pat==='patrol'?120:150,r:16,alert:0,chasing:false,atkCd:0,hp:120,maxHp:120,flash:0,dead:false});
    S.guards=[
      mkGuard(450,230,0,'stat'), mkGuard(680,400,Math.PI,'patrol'),
      mkGuard(950,290,Math.PI,'stat'), mkGuard(1230,460,0,'patrol'),
      mkGuard(1560,260,Math.PI,'stat'), mkGuard(1850,440,0,'patrol'),
      mkGuard(2180,250,Math.PI,'stat'), mkGuard(2480,450,0,'patrol'),
      mkGuard(2770,250,Math.PI,'stat')
    ];
  };
  S.init();
  const burst=(x,y,n,color,spd)=>{
    for(let i=0;i<n;i++){
      const a=Math.random()*Math.PI*2, v=rnd(spd*.25,spd);
      S.particles.push({x,y,vx:Math.cos(a)*v,vy:Math.sin(a)*v,
        life:rnd(.3,.7),max:.7,color,size:rnd(2,4)});
    }
  };
  function losBlocked(x1,y1,x2,y2){
    for(const c of S.crates)
      for(let tt=.05;tt<=.95;tt+=.06){
        const px=x1+(x2-x1)*tt, py=y1+(y2-y1)*tt;
        if(px>=c.x&&px<=c.x+c.w&&py>=c.y&&py<=c.y+c.h) return true;
      }
    return false;
  }
  function collideRect(obj,c){
    const cx=clamp(obj.x,c.x,c.x+c.w), cy=clamp(obj.y,c.y,c.y+c.h);
    const dx=obj.x-cx, dy=obj.y-cy, d=Math.hypot(dx,dy);
    if(d<obj.r&&d>0){obj.x+=dx/d*(obj.r-d); obj.y+=dy/d*(obj.r-d);}
    else if(d===0){obj.x=c.x-obj.r;}
  }
  function collideWorld(obj){
    obj.x=clamp(obj.x,20,WORLD_W-20); obj.y=clamp(obj.y,70,WORLD_H-20);
    for(const c of S.crates) collideRect(obj,c);
  }
  S.update=function(dt){
    S.time+=dt; S.msgT=Math.max(0,S.msgT-dt);
    for(const pt of S.particles){pt.x+=pt.vx*dt;pt.y+=pt.vy*dt;
      pt.vx*=.93;pt.vy*=.93;pt.life-=dt;}
    S.particles=S.particles.filter(pt=>pt.life>0);
    if(S.state==='dead'){
      S.endT+=dt;
      if(S.endT>.6&&(press('KeyR')||press('Enter')||press('Space'))) S.init();
      return;
    }
    if(S.state==='win'){
      S.endT+=dt;
      if(S.endT>1&&!S.exitCalled&&(press('Space')||press('Enter')||press('KeyJ'))){
        S.exitCalled=true;
        onWin();
      }
      return;
    }
    const p=S.p;
    p.atkCd=Math.max(0,p.atkCd-dt);
    p.swing=Math.max(0,p.swing-dt);
    p.invuln=Math.max(0,p.invuln-dt);
    let mx=0,my=0;
    if(keys['KeyW']||keys['ArrowUp']) my--;
    if(keys['KeyS']||keys['ArrowDown']) my++;
    if(keys['KeyA']||keys['ArrowLeft']) mx--;
    if(keys['KeyD']||keys['ArrowRight']) mx++;
    const mag=Math.hypot(mx,my);
    if(mag>0){mx/=mag;my/=mag;p.fx=mx;p.fy=my;}
    p.x+=mx*p.speed*dt; p.y+=my*p.speed*dt;
    collideWorld(p);
    if((press('KeyJ')||press('Space')||press('KeyK'))&&p.atkCd<=0){
      p.atkCd=.4; p.swing=.15;
      const ang=Math.atan2(p.fy,p.fx), range=70, half=Math.PI/2.1;
      for(const g of S.guards){
        if(g.dead) continue;
        const dx=g.x-p.x, dy=g.y-p.y, d=Math.hypot(dx,dy);
        if(d>range+g.r) continue;
        let da=Math.atan2(dy,dx)-ang;
        while(da>Math.PI) da-=Math.PI*2;
        while(da<-Math.PI) da+=Math.PI*2;
        if(Math.abs(da)>half) continue;
        g.hp-=45; g.flash=.18; g.alert=100; g.chasing=true;
        burst(g.x,g.y,8,'#ff6b6b',180);
        if(g.hp<=0){g.dead=true;burst(g.x,g.y,18,'#ff3b3b',240);}
      }
    }
    S.camX=clamp(p.x-W/2,0,WORLD_W-W);
    for(const g of S.guards){
      if(g.dead) continue;
      g.flash=Math.max(0,g.flash-dt); g.atkCd=Math.max(0,g.atkCd-dt);
      const dx=p.x-g.x, dy=p.y-g.y, d=Math.hypot(dx,dy);
      let sees=false;
      if(d<g.viewDist){
        let da=Math.atan2(dy,dx)-g.angle;
        while(da>Math.PI) da-=Math.PI*2;
        while(da<-Math.PI) da+=Math.PI*2;
        if(Math.abs(da)<g.viewAngle&&!losBlocked(g.x,g.y,p.x,p.y)) sees=true;
      }
      if(sees){g.alert=Math.min(100,g.alert+65*dt); g.lastSeen={x:p.x,y:p.y};}
      else if(!g.chasing){g.alert=Math.max(0,g.alert-35*dt);}
      if(g.alert>=100) g.chasing=true;
      if(g.chasing){
        const tx=sees?p.x:(g.lastSeen?.x??p.x);
        const ty=sees?p.y:(g.lastSeen?.y??p.y);
        const ddx=tx-g.x, ddy=ty-g.y, dd=Math.hypot(ddx,ddy)||1;
        g.x+=(ddx/dd)*g.speed*dt; g.y+=(ddy/dd)*g.speed*dt;
        g.angle=Math.atan2(ddy,ddx); collideWorld(g);
        if(d<g.r+p.r+4&&g.atkCd<=0){
          g.atkCd=1;
          if(p.invuln<=0){p.hp-=12;p.invuln=.7; burst(p.x,p.y,7,'#ff5566',150);}
          if(p.hp<=0){p.hp=0;S.state='dead';S.endT=0; burst(p.x,p.y,25,'#5fb2ff',260);}
        }
        if(!sees){g.loseT=(g.loseT||0)+dt; if(g.loseT>3.5){g.chasing=false;g.alert=0;g.loseT=0;}}
        else g.loseT=0;
      } else if(g.pat==='patrol'){
        const tgt=g.points[g.patIdx];
        const ddx=tgt.x-g.x, ddy=tgt.y-g.y, dd=Math.hypot(ddx,ddy);
        if(dd<6){g.patIdx=(g.patIdx+1)%g.points.length;}
        else{g.x+=(ddx/dd)*g.speed*.35*dt; g.y+=(ddy/dd)*g.speed*.35*dt;
          g.angle=Math.atan2(ddy,ddx); collideWorld(g);}
      } else {
        g.angle=g.baseAngle+Math.sin(S.time*.9+g.x*.01)*.55;
      }
    }
    S.guards=S.guards.filter(g=>!g.dead);
    if(p.x>WORLD_W-160){S.state='win';S.endT=0;}
  };
  S.draw=function(){
    drawArenaBg('road',S.time);
    ctx.save();
    ctx.translate(-S.camX,0);
    ctx.fillStyle='rgba(0,0,0,.25)'; ctx.fillRect(0,WORLD_H*.55,WORLD_W,WORLD_H);
    ctx.strokeStyle='rgba(255,200,120,.08)';
    for(let i=0;i<WORLD_W;i+=80){
      ctx.beginPath(); ctx.moveTo(i,WORLD_H*.55); ctx.lineTo(i,WORLD_H); ctx.stroke();
    }
    if(S.camX+W>WORLD_W-220){
      const gx=WORLD_W-140, gy=200, gw=120, gh=320;
      ctx.fillStyle='#1a0e20'; ctx.fillRect(gx,gy,gw,gh);
      ctx.strokeStyle='#ffd76a'; ctx.lineWidth=4; ctx.strokeRect(gx,gy,gw,gh);
      ctx.beginPath(); ctx.arc(gx+gw/2,gy,gw/2,Math.PI,0);
      ctx.fillStyle='#1a0e20'; ctx.fill();
      ctx.strokeStyle='#ffd76a'; ctx.lineWidth=4; ctx.stroke();
      ctx.font='bold 18px "Trebuchet MS"'; ctx.fillStyle='#ffd76a';
      ctx.textAlign='center'; ctx.fillText('ЗАМОК',gx+gw/2,gy-20); ctx.textAlign='left';
      ctx.globalAlpha=.3+.15*Math.sin(S.time*3);
      ctx.fillStyle='#ffd76a'; ctx.fillRect(gx-10,gy-10,gw+20,gh+20);
      ctx.globalAlpha=1;
    }
    for(const c of S.crates){
      ctx.fillStyle='#3a2818'; ctx.fillRect(c.x,c.y,c.w,c.h);
      ctx.strokeStyle='#6a4a2a'; ctx.lineWidth=2; ctx.strokeRect(c.x,c.y,c.w,c.h);
      ctx.strokeStyle='rgba(0,0,0,.35)';
      for(let i=1;i<3;i++){
        ctx.beginPath(); ctx.moveTo(c.x,c.y+c.h*i/3); ctx.lineTo(c.x+c.w,c.y+c.h*i/3); ctx.stroke();
      }
    }
    for(const g of S.guards){
      if(g.dead) continue;
      ctx.beginPath();
      ctx.moveTo(g.x,g.y);
      ctx.arc(g.x,g.y,g.viewDist,g.angle-g.viewAngle,g.angle+g.viewAngle);
      ctx.closePath();
      if(g.chasing) ctx.fillStyle='rgba(255,60,60,.18)';
      else if(g.alert>50) ctx.fillStyle='rgba(255,200,60,.15)';
      else ctx.fillStyle='rgba(255,200,100,.10)';
      ctx.fill();
      const flash=g.flash>0;
      ctx.shadowBlur=flash?26:12;
      ctx.shadowColor=flash?'#fff':(g.chasing?'#ff5060':'#ffcc55');
      ctx.beginPath(); ctx.arc(g.x,g.y,g.r,0,7);
      ctx.fillStyle=flash?'#fff':(g.chasing?'#ff5060':'#e0a03a'); ctx.fill();
      ctx.shadowBlur=0; ctx.strokeStyle='#fff'; ctx.lineWidth=2; ctx.stroke();
      circle(g.x-4,g.y-2,2,'#fff'); circle(g.x+4,g.y-2,2,'#fff');
      circle(g.x-4,g.y-2,1,'#1a0a0a'); circle(g.x+4,g.y-2,1,'#1a0a0a');
      ctx.strokeStyle='#1a0a0a'; ctx.lineWidth=1.5;
      ctx.beginPath(); ctx.moveTo(g.x-4,g.y+3); ctx.lineTo(g.x+4,g.y+3); ctx.stroke();
      ctx.beginPath(); ctx.moveTo(g.x,g.y);
      ctx.lineTo(g.x+Math.cos(g.angle)*g.r*1.6,g.y+Math.sin(g.angle)*g.r*1.6);
      ctx.strokeStyle='rgba(255,255,255,.85)'; ctx.lineWidth=2; ctx.stroke();
      if(g.alert>0&&!g.chasing){
        ctx.fillStyle='rgba(0,0,0,.6)'; ctx.fillRect(g.x-16,g.y-g.r-16,32,5);
        ctx.fillStyle=g.alert>=100?'#ff3b3b':'#ffcc00';
        ctx.fillRect(g.x-16,g.y-g.r-16,32*(g.alert/100),5);
      }
      if(g.chasing){
        ctx.font='bold 22px "Trebuchet MS"'; ctx.fillStyle='#ff3b3b';
        ctx.textAlign='center'; ctx.fillText('!',g.x,g.y-g.r-14); ctx.textAlign='left';
      }
      if(g.hp<g.maxHp){
        ctx.fillStyle='rgba(0,0,0,.6)'; ctx.fillRect(g.x-18,g.y+g.r+6,36,4);
        ctx.fillStyle='#ff6b7d'; ctx.fillRect(g.x-18,g.y+g.r+6,36*(g.hp/g.maxHp),4);
      }
    }
    for(const pt of S.particles){
      ctx.globalAlpha=pt.life/pt.max; ctx.fillStyle=pt.color;
      ctx.fillRect(pt.x-pt.size/2,pt.y-pt.size/2,pt.size,pt.size);
    }
    ctx.globalAlpha=1;
    if(S.state!=='dead'){
      const p=S.p, ang=Math.atan2(p.fy,p.fx);
      ctx.beginPath(); ctx.ellipse(p.x,p.y+p.r*.9,p.r*.9,p.r*.4,0,0,7);
      ctx.fillStyle='rgba(0,0,0,.35)'; ctx.fill();
      if(p.swing>0){
        const k=p.swing/.15;
        ctx.beginPath(); ctx.arc(p.x,p.y,p.r+30,ang-.9,ang+.9);
        ctx.strokeStyle=`rgba(160,220,255,${k})`;
        ctx.lineWidth=10*k+3; ctx.lineCap='round';
        ctx.shadowBlur=18; ctx.shadowColor='#7fd4ff'; ctx.stroke();
        ctx.shadowBlur=0; ctx.lineCap='butt';
      }
      const blink=p.invuln>0&&Math.floor(p.invuln*22)%2===0;
      circle(p.x,p.y,p.r,blink?'#fff':'#5fb2ff',16);
      ctx.lineWidth=3; ctx.strokeStyle='#b6e2ff'; ctx.stroke();
      circle(p.x-3,p.y-2,1.8,'#fff'); circle(p.x+3,p.y-2,1.8,'#fff');
      circle(p.x-3,p.y-2,.8,'#0a0a1a'); circle(p.x+3,p.y-2,.8,'#0a0a1a');
      ctx.beginPath();
      ctx.moveTo(p.x+Math.cos(ang)*p.r*.35,p.y+Math.sin(ang)*p.r*.35);
      ctx.lineTo(p.x+Math.cos(ang)*(p.r+7),p.y+Math.sin(ang)*(p.r+7));
      ctx.strokeStyle='rgba(255,255,255,.85)'; ctx.lineWidth=3; ctx.stroke();
    }
    ctx.restore();

    const p=S.p;
    bar(24,22,280,20,p.hp/p.maxHp,'#4ad6a0','#2aa8ff');
    ctx.font='bold 13px "Trebuchet MS"'; ctx.fillStyle='#dbe7ff';
    ctx.fillText(`HP ${Math.ceil(p.hp)}/${p.maxHp}`,34,37);

    const pw2=280, px2=W-pw2-24, py2=22;
    bar(px2,py2,pw2,20,p.x/WORLD_W,'#ffd76a',null,'rgba(255,215,120,.6)');
    ctx.font='bold 13px "Trebuchet MS"'; ctx.fillStyle='#1a0e20';
    ctx.fillText('ЗАМОК',px2+pw2-58,py2+15);

    ctx.font='13px "Trebuchet MS"'; ctx.fillStyle='rgba(200,220,255,.55)';
    ctx.textAlign='center';
    ctx.fillText('Не попадайтесь в конусы зрения. Прячьтесь за ящиками.',W/2,H-22);
    ctx.textAlign='left';

    if(S.msgT>0&&S.msg){
      ctx.globalAlpha=Math.min(1,S.msgT);
      ctx.textAlign='center'; ctx.font='bold 40px "Trebuchet MS"';
      ctx.fillStyle='#ffd76a'; ctx.shadowBlur=22; ctx.shadowColor='#ff8a3c';
      ctx.fillText(S.msg,W/2,H*.28); ctx.shadowBlur=0; ctx.globalAlpha=1;
      ctx.textAlign='left';
    }
    if(S.state==='dead'||S.state==='win'){
      ctx.fillStyle='rgba(20,0,8,.7)'; ctx.fillRect(0,0,W,H);
      ctx.textAlign='center'; ctx.font='bold 48px "Trebuchet MS"';
      ctx.fillStyle=S.state==='dead'?'#ff5566':'#ffd76a';
      ctx.fillText(S.state==='dead'?'ВАС ЗАДЕРЖАЛИ':'ВОРОТА ЗАМКА',W/2,H/2-20);
      ctx.font='18px "Trebuchet MS"'; ctx.fillStyle='rgba(220,230,255,.85)';
      ctx.fillText(S.state==='dead'?'нажмите R':'нажмите ПРОБЕЛ',W/2,H/2+28);
      ctx.textAlign='left';
    }
  };
  return S;
}

/* ============ ДЕРЕВНЯ ============ */
function VillageScene(onWin){
  const S={};
  const WORLD_W=2400, WORLD_H=680;
  S.init=function(){
    S.p={x:80,y:WORLD_H/2,r:14,speed:250,fx:1,fy:0};
    S.camX=0; S.time=0; S.state='play'; S.endT=0;
    S.msg='ДЕРЕВНЯ ВИДИНТАУН'; S.msgT=4.5;
    S.exitCalled=false;
    S.companion={x:60,y:WORLD_H/2+50,r:13,speed:260};
    S.houses=[
      {x:250,y:80,w:140,h:120},{x:500,y:80,w:120,h:100},{x:750,y:80,w:150,h:130},
      {x:1050,y:80,w:130,h:110},{x:1400,y:80,w:140,h:120},{x:1800,y:80,w:150,h:130},
      {x:350,y:460,w:140,h:120},{x:700,y:490,w:120,h:100},{x:1000,y:470,w:140,h:120},
      {x:1300,y:480,w:130,h:110},{x:1650,y:460,w:140,h:120}
    ];
    S.npcs=[
      {x:620,y:330,r:13,color:'#7fb2ff',name:'Крестьянин',
       text:'Портал? Да, стражник у восточного края деревни. Но он берёт плату... страшную цену. Человеческую душу.'},
      {x:1010,y:360,r:13,color:'#a080c0',name:'Старуха',
       text:'Мой сын тоже ушёл в портал. Не вернулся. Будь осторожен, воин. Стражник хитёр.'},
      {x:1450,y:330,r:13,color:'#c08060',name:'Кузнец',
       text:'Король Зла забрал нашу магию. Только портал и остался. Иди на восток, если смел.'}
    ];
    S.portalGuard={x:WORLD_W-180,y:WORLD_H/2,r:26,name:'СТРАЖНИК ПОРТАЛА'};
    S.lastNpcId=null;
    stopSpeech();
  };
  S.init();
  function collideRect(obj,c){
    const cx=clamp(obj.x,c.x,c.x+c.w), cy=clamp(obj.y,c.y,c.y+c.h);
    const dx=obj.x-cx, dy=obj.y-cy, d=Math.hypot(dx,dy);
    if(d<obj.r&&d>0){obj.x+=dx/d*(obj.r-d); obj.y+=dy/d*(obj.r-d);}
    else if(d===0){obj.x=c.x-obj.r;}
  }
  S.update=function(dt){
    S.time+=dt; S.msgT=Math.max(0,S.msgT-dt);
    if(press('KeyM')){ voiceEnabled=!voiceEnabled; if(!voiceEnabled) stopSpeech(); }
    if(S.state==='win'){
      S.endT+=dt;
      if(S.endT>1&&!S.exitCalled&&(press('Space')||press('Enter'))){
        S.exitCalled=true;
        onWin();
      }
      return;
    }
    const p=S.p;
    let mx=0,my=0;
    if(keys['KeyW']||keys['ArrowUp']) my--;
    if(keys['KeyS']||keys['ArrowDown']) my++;
    if(keys['KeyA']||keys['ArrowLeft']) mx--;
    if(keys['KeyD']||keys['ArrowRight']) mx++;
    const mag=Math.hypot(mx,my);
    if(mag>0){mx/=mag;my/=mag;p.fx=mx;p.fy=my;}
    p.x=clamp(p.x+mx*p.speed*dt,20,WORLD_W-20);
    p.y=clamp(p.y+my*p.speed*dt,60,WORLD_H-20);
    for(const h of S.houses) collideRect(p,h);
    S.camX=clamp(p.x-W/2,0,WORLD_W-W);

    const c=S.companion;
    const cdx=p.x-c.x-50, cdy=p.y-c.y, cd=Math.hypot(cdx,cdy);
    if(cd>50){c.x+=(cdx/cd)*c.speed*dt; c.y+=(cdy/cd)*c.speed*dt;}
    for(const h of S.houses) collideRect(c,h);
    c.x=clamp(c.x,20,WORLD_W-20); c.y=clamp(c.y,60,WORLD_H-20);

    let nearestNpc=null;
    for(const n of S.npcs){
      n.near = Math.hypot(p.x-n.x,p.y-n.y) < 100;
      if(n.near) nearestNpc=n;
    }
    if(nearestNpc){
      if(S.lastNpcId!==nearestNpc.name){
        S.lastNpcId=nearestNpc.name;
        if(voiceEnabled) speak(nearestNpc.text,'companion');
      }
    } else if(S.lastNpcId){
      S.lastNpcId=null;
      if(voiceEnabled) stopSpeech();
    }

    if(Math.hypot(p.x-S.portalGuard.x,p.y-S.portalGuard.y)<90 && S.state==='play'){
      S.state='win'; S.endT=0;
    }
  };
  S.draw=function(){
    drawArenaBg('village',S.time);
    ctx.save();
    ctx.translate(-S.camX,0);
    ctx.fillStyle='rgba(0,0,0,.22)'; ctx.fillRect(0,WORLD_H*.5,WORLD_W,WORLD_H);
    for(const h of S.houses){
      ctx.fillStyle='#3a2818'; ctx.fillRect(h.x,h.y+h.h*.35,h.w,h.h*.65);
      ctx.strokeStyle='#5a3a1a'; ctx.lineWidth=2; ctx.strokeRect(h.x,h.y+h.h*.35,h.w,h.h*.65);
      ctx.beginPath();
      ctx.moveTo(h.x-10,h.y+h.h*.35);
      ctx.lineTo(h.x+h.w/2,h.y);
      ctx.lineTo(h.x+h.w+10,h.y+h.h*.35);
      ctx.closePath();
      ctx.fillStyle='#5a2818'; ctx.fill();
      ctx.strokeStyle='#7a3a1a'; ctx.stroke();
      ctx.fillStyle='#1a0e08'; ctx.fillRect(h.x+h.w/2-12,h.y+h.h*.7,24,30);
      ctx.fillStyle='#ffb050';
      ctx.fillRect(h.x+15,h.y+h.h*.5,18,18);
      ctx.fillRect(h.x+h.w-33,h.y+h.h*.5,18,18);
      ctx.strokeStyle='#3a1a08'; ctx.lineWidth=1.5;
      ctx.strokeRect(h.x+15,h.y+h.h*.5,18,18);
      ctx.strokeRect(h.x+h.w-33,h.y+h.h*.5,18,18);
    }
    for(const n of S.npcs){
      ctx.save(); ctx.translate(n.x,n.y);
      ctx.beginPath(); ctx.ellipse(0,n.r*.9,n.r*.9,n.r*.4,0,0,7);
      ctx.fillStyle='rgba(0,0,0,.35)'; ctx.fill();
      circle(0,0,n.r,n.color,10);
      ctx.strokeStyle='rgba(255,255,255,.4)'; ctx.lineWidth=1.5; ctx.stroke();
      circle(-3,-2,1.8,'#fff'); circle(3,-2,1.8,'#fff');
      circle(-3,-2,.8,'#1a0a1a'); circle(3,-2,.8,'#1a0a1a');
      ctx.strokeStyle='#1a0a1a'; ctx.lineWidth=1.2;
      ctx.beginPath(); ctx.moveTo(-2,3); ctx.lineTo(2,3); ctx.stroke();
      ctx.restore();
      if(n.near){
        ctx.font='bold 20px "Trebuchet MS"'; ctx.fillStyle='#ffd76a';
        ctx.textAlign='center'; ctx.fillText('💬',n.x,n.y-n.r-14); ctx.textAlign='left';
      }
    }
    const g=S.portalGuard;
    ctx.globalAlpha=.25+.1*Math.sin(S.time*3);
    circle(g.x,g.y,g.r+30,'#8a1030'); ctx.globalAlpha=1;
    circle(g.x,g.y,g.r,'#8a1030',20);
    ctx.strokeStyle='#ff6b8d'; ctx.lineWidth=3; ctx.stroke();
    ctx.fillStyle='#fff';
    ctx.beginPath(); ctx.ellipse(g.x-7,g.y-3,4,5,0,0,7); ctx.fill();
    ctx.beginPath(); ctx.ellipse(g.x+7,g.y-3,4,5,0,0,7); ctx.fill();
    circle(g.x-7,g.y-3,2,'#ff1a1a'); circle(g.x+7,g.y-3,2,'#ff1a1a');
    ctx.strokeStyle='#1a0a0a'; ctx.lineWidth=2;
    ctx.beginPath(); ctx.moveTo(g.x-8,g.y+8); ctx.lineTo(g.x+8,g.y+8); ctx.stroke();
    ctx.fillStyle='#ffd76a';
    ctx.beginPath();
    ctx.moveTo(g.x-14,g.y-g.r-4); ctx.lineTo(g.x-8,g.y-g.r-20);
    ctx.lineTo(g.x-3,g.y-g.r-8); ctx.lineTo(g.x,g.y-g.r-22);
    ctx.lineTo(g.x+3,g.y-g.r-8); ctx.lineTo(g.x+8,g.y-g.r-20);
    ctx.lineTo(g.x+14,g.y-g.r-4); ctx.closePath(); ctx.fill();
    ctx.strokeStyle='#b8860b'; ctx.lineWidth=1.2; ctx.stroke();
    ctx.font='bold 14px "Trebuchet MS"'; ctx.fillStyle='#ff6b8d';
    ctx.textAlign='center'; ctx.fillText('СТРАЖНИК',g.x,g.y-g.r-30); ctx.textAlign='left';

    const c=S.companion;
    ctx.save(); ctx.translate(c.x,c.y);
    ctx.beginPath(); ctx.ellipse(0,c.r*.9,c.r*.9,c.r*.4,0,0,7);
    ctx.fillStyle='rgba(0,0,0,.35)'; ctx.fill();
    circle(0,0,c.r,'#4ad6a0',14);
    ctx.lineWidth=2.5; ctx.strokeStyle='#a0ffd0'; ctx.stroke();
    circle(-3,-1,1.8,'#fff'); circle(3,-1,1.8,'#fff');
    circle(-3,-1,.8,'#0a1a1a'); circle(3,-1,.8,'#0a1a1a');
    ctx.strokeStyle='#1a4a3a'; ctx.lineWidth=1.2;
    ctx.beginPath(); ctx.arc(0,3,2.5,0,Math.PI); ctx.stroke();
    ctx.restore();

    const p=S.p;
    ctx.save(); ctx.translate(p.x,p.y);
    ctx.beginPath(); ctx.ellipse(0,p.r*.9,p.r*.9,p.r*.4,0,0,7);
    ctx.fillStyle='rgba(0,0,0,.35)'; ctx.fill();
    circle(0,0,p.r,'#5fb2ff',16);
    ctx.lineWidth=3; ctx.strokeStyle='#b6e2ff'; ctx.stroke();
    circle(-3,-2,1.8,'#fff'); circle(3,-2,1.8,'#fff');
    circle(-3,-2,.8,'#0a0a1a'); circle(3,-2,.8,'#0a0a1a');
    const ang=Math.atan2(p.fy,p.fx);
    ctx.beginPath();
    ctx.moveTo(Math.cos(ang)*p.r*.35,Math.sin(ang)*p.r*.35);
    ctx.lineTo(Math.cos(ang)*(p.r+7),Math.sin(ang)*(p.r+7));
    ctx.strokeStyle='rgba(255,255,255,.85)'; ctx.lineWidth=3; ctx.stroke();
    ctx.restore();

    ctx.restore();

    const near=S.npcs.find(n=>n.near);
    if(near) drawSpeechBubble(near.x-S.camX,near.y-50,near.text,near.name,S.time);

    ctx.font='bold 40px "Trebuchet MS"'; ctx.fillStyle='#ffd76a';
    ctx.textAlign='center'; ctx.shadowBlur=20; ctx.shadowColor='#ff8a3c';
    if(S.msgT>0){ctx.globalAlpha=Math.min(1,S.msgT); ctx.fillText(S.msg,W/2,H*.15); ctx.globalAlpha=1;}
    ctx.shadowBlur=0; ctx.textAlign='left';

    ctx.font='13px "Trebuchet MS"'; ctx.fillStyle='rgba(200,220,255,.55)';
    ctx.textAlign='center';
    ctx.fillText('Подойдите к NPC, чтобы поговорить. Найдите стража портала на востоке.',W/2,H-22);
    ctx.textAlign='left';

    if(S.state==='win'){
      ctx.fillStyle='rgba(0,10,20,.6)'; ctx.fillRect(0,0,W,H);
      ctx.textAlign='center'; ctx.font='bold 44px "Trebuchet MS"';
      ctx.fillStyle='#ffd76a'; ctx.shadowBlur=24; ctx.shadowColor='#ffb03c';
      ctx.fillText('СТРАЖНИК ПОРТАЛА',W/2,H/2-20); ctx.shadowBlur=0;
      ctx.font='18px "Trebuchet MS"'; ctx.fillStyle='rgba(220,230,255,.85)';
      ctx.fillText('нажмите ПРОБЕЛ',W/2,H/2+28);
      ctx.textAlign='left';
    }
  };
  return S;
}

/* ============ СЮЖЕТНЫЕ БИТЫ ============ */
const introBeats=[
  {dur:6,bg:'city',effect:'embers',label:'ГОД 2598',
    actors:[heroActor(380,380),brotherActor(580,380,{emotion:'neutral'})],
    text:'Год 2598. Планета Костмикс-2598. Мы с братом стояли плечом к плечу.'},
  {dur:6,bg:'city',
    actors:[heroActor(380,380),brotherActor(580,380,{emotion:'neutral'}),
      {id:'e1',x:820,y:280,color:'#8a1030',seed:4},
      {id:'e2',x:860,y:340,color:'#8a1030',seed:5},
      {id:'e3',x:820,y:440,color:'#8a1030',seed:6}],
    text:'Тьма пришла к стенам города Эндексис.'},
  {dur:6,bg:'city',effect:'flash',
    actors:[heroActor(380,380,{swing:true,emotion:'angry'}),
      brotherActor(580,380,{swing:true,emotion:'angry'}),
      {id:'e1',x:760,y:340,color:'#8a1030',seed:4}],
    speech:{actor:'hero',name:'Я',text:'Держись, брат! Не отдадим им город!'}},
  {dur:6,bg:'city',
    actors:[heroActor(380,380,{emotion:'neutral'}),brotherActor(580,380,{emotion:'neutral'})],
    speech:{actor:'brother',name:'БРАТ',text:'Пока мы вместе — Эндексис не падёт.'}}
];

const aftermathBeats=[
  {dur:6,bg:'city',effect:'redflash',label:'ПОСЛЕ БИТВЫ',
    actors:[brotherActor(480,380),
      {id:'e1',x:380,y:300,color:'#8a1030',seed:4},
      {id:'e2',x:580,y:300,color:'#8a1030',seed:5},
      {id:'e3',x:380,y:460,color:'#8a1030',seed:6},
      {id:'e4',x:580,y:460,color:'#8a1030',seed:7}],
    text:'Контр-наступление тьмы было тяжёлым. Брат остался прикрывать отход.'},
  {dur:6,bg:'city',effect:'fadeout',
    actors:[brotherActor(480,380,{from:{x:580,y:380}}),
      {id:'e1',x:380,y:300,color:'#8a1030',seed:4},
      {id:'e2',x:580,y:300,color:'#8a1030',seed:5},
      {id:'e3',x:380,y:460,color:'#8a1030',seed:6},
      {id:'e4',x:580,y:460,color:'#8a1030',seed:7}],
    text:'Волна врагов захлестнула его. Рация молчала.'},
  {dur:6,bg:'city',actors:[heroActor(480,420,{emotion:'sad'})],
    text:'Я подал в отставку. Четыре года тишины.'}
];

const radioBeats=[
  {dur:5.5,bg:'castle',effect:'embers',label:'4 ГОДА СПУСТЯ',
    actors:[heroActor(400,400,{emotion:'calm'})],
    text:'Четыре года спустя. Я живу в тишине, вдали от войны.'},
  {dur:7,bg:'castle',effect:'redflash',
    actors:[heroActor(400,400,{emotion:'neutral'}),
      {id:'radio',x:640,y:420,color:'#ff8a3c',seed:8,pants:'#7a3a1a',emotion:'evil'}],
    speech:{actor:'radio',name:'📻 РАДИО',text:'«Говорит Король Зла. Все, кто ещё сопротивляется — сдавайтесь. Костмикс теперь мой!»'}},
  {dur:7,bg:'castle',
    actors:[heroActor(400,400,{glow:'#5fb2ff',emotion:'surprised'}),
      {id:'radio',x:640,y:420,color:'#ff8a3c',seed:8,pants:'#7a3a1a',emotion:'evil'}],
    speech:{actor:'hero',name:'Я',text:'...этот голос. Я слишком хорошо его знаю. Это мой брат.'}},
  {dur:6,bg:'castle',
    actors:[heroActor(480,400,{weapon:false,emotion:'sad'})],
    text:'Я вернулся в армию. Но не с мечом. С почтовой сумкой.'},
  {dur:6,bg:'castle',
    actors:[heroActor(480,400,{weapon:false,emotion:'neutral'})],
    text:'Письмо с печатью командования. Лично в руки Королю Зла. Пропуск в главный замок.'}
];

const castleBeats=[
  {dur:5.5,bg:'castle',effect:'embers',label:'ТРОННЫЙ ЗАЛ',
    actors:[brotherActor(560,300,{crown:true,emotion:'evil'})],
    text:'Тронный зал. Он сидит на троне из чёрного металла. На голове — корона.'},
  {dur:6,bg:'castle',
    actors:[brotherActor(560,300,{crown:true,emotion:'evil'}),heroActor(200,380,{weapon:false,emotion:'neutral'})],
    speech:{actor:'brother',name:'БРАТ',text:'Ха-ха-ха! Почтальон? Как предусмотрительно.'}},
  {dur:7,bg:'castle',
    actors:[brotherActor(560,300,{crown:true,emotion:'evil'}),heroActor(300,360,{weapon:false,emotion:'angry'})],
    speech:{actor:'hero',name:'ГГ',text:'Брат, зачем ты стал Королём Зла? Мы же с тобой сражались против него!'}},
  {dur:8,bg:'castle',effect:'redflash',
    actors:[brotherActor(560,300,{crown:true,emotion:'evil'}),heroActor(300,360,{weapon:false,emotion:'angry'})],
    speech:{actor:'brother',name:'БРАТ',text:'Ха-ха-ха! Ты не представляешь, какую власть я получу! Присоединяйся или я дам приказ на твою публичную казнь в Эндексисе!'}},
  {dur:6,bg:'castle',
    actors:[brotherActor(560,300,{crown:true,emotion:'evil'}),heroActor(340,360,{weapon:false,emotion:'angry'})],
    speech:{actor:'hero',name:'ГГ',text:'Да пошёл ты к чёрту. Ты мне не брат.'}},
  {dur:6.5,bg:'castle',effect:'redflash',
    actors:[brotherActor(560,300,{crown:true,glow:'#ff3b6b',emotion:'evil'}),
      heroActor(340,360,{weapon:false,emotion:'surprised'})],
    speech:{actor:'brother',name:'БРАТ',text:'Тогда познай мою истинную силу! Тёмная магия, которую ты не сможешь остановить!'}},
  {dur:6,bg:'castle',effect:'flash',
    actors:[brotherActor(560,300,{crown:true,glow:'#ff3b6b',emotion:'evil'})],
    text:'Тёмная магия обрушилась на меня. Я не мог даже поднять меч. Он слишком силён.'},
  {dur:6,bg:'castle',
    actors:[heroActor(200,500,{weapon:false,emotion:'sad'}),
      brotherActor(560,300,{crown:true,emotion:'evil'})],
    speech:{actor:'hero',name:'ГГ',text:'(про себя) Мне не победить его сейчас. Нужно найти другой путь. Бежать.'}},
  {dur:7,bg:'castle',effect:'redflash',
    actors:[brotherActor(500,300,{crown:true,emotion:'evil'})],
    speech:{actor:'brother',name:'БРАТ',text:'Ха-ха-ха-ха! Беги, братец! Я до тебя доберусь! Но сейчас план по захвату Костмикса важнее!'}}
];

/* === ХРАМ: НАМЁК НА ОТЦА === */
const templeBeats=[
  {dur:5.5,bg:'temple',label:'ХРАМ НА ПУСТОШИ',
    actors:[heroActor(480,420,{emotion:'sad'})],
    text:'Я не знал, куда идти. Ноги сами привели меня в старый храм на краю пустоши.'},
  {dur:5,bg:'temple',actors:[heroActor(480,420,{emotion:'neutral'})],
    text:'Храм был пуст. Или мне так казалось.'},
  {dur:8,bg:'temple',effect:'divine',
    actors:[heroActor(480,420,{glow:'#ffe9a0',emotion:'surprised'}),
      godActor(480,180,{from:{x:480,y:-60}})],
    speech:{actor:'god',name:'БОГ',text:'Ты пришёл с гневом, но без ненависти. С болью, но без страха. Я дам тебе силу, сын мой.'}},
  {dur:5.5,bg:'temple',effect:'divine',
    actors:[heroActor(480,420,{glow:'#ffe9a0',emotion:'surprised'})],
    text:'"Сын мой"... этот голос. Он звучал так, будто я слышал его раньше. Очень давно.'},
  {dur:7,bg:'temple',effect:'divine',
    actors:[heroActor(480,420,{glow:'#ffe9a0',emotion:'calm'})],
    text:'Свет прошёл сквозь меня. Я почувствовал, что способен в одиночку победить сотню солдат и остаться без единой царапины.'},
  {dur:6.5,bg:'temple',effect:'divine',
    actors:[heroActor(480,420,{glow:'#ffe9a0',emotion:'angry'})],
    speech:{actor:'hero',name:'ГГ',text:'Клянусь. Я изгоню его с этой планеты. Но сначала мне нужна помощь.'}}
];

const companionMeetBeats=[
  {dur:5.5,bg:'road',label:'НА ВЫХОДЕ ИЗ ХРАМА',
    actors:[heroActor(480,420,{emotion:'neutral'})],
    text:'Выйдя из храма, я встретил странника. Он назвался Рейном.'},
  {dur:6.5,bg:'road',
    actors:[heroActor(400,420,{emotion:'surprised'}),companionActor(560,420)],
    speech:{actor:'companion',name:'РЕЙН',text:'Ты идёшь против Короля Зла? Я тоже. Мой город сожгли его солдаты.'}},
  {dur:6.5,bg:'road',
    actors:[heroActor(400,420,{emotion:'sad'}),companionActor(560,420)],
    speech:{actor:'hero',name:'ГГ',text:'Он слишком силён. У него тёмная магия. Мне нужно оружие против неё.'}},
  {dur:7,bg:'road',
    actors:[heroActor(400,420,{emotion:'neutral'}),companionActor(560,420)],
    speech:{actor:'companion',name:'РЕЙН',text:'В деревне Видинтаун есть портал в иное измерение. Говорят, там спрятан артефакт — камень «Адский жнец».'}},
  {dur:5.5,bg:'road',
    actors:[heroActor(400,420,{emotion:'neutral'}),companionActor(560,420)],
    speech:{actor:'hero',name:'ГГ',text:'Тогда идём. Вдвоём у нас есть шанс.'}},
  {dur:5.5,bg:'road',
    actors:[heroActor(480,420,{emotion:'sad'})],
    text:'Голос бога не выходил у меня из головы. "Сын мой"... Кого же он мне напоминает?'}
];

const portalGuardBeats=[
  {dur:7,bg:'village',label:'ВОСТОЧНЫЙ КРАЙ ВИДИНТАУНА',
    actors:[heroActor(300,420,{emotion:'neutral'}),companionActor(400,420),guardActor(700,420)],
    speech:{actor:'guard',name:'СТРАЖНИК',text:'Плата за проход — человеческая душа. Иначе портал не откроется.'}},
  {dur:6.5,bg:'village',
    actors:[heroActor(300,420,{emotion:'sad'}),companionActor(400,420),guardActor(700,420)],
    speech:{actor:'hero',name:'ГГ',text:'У меня нет души, которую я могу отдать. Я не могу...'}},
  {dur:6.5,bg:'village',
    actors:[heroActor(300,420,{emotion:'sad'}),companionActor(400,420,{emotion:'calm'}),guardActor(700,420)],
    speech:{actor:'companion',name:'РЕЙН',text:'Я подпишу. Моя жизнь — малая цена за свободу Костмикса.'}},
  {dur:7,bg:'village',effect:'redflash',
    actors:[heroActor(300,420,{emotion:'sad'}),companionActor(420,420,{glow:'#ff3b6b',emotion:'sad'}),guardActor(700,420,{glow:'#8a1030'})],
    text:'Контракт подписан. Душа Рейна заперта в страже. Но он всё ещё с нами — пока.'},
  {dur:5.5,bg:'void',effect:'void',
    actors:[heroActor(400,420,{emotion:'neutral'}),companionActor(520,420)],
    text:'Портал открылся. Мы шагнули в неизвестность.'}
];

const crownAndStoneBeats=[
  {dur:7,bg:'void',effect:'void',label:'ПАРАЛЛЕЛЬНОЕ ИЗМЕРЕНИЕ',
    actors:[heroActor(400,420,{glow:'#ffe9a0',emotion:'surprised'}),companionActor(520,420)],
    text:'Мы нашли кусочек короны. Он упал с головы брата. Без полной короны его магия слабее.'},
  {dur:7,bg:'void',effect:'void',
    actors:[heroActor(400,420,{glow:'#ffe9a0',emotion:'calm'}),companionActor(520,420)],
    text:'А в глубине измерения — камень «Адский жнец». Он забирает злую душу и заменяет её доброй.'},
  {dur:6.5,bg:'void',effect:'void',
    actors:[heroActor(400,420,{glow:'#ffe9a0',emotion:'neutral'}),companionActor(520,420)],
    speech:{actor:'companion',name:'РЕЙН',text:'Значит, брат здесь был. И оставил кусочек короны. Теперь у нас есть шанс.'}},
  {dur:6,bg:'void',effect:'void',
    actors:[heroActor(400,420,{glow:'#ffe9a0',emotion:'angry'}),companionActor(520,420)],
    speech:{actor:'hero',name:'ГГ',text:'С камнем и ослабленной магией мы сможем его одолеть. Идём в замок.'}}
];

const stoneUsageBeats=[
  {dur:6,bg:'castle',effect:'embers',label:'ЗАМОК КОРОЛЯ ЗЛА',
    actors:[brotherActor(480,380,{crown:true,emotion:'evil'}),
      heroActor(300,380,{glow:'#ffe9a0',emotion:'neutral'}),companionActor(400,420)],
    text:'Брат ослаб. Без полной короны его тёмная магия угасала.'},
  {dur:6.5,bg:'castle',effect:'divine',
    actors:[brotherActor(480,380,{crown:true,emotion:'surprised'}),
      heroActor(360,380,{glow:'#ffe9a0',emotion:'sad'})],
    speech:{actor:'hero',name:'ГГ',text:'Прости, брат. Это для твоего же блага. Я верну тебя.'}},
  {dur:7,bg:'castle',effect:'flash',
    actors:[brotherActor(480,380,{crown:false,glow:'#ff3b6b',emotion:'surprised'})],
    text:'Камень «Адский жнец» вспыхнул. Из брата вырвалась тёмная сущность...'},
  {dur:6,bg:'castle',effect:'redflash',
    actors:[brotherActor(480,380,{crown:false,glow:'#ff3b6b',emotion:'surprised'})],
    text:'...и растворилась в воздухе, забрав с собой всё зло.'},
  {dur:6,bg:'castle',
    actors:[brotherActor(480,420,{crown:false,emotion:'sad'}),
      heroActor(360,420,{glow:'#ffe9a0',emotion:'sad'})],
    text:'Брат рухнул на колени. Он снова был просто моим братом.'}
];

const castleExitBeats=[
  {dur:6,bg:'castle',effect:'embers',label:'ПРИМИРЕНИЕ',
    actors:[brotherActor(480,420,{emotion:'sad'}),heroActor(360,420,{emotion:'sad'})],
    speech:{actor:'brother',name:'БРАТ',text:'...ты всё-таки пришёл. Я... я не помню, что со мной было. Только голос... который звал к власти.'}},
  {dur:6,bg:'castle',
    actors:[brotherActor(480,420,{emotion:'neutral'}),heroActor(360,420,{emotion:'neutral'})],
    speech:{actor:'hero',name:'ГГ',text:'Я всегда приходил. Ты просто забыл. И я вернул тебя.'}},
  {dur:6,bg:'castle',
    actors:[brotherActor(480,420),heroActor(360,420),companionActor(560,460)],
    text:'Мы вышли из замка втроём. Рейн — его душа всё ещё в страже портала. Но он был с нами.'},
  {dur:6.5,bg:'castle',effect:'embers',
    actors:[heroActor(400,420),brotherActor(520,420),companionActor(640,420)],
    text:'Солдаты зла ещё бродили по улицам Костмикса. Их нужно было изгнать навсегда.'},
  {dur:7,bg:'castle',effect:'redflash',
    actors:[heroActor(400,420,{emotion:'surprised'}),brotherActor(520,420),
      companionActor(640,420),
      {id:'portal',x:200,y:280,color:'#8a1030',glow:'#ff3b6b',emotion:'evil',weapon:false}],
    text:'Вдруг воздух раскололся. Из портала явился лик самого Зла — как голограмма. Портал был запечатан изнутри.'},
  {dur:9,bg:'castle',effect:'redflash',
    actors:[heroActor(400,420,{emotion:'angry'}),brotherActor(520,420,{emotion:'sad'}),
      companionActor(640,420),
      {id:'portal',x:200,y:280,color:'#8a1030',glow:'#ff3b6b',emotion:'evil',weapon:false}],
    speech:{actor:'portal',name:'ЗЛО',text:'Ты предал меня, брат. Ответственность придёт. Не сейчас. Но она придёт. И ты не узнаешь ни дня, ни часа.'}},
  {dur:6,bg:'castle',effect:'flash',
    actors:[heroActor(400,420,{emotion:'surprised'}),brotherActor(520,420,{emotion:'sad'}),companionActor(640,420)],
    text:'Портал схлопнулся. На его месте осталась лишь записка.'},
  {dur:8,bg:'castle',
    actors:[heroActor(400,420,{emotion:'neutral'}),brotherActor(520,420),companionActor(640,420)],
    speech:{actor:'hero',name:'ГГ',text:'«Сыновья мои, я жив. Придите ко мне в замок зла в городе Тенеграде. Помогите отцу. — Ваш отец.»'}},
  {dur:7,bg:'castle',
    actors:[heroActor(400,420),brotherActor(520,420,{emotion:'sad'}),companionActor(640,420)],
    speech:{actor:'brother',name:'БРАТ',text:'Отец умер шесть лет назад. Я сам хоронил его. Это не может быть он.'}},
  {dur:7,bg:'castle',
    actors:[heroActor(400,420,{emotion:'sad'}),brotherActor(520,420),companionActor(640,420)],
    speech:{actor:'hero',name:'ГГ',text:'Я хочу увидеть отца. Даже если это ловушка — я пойду. Один, если нужно.'}},
  {dur:7,bg:'castle',
    actors:[heroActor(400,420),brotherActor(520,420,{emotion:'angry'}),companionActor(640,420)],
    speech:{actor:'brother',name:'БРАТ',text:'Нет. Если ты идёшь — мы идём вместе. Но будь готов: это может быть засада.'}},
  {dur:6,bg:'castle',
    actors:[heroActor(400,420),brotherActor(520,420),companionActor(640,420,{emotion:'calm'})],
    speech:{actor:'companion',name:'РЕЙН',text:'Я с вами. До конца.'}}
];

const pathToTengradBeats=[
  {dur:5.5,bg:'road',label:'ПУТЬ В ТЕНЕГРАД',
    actors:[heroActor(400,420),brotherActor(520,420),companionActor(640,420)],
    text:'Мы шли три дня. Тенеград встретил нас пустыми улицами и тишиной.'},
  {dur:6,bg:'road',
    actors:[heroActor(400,420,{emotion:'neutral'}),brotherActor(520,420,{emotion:'angry'}),companionActor(640,420)],
    speech:{actor:'brother',name:'БРАТ',text:'Слишком тихо. Так не бывает. Готовьте оружие.'}},
  {dur:5.5,bg:'road',effect:'embers',
    actors:[heroActor(400,420),brotherActor(520,420),companionActor(640,420)],
    text:'Ворота замка распахнулись сами. Мы вошли внутрь.'}
];

const ambushIntroBeats=[
  {dur:6,bg:'tengrad',effect:'redflash',label:'ЛОВУШКА',
    actors:[heroActor(400,420,{emotion:'surprised'}),brotherActor(520,420,{emotion:'angry'}),companionActor(640,420),
      {id:'portal',x:200,y:280,color:'#8a1030',glow:'#ff3b6b',emotion:'evil',weapon:false}],
    speech:{actor:'portal',name:'ЗЛО',text:'Ха-ха-ха! Вот и вы! Я же говорил — ответственность придёт! Это ловушка, глупцы!'}},
  {dur:5.5,bg:'tengrad',effect:'redflash',
    actors:[heroActor(400,420,{emotion:'angry'}),brotherActor(520,420,{emotion:'angry'}),companionActor(640,420)],
    text:'Стены содрогнулись. Сотни солдат зла вышли из тени. Засада.'},
  {dur:6,bg:'tengrad',
    actors:[heroActor(400,420,{emotion:'neutral'}),brotherActor(520,420,{emotion:'angry'}),companionActor(640,420)],
    speech:{actor:'hero',name:'ГГ',text:'Что ж... Примем бой. Мы выгоним их навсегда. Все вместе.'}}
];

const finalVictoryBeats=[
  {dur:6,bg:'tengrad',effect:'embers',label:'ИЗГНАНИЕ ЗЛА',
    actors:[heroActor(400,420,{glow:'#ffe9a0'}),brotherActor(520,420),companionActor(640,420)],
    text:'Теневой вестник пал. Его армия дрогнула и рассыпалась пеплом.'},
  {dur:6,bg:'city',
    actors:[heroActor(400,420,{glow:'#ffe9a0'}),brotherActor(520,420),companionActor(640,420)],
    text:'Солдаты зла бежали из Костмикса. Навсегда.'},
  {dur:5.5,bg:'city',
    actors:[heroActor(400,420),brotherActor(520,420),companionActor(640,420)],
    text:'Планета вздохнула свободно впервые за много лет.'}
];

/* === ФИНАЛ: РАСКРЫТИЕ — БОГ = ОТЕЦ === */
const finalEndingBeats=[
  {dur:6,bg:'city',effect:'embers',
    actors:[heroActor(400,420),brotherActor(520,420,{emotion:'neutral'})],
    text:'Брат стоял рядом. Живой. Настоящий.'},
  {dur:7,bg:'city',
    actors:[heroActor(400,420,{emotion:'neutral'}),brotherActor(520,420,{emotion:'sad'})],
    speech:{actor:'brother',name:'БРАТ',text:'Прости меня. Я не помню, как стал тем, кем был. Но я помню, что ты пришёл за мной.'}},
  {dur:6.5,bg:'city',
    actors:[heroActor(400,420,{emotion:'neutral'}),brotherActor(520,420)],
    speech:{actor:'hero',name:'ГГ',text:'Я всегда приду. Ты — мой брат.'}},
  {dur:6,bg:'city',
    actors:[heroActor(400,420,{emotion:'sad'}),brotherActor(520,420),companionActor(640,460)],
    text:'Рейн стоял рядом. Его душа осталась в страже портала. Но он улыбался.'},
  // === Возвращение в храм ===
  {dur:6,bg:'temple',effect:'divine',label:'ВОЗВРАЩЕНИЕ В ХРАМ',
    actors:[heroActor(400,420),brotherActor(520,420)],
    text:'Мы вернулись в храм — тот самый, где я получил силу. Свет снова зажёгся.'},
  {dur:7,bg:'temple',effect:'divine',
    actors:[heroActor(400,420,{emotion:'surprised'}),brotherActor(520,420,{emotion:'surprised'}),
      fatherActor(480,180,{from:{x:480,y:-60}})],
    text:'Свет сгустился в знакомую фигуру.'},
  {dur:8,bg:'temple',effect:'divine',
    actors:[heroActor(400,420,{emotion:'surprised'}),brotherActor(520,420,{emotion:'surprised'}),
      fatherActor(480,180)],
    speech:{actor:'father',name:'ОТЕЦ',text:'Сыновья мои. Я ждал вас.'}},
  {dur:8,bg:'temple',effect:'divine',
    actors:[heroActor(400,420,{emotion:'surprised'}),brotherActor(520,420,{emotion:'surprised'}),
      fatherActor(480,180)],
    speech:{actor:'brother',name:'БРАТ',text:'...отец? Это ты? Но мы похоронили тебя шесть лет назад!'}},
  {dur:9,bg:'temple',effect:'divine',
    actors:[heroActor(400,420,{emotion:'sad'}),brotherActor(520,420,{emotion:'sad'}),
      fatherActor(480,180)],
    speech:{actor:'father',name:'ОТЕЦ',text:'Моё тело умерло. Но душа не могла вас оставить. Я стал светом этого храма. Ждал, когда вы будете готовы.'}},
  {dur:8,bg:'temple',effect:'divine',
    actors:[heroActor(400,420,{emotion:'surprised'}),brotherActor(520,420,{emotion:'sad'}),
      fatherActor(480,180)],
    speech:{actor:'hero',name:'ГГ',text:'Значит, "сын мой" в храме... это было мне. А записка — это была не ты?'}},
  {dur:9,bg:'temple',effect:'redflash',
    actors:[heroActor(400,420,{emotion:'angry'}),brotherActor(520,420,{emotion:'angry'}),
      fatherActor(480,180)],
    speech:{actor:'father',name:'ОТЕЦ',text:'Это Зло играло на вашей боли. Оно знало, как сильно вы хотели увидеть меня. Но теперь — я с вами. По-настоящему.'}},
  {dur:7,bg:'temple',effect:'divine',
    actors:[heroActor(400,420,{emotion:'sad'}),brotherActor(520,420,{emotion:'sad'}),
      fatherActor(480,180)],
    speech:{actor:'brother',name:'БРАТ',text:'Отец... прости меня. Я столько всего натворил...'}},
  {dur:7,bg:'temple',effect:'divine',
    actors:[heroActor(400,420),brotherActor(520,420),
      fatherActor(480,180)],
    speech:{actor:'father',name:'ОТЕЦ',text:'Ты вернулся. Этого достаточно. Я горжусь вами обоими.'}},
  {dur:7,bg:'temple',effect:'divine',
    actors:[heroActor(400,420,{glow:'#ffe9a0'}),brotherActor(520,420,{glow:'#ffe9a0'}),
      fatherActor(480,180)],
    text:'Свет храма окутал нас. Впервые за много лет мы были вместе — все трое.'},
  // === Финальные строки ===
  {dur:6.5,bg:'city',effect:'embers',
    actors:[heroActor(400,420,{emotion:'calm'})],
    text:'Костмикс-2598 был свободен. Отец был с нами. Всегда был — просто мы не знали.'},
  {dur:6,bg:'city',
    actors:[heroActor(400,420,{emotion:'sad'})],
    text:'Но голос Зла не выходил из головы: «Ответственность придёт. И ты не узнаешь ни дня, ни часа».'},
  {dur:5,bg:'city',
    actors:[heroActor(400,420,{emotion:'calm'})],
    text:'Когда-нибудь. Но не сегодня.'},
  {dur:4,bg:'city',
    actors:[heroActor(400,420)],
    text:'КОНЕЦ'}
];

/* ============ УРОВНИ ============ */
const level1=next=>PlayScene({superHero:false,bg:'city',maxAlive:8,
  title:'ОБОРОНА ЭНДЕКСИСА',
  waves:[{type:'grunt',count:4},{type:'grunt',count:5},
    {type:'knight',count:2},{type:'grunt',count:4}],
  onWin:next});
const portalDimension=next=>PlayScene({superHero:true,bg:'void',maxAlive:16,
  withCompanion:true,pickup:true,title:'ПОРТАЛЬНОЕ ИЗМЕРЕНИЕ',
  waves:[{type:'grunt',count:12},{type:'knight',count:8},{type:'brute',count:4},
    {type:'grunt',count:15},{type:'knight',count:10},{type:'brute',count:6}],
  onWin:next});
const level2=next=>PlayScene({superHero:true,bg:'castle',maxAlive:16,
  boss:true,withCompanion:true,title:'ШТУРМ ЗАМКА КОРОЛЯ ЗЛА',
  waves:[{type:'grunt',count:15},{type:'knight',count:8},{type:'brute',count:4},
    {type:'grunt',count:20},{type:'knight',count:10},{type:'brute',count:6}],
  onWin:next});
const ambushLevel=next=>PlayScene({superHero:true,bg:'tengrad',maxAlive:20,
  boss:true,withCompanion:true,bossHp:2600,bossName:'ТЕНЕВОЙ ВЕСТНИК',
  title:'ЗАСАДА В ТЕНЕГРАДЕ',
  waves:[{type:'grunt',count:15},{type:'knight',count:8},{type:'brute',count:4},
    {type:'grunt',count:18},{type:'knight',count:10},{type:'brute',count:5},
    {type:'grunt',count:12}],
  onWin:next});
const infiltration=next=>InfiltrationScene(next);
const village=next=>VillageScene(next);

/* ============ ЗАПУСК ============ */
let current=null;
function go(s){ stopSpeech(); current=s; }
function seq(steps){
  let i=0; const next=()=>{if(i<steps.length) steps[i++](next);};
  next();
}
function startGame(){
  seq([
    next=>go(CinematicScene(introBeats,next)),
    next=>go(level1(next)),
    next=>go(CinematicScene(aftermathBeats,next)),
    next=>go(CinematicScene(radioBeats,next)),
    next=>go(infiltration(next)),
    next=>go(CinematicScene(castleBeats,next)),
    next=>go(CinematicScene(templeBeats,next)),
    next=>go(CinematicScene(companionMeetBeats,next)),
    next=>go(village(next)),
    next=>go(CinematicScene(portalGuardBeats,next)),
    next=>go(portalDimension(next)),
    next=>go(CinematicScene(crownAndStoneBeats,next)),
    next=>go(level2(next)),
    next=>go(CinematicScene(stoneUsageBeats,next)),
    next=>go(CinematicScene(castleExitBeats,next)),
    next=>go(CinematicScene(pathToTengradBeats,next)),
    next=>go(CinematicScene(ambushIntroBeats,next)),
    next=>go(ambushLevel(next)),
    next=>go(CinematicScene(finalVictoryBeats,next)),
    next=>go(CinematicScene(finalEndingBeats,next)),
    next=>go(TitleScene(()=>startGame()))
  ]);
}
go(TitleScene(()=>startGame()));

/* ============ ГЛАВНЫЙ ЦИКЛ ============ */
let last=performance.now();
function loop(now){
  const dt=Math.min(.05,(now-last)/1000);
  last=now;
  if(current){current.update(dt); current.draw();}
  pressed.clear();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>

Game Source: Костмикс-2598: Клятва брата

Creator: PrismMeteor75

Libraries: none

Complexity: complex (1838 lines, 88.2 KB)

The full source code is displayed above on this page.

Remix Instructions

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