Костмикс-2598: Клятва брата
by PrismMeteor751838 lines88.2 KB
<!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.