НЕОН: Арена
by BlazingBear242309 lines93.1 KB
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="theme-color" content="#05060d">
<title>НЕОН: Арена</title>
<style>
*{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent;}
.hidden{display:none !important;}
html,body{height:100%;overflow:hidden;background:#05060d;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,system-ui,sans-serif;
color:#e8f0ff;user-select:none;-webkit-user-select:none;overscroll-behavior:none;}
canvas{position:fixed;inset:0;display:block;touch-action:none;}
#hud{position:fixed;inset:0;pointer-events:none;z-index:10;
padding:calc(10px + env(safe-area-inset-top)) 12px 0 12px;}
.hud-row{display:flex;align-items:center;gap:10px;}
.stat{display:flex;align-items:center;gap:8px;flex:1;}
.stat.center{flex-direction:column;gap:2px;justify-content:center;align-items:center;}
.stat.right{justify-content:flex-end;gap:8px;}
.hpbar{width:clamp(70px,22vw,150px);height:14px;border-radius:8px;
background:rgba(255,255,255,.09);border:1px solid rgba(140,180,255,.25);overflow:hidden;}
#hpFill{height:100%;width:100%;border-radius:8px;
background:linear-gradient(90deg,#ff4d6d,#ff9f45,#4cff9d);transition:width .18s ease;}
#hpText{font-size:13px;font-weight:700;color:#cfe3ff;min-width:28px;}
#lvlText{font-size:14px;font-weight:800;letter-spacing:.5px;color:#dff0ff;
text-shadow:0 0 10px rgba(76,214,255,.6);}
#progText{font-size:11px;color:#8fb6d8;font-weight:600;letter-spacing:.5px;}
.coins{font-size:13px;font-weight:800;color:#ffd86b;text-shadow:0 0 10px rgba(255,200,60,.5);white-space:nowrap;}
.energy-hud{font-size:13px;font-weight:800;color:#7ee0ff;text-shadow:0 0 10px rgba(120,224,255,.5);white-space:nowrap;}
.icon-btn{pointer-events:auto;width:40px;height:40px;border-radius:12px;
border:1px solid rgba(140,180,255,.3);background:rgba(18,26,48,.75);
color:#cfe3ff;font-size:15px;font-weight:800;cursor:pointer;
-webkit-appearance:none;appearance:none;}
#btnDash{position:fixed;z-index:11;
right:calc(18px + env(safe-area-inset-right));
bottom:calc(132px + env(safe-area-inset-bottom));
width:66px;height:66px;border-radius:50%;font-size:26px;
border:2px solid rgba(120,220,255,.55);
background:radial-gradient(circle at 35% 30%,rgba(76,214,255,.4),rgba(10,20,40,.85));
color:#bff0ff;cursor:pointer;touch-action:none;
box-shadow:0 0 24px rgba(76,214,255,.35);
-webkit-appearance:none;appearance:none;padding:0;line-height:1;}
#btnDash:active{transform:scale(.9);}
#btnDash.cool{opacity:.35;}
#btnFire{position:fixed;z-index:11;
right:calc(18px + env(safe-area-inset-right));
bottom:calc(24px + env(safe-area-inset-bottom));
width:92px;height:92px;border-radius:50%;font-size:38px;
border:2px solid rgba(255,140,80,.65);
background:radial-gradient(circle at 35% 30%,rgba(255,140,80,.45),rgba(40,15,10,.85));
color:#ffd0b0;cursor:pointer;touch-action:none;
box-shadow:0 0 28px rgba(255,140,80,.4);
-webkit-appearance:none;appearance:none;padding:0;line-height:1;}
#btnFire.pressed,#btnFire:active{transform:scale(.9);
background:radial-gradient(circle at 35% 30%,rgba(255,190,130,.65),rgba(60,25,15,.9));
box-shadow:0 0 40px rgba(255,160,80,.75);}
#banner{position:fixed;left:50%;top:38%;transform:translate(-50%,-50%) scale(.7);
z-index:20;font-size:clamp(20px,6vw,40px);font-weight:900;letter-spacing:2px;
color:#fff;text-shadow:0 0 24px rgba(76,214,255,.9),0 0 60px rgba(76,214,255,.5);
opacity:0;pointer-events:none;transition:opacity .25s,transform .25s;
text-align:center;white-space:nowrap;}
#banner.show{opacity:1;transform:translate(-50%,-50%) scale(1);}
#toasts{position:fixed;left:50%;bottom:calc(120px + env(safe-area-inset-bottom));
transform:translateX(-50%);z-index:30;display:flex;flex-direction:column;gap:8px;
align-items:center;pointer-events:none;width:92%;}
.toast{background:rgba(16,24,46,.94);border:1px solid rgba(255,210,90,.5);
color:#ffe9a8;padding:10px 16px;border-radius:12px;font-size:13px;font-weight:700;
box-shadow:0 6px 24px rgba(0,0,0,.5);animation:tin .3s ease;text-align:center;}
.toast.out{opacity:0;transform:translateY(-8px);transition:.4s;}
@keyframes tin{from{opacity:0;transform:translateY(14px);}to{opacity:1;transform:none;}}
#overlay{position:fixed;inset:0;z-index:40;
display:flex;
background:radial-gradient(circle at 50% 40%,rgba(12,20,44,.9),rgba(3,4,10,.97));
overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:0;}
#overlay.hidden{display:none !important;}
.screen{width:100%;max-width:520px;margin:auto;flex-shrink:0;
display:flex;flex-direction:column;align-items:center;gap:12px;
padding:calc(8px + env(safe-area-inset-top)) 16px calc(32px + env(safe-area-inset-bottom));}
.screen.hidden{display:none !important;}
.title{font-size:clamp(34px,11vw,58px);font-weight:900;letter-spacing:3px;line-height:1;
text-align:center;color:#fff;
text-shadow:0 0 14px rgba(76,214,255,.9),0 0 50px rgba(76,214,255,.45);}
.title span{display:block;font-size:.62em;letter-spacing:9px;color:#b45cff;
text-shadow:0 0 18px rgba(180,92,255,.9);margin-top:4px;}
h2{font-size:22px;font-weight:900;letter-spacing:2px;color:#eaf4ff;
text-shadow:0 0 16px rgba(76,214,255,.55);}
.dead{color:#ff6b8a;text-shadow:0 0 22px rgba(255,60,100,.7);}
.subtitle{font-size:12.5px;color:#8fb6d8;text-align:center;max-width:340px;line-height:1.65;font-weight:600;}
.btn{width:100%;max-width:330px;padding:15px 20px;border-radius:14px;cursor:pointer;
font-size:15px;font-weight:800;letter-spacing:1.4px;color:#dcecff;
border:1px solid rgba(120,170,255,.3);
background:linear-gradient(180deg,rgba(30,44,80,.9),rgba(14,22,44,.9));
transition:transform .09s;position:relative;
-webkit-appearance:none;appearance:none;}
.btn:active{transform:scale(.97);}
.btn.primary{border-color:rgba(76,214,255,.65);color:#fff;
background:linear-gradient(180deg,rgba(40,110,180,.95),rgba(20,50,100,.95));
box-shadow:0 0 26px rgba(76,214,255,.35);}
.btn.danger{border-color:rgba(255,90,120,.5);color:#ffd3dc;
background:linear-gradient(180deg,rgba(90,24,44,.9),rgba(40,10,22,.9));}
.btn.gold{border-color:rgba(255,200,80,.6);color:#fff5d0;
background:linear-gradient(180deg,rgba(140,90,20,.95),rgba(70,40,10,.95));
box-shadow:0 0 22px rgba(255,180,40,.35);}
.btn.cyan{border-color:rgba(120,224,255,.6);color:#eafcff;
background:linear-gradient(180deg,rgba(30,110,160,.95),rgba(10,50,90,.95));
box-shadow:0 0 22px rgba(120,224,255,.35);}
.btn.green{border-color:rgba(76,255,157,.6);color:#eaffef;
background:linear-gradient(180deg,rgba(20,120,70,.95),rgba(10,60,35,.95));
box-shadow:0 0 22px rgba(76,255,157,.35);}
.btn.small{width:auto;padding:9px 14px;font-size:14px;max-width:none;border-radius:11px;}
.badge{display:inline-block;background:#ff3d6e;color:#fff;border-radius:8px;
font-size:11px;padding:1px 7px;margin-left:6px;vertical-align:middle;
animation:pulse 1.4s infinite;}
.badge.hidden{display:none !important;}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.hint{font-size:11.5px;color:#7d97b8;text-align:center;line-height:1.7;max-width:340px;margin-top:6px;}
.res-row{display:flex;gap:18px;align-items:center;justify-content:center;flex-wrap:wrap;}
.coins-big{font-size:19px;font-weight:900;color:#ffd86b;
text-shadow:0 0 14px rgba(255,200,60,.55);}
.energy-big{font-size:19px;font-weight:900;color:#7ee0ff;
text-shadow:0 0 14px rgba(120,224,255,.55);}
.head{position:sticky;top:0;z-index:6;
display:flex;align-items:center;gap:10px;width:100%;
padding:10px 4px;margin:0 -4px;
background:linear-gradient(180deg,rgba(6,10,20,.98) 65%,rgba(6,10,20,0));}
.head h2{flex:1;text-align:center;}
.head .btn.small{font-size:18px;padding:10px 16px;line-height:1;min-width:46px;}
.menu-footer{display:flex;align-items:center;gap:10px;margin-top:4px;
padding:8px 14px;border-radius:12px;background:rgba(14,20,40,.7);
border:1px solid rgba(120,160,255,.2);font-size:13px;color:#a8c4e4;font-weight:700;
flex-wrap:wrap;justify-content:center;}
.menu-footer .btn.small{padding:7px 12px;font-size:12px;}
.auth-input{width:100%;max-width:330px;padding:14px 18px;border-radius:12px;
background:rgba(14,20,40,.9);border:1px solid rgba(120,170,255,.3);
color:#e8f0ff;font-size:16px;font-weight:600;outline:none;
-webkit-appearance:none;appearance:none;font-family:inherit;
transition:border-color .15s,box-shadow .15s;}
.auth-input:focus{border-color:rgba(76,214,255,.7);box-shadow:0 0 18px rgba(76,214,255,.28);}
.auth-input::placeholder{color:#5f7694;font-weight:600;}
.auth-err{color:#ff6b8a;font-size:13px;font-weight:700;min-height:18px;text-align:center;
max-width:330px;line-height:1.4;}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(136px,1fr));
gap:11px;width:100%;margin-top:6px;}
.card{background:rgba(14,20,40,.85);border:1.5px solid rgba(120,160,255,.2);
border-radius:15px;padding:13px 10px;display:flex;flex-direction:column;
align-items:center;gap:7px;cursor:pointer;transition:transform .09s;
-webkit-appearance:none;appearance:none;color:inherit;font:inherit;}
.card:active{transform:scale(.96);}
.card.equipped{border-color:#4cff9d;box-shadow:0 0 20px rgba(76,255,157,.4);}
.dot{width:52px;height:52px;border-radius:50%;background:var(--c);
box-shadow:0 0 18px var(--c),inset 0 -6px 14px rgba(0,0,0,.45);
border:2px solid rgba(255,255,255,.35);}
.dot.rainbow{animation:rainbow 3s linear infinite;}
@keyframes rainbow{0%{filter:hue-rotate(0)}100%{filter:hue-rotate(360deg)}}
.cname{font-size:13px;font-weight:800;color:#e8f2ff;text-align:center;}
.crar{font-size:10px;font-weight:800;letter-spacing:1px;text-transform:uppercase;}
.cbtn{font-size:12px;font-weight:800;padding:6px 10px;border-radius:9px;width:100%;
text-align:center;background:rgba(255,255,255,.07);color:#cfe3ff;}
.card.equipped .cbtn{background:rgba(76,255,157,.18);color:#8dffc0;}
.r-common .crar{color:#9aa4b2;} .r-common .dot{--c:#9aa4b2;}
.r-uncommon .crar{color:#4cd97b;} .r-rare .crar{color:#3fa9ff;}
.r-epic .crar{color:#b45cff;} .r-legend .crar{color:#ffb020;}
.r-legend{border-color:rgba(255,176,32,.5);box-shadow:0 0 18px rgba(255,176,32,.22);}
.timer{font-size:12.5px;color:#8fb6d8;font-weight:600;}
.timer b{color:#4cd6ff;}
.qlist{display:flex;flex-direction:column;gap:11px;width:100%;margin-top:6px;}
.quest{background:rgba(14,20,40,.85);border:1.5px solid rgba(120,160,255,.2);
border-radius:15px;padding:13px 15px;display:flex;gap:12px;align-items:center;}
.quest.done{border-color:rgba(76,255,157,.55);background:rgba(16,42,32,.8);}
.qicon{font-size:24px;width:34px;text-align:center;}
.qbody{flex:1;min-width:0;}
.qname{font-size:13.5px;font-weight:800;color:#e8f2ff;margin-bottom:6px;}
.qbar{height:8px;border-radius:6px;background:rgba(255,255,255,.09);overflow:hidden;}
.qfill{height:100%;border-radius:6px;background:linear-gradient(90deg,#4cd6ff,#b45cff);transition:width .3s;}
.quest.done .qfill{background:linear-gradient(90deg,#4cff9d,#9dff4c);}
.qprog{font-size:11px;color:#8fb6d8;margin-top:5px;font-weight:600;}
.qrew{font-size:12px;font-weight:900;color:#ffd86b;white-space:nowrap;text-align:right;line-height:1.5;}
.qrew .e{color:#7ee0ff;}
.stats-out{font-size:15px;color:#b9d4ee;text-align:center;line-height:1.9;font-weight:600;}
.stats-out b{color:#4cd6ff;}
.upg-intro{font-size:12px;color:#8fb6d8;text-align:center;line-height:1.6;max-width:360px;font-weight:600;}
.upg-intro .warn{color:#ffb03a;font-weight:800;}
.upg-list{display:flex;flex-direction:column;gap:11px;width:100%;margin-top:6px;}
.upg-card{background:rgba(14,20,40,.85);border:1.5px solid rgba(120,160,255,.2);
border-radius:15px;padding:13px 15px;display:flex;gap:12px;align-items:center;width:100%;}
.upg-card.maxed{border-color:rgba(76,255,157,.5);background:rgba(16,42,32,.75);}
.upg-card.can-buy{border-color:rgba(120,224,255,.5);box-shadow:0 0 14px rgba(120,224,255,.18);}
.upg-card.boss-immune{border-color:rgba(255,176,32,.35);}
.upg-icon{font-size:26px;width:38px;text-align:center;}
.upg-body{flex:1;min-width:0;}
.upg-name{font-size:14px;font-weight:800;color:#e8f2ff;margin-bottom:3px;}
.upg-lvl{font-size:11px;color:#8fb6d8;font-weight:700;}
.upg-val{font-size:11.5px;color:#b9d4ee;font-weight:600;margin-top:4px;}
.upg-val b{color:#7ee0ff;}
.upg-note{font-size:10.5px;color:#ffb03a;font-weight:700;margin-top:3px;}
.upg-btn{padding:10px 12px;border-radius:10px;
border:1px solid rgba(76,214,255,.6);
background:linear-gradient(180deg,rgba(40,110,180,.9),rgba(20,50,100,.9));
color:#fff;font-weight:800;font-size:12.5px;cursor:pointer;
-webkit-appearance:none;appearance:none;font-family:inherit;
min-width:88px;text-align:center;}
.upg-btn:disabled{opacity:.4;border-color:rgba(120,160,255,.3);
background:linear-gradient(180deg,rgba(30,44,80,.7),rgba(14,22,44,.7));cursor:default;}
#fatal{position:fixed;inset:0;z-index:9999;background:#1a0000;
color:#ffb0b0;padding:20px;font-family:ui-monospace,Menlo,monospace;
font-size:13px;line-height:1.5;overflow:auto;white-space:pre-wrap;display:none;}
#fatal b{color:#ff5a6e;font-size:16px;display:block;margin-bottom:12px;}
</style>
</head>
<body>
<canvas id="cv"></canvas>
<div id="hud" class="hidden">
<div class="hud-row">
<div class="stat">
<div class="hpbar"><div id="hpFill"></div></div>
<span id="hpText">120</span>
</div>
<div class="stat center">
<div id="lvlText">Уровень 1</div>
<div id="progText">0/10</div>
</div>
<div class="stat right">
<span class="coins">🪙 <span id="coinText">0</span></span>
<span class="energy-hud">🔋 <span id="energyText">0</span></span>
<button id="btnMute" class="icon-btn" type="button" title="Звук">🔊</button>
<button id="btnPause" class="icon-btn" type="button">❚❚</button>
</div>
</div>
</div>
<button id="btnDash" class="hidden" type="button">⚡</button>
<button id="btnFire" class="hidden" type="button">🔥</button>
<div id="banner"></div>
<div id="toasts"></div>
<div id="fatal"></div>
<div id="overlay">
<div id="scr-auth" class="screen">
<h1 class="title">НЕОН<span>АРЕНА</span></h1>
<div class="subtitle">Войди в аккаунт, чтобы твой прогресс сохранялся на устройстве</div>
<button class="btn primary" type="button" onclick="showLogin()">ВХОД</button>
<button class="btn" type="button" onclick="showRegister()">РЕГИСТРАЦИЯ</button>
<button class="btn gold" type="button" onclick="loginAsGuest()">ИГРАТЬ КАК ГОСТЬ</button>
<button class="btn small" type="button" id="btnMuteAuth" style="margin-top:4px">🔊 Звук вкл</button>
<div class="hint">Гостевой прогресс сохраняется отдельно.<br>Зарегистрируйся, чтобы не потерять монеты и скины.</div>
</div>
<div id="scr-login" class="screen hidden">
<div class="head">
<button class="btn small" type="button" onclick="showAuth()">←</button>
<h2>ВХОД</h2>
<div style="width:46px"></div>
</div>
<input id="loginUser" class="auth-input" type="text" placeholder="Имя игрока" maxlength="16" autocomplete="username" enterkeyhint="next">
<input id="loginPass" class="auth-input" type="password" placeholder="Пароль" maxlength="32" autocomplete="current-password" enterkeyhint="go">
<button class="btn primary" type="button" onclick="doLogin()">ВОЙТИ</button>
<div id="loginErr" class="auth-err"></div>
</div>
<div id="scr-register" class="screen hidden">
<div class="head">
<button class="btn small" type="button" onclick="showAuth()">←</button>
<h2>РЕГИСТРАЦИЯ</h2>
<div style="width:46px"></div>
</div>
<input id="regUser" class="auth-input" type="text" placeholder="Имя игрока (3–16)" maxlength="16" autocomplete="username" enterkeyhint="next">
<input id="regPass" class="auth-input" type="password" placeholder="Пароль (4+)" maxlength="32" autocomplete="new-password" enterkeyhint="next">
<input id="regPass2" class="auth-input" type="password" placeholder="Повтори пароль" maxlength="32" autocomplete="new-password" enterkeyhint="go">
<button class="btn primary" type="button" onclick="doRegister()">СОЗДАТЬ АККАУНТ</button>
<div id="regErr" class="auth-err"></div>
</div>
<div id="scr-menu" class="screen hidden">
<h1 class="title">НЕОН<span>АРЕНА</span></h1>
<div class="res-row">
<span class="coins-big">🪙 <span id="menuCoins">0</span></span>
<span class="energy-big">🔋 <span id="menuEnergy">0</span></span>
</div>
<button class="btn primary" type="button" id="btnPlay" onclick="continueGame()">ИГРАТЬ</button>
<button class="btn" type="button" id="btnRestart" onclick="startGame(1)">С НАЧАЛА</button>
<button class="btn cyan" type="button" onclick="openUpgrades()">ПРОКАЧКА</button>
<button class="btn" type="button" onclick="openShop()">МАГАЗИН СКИНОВ</button>
<button class="btn" type="button" onclick="openQuests()">КВЕСТЫ <span id="questBadge" class="badge hidden">!</span></button>
<div class="menu-footer">
<span id="userBadge">👤 Гость</span>
<button class="btn small" type="button" id="btnMuteMenu">🔊</button>
<button class="btn small" type="button" onclick="logout()">ВЫЙТИ</button>
</div>
<div class="hint">📱 СТИК слева — движение · 🔥 — огонь · ⚡ — рывок<br>
💻 WASD — движение · ЛКМ / ПРОБЕЛ — огонь · SHIFT — рывок · ESC — пауза<br>
💚 Аптечки на карте восстанавливают здоровье</div>
</div>
<div id="scr-upgrades" class="screen hidden">
<div class="head">
<button class="btn small" type="button" onclick="closeSub()">←</button>
<h2>ПРОКАЧКА</h2>
<div class="res-row"><span class="energy-big">🔋 <span id="upgEnergy">0</span></span></div>
</div>
<div class="upg-intro">Энергию 🔋 дают за квесты (10–15 за каждый).<br>Улучшения применяются в следующем бою.<br>
<span class="warn">⚠ Урон пули не действует на боссов.</span></div>
<div id="upgradeList" class="upg-list"></div>
</div>
<div id="scr-shop" class="screen hidden">
<div class="head">
<button class="btn small" type="button" onclick="closeSub()">←</button>
<h2>МАГАЗИН</h2>
<div class="res-row"><span class="coins-big">🪙 <span id="shopCoins">0</span></span></div>
</div>
<div id="shopGrid" class="grid"></div>
</div>
<div id="scr-quests" class="screen hidden">
<div class="head">
<button class="btn small" type="button" onclick="closeSub()">←</button>
<h2>КВЕСТЫ</h2>
<div class="res-row">
<span class="coins-big">🪙 <span id="questCoins">0</span></span>
<span class="energy-big">🔋 <span id="questEnergy">0</span></span>
</div>
</div>
<div class="timer">Новые задания через <b id="questTimer">15:00</b></div>
<div id="questList" class="qlist"></div>
<div class="hint">Монеты 🪙 и энергия 🔋 падают только за выполненные квесты.<br>Задания обновляются каждые 15 минут.</div>
</div>
<div id="scr-pause" class="screen hidden">
<h2>ПАУЗА</h2>
<button class="btn primary" type="button" onclick="togglePause()">ПРОДОЛЖИТЬ</button>
<button class="btn" type="button" onclick="openQuestsFromPause()">КВЕСТЫ</button>
<button class="btn danger" type="button" onclick="goMenu()">В ГЛАВНОЕ МЕНЮ</button>
</div>
<div id="scr-over" class="screen hidden">
<h2 class="dead">ВЫ ПОГИБЛИ</h2>
<div class="stats-out" id="overStats"></div>
<button class="btn primary" type="button" id="btnRetryLevel">С ЭТОГО УРОВНЯ</button>
<button class="btn gold" type="button" onclick="startGame(1)">С НАЧАЛА</button>
<button class="btn" type="button" onclick="goMenu()">В ГЛАВНОЕ МЕНЮ</button>
</div>
</div>
<script>
window.addEventListener('error', function(e){
showFatal((e.error && e.error.stack) ? e.error.stack : (e.message || 'Unknown error') + ' @ ' + (e.filename||'') + ':' + (e.lineno||''));
});
window.addEventListener('unhandledrejection', function(e){
showFatal('Promise rejected: ' + (e.reason && e.reason.stack ? e.reason.stack : e.reason));
});
function showFatal(msg){
try{
var el = document.getElementById('fatal');
if(el){
el.style.display = 'block';
el.innerHTML = '<b>⚠ ОШИБКА</b>' + String(msg).replace(/</g,'<');
}
}catch(e){}
}
'use strict';
const TAU = Math.PI * 2;
const rand = (a,b) => a + Math.random()*(b-a);
const randInt = (a,b) => Math.floor(rand(a,b+1));
const clamp = (v,a,b) => v<a?a:(v>b?b:v);
const pick = a => a[Math.floor(Math.random()*a.length)];
function shuffle(a){ for(let i=a.length-1;i>0;i--){const j=Math.floor(Math.random()*(i+1));const t=a[i];a[i]=a[j];a[j]=t;} return a; }
function hexToRgb(hex){
hex = hex.replace('#','');
if(hex.length===3) hex = hex.split('').map(c=>c+c).join('');
const n = parseInt(hex,16);
return {r:(n>>16)&255, g:(n>>8)&255, b:n&255};
}
function hslToRgb(h,s,l){
let r,g,b;
if(s===0){ r=g=b=l; } else {
const hue=(p,q,t)=>{ if(t<0)t+=1; if(t>1)t-=1;
if(t<1/6) return p+(q-p)*6*t; if(t<1/2) return q;
if(t<2/3) return p+(q-p)*(2/3-t)*6; return p; };
const q = l<0.5 ? l*(1+s) : l+s-l*s; const p = 2*l-q;
r=hue(p,q,h+1/3); g=hue(p,q,h); b=hue(p,q,h-1/3);
}
return {r:Math.round(r*255), g:Math.round(g*255), b:Math.round(b*255)};
}
const rgba = (c,a) => 'rgba('+(c.r|0)+','+(c.g|0)+','+(c.b|0)+','+a+')';
function circleRect(px,py,pr, rx,ry,rw,rh){
const cx = clamp(px, rx, rx+rw);
const cy = clamp(py, ry, ry+rh);
const dx = px-cx, dy = py-cy;
return dx*dx + dy*dy < pr*pr;
}
function pointLineDist(px,py, x1,y1, x2,y2){
const dx = x2-x1, dy = y2-y1;
const len2 = dx*dx + dy*dy || 1;
const t = clamp(((px-x1)*dx + (py-y1)*dy) / len2, 0, 1);
const cx = x1 + t*dx, cy = y1 + t*dy;
return Math.hypot(px-cx, py-cy);
}
/* ==========================================================
ЗВУК
========================================================== */
const MUTED_KEY = 'neonArena_muted';
let muted = false;
try{ muted = localStorage.getItem(MUTED_KEY) === '1'; }catch(e){}
const Sound = {
ctx:null, master:null, musicGain:null, sfxGain:null,
musicTimer:null, musicType:null, musicSeq:null, musicStep:0,
musicStepDur:0, nextNoteTime:0, musicVol:0.20, muted:muted,
init(){
if(this.ctx) return;
try{
const AC = window.AudioContext || window.webkitAudioContext;
if(!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = this.muted ? 0 : 0.75;
this.master.connect(this.ctx.destination);
this.musicGain = this.ctx.createGain();
this.musicGain.gain.value = this.musicVol;
this.musicGain.connect(this.master);
this.sfxGain = this.ctx.createGain();
this.sfxGain.gain.value = 0.35;
this.sfxGain.connect(this.master);
}catch(e){ this.ctx = null; }
},
resume(){ if(this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); },
setMuted(m){
this.muted = m; muted = m;
try{ localStorage.setItem(MUTED_KEY, m ? '1' : '0'); }catch(e){}
if(this.master) this.master.gain.value = m ? 0 : 0.75;
updateMuteButtons();
},
toggleMute(){ this.setMuted(!this.muted); },
setMusicVolume(v){ this.musicVol = v; if(this.musicGain) this.musicGain.gain.value = v; },
startMusic(type){
if(!this.ctx) return;
if(this.musicType === type) return;
this.stopMusic();
this.musicType = type;
this.musicSeq = type === 'menu' ? MENU_SEQ : GAME_SEQ;
this.musicStepDur = type === 'menu' ? 0.30 : 0.14;
this.musicStep = 0;
this.nextNoteTime = this.ctx.currentTime + 0.08;
this.musicTimer = setInterval(() => this.musicPlay(), 25);
},
stopMusic(){
if(this.musicTimer){ clearInterval(this.musicTimer); this.musicTimer = null; }
this.musicType = null;
},
musicPlay(){
if(!this.ctx || !this.musicSeq) return;
const lookahead = 0.18, now = this.ctx.currentTime;
let guard = 0;
while(this.nextNoteTime < now + lookahead && guard++ < 80){
const idx = this.musicStep % this.musicSeq.length;
const notes = this.musicSeq[idx];
if(notes && notes.length){
for(let i=0;i<notes.length;i++){
const n = notes[i];
this.playMusicNote(n[0], n[1], n[2], this.nextNoteTime, n[3]);
}
}
this.musicStep++;
this.nextNoteTime += this.musicStepDur;
}
},
playMusicNote(freq, dur, vol, when, type){
if(!this.ctx) return;
if(type === 'noise'){
const sr = this.ctx.sampleRate;
const size = Math.max(1, Math.floor(sr * dur));
const buf = this.ctx.createBuffer(1, size, sr);
const d = buf.getChannelData(0);
for(let i=0;i<size;i++) d[i] = Math.random()*2 - 1;
const src = this.ctx.createBufferSource(); src.buffer = buf;
const f = this.ctx.createBiquadFilter();
f.type = 'highpass'; f.frequency.value = 6500;
const g = this.ctx.createGain();
g.gain.setValueAtTime(vol, when);
g.gain.exponentialRampToValueAtTime(0.001, when + dur);
src.connect(f); f.connect(g); g.connect(this.musicGain);
src.start(when); src.stop(when + dur);
return;
}
if(type === 'kick'){
const o = this.ctx.createOscillator(), g = this.ctx.createGain();
o.frequency.setValueAtTime(130, when);
o.frequency.exponentialRampToValueAtTime(38, when + 0.1);
g.gain.setValueAtTime(vol, when);
g.gain.exponentialRampToValueAtTime(0.001, when + dur);
o.connect(g); g.connect(this.musicGain);
o.start(when); o.stop(when + dur + 0.05);
return;
}
const o = this.ctx.createOscillator(), g = this.ctx.createGain();
o.type = type || 'triangle';
o.frequency.value = freq;
g.gain.setValueAtTime(0, when);
g.gain.linearRampToValueAtTime(vol, when + 0.012);
g.gain.exponentialRampToValueAtTime(0.001, when + dur);
o.connect(g); g.connect(this.musicGain);
o.start(when); o.stop(when + dur + 0.05);
},
sfx(name){
if(!this.ctx || this.muted) return;
const t = this.ctx.currentTime;
if(name === 'shoot'){
const o = this.ctx.createOscillator(), g = this.ctx.createGain();
o.type = 'square';
o.frequency.setValueAtTime(900, t);
o.frequency.exponentialRampToValueAtTime(280, t + 0.06);
g.gain.setValueAtTime(0.10, t);
g.gain.exponentialRampToValueAtTime(0.001, t + 0.07);
o.connect(g); g.connect(this.sfxGain);
o.start(t); o.stop(t + 0.09);
}
else if(name === 'hit'){ this.noise(t, 0.05, 0.12, 2500); }
else if(name === 'boss_hit'){ this.noise(t, 0.09, 0.20, 700); this.osc(220, 0.09, 0.12, 'sawtooth', t); }
else if(name === 'explosion'){ this.noise(t, 0.24, 0.28, 250); this.osc(90, 0.22, 0.16, 'sawtooth', t, 40); }
else if(name === 'player_hit'){ this.osc(340, 0.22, 0.22, 'sawtooth', t, 90); }
else if(name === 'coin'){ this.osc(880, 0.09, 0.14, 'triangle', t); this.osc(1320, 0.16, 0.14, 'triangle', t + 0.08); }
else if(name === 'energy'){ this.osc(660, 0.10, 0.13, 'sine', t); this.osc(990, 0.14, 0.13, 'sine', t + 0.07); this.osc(1480, 0.18, 0.11, 'sine', t + 0.14); }
else if(name === 'heal'){
this.osc(660, 0.12, 0.14, 'sine', t);
this.osc(880, 0.16, 0.13, 'sine', t + 0.07);
this.osc(1180, 0.22, 0.11, 'sine', t + 0.15);
}
else if(name === 'upgrade'){ this.osc(440, 0.14, 0.16, 'triangle', t); this.osc(660, 0.16, 0.16, 'triangle', t + 0.10); this.osc(880, 0.22, 0.16, 'triangle', t + 0.20); }
else if(name === 'level_up'){ [523, 659, 784, 1047].forEach((f, i) => this.osc(f, 0.35, 0.14, 'triangle', t + i*0.08)); }
else if(name === 'boss_die'){ [523, 659, 784, 1047, 1319].forEach((f, i) => this.osc(f, 0.5, 0.18, 'triangle', t + i*0.1)); this.noise(t, 0.6, 0.22, 250); }
else if(name === 'death'){ this.osc(420, 0.4, 0.24, 'sawtooth', t, 80); this.osc(300, 0.55, 0.20, 'sawtooth', t + 0.12, 60); }
else if(name === 'dash'){ this.noise(t, 0.14, 0.14, 3500); }
else if(name === 'click'){ this.osc(1200, 0.045, 0.09, 'square', t); }
},
osc(freq, dur, vol, type, when, targetFreq){
const o = this.ctx.createOscillator(), g = this.ctx.createGain();
o.type = type || 'sine';
o.frequency.setValueAtTime(freq, when);
if(targetFreq) o.frequency.exponentialRampToValueAtTime(targetFreq, when + dur);
g.gain.setValueAtTime(vol, when);
g.gain.exponentialRampToValueAtTime(0.001, when + dur);
o.connect(g); g.connect(this.sfxGain);
o.start(when); o.stop(when + dur + 0.03);
},
noise(when, dur, vol, hp){
const sr = this.ctx.sampleRate;
const size = Math.max(1, Math.floor(sr * dur));
const buf = this.ctx.createBuffer(1, size, sr);
const d = buf.getChannelData(0);
for(let i=0;i<size;i++) d[i] = Math.random()*2 - 1;
const src = this.ctx.createBufferSource(); src.buffer = buf;
const f = this.ctx.createBiquadFilter();
f.type = 'highpass'; f.frequency.value = hp || 1000;
const g = this.ctx.createGain();
g.gain.setValueAtTime(vol, when);
g.gain.exponentialRampToValueAtTime(0.001, when + dur);
src.connect(f); f.connect(g); g.connect(this.sfxGain);
src.start(when); src.stop(when + dur);
},
};
const MENU_SEQ = (function(){
const s = []; for(let i=0;i<32;i++) s.push([]);
s[0].push([220, 2.0, 0.10, 'sine']); s[0].push([330, 2.0, 0.05, 'sine']); s[0].push([262, 2.0, 0.05, 'sine']);
s[8].push([175, 2.0, 0.10, 'sine']); s[8].push([262, 2.0, 0.05, 'sine']); s[8].push([220, 2.0, 0.05, 'sine']);
s[16].push([262, 2.0, 0.10, 'sine']); s[16].push([330, 2.0, 0.05, 'sine']); s[16].push([392, 2.0, 0.05, 'sine']);
s[24].push([196, 2.0, 0.10, 'sine']); s[24].push([294, 2.0, 0.05, 'sine']); s[24].push([247, 2.0, 0.05, 'sine']);
const mel = [[0,440],[2,523],[4,659],[6,523],[8,349],[10,440],[12,523],[14,440],
[16,523],[18,659],[20,784],[22,659],[24,494],[26,587],[28,784],[30,587]];
for(let i=0;i<mel.length;i++){ s[mel[i][0]].push([mel[i][1], 0.32, 0.15, 'triangle']); }
return s;
})();
const GAME_SEQ = (function(){
const s = []; for(let i=0;i<32;i++) s.push([]);
const bass = [[0,110],[8,87],[16,131],[24,98]];
for(let i=0;i<bass.length;i++) s[bass[i][0]].push([bass[i][1], 0.65, 0.20, 'sawtooth']);
const arp = [[1,220],[2,330],[3,262],[4,330],[5,262],[6,330],[7,440],
[9,175],[10,262],[11,220],[12,262],[13,220],[14,262],[15,349],
[17,262],[18,392],[19,330],[20,392],[21,330],[22,392],[23,523],
[25,196],[26,294],[27,247],[28,294],[29,247],[30,294],[31,392]];
for(let i=0;i<arp.length;i++) s[arp[i][0]].push([arp[i][1], 0.09, 0.12, 'square']);
for(let i=0;i<32;i+=2){ s[i].push([0, 0.03, 0.07, 'noise']); }
[0,8,16,24].forEach(k => s[k].push([60, 0.14, 0.26, 'kick']));
return s;
})();
function updateMuteButtons(){
try{
const ic = Sound.muted ? '🔇' : '🔊';
const b1 = document.getElementById('btnMute');
const b2 = document.getElementById('btnMuteMenu');
const b3 = document.getElementById('btnMuteAuth');
if(b1) b1.textContent = ic;
if(b2) b2.textContent = ic;
if(b3) b3.textContent = ic + (Sound.muted ? ' Звук выкл' : ' Звук вкл');
}catch(e){}
}
/* ==========================================================
ПРОКАЧКА
========================================================== */
const UPGRADES = [
{id:'damage', name:'Урон пули', icon:'⚔️', maxLevel:20, costBase:10, costStep:8,
base:14, perLevel:3, bossNote:true},
{id:'speed', name:'Скорость', icon:'💨', maxLevel:10, costBase:8, costStep:6,
base:275, perLevel:15},
{id:'hp', name:'Макс. здоровье', icon:'❤️', maxLevel:15, costBase:12, costStep:10,
base:120, perLevel:20},
{id:'fireRate', name:'Скорострельность',icon:'🔥', maxLevel:10, costBase:10, costStep:8,
base:0.14, perLevel:-0.009, isTime:true},
{id:'healBonus',name:'Сила аптечек', icon:'💚', maxLevel:10, costBase:10, costStep:9,
base:40, perLevel:8},
];
function getUpgradeLevel(id){
if(!save.upgrades) return 0;
const v = save.upgrades[id];
return (typeof v === 'number') ? v : 0;
}
function getUpgradeValue(id){
const up = UPGRADES.filter(u => u.id === id)[0];
if(!up) return 0;
const lvl = getUpgradeLevel(id);
let v = up.base + up.perLevel * lvl;
if(up.isTime) v = Math.max(0.05, v);
return v;
}
function getUpgradeCost(id){
const up = UPGRADES.filter(u => u.id === id)[0];
if(!up) return 0;
const lvl = getUpgradeLevel(id);
return up.costBase + lvl * up.costStep;
}
const SKINS = [
{id:'novice', name:'Новичок', rarity:'Базовый', price:0, color:'#7ec8ff'},
{id:'steel', name:'Сталь', rarity:'Базовый', price:80, color:'#b9c4d4'},
{id:'ember', name:'Уголёк', rarity:'Базовый', price:140, color:'#ff8a4c'},
{id:'lime', name:'Лайм', rarity:'Необычный', price:280, color:'#a6ff4c'},
{id:'aqua', name:'Аква', rarity:'Необычный', price:340, color:'#38e8d0'},
{id:'violet', name:'Фиалка', rarity:'Необычный', price:420, color:'#b06cff'},
{id:'neon', name:'Неон', rarity:'Редкий', price:760, color:'#ff2fd0', glow:true},
{id:'emerald',name:'Изумруд', rarity:'Редкий', price:880, color:'#19ff8a', glow:true},
{id:'gold', name:'Золото', rarity:'Редкий', price:1000, color:'#ffd23f', glow:true},
{id:'plasma', name:'Плазма', rarity:'Эпический', price:1700, color:'#7b5cff', glow:true, trail:true},
{id:'cosmos', name:'Космос', rarity:'Эпический', price:1950, color:'#4a63d8', glow:true, trail:true},
{id:'inferno',name:'Инферно', rarity:'Эпический', price:2250, color:'#ff3d1f', glow:true, trail:true},
{id:'rainbow',name:'Радуга', rarity:'Легендарный',price:3800, color:'#ff0055', glow:true, trail:true, rainbow:true},
{id:'void', name:'Пустота', rarity:'Легендарный',price:4500, color:'#5a1ecc', glow:true, trail:true, dark:true},
{id:'dragon', name:'Дракон', rarity:'Легендарный',price:5400, color:'#ffb800', glow:true, trail:true, dragon:true},
];
const RARITY_CLASS = {'Базовый':'r-common','Необычный':'r-uncommon','Редкий':'r-rare','Эпический':'r-epic','Легендарный':'r-legend'};
function skinById(id){
for(let i=0;i<SKINS.length;i++) if(SKINS[i].id === id) return SKINS[i];
return SKINS[0];
}
/* ==========================================================
АККАУНТЫ + СЕЙВ
========================================================== */
const ACCOUNTS_KEY = 'neonArena_accounts';
const CURRENT_KEY = 'neonArena_current';
let accounts = {};
let currentUser = null;
function defaultSave(){
return {
coins:0, energy:0,
owned:['novice'], skin:'novice',
quests:null, cycleStart:0, best:1,
lastLevel:1,
upgrades:{damage:0, speed:0, hp:0, fireRate:0, healBonus:0}
};
}
let save = defaultSave();
let returnTo = 'menu';
let lastDeathLevel = 1;
function normalizeSave(){
if(typeof save.coins !== 'number' || isNaN(save.coins)) save.coins = 0;
if(typeof save.energy !== 'number' || isNaN(save.energy)) save.energy = 0;
if(!Array.isArray(save.owned) || !save.owned.length) save.owned = ['novice'];
if(save.owned.indexOf('novice') < 0) save.owned.push('novice');
if(!save.skin) save.skin = 'novice';
if(typeof save.lastLevel !== 'number' || save.lastLevel < 1 || isNaN(save.lastLevel)) save.lastLevel = 1;
if(save.lastLevel > 9999) save.lastLevel = 9999;
if(!save.upgrades || typeof save.upgrades !== 'object') save.upgrades = {};
for(let i=0;i<UPGRADES.length;i++){
const id = UPGRADES[i].id;
if(typeof save.upgrades[id] !== 'number') save.upgrades[id] = 0;
save.upgrades[id] = clamp(Math.floor(save.upgrades[id]), 0, UPGRADES[i].maxLevel);
}
}
function loadAccounts(){
try{ accounts = JSON.parse(localStorage.getItem(ACCOUNTS_KEY) || '{}'); }catch(e){ accounts = {}; }
if(!accounts || typeof accounts !== 'object') accounts = {};
}
function saveAccounts(){ try{ localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); }catch(e){} }
function hashPass(str){
let h = 5381;
for(let i=0;i<str.length;i++) h = ((h<<5)+h) ^ str.charCodeAt(i);
return (h >>> 0).toString(36) + '_' + str.length;
}
function getSaveKey(){
if(currentUser === 'guest') return 'neonArena_save_guest';
if(currentUser) return 'neonArena_save_' + currentUser;
return 'neonArena_save_default';
}
function setCurrentUser(u){
currentUser = u;
try{ if(u) localStorage.setItem(CURRENT_KEY, u); else localStorage.removeItem(CURRENT_KEY); }catch(e){}
}
function loadSave(){
save = defaultSave();
try{
const r = localStorage.getItem(getSaveKey());
if(r) save = Object.assign(defaultSave(), JSON.parse(r));
}catch(e){}
normalizeSave();
}
function writeSave(){ try{ localStorage.setItem(getSaveKey(), JSON.stringify(save)); }catch(e){} }
/* ==========================================================
КВЕСТЫ
========================================================== */
const CYCLE_MS = 15*60*1000;
const QUEST_POOL = [
{type:'kill', name:'Зачистка', icon:'💀', target:25},
{type:'kill', name:'Бойня', icon:'💀', target:60},
{type:'kill', name:'Геноцид', icon:'💀', target:120},
{type:'kill', name:'Резня', icon:'🩸', target:40},
{type:'kill', name:'Мясорубка', icon:'🩸', target:90},
{type:'kill', name:'Легион смерти', icon:'☠️', target:200},
{type:'level', name:'Прорыв', icon:'🏁', target:2},
{type:'level', name:'Марафон', icon:'🏁', target:5},
{type:'level', name:'Спринт', icon:'⚡', target:3},
{type:'level', name:'Легенда арены', icon:'🏆', target:8},
{type:'boss', name:'Охотник на боссов', icon:'👑', target:1},
{type:'boss', name:'Убийца королей', icon:'👑', target:3},
{type:'boss', name:'Гроза боссов', icon:'⚔️', target:2},
{type:'boss', name:'Палач королей', icon:'🗡', target:5},
{type:'survive',name:'Живучесть', icon:'⏱', target:180},
{type:'survive',name:'Несокрушимый', icon:'⏱', target:420},
{type:'survive',name:'Стойкость', icon:'🛡', target:300},
{type:'survive',name:'Бессмертный', icon:'💎', target:600},
{type:'heal', name:'Полевой медик', icon:'💚', target:3},
{type:'heal', name:'Санитар', icon:'💚', target:8},
{type:'heal', name:'Госпиталь', icon:'🏥', target:15},
];
function genQuests(){
const pool = QUEST_POOL.slice();
shuffle(pool);
const usedTypes = {};
const out = [];
for(let i=0;i<pool.length && out.length<10;i++){
const q = pool[i];
const cnt = usedTypes[q.type] || 0;
if(cnt < 2){
out.push({
type:q.type, name:q.name, icon:q.icon, target:q.target,
rewardCoins: randInt(15,25),
rewardEnergy: randInt(10,15),
progress:0, done:false
});
usedTypes[q.type] = cnt + 1;
}
}
return out;
}
function checkCycle(){
const now = Date.now();
if(!save.quests || !save.cycleStart || now - save.cycleStart >= CYCLE_MS){
save.cycleStart = now;
save.quests = genQuests();
writeSave();
renderQuests();
updateBadge();
}
}
function addQuestProgress(type, amount){
if(!save.quests) return;
let changed = false;
for(let i=0;i<save.quests.length;i++){
const q = save.quests[i];
if(q.type === type && !q.done){
q.progress += amount;
if(q.progress >= q.target){
q.progress = q.target; q.done = true;
const c = (typeof q.rewardCoins === 'number') ? q.rewardCoins : 0;
const en = (typeof q.rewardEnergy === 'number') ? q.rewardEnergy : 0;
save.coins += c; save.energy += en;
changed = true;
Sound.sfx('coin');
setTimeout(function(){ Sound.sfx('energy'); }, 220);
toast(q.icon + ' ' + q.name + ' — выполнено! +' + c + ' 🪙 +' + en + ' 🔋');
}
}
}
if(changed){ writeSave(); updateHud(); updateBadge(); renderQuests(); renderUpgrades(); }
}
/* ==========================================================
CANVAS
========================================================== */
const cv = document.getElementById('cv');
const ctx = cv ? cv.getContext('2d') : null;
let W=0,H=0,DPR=1;
function resize(){
if(!cv || !ctx) return;
DPR = Math.min(window.devicePixelRatio||1, 2);
W = window.innerWidth; H = window.innerHeight;
cv.width = Math.floor(W*DPR); cv.height = Math.floor(H*DPR);
cv.style.width = W+'px'; cv.style.height = H+'px';
ctx.setTransform(DPR,0,0,DPR,0,0);
}
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', function(){ setTimeout(resize,120); });
const G = {
scene:'auth', level:1, mode:'normal', player:null,
enemies:[], pb:[], eb:[], parts:[], trail:[],
beams:[], warnings:[], events:[],
medkits:[], medkitTimer:8,
killed:0, quota:0, boss:null, spawnTimer:0, time:0,
clearTimer:0, shakeT:0, shakeMag:0,
killsTotal:0, bossesTotal:0, surviveTimer:0, healsTotal:0,
bossSpawned:false,
};
const $ = id => document.getElementById(id);
const overlay = $('overlay');
const hud = $('hud');
const btnDash = $('btnDash');
const btnFire = $('btnFire');
function showScreen(name){
if(!overlay) return;
overlay.classList.remove('hidden');
const screens = ['scr-auth','scr-login','scr-register','scr-menu','scr-upgrades','scr-shop','scr-quests','scr-pause','scr-over'];
for(let i=0;i<screens.length;i++){
const el = $(screens[i]);
if(el) el.classList.toggle('hidden', screens[i] !== name);
}
overlay.scrollTop = 0;
}
function hideOverlay(){ if(overlay) overlay.classList.add('hidden'); }
function toast(msg){
const el = document.createElement('div');
el.className = 'toast'; el.textContent = msg;
const t = $('toasts'); if(t) t.appendChild(el);
setTimeout(function(){ el.classList.add('out'); setTimeout(function(){ el.remove(); },420); }, 2600);
}
let bannerTO = null;
function banner(txt){
const b = $('banner'); if(!b) return;
b.textContent = txt; b.classList.add('show');
clearTimeout(bannerTO);
bannerTO = setTimeout(function(){ b.classList.remove('show'); }, 1500);
}
/* ==========================================================
AUTH UI
========================================================== */
function showAuth(){
const le = $('loginErr'); if(le) le.textContent = '';
const re = $('regErr'); if(re) re.textContent = '';
G.scene = 'auth';
Sound.startMusic('menu');
showScreen('scr-auth');
}
function showLogin(){ const e = $('loginErr'); if(e) e.textContent = ''; G.scene = 'auth'; showScreen('scr-login'); }
function showRegister(){ const e = $('regErr'); if(e) e.textContent = ''; G.scene = 'auth'; showScreen('scr-register'); }
function loginAsGuest(){
setCurrentUser('guest');
loadSave();
refreshAllUI();
goMenu();
}
function doLogin(){
const u = $('loginUser').value.trim();
const p = $('loginPass').value;
const err = $('loginErr');
if(!u || !p){ err.textContent = 'Заполни оба поля'; return; }
if(!accounts[u]){ err.textContent = 'Игрок не найден'; return; }
if(accounts[u].pass !== hashPass(p)){ err.textContent = 'Неверный пароль'; return; }
setCurrentUser(u); loadSave(); refreshAllUI();
$('loginUser').value = ''; $('loginPass').value = '';
goMenu();
toast('С возвращением, ' + u + '!');
}
function doRegister(){
const u = $('regUser').value.trim();
const p = $('regPass').value;
const p2 = $('regPass2').value;
const err = $('regErr');
if(u.length < 3 || u.length > 16){ err.textContent = 'Имя: от 3 до 16 символов'; return; }
if(!/^[a-zA-Zа-яА-Я0-9_]+$/.test(u)){ err.textContent = 'Только буквы, цифры и _'; return; }
if(p.length < 4){ err.textContent = 'Пароль: минимум 4 символа'; return; }
if(p !== p2){ err.textContent = 'Пароли не совпадают'; return; }
if(accounts[u]){ err.textContent = 'Имя уже занято'; return; }
accounts[u] = { pass: hashPass(p), created: Date.now() };
saveAccounts();
setCurrentUser(u);
try{ localStorage.removeItem('neonArena_save_' + u); }catch(e){}
loadSave(); refreshAllUI();
$('regUser').value = ''; $('regPass').value = ''; $('regPass2').value = '';
goMenu();
toast('Добро пожаловать, ' + u + '!');
}
function logout(){
setCurrentUser(null);
save = defaultSave();
refreshAllUI();
showAuth();
}
(function bindAuthKeys(){
const lu = $('loginUser'), lp = $('loginPass');
const ru = $('regUser'), rp = $('regPass'), rp2 = $('regPass2');
if(lu) lu.addEventListener('keydown', function(e){ if(e.key === 'Enter' && lp) lp.focus(); });
if(lp) lp.addEventListener('keydown', function(e){ if(e.key === 'Enter') doLogin(); });
if(ru) ru.addEventListener('keydown', function(e){ if(e.key === 'Enter' && rp) rp.focus(); });
if(rp) rp.addEventListener('keydown', function(e){ if(e.key === 'Enter' && rp2) rp2.focus(); });
if(rp2) rp2.addEventListener('keydown', function(e){ if(e.key === 'Enter') doRegister(); });
})();
function refreshAllUI(){
const ub = $('userBadge');
if(ub) ub.textContent = currentUser === 'guest'
? '👤 Гость'
: (currentUser ? '👤 ' + currentUser : '👤 Не в аккаунте');
checkCycle();
updateBadge();
updateHud();
updateQuestTimer();
renderShop();
renderQuests();
renderUpgrades();
}
/* ==========================================================
SHOP
========================================================== */
function renderShop(){
const grid = $('shopGrid'); if(!grid) return;
grid.innerHTML = '';
const order = {'Базовый':0,'Необычный':1,'Редкий':2,'Эпический':3,'Легендарный':4};
const sorted = SKINS.slice().sort(function(a,b){ return order[a.rarity]-order[b.rarity] || a.price-b.price; });
for(let i=0;i<sorted.length;i++){
const sk = sorted[i];
const owned = save.owned.indexOf(sk.id) >= 0;
const equipped = save.skin === sk.id;
const card = document.createElement('div');
card.className = 'card ' + (RARITY_CLASS[sk.rarity]||'') + (equipped?' equipped':'');
const dot = document.createElement('div');
dot.className = 'dot' + (sk.rainbow?' rainbow':'');
dot.style.setProperty('--c', sk.color);
const nm = document.createElement('div'); nm.className='cname'; nm.textContent = sk.name;
const rr = document.createElement('div'); rr.className='crar'; rr.textContent = sk.rarity;
const bt = document.createElement('div'); bt.className='cbtn';
bt.textContent = equipped ? 'НАДЕТО' : (owned ? 'НАДЕТЬ' : sk.price + ' 🪙');
card.appendChild(dot); card.appendChild(nm); card.appendChild(rr); card.appendChild(bt);
card.addEventListener('click', (function(skCopy){
return function(){
if(save.skin === skCopy.id) return;
if(save.owned.indexOf(skCopy.id) >= 0){
save.skin = skCopy.id; writeSave(); renderShop(); updateHud();
} else if(save.coins >= skCopy.price){
save.coins -= skCopy.price; save.owned.push(skCopy.id); save.skin = skCopy.id;
writeSave(); renderShop(); updateHud(); Sound.sfx('coin');
toast('Куплен скин: ' + skCopy.name + ' (' + skCopy.rarity + ')');
} else toast('Недостаточно монет! Выполняй квесты.');
};
})(sk));
grid.appendChild(card);
}
const sc = $('shopCoins'); if(sc) sc.textContent = save.coins;
}
/* ==========================================================
UPGRADES UI
========================================================== */
function renderUpgrades(){
const list = $('upgradeList'); if(!list) return;
list.innerHTML = '';
for(let i=0;i<UPGRADES.length;i++){
const up = UPGRADES[i];
const lvl = getUpgradeLevel(up.id);
const maxed = lvl >= up.maxLevel;
const cost = getUpgradeCost(up.id);
const canBuy = !maxed && save.energy >= cost;
let cur = up.base + up.perLevel * lvl;
let next = up.base + up.perLevel * (lvl + 1);
if(up.isTime){ cur = Math.max(0.05, cur); next = Math.max(0.05, next); }
let valText;
if(up.isTime){
valText = 'Перезарядка: <b>' + cur.toFixed(3) + 'с</b>';
if(!maxed) valText += ' → ' + next.toFixed(3) + 'с';
} else {
valText = 'Значение: <b>' + cur + '</b>';
if(!maxed) valText += ' → ' + next;
}
let noteHtml = '';
if(up.bossNote) noteHtml = '<div class="upg-note">⚠ По боссам — урона НЕ добавляет</div>';
const el = document.createElement('div');
el.className = 'upg-card' + (maxed ? ' maxed' : '') + (canBuy ? ' can-buy' : '') + (up.bossNote ? ' boss-immune' : '');
el.innerHTML =
'<div class="upg-icon">' + up.icon + '</div>' +
'<div class="upg-body">' +
'<div class="upg-name">' + up.name + '</div>' +
'<div class="upg-lvl">Ур. ' + lvl + ' / ' + up.maxLevel + '</div>' +
'<div class="upg-val">' + valText + '</div>' +
noteHtml +
'</div>' +
'<button class="upg-btn" type="button" ' + (maxed ? 'disabled' : '') + '>' +
(maxed ? 'МАКС' : '🔋 ' + cost) +
'</button>';
if(!maxed){
(function(id){
el.querySelector('.upg-btn').addEventListener('click', function(e){
e.preventDefault(); buyUpgrade(id);
});
})(up.id);
}
list.appendChild(el);
}
const ue = $('upgEnergy'); if(ue) ue.textContent = save.energy;
}
function buyUpgrade(id){
const up = UPGRADES.filter(u => u.id === id)[0];
if(!up) return;
const lvl = getUpgradeLevel(id);
if(lvl >= up.maxLevel) return;
const cost = getUpgradeCost(id);
if(save.energy < cost){ toast('Не хватает энергии! 🔋 Выполняй квесты.'); return; }
save.energy -= cost;
save.upgrades[id] = lvl + 1;
writeSave();
Sound.sfx('upgrade');
renderUpgrades();
updateHud();
toast('Улучшено: ' + up.name + ' → ур. ' + (lvl + 1));
}
/* ==========================================================
QUESTS UI
========================================================== */
function renderQuests(){
const list = $('questList'); if(!list) return;
if(!save.quests || !Array.isArray(save.quests)) save.quests = genQuests();
list.innerHTML = '';
for(let i=0;i<save.quests.length;i++){
const q = save.quests[i];
const pct = clamp(q.progress / q.target, 0, 1) * 100;
const el = document.createElement('div');
el.className = 'quest' + (q.done ? ' done' : '');
const prog = q.done ? 'Выполнено ✓' : (Math.floor(q.progress) + ' / ' + q.target);
const rc = (typeof q.rewardCoins === 'number') ? q.rewardCoins : 0;
const re = (typeof q.rewardEnergy === 'number') ? q.rewardEnergy : 0;
el.innerHTML =
'<div class="qicon">'+q.icon+'</div>'+
'<div class="qbody">'+
'<div class="qname">'+q.name+'</div>'+
'<div class="qbar"><div class="qfill" style="width:'+pct+'%"></div></div>'+
'<div class="qprog">'+prog+'</div>'+
'</div>'+
'<div class="qrew">+'+rc+' 🪙<br><span class="e">+'+re+' 🔋</span></div>';
list.appendChild(el);
}
const qc = $('questCoins'); if(qc) qc.textContent = save.coins;
const qe = $('questEnergy'); if(qe) qe.textContent = save.energy;
}
function updateBadge(){
const el = $('questBadge'); if(!el) return;
const has = save.quests && save.quests.some(function(q){ return !q.done; });
el.classList.toggle('hidden', !has);
}
function updateQuestTimer(){
const el = $('questTimer'); if(!el) return;
const left = Math.max(0, CYCLE_MS - (Date.now() - save.cycleStart));
const m = Math.floor(left/60000), s = Math.floor((left%60000)/1000);
el.textContent = String(m).padStart(2,'0') + ':' + String(s).padStart(2,'0');
}
function updateHud(){
const ct = $('coinText'); if(ct) ct.textContent = save.coins;
const mc = $('menuCoins'); if(mc) mc.textContent = save.coins;
const et = $('energyText'); if(et) et.textContent = save.energy;
const me = $('menuEnergy'); if(me) me.textContent = save.energy;
if(G.player){
const p = G.player;
const hf = $('hpFill'); if(hf) hf.style.width = clamp(p.hp/p.maxHp,0,1)*100 + '%';
const ht = $('hpText'); if(ht) ht.textContent = Math.max(0, Math.ceil(p.hp));
}
const lt = $('lvlText'); if(lt) lt.textContent = 'Уровень ' + G.level;
const pt = $('progText'); if(pt) pt.textContent = G.mode === 'boss' ? '☠ БОСС' : (G.killed + '/' + G.quota);
}
/* ==========================================================
INPUT
========================================================== */
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
let inputMode = isTouchDevice ? 'touch' : 'mouse';
let firing = false;
let mouseX = (window.innerWidth||400)/2, mouseY = (window.innerHeight||600)/2;
function ensureAudio(){
if(!Sound.ctx) Sound.init();
Sound.resume();
if(Sound.ctx && !Sound.musicType){
if(G.scene === 'playing' || G.scene === 'paused') Sound.startMusic('game');
else Sound.startMusic('menu');
}
}
document.addEventListener('touchstart', ensureAudio);
document.addEventListener('mousedown', ensureAudio);
const keys = {};
addEventListener('keydown', function(e){
const tag = e.target && e.target.tagName;
if(tag === 'INPUT' || tag === 'TEXTAREA') return;
const k = e.key.toLowerCase();
keys[k] = true;
if(k === 'escape' || k === 'p'){ togglePause(); }
if(k === ' '){ e.preventDefault(); firing = true; }
if(k === 'shift' || k === 'e' || k === 'у'){ doDash(); }
});
addEventListener('keyup', function(e){
const k = e.key.toLowerCase();
keys[k] = false;
if(k === ' ') firing = false;
});
const joy = {active:false, id:null, ox:0, oy:0, x:0, y:0, dx:0, dy:0};
const JOY_R = 58;
function onTouchStart(e){
if(G.scene !== 'playing') return;
inputMode = 'touch'; e.preventDefault();
for(let i=0;i<e.changedTouches.length;i++){
const t = e.changedTouches[i];
if(joy.id === null){
joy.id = t.identifier; joy.ox = t.clientX; joy.oy = t.clientY;
joy.x = t.clientX; joy.y = t.clientY;
joy.active = true; joy.dx = 0; joy.dy = 0;
}
}
}
function onTouchMove(e){
if(joy.id === null) return;
e.preventDefault();
for(let i=0;i<e.changedTouches.length;i++){
const t = e.changedTouches[i];
if(t.identifier === joy.id){
joy.x = t.clientX; joy.y = t.clientY;
let dx = joy.x - joy.ox, dy = joy.y - joy.oy;
const d = Math.sqrt(dx*dx+dy*dy);
if(d > JOY_R){
joy.ox += (dx/d)*(d-JOY_R); joy.oy += (dy/d)*(d-JOY_R);
dx = joy.x - joy.ox; dy = joy.y - joy.oy;
}
const dd = Math.sqrt(dx*dx+dy*dy) || 1;
const mag = Math.min(dd / JOY_R, 1);
joy.dx = (dx/dd)*mag; joy.dy = (dy/dd)*mag;
}
}
}
function onTouchEnd(e){
for(let i=0;i<e.changedTouches.length;i++){
const t = e.changedTouches[i];
if(t.identifier === joy.id){ joy.id = null; joy.active = false; joy.dx = 0; joy.dy = 0; }
}
}
if(cv){
cv.addEventListener('touchstart', onTouchStart, {passive:false});
cv.addEventListener('touchmove', onTouchMove, {passive:false});
cv.addEventListener('touchend', onTouchEnd, {passive:false});
cv.addEventListener('touchcancel', onTouchEnd, {passive:false});
}
addEventListener('mousemove', function(e){
mouseX = e.clientX; mouseY = e.clientY;
if(!isTouchDevice) inputMode = 'mouse';
});
addEventListener('mousedown', function(e){
if(e.button !== 0) return;
if(G.scene !== 'playing') return;
const t = e.target;
if(t && t.closest && (t.closest('button') || t.closest('input'))) return;
if(isTouchDevice && inputMode === 'touch') return;
firing = true;
});
addEventListener('mouseup', function(e){ if(e.button === 0) firing = false; });
if(cv) cv.addEventListener('contextmenu', function(e){ e.preventDefault(); });
function fireDown(e){ e.preventDefault(); firing = true; if(btnFire) btnFire.classList.add('pressed'); }
function fireUp(e){ if(e) e.preventDefault(); firing = false; if(btnFire) btnFire.classList.remove('pressed'); }
if(btnFire){
btnFire.addEventListener('touchstart', fireDown, {passive:false});
btnFire.addEventListener('touchend', fireUp, {passive:false});
btnFire.addEventListener('touchcancel', fireUp, {passive:false});
btnFire.addEventListener('mousedown', fireDown);
}
addEventListener('mouseup', fireUp);
/* ==========================================================
PLAYER
========================================================== */
function makePlayer(){
const dmgUpgraded = getUpgradeValue('damage');
const speed = getUpgradeValue('speed');
const hpMax = getUpgradeValue('hp');
const fRate = getUpgradeValue('fireRate');
return {
x: W/2, y: H/2, r: 14,
hp: hpMax, maxHp: hpMax,
speed: speed,
damage: dmgUpgraded,
baseDamage: 14,
fireCd: 0, fireRate: fRate, invuln: 1.5,
dashTime: 0, dashCd: 0, dashX: 1, dashY: 0,
moveX: 0, moveY: 0, aimX: 1, aimY: 0, hurtFlash: 0,
};
}
/* ==========================================================
ENEMIES
========================================================== */
function isBossLevel(l){ return l % 3 === 0; }
function enemyStats(type, level){
const hpS = 1 + (level-1)*0.15;
const spS = 1 + (level-1)*0.020;
const dmS = 1 + (level-1)*0.08;
if(type === 'grunt') return {r:13, hp:22*hpS, speed:95*spS, dmg:10*dmS, color:'#ff5a6e', sides:3, rot:0.9};
if(type === 'runner') return {r:10, hp:14*hpS, speed:178*spS, dmg:7*dmS, color:'#ffb03a', sides:4, rot:2.4};
if(type === 'shooter') return {r:13, hp:26*hpS, speed:70*spS, dmg:8*dmS, color:'#4cd6ff', sides:5, rot:0.6};
return {r:21, hp:88*hpS, speed:52*spS, dmg:18*dmS, color:'#a45cff', sides:6, rot:0.35};
}
function pickEnemyType(level){
const pool = [{t:'grunt', w:10}];
if(level >= 3) pool.push({t:'runner', w: 4 + level*0.7});
if(level >= 5) pool.push({t:'shooter', w: 2.5 + level*0.5});
if(level >= 7) pool.push({t:'tank', w: 1.2 + level*0.3});
let total = 0; for(let i=0;i<pool.length;i++) total += pool[i].w;
let r = Math.random()*total;
for(let i=0;i<pool.length;i++){ r -= pool[i].w; if(r <= 0) return pool[i].t; }
return 'grunt';
}
function spawnPos(){
const m = 70; const side = randInt(0,3);
if(side === 0) return {x: rand(0,W), y: -m};
if(side === 1) return {x: W+m, y: rand(0,H)};
if(side === 2) return {x: rand(0,W), y: H+m};
return {x: -m, y: rand(0,H)};
}
function spawnEnemy(){
const type = pickEnemyType(G.level);
const st = enemyStats(type, G.level);
const pos = spawnPos();
G.enemies.push({
type:type, x: pos.x, y: pos.y, r: st.r,
hp: st.hp, maxHp: st.hp, speed: st.speed, dmg: st.dmg,
color: st.color, sides: st.sides, rot: Math.random()*TAU, rotSpd: st.rot,
atkCd: 0, shootCd: rand(0.6, 1.8), keep: rand(190, 280),
hitFlash: 0, isBoss:false, dead:false,
});
}
function bossHpByIndex(bossIndex){
return Math.round(500 * Math.pow(1.3, bossIndex - 1));
}
function spawnBoss(){
const bossIndex = Math.floor(G.level / 3);
const hp = bossHpByIndex(bossIndex);
const b = {
type:'boss', isBoss:true, dead:false,
x: W/2, y: -90, r: 44, hp: hp, maxHp: hp,
speed: 55 + bossIndex*3, dmg: 18 + bossIndex*3,
color:'#ff2f6e',
patternTimer: 1.5,
strafeDir: Math.random() < .5 ? 1 : -1,
hitFlash: 0, sides: 8, rot: 0, rotSpd: 0.6, entering: true,
bossIndex: bossIndex,
};
G.enemies.push(b); G.boss = b;
G.bossSpawned = true;
banner('⚠ БОСС ' + bossIndex + ' ⚠');
G.shakeT = 0.5; G.shakeMag = 12;
}
/* ==========================================================
АПТЕЧКИ
========================================================== */
function spawnMedkit(){
const m = 60;
let x, y, tries = 0;
do{
x = rand(m, W-m);
y = rand(m, H-m);
tries++;
} while(G.player && tries < 20 && Math.hypot(x - G.player.x, y - G.player.y) < 120);
const healAmount = getUpgradeValue('healBonus');
G.medkits.push({
x: x, y: y, r: 14,
heal: healAmount,
life: 15,
maxLife: 15,
pulse: Math.random() * 2,
});
}
/* ==========================================================
ПАТТЕРНЫ БОССА
========================================================== */
function runBossPattern(b){
const phase = b.hp / b.maxHp;
const rate = phase < 0.35 ? 1.5 : (phase < 0.7 ? 1.25 : 1);
const pattern = pick(['spiral','bones','blaster','spears','minions']);
if(pattern === 'spiral'){
const n = 12 + Math.floor((1-phase)*8);
const off = Math.random()*TAU;
const sp = 190 + G.level*4;
for(let i=0;i<n;i++){
const a = off + i/n*TAU;
G.eb.push({x:b.x, y:b.y, vx:Math.cos(a)*sp, vy:Math.sin(a)*sp,
r:6, dmg:b.dmg*0.5, life:4, color:'#ff5a8a', kind:'normal'});
}
G.shakeT = 0.2; G.shakeMag = 6;
b.patternTimer = 2.1 / rate;
}
else if(pattern === 'bones'){
const spd = 280 + G.level*6;
const rows = 4 + Math.floor((1-phase)*3);
const fromLeft = Math.random() < 0.5;
for(let i=0;i<rows;i++){
(function(iLocal){
const t = G.time + 0.15 + iLocal*0.12;
const y = 90 + (H-180) * (iLocal/rows) + rand(-25,25);
const fromL = (iLocal % 2 === 0) ? fromLeft : !fromLeft;
G.events.push({t: t, fn: function(){
const x = fromL ? -60 : W+60;
const vx = fromL ? spd : -spd;
G.eb.push({x:x, y:y, vx:vx, vy:0, r:10, w:70, h:14,
dmg:b.dmg*0.5, life:4, color:'#ffffff', kind:'bone'});
}});
})(i);
}
b.patternTimer = 2.3 / rate;
}
else if(pattern === 'blaster'){
const n = 2 + Math.floor((1-phase)*1);
for(let i=0;i<n;i++){
(function(iLocal){
const t = G.time + 0.45 + iLocal*0.6;
G.events.push({t: t, fn: function(){
if(!G.player) return;
const px = G.player.x, py = G.player.y;
const a = Math.atan2(py - b.y, px - b.x) + rand(-0.12, 0.12);
G.beams.push({
x: b.x, y: b.y, angle: a,
len: Math.max(W,H)*1.5, w: 30,
phase: 'warn', t: 0.7, tmax: 0.7,
dmg: b.dmg * 0.7, color: '#7ee0ff', hit: false,
});
}});
})(i);
}
b.patternTimer = 2.6 / rate;
}
else if(pattern === 'spears'){
const n = 4 + Math.floor((1-phase)*4);
const px = G.player ? G.player.x : W/2;
const py = G.player ? G.player.y : H/2;
for(let i=0;i<n;i++){
(function(iLocal){
const t = G.time + 0.3 + iLocal*0.22;
const tx = clamp(px + rand(-170, 170), 30, W-30);
const ty = clamp(py + rand(-100, 100), 30, H-30);
G.events.push({t: t, fn: function(){
G.warnings.push({x: tx, y: ty, r: 24, t: 0.6, tmax: 0.6, color:'#ffb03a'});
G.events.push({t: t + 0.6, fn: function(){
G.eb.push({x: tx, y: -30, vx: 0, vy: 500,
r:8, w:12, h:46, dmg:b.dmg*0.55, life:3,
color:'#ffb03a', kind:'spear'});
}});
}});
})(i);
}
b.patternTimer = 2.7 / rate;
}
else if(pattern === 'minions'){
const n = 2 + Math.floor((1-phase)*2);
for(let i=0;i<n;i++){
const a = Math.random()*TAU;
const st = enemyStats('grunt', G.level);
G.enemies.push({
type:'grunt', x: b.x + Math.cos(a)*70, y: b.y + Math.sin(a)*70, r: st.r,
hp: st.hp*0.7, maxHp: st.hp*0.7, speed: st.speed*1.1, dmg: st.dmg,
color:'#ff5a6e', sides:3, rot:0, rotSpd:1.4, atkCd:0, shootCd:1, keep:200,
hitFlash:0, isBoss:false, dead:false
});
}
explode(b.x, b.y, '#ff2f6e', 18, 4);
b.patternTimer = 2.0 / rate;
}
}
/* ==========================================================
LEVEL FLOW
========================================================== */
function startLevel(){
G.enemies.length = 0; G.pb.length = 0; G.eb.length = 0; G.parts.length = 0;
G.beams.length = 0; G.warnings.length = 0; G.events.length = 0;
G.medkits.length = 0; G.medkitTimer = 8 + rand(0, 5);
G.boss = null; G.killed = 0;
G.bossSpawned = false;
save.lastLevel = G.level;
writeSave();
G.mode = isBossLevel(G.level) ? 'boss' : 'normal';
G.quota = G.mode === 'boss' ? 999 : Math.min(5 + G.level, 20);
G.spawnTimer = 0.6;
if(G.mode === 'boss'){
setTimeout(function(){ if((G.scene==='playing') && !G.boss) spawnBoss(); }, 300);
}
updateHud();
}
function continueGame(){
startGame(save.lastLevel || 1);
}
function startGame(fromLevel){
G.level = fromLevel || 1;
G.time = 0; G.killsTotal = 0; G.bossesTotal = 0; G.surviveTimer = 0; G.healsTotal = 0;
G.player = makePlayer();
startLevel();
G.scene = 'playing'; hideOverlay();
if(hud) hud.classList.remove('hidden');
if(btnDash) btnDash.classList.remove('hidden');
if(isTouchDevice && btnFire) btnFire.classList.remove('hidden');
updateHud();
if(G.level > 1) banner('УРОВЕНЬ ' + G.level);
Sound.startMusic('game');
Sound.setMusicVolume(0.20);
}
function completeLevel(){
if(G.scene !== 'playing') return;
G.scene = 'clear'; G.clearTimer = 2.0;
addQuestProgress('level', 1);
if(G.mode === 'boss'){ addQuestProgress('boss', 1); G.bossesTotal++; }
for(let i=0;i<G.enemies.length;i++){
const e = G.enemies[i];
explode(e.x, e.y, e.color, e.isBoss?40:12, e.isBoss?6:3);
}
G.enemies.length = 0; G.eb.length = 0; G.boss = null;
G.beams.length = 0; G.warnings.length = 0; G.events.length = 0;
if(G.player) G.player.hp = Math.min(G.player.maxHp, G.player.hp + 45);
G.shakeT = 0.35; G.shakeMag = 8;
banner(G.mode === 'boss' ? 'БОСС ПОВЕРЖЕН!' : 'УРОВЕНЬ ' + G.level + ' ПРОЙДЕН');
Sound.sfx('level_up');
writeSave();
}
function gameOver(){
G.scene = 'gameover';
lastDeathLevel = G.level;
save.best = Math.max(save.best || 1, G.level);
writeSave();
if(hud) hud.classList.add('hidden');
if(btnDash) btnDash.classList.add('hidden');
if(btnFire) btnFire.classList.add('hidden');
firing = false; if(btnFire) btnFire.classList.remove('pressed');
const os = $('overStats');
if(os) os.innerHTML =
'Вы погибли на уровне: <b>'+G.level+'</b><br>'+
'Убито врагов: <b>'+G.killsTotal+'</b><br>'+
'Побеждено боссов: <b>'+G.bossesTotal+'</b><br>'+
'Подобрано аптечек: <b>'+G.healsTotal+'</b> 💚<br>'+
'Монет: <b>'+save.coins+'</b> 🪙 · Энергии: <b>'+save.energy+'</b> 🔋';
Sound.sfx('death');
showScreen('scr-over');
}
(function bindRetry(){
const el = $('btnRetryLevel');
if(el) el.addEventListener('click', function(){ startGame(lastDeathLevel); });
})();
function explode(x, y, color, count, size){
for(let i=0;i<count;i++){
const a = Math.random()*TAU, s = rand(40, 260);
G.parts.push({
x: x, y: y, vx: Math.cos(a)*s, vy: Math.sin(a)*s,
life: rand(0.25,0.65), max: 0.65,
r: rand(1.5, size||3.5), color: color
});
}
}
function doDash(){
const p = G.player;
if(!p || G.scene !== 'playing') return;
if(p.dashCd > 0 || p.dashTime > 0) return;
let dx = p.moveX, dy = p.moveY;
if(Math.sqrt(dx*dx+dy*dy) < 0.15){ dx = p.aimX; dy = p.aimY; }
const l = Math.sqrt(dx*dx+dy*dy) || 1;
p.dashX = dx/l; p.dashY = dy/l;
p.dashTime = 0.16; p.dashCd = 1.5;
p.invuln = Math.max(p.invuln, 0.34);
explode(p.x, p.y, '#9fe8ff', 10, 3);
Sound.sfx('dash');
}
if(btnDash){
btnDash.addEventListener('touchstart', function(e){ e.preventDefault(); doDash(); }, {passive:false});
btnDash.addEventListener('mousedown', function(e){ e.preventDefault(); doDash(); });
}
/* ==========================================================
PAUSE / MENU
========================================================== */
function togglePause(){
if(G.scene === 'playing'){
G.scene = 'paused'; showScreen('scr-pause');
if(hud) hud.classList.add('hidden');
if(btnDash) btnDash.classList.add('hidden');
if(btnFire) btnFire.classList.add('hidden');
firing = false; if(btnFire) btnFire.classList.remove('pressed');
Sound.setMusicVolume(0.06);
} else if(G.scene === 'paused'){
G.scene = 'playing'; hideOverlay();
if(hud) hud.classList.remove('hidden');
if(btnDash) btnDash.classList.remove('hidden');
if(isTouchDevice && btnFire) btnFire.classList.remove('hidden');
Sound.setMusicVolume(0.20);
}
}
function goMenu(){
G.scene = 'menu'; showScreen('scr-menu');
if(hud) hud.classList.add('hidden');
if(btnDash) btnDash.classList.add('hidden');
if(btnFire) btnFire.classList.add('hidden');
firing = false; if(btnFire) btnFire.classList.remove('pressed');
updateHud(); updateBadge();
const ub = $('userBadge');
if(ub) ub.textContent = currentUser === 'guest'
? '👤 Гость'
: (currentUser ? '👤 ' + currentUser : '👤 Не в аккаунте');
const bp = $('btnPlay'), br = $('btnRestart');
const lvl = save.lastLevel || 1;
if(bp) bp.textContent = (lvl > 1) ? ('ПРОДОЛЖИТЬ · УР. ' + lvl) : 'ИГРАТЬ';
if(br){
if(lvl > 1) br.classList.remove('hidden');
else br.classList.add('hidden');
}
Sound.startMusic('menu');
Sound.setMusicVolume(0.20);
}
function openShop(){ returnTo = 'menu'; renderShop(); showScreen('scr-shop'); }
function openUpgrades(){ returnTo = 'menu'; renderUpgrades(); showScreen('scr-upgrades'); }
function openQuests(){ returnTo = 'menu'; renderQuests(); showScreen('scr-quests'); }
function openQuestsFromPause(){ returnTo = 'pause'; renderQuests(); showScreen('scr-quests'); }
function closeSub(){
if(returnTo === 'pause') showScreen('scr-pause');
else showScreen('scr-menu');
}
(function bindUI(){
const bp = $('btnPause'); if(bp) bp.addEventListener('click', togglePause);
const bm = $('btnMute'); if(bm) bm.addEventListener('click', function(){ Sound.toggleMute(); });
const bmm = $('btnMuteMenu'); if(bmm) bmm.addEventListener('click', function(){ Sound.toggleMute(); });
const bma = $('btnMuteAuth'); if(bma) bma.addEventListener('click', function(){ Sound.toggleMute(); });
})();
document.addEventListener('click', function(e){
const t = e.target;
if(t && t.closest && t.closest('button')) Sound.sfx('click');
});
/* ==========================================================
UPDATE
========================================================== */
function update(dt){
G.time += dt;
const p = G.player; if(!p) return;
let ix = 0, iy = 0;
if(keys['w'] || keys['arrowup'] || keys['ц']) iy -= 1;
if(keys['s'] || keys['arrowdown'] || keys['ы']) iy += 1;
if(keys['a'] || keys['arrowleft'] || keys['ф']) ix -= 1;
if(keys['d'] || keys['arrowright'] || keys['в']) ix += 1;
ix += joy.dx; iy += joy.dy;
const mag = Math.sqrt(ix*ix+iy*iy);
if(mag > 1){ ix /= mag; iy /= mag; }
p.moveX = ix; p.moveY = iy;
if(p.invuln > 0) p.invuln -= dt;
if(p.hurtFlash > 0) p.hurtFlash -= dt;
if(p.dashCd > 0) p.dashCd -= dt;
if(p.fireCd > 0) p.fireCd -= dt;
if(p.dashTime > 0){
p.dashTime -= dt;
p.x += p.dashX * 980 * dt; p.y += p.dashY * 980 * dt;
if(Math.random() < 0.7) G.parts.push({x:p.x, y:p.y, vx:rand(-30,30), vy:rand(-30,30),
life:0.3, max:0.3, r:rand(2,4), color:'#8fe6ff'});
} else {
p.x += ix * p.speed * dt; p.y += iy * p.speed * dt;
}
p.x = clamp(p.x, p.r, W - p.r);
p.y = clamp(p.y, p.r, H - p.r);
const sk = skinById(save.skin);
if(sk.trail){
G.trail.push({x:p.x, y:p.y, life:0.35});
if(G.trail.length > 22) G.trail.shift();
} else if(G.trail.length){ G.trail.shift(); }
for(let i=G.trail.length-1;i>=0;i--){
G.trail[i].life -= dt;
if(G.trail[i].life <= 0) G.trail.splice(i,1);
}
G.surviveTimer += dt;
if(G.surviveTimer >= 1){ addQuestProgress('survive', 1); G.surviveTimer -= 1; }
for(let i=G.events.length-1;i>=0;i--){
if(G.time >= G.events[i].t){
const fn = G.events[i].fn;
G.events.splice(i,1);
fn();
}
}
if(G.medkits.length < 3){
G.medkitTimer -= dt;
if(G.medkitTimer <= 0){
G.medkitTimer = rand(18, 28);
spawnMedkit();
}
}
for(let i=G.medkits.length-1;i>=0;i--){
const m = G.medkits[i];
m.life -= dt;
m.pulse += dt;
if(m.life <= 0){ G.medkits.splice(i,1); continue; }
const mdx = p.x - m.x, mdy = p.y - m.y;
if(mdx*mdx + mdy*mdy < (p.r + m.r)*(p.r + m.r)){
const need = p.maxHp - p.hp;
if(need <= 0.5) continue;
const heal = Math.min(m.heal, need);
p.hp += heal;
G.healsTotal++;
addQuestProgress('heal', 1);
Sound.sfx('heal');
toast('+ ' + Math.round(heal) + ' ❤️');
explode(m.x, m.y, '#4cff9d', 16, 3.4);
G.medkits.splice(i,1);
}
}
if(inputMode === 'mouse'){
const dx = mouseX - p.x, dy = mouseY - p.y;
const l = Math.sqrt(dx*dx+dy*dy) || 1;
p.aimX = dx/l; p.aimY = dy/l;
} else {
let nearest = null, nd = 1e9;
for(let i=0;i<G.enemies.length;i++){
const e = G.enemies[i];
if(e.dead) continue;
const d = Math.sqrt((e.x-p.x)*(e.x-p.x)+(e.y-p.y)*(e.y-p.y));
if(d < nd){ nd = d; nearest = e; }
}
if(nearest){
const dx = nearest.x - p.x, dy = nearest.y - p.y;
const l = Math.sqrt(dx*dx+dy*dy) || 1;
p.aimX = dx/l; p.aimY = dy/l;
} else if(mag > 0.15){
p.aimX = ix; p.aimY = iy;
}
}
if(firing && p.fireCd <= 0){
p.fireCd = p.fireRate;
const levelBonus = G.level * 1.6;
const dmgEnemy = p.damage + levelBonus;
const dmgBoss = p.baseDamage + levelBonus;
const spread = (Math.random()-0.5) * 0.05;
const ca = Math.cos(spread), sa = Math.sin(spread);
const vx = (p.aimX*ca - p.aimY*sa) * 720;
const vy = (p.aimX*sa + p.aimY*ca) * 720;
G.pb.push({
x: p.x + p.aimX*16, y: p.y + p.aimY*16,
vx: vx, vy: vy, r: 4.5,
dmg: dmgEnemy, dmgBoss: dmgBoss,
life: 1.4
});
G.parts.push({x:p.x + p.aimX*20, y:p.y + p.aimY*20,
vx:p.aimX*140 + rand(-40,40), vy:p.aimY*140 + rand(-40,40),
life:0.14, max:0.14, r:2.5, color:'#ffd6a0'});
Sound.sfx('shoot');
}
if(G.mode !== 'boss'){
const maxEnemies = Math.min(4 + G.level*0.8, 18);
G.spawnTimer -= dt;
const spawnInterval = Math.max(0.35, (1.4 - G.level*0.035));
if(G.spawnTimer <= 0 && G.enemies.length < maxEnemies){
G.spawnTimer = spawnInterval; spawnEnemy();
}
} else {
const maxEnemies = 5;
G.spawnTimer -= dt;
if(G.spawnTimer <= 0 && G.enemies.length < maxEnemies){
G.spawnTimer = 3.2;
spawnEnemy();
}
}
for(let i=G.pb.length-1;i>=0;i--){
const b = G.pb[i];
b.x += b.vx*dt; b.y += b.vy*dt; b.life -= dt;
if(b.life <= 0 || b.x < -30 || b.x > W+30 || b.y < -30 || b.y > H+30){ G.pb.splice(i,1); continue; }
for(let j=0;j<G.enemies.length;j++){
const e = G.enemies[j];
if(e.dead) continue;
if(Math.sqrt((e.x-b.x)*(e.x-b.x)+(e.y-b.y)*(e.y-b.y)) < e.r + b.r){
const applied = e.isBoss ? (b.dmgBoss || b.dmg) : b.dmg;
e.hp -= applied; e.hitFlash = 0.12;
explode(b.x, b.y, e.color, 4, 2.6);
if(e.hp <= 0){ killEnemy(e); }
else { Sound.sfx(e.isBoss ? 'boss_hit' : 'hit'); }
G.pb.splice(i,1); break;
}
}
}
for(let i=G.enemies.length-1;i>=0;i--){
const e = G.enemies[i];
if(e.dead){ G.enemies.splice(i,1); continue; }
if(e.hitFlash > 0) e.hitFlash -= dt;
e.rot += e.rotSpd * dt;
const dx = p.x - e.x, dy = p.y - e.y;
const d = Math.sqrt(dx*dx+dy*dy) || 1;
const ux = dx/d, uy = dy/d;
if(e.isBoss){ updateBoss(e, dt, d, ux, uy); }
else if(e.type === 'shooter'){
if(d > e.keep){ e.x += ux*e.speed*dt; e.y += uy*e.speed*dt; }
else if(d < e.keep - 55){ e.x -= ux*e.speed*dt; e.y -= uy*e.speed*dt; }
else { e.x += -uy*e.speed*0.6*dt; e.y += ux*e.speed*0.6*dt; }
e.shootCd -= dt;
if(e.shootCd <= 0 && d < 560){
e.shootCd = Math.max(1.0, 2.2 - G.level*0.03);
const sp = 240 + G.level*5;
G.eb.push({x:e.x, y:e.y, vx:ux*sp, vy:uy*sp, r:5.5, dmg:e.dmg, life:3.2, color:'#4cd6ff', kind:'normal'});
}
} else {
e.x += ux * e.speed * dt; e.y += uy * e.speed * dt;
}
for(let j=0;j<G.enemies.length;j++){
const o = G.enemies[j];
if(o === e || o.dead) continue;
const ddx = e.x-o.x, ddy = e.y-o.y;
const dd = Math.sqrt(ddx*ddx+ddy*ddy); const minD = e.r + o.r;
if(dd > 0 && dd < minD){
const push = (minD-dd)/dd * 0.5;
e.x += ddx*push; e.y += ddy*push;
}
}
if(e.atkCd > 0) e.atkCd -= dt;
if(d < p.r + e.r && e.atkCd <= 0 && p.invuln <= 0){
damagePlayer(e.dmg); e.atkCd = 0.75;
e.x -= ux*22; e.y -= uy*22;
}
if(!e.isBoss){
e.x = clamp(e.x, -80, W+80);
e.y = clamp(e.y, -80, H+80);
}
}
for(let i=G.eb.length-1;i>=0;i--){
const b = G.eb[i];
b.x += b.vx*dt; b.y += b.vy*dt; b.life -= dt;
if(b.life <= 0 || b.x < -100 || b.x > W+100 || b.y < -100 || b.y > H+100){ G.eb.splice(i,1); continue; }
let hit = false;
if(b.kind === 'bone' || b.kind === 'spear'){
hit = circleRect(p.x, p.y, p.r, b.x - b.w/2, b.y - b.h/2, b.w, b.h);
} else {
const dx = p.x-b.x, dy = p.y-b.y;
hit = (dx*dx+dy*dy) < (p.r + b.r)*(p.r + b.r);
}
if(hit && p.invuln <= 0){
damagePlayer(b.dmg);
explode(b.x, b.y, b.color, 6, 3);
G.eb.splice(i,1);
}
}
for(let i=G.beams.length-1;i>=0;i--){
const bm = G.beams[i];
bm.t -= dt;
if(bm.phase === 'warn'){
if(bm.t <= 0){
bm.phase = 'fire'; bm.t = 0.18; bm.hit = false;
G.shakeT = 0.18; G.shakeMag = 6;
explode(bm.x, bm.y, bm.color, 14, 4);
Sound.sfx('boss_hit');
}
} else if(bm.phase === 'fire'){
if(!bm.hit && p.invuln <= 0){
const x2 = bm.x + Math.cos(bm.angle)*bm.len;
const y2 = bm.y + Math.sin(bm.angle)*bm.len;
const dist = pointLineDist(p.x, p.y, bm.x, bm.y, x2, y2);
if(dist < p.r + bm.w/2){
damagePlayer(bm.dmg);
bm.hit = true;
}
}
if(bm.t <= 0) G.beams.splice(i,1);
}
}
for(let i=G.warnings.length-1;i>=0;i--){
G.warnings[i].t -= dt;
if(G.warnings[i].t <= 0) G.warnings.splice(i,1);
}
for(let i=G.parts.length-1;i>=0;i--){
const q = G.parts[i];
q.x += q.vx*dt; q.y += q.vy*dt;
q.vx *= 0.94; q.vy *= 0.94;
q.life -= dt;
if(q.life <= 0) G.parts.splice(i,1);
}
if(G.shakeT > 0) G.shakeT -= dt;
if(G.mode === 'boss'){
if(G.bossSpawned && !G.boss) completeLevel();
} else if(G.killed >= G.quota){
completeLevel();
}
updateHud();
}
function updateBoss(b, dt, d, ux, uy){
const p = G.player;
if(b.entering){
b.y += 90*dt;
if(b.y >= 110){ b.y = 110; b.entering = false; }
return;
}
if(d > 230){ b.x += ux*b.speed*dt; b.y += uy*b.speed*dt; }
else if(d < 160){ b.x -= ux*b.speed*0.8*dt; b.y -= uy*b.speed*0.8*dt; }
b.x += -uy * b.speed * 0.5 * b.strafeDir * dt;
b.y += ux * b.speed * 0.5 * b.strafeDir * dt;
b.x = clamp(b.x, b.r+10, W-b.r-10);
b.y = clamp(b.y, b.r+10, H-b.r-10);
if(Math.random() < 0.0025){
b.x = clamp(p.x + rand(-260, 260), b.r+10, W-b.r-10);
b.y = clamp(p.y + rand(-260, 260), b.r+10, H-b.r-10);
explode(b.x, b.y, '#ff2f6e', 16, 4);
}
b.patternTimer -= dt;
if(b.patternTimer <= 0){ runBossPattern(b); }
}
function killEnemy(e){
if(e.dead) return;
e.dead = true;
G.killsTotal++;
if(e.isBoss){
G.boss = null;
G.events.length = 0; G.beams.length = 0; G.warnings.length = 0;
explode(e.x, e.y, '#ff2f6e', 60, 7);
explode(e.x, e.y, '#ffd86b', 40, 5);
G.shakeT = 0.6; G.shakeMag = 18;
Sound.sfx('boss_die');
} else {
explode(e.x, e.y, e.color, 12, 3.4);
G.killed++;
addQuestProgress('kill', 1);
Sound.sfx('explosion');
}
}
function damagePlayer(dmg){
const p = G.player;
if(!p || p.invuln > 0) return;
p.hp -= dmg; p.invuln = 0.7; p.hurtFlash = 0.25;
G.shakeT = 0.22; G.shakeMag = 7;
explode(p.x, p.y, '#ff4d6d', 10, 3);
Sound.sfx('player_hit');
if(p.hp <= 0){ p.hp = 0; gameOver(); }
}
/* ==========================================================
RENDER
========================================================== */
function drawBg(){
if(!ctx) return;
ctx.fillStyle = '#05060d'; ctx.fillRect(0,0,W,H);
const gs = 62;
const ox = (G.time * 9) % gs, oy = (G.time * 6) % gs;
ctx.strokeStyle = 'rgba(70,110,200,0.10)'; ctx.lineWidth = 1;
ctx.beginPath();
for(let x = -gs + ox; x < W + gs; x += gs){ ctx.moveTo(x, 0); ctx.lineTo(x, H); }
for(let y = -gs + oy; y < H + gs; y += gs){ ctx.moveTo(0, y); ctx.lineTo(W, y); }
ctx.stroke();
const vg = ctx.createRadialGradient(W/2, H/2, Math.min(W,H)*0.22, W/2, H/2, Math.max(W,H)*0.78);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(0,0,0,0.78)');
ctx.fillStyle = vg; ctx.fillRect(0,0,W,H);
}
function drawPoly(x, y, r, sides, rot, fill, stroke){
ctx.beginPath();
for(let i=0;i<sides;i++){
const a = rot + i/sides*TAU;
const px = x + Math.cos(a)*r, py = y + Math.sin(a)*r;
if(i) ctx.lineTo(px,py); else ctx.moveTo(px,py);
}
ctx.closePath();
if(fill){ ctx.fillStyle = fill; ctx.fill(); }
if(stroke){ ctx.strokeStyle = stroke; ctx.lineWidth = 2; ctx.stroke(); }
}
function skinRGB(sk){
if(sk.rainbow){ const h = (G.time * 150) % 360; return hslToRgb(h/360, 1, 0.6); }
return hexToRgb(sk.color);
}
function render(){
if(!ctx) return;
drawBg();
if(!G.player) return;
ctx.save();
if(G.shakeT > 0){
const m = G.shakeMag * (G.shakeT / 0.6);
ctx.translate(rand(-m,m), rand(-m,m));
}
const p = G.player;
const sk = skinById(save.skin);
const rgb = skinRGB(sk);
for(let i=0;i<G.medkits.length;i++){
const m = G.medkits[i];
const blink = m.life < 4 && Math.floor(G.time*12) % 2 === 0;
if(blink) continue;
const pulse = 0.5 + 0.5*Math.sin(m.pulse * 4);
const scale = 1 + 0.09 * pulse;
ctx.beginPath();
ctx.arc(m.x, m.y, m.r * 2.6 * scale, 0, TAU);
ctx.fillStyle = 'rgba(76,255,157,' + (0.10 + 0.08*pulse) + ')';
ctx.fill();
ctx.beginPath();
ctx.arc(m.x, m.y, m.r * 1.6 * scale, 0, TAU);
ctx.strokeStyle = 'rgba(76,255,157,' + (0.5 - 0.3*pulse) + ')';
ctx.lineWidth = 2;
ctx.stroke();
ctx.save();
ctx.translate(m.x, m.y);
ctx.scale(scale, scale);
ctx.beginPath();
ctx.arc(0, 0, m.r, 0, TAU);
ctx.fillStyle = '#ffffff';
ctx.shadowColor = '#4cff9d'; ctx.shadowBlur = 20;
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = '#ff3d5a';
ctx.fillRect(-8, -3, 16, 6);
ctx.fillRect(-3, -8, 6, 16);
ctx.restore();
}
for(let i=0;i<G.warnings.length;i++){
const w = G.warnings[i];
const a = 1 - (w.t / w.tmax);
const pulse = 0.5 + 0.5 * Math.sin(G.time * 22);
ctx.save();
ctx.translate(w.x, w.y);
ctx.beginPath();
ctx.arc(0, 0, w.r + 4*pulse, 0, TAU);
ctx.strokeStyle = rgba(hexToRgb(w.color), 0.9);
ctx.lineWidth = 3; ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, w.r * (0.3 + a*0.7), 0, TAU);
ctx.fillStyle = rgba(hexToRgb(w.color), 0.28); ctx.fill();
ctx.beginPath();
ctx.moveTo(-w.r-10, 0); ctx.lineTo(-w.r+4, 0);
ctx.moveTo(w.r-4, 0); ctx.lineTo(w.r+10, 0);
ctx.moveTo(0, -w.r-10); ctx.lineTo(0, -w.r+4);
ctx.moveTo(0, w.r-4); ctx.lineTo(0, w.r+10);
ctx.strokeStyle = rgba(hexToRgb(w.color), 0.7);
ctx.lineWidth = 2; ctx.stroke();
ctx.restore();
}
for(let i=0;i<G.beams.length;i++){
const bm = G.beams[i];
const x2 = bm.x + Math.cos(bm.angle)*bm.len;
const y2 = bm.y + Math.sin(bm.angle)*bm.len;
if(bm.phase === 'warn'){
const pulse = 0.5 + 0.5 * Math.sin(G.time * 30);
ctx.beginPath();
ctx.moveTo(bm.x, bm.y); ctx.lineTo(x2, y2);
ctx.strokeStyle = 'rgba(255,90,90,' + (0.35 + 0.4*pulse) + ')';
ctx.lineWidth = 3; ctx.stroke();
ctx.beginPath();
ctx.moveTo(bm.x, bm.y); ctx.lineTo(x2, y2);
ctx.strokeStyle = 'rgba(255,180,180,.55)';
ctx.lineWidth = 1.2; ctx.stroke();
ctx.beginPath();
ctx.arc(bm.x, bm.y, 10 + 6*pulse, 0, TAU);
ctx.fillStyle = rgba(hexToRgb(bm.color), 0.5); ctx.fill();
} else {
const alpha = clamp(bm.t / 0.18, 0, 1);
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.beginPath();
ctx.moveTo(bm.x, bm.y); ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(hexToRgb(bm.color), 0.35 * alpha);
ctx.lineWidth = bm.w * 2; ctx.lineCap = 'round'; ctx.stroke();
ctx.beginPath();
ctx.moveTo(bm.x, bm.y); ctx.lineTo(x2, y2);
ctx.strokeStyle = rgba(hexToRgb(bm.color), 0.8 * alpha);
ctx.lineWidth = bm.w; ctx.stroke();
ctx.beginPath();
ctx.moveTo(bm.x, bm.y); ctx.lineTo(x2, y2);
ctx.strokeStyle = 'rgba(255,255,255,' + (0.95*alpha) + ')';
ctx.lineWidth = bm.w * 0.35; ctx.stroke();
ctx.restore();
ctx.beginPath();
ctx.arc(bm.x, bm.y, 16, 0, TAU);
ctx.fillStyle = 'rgba(255,255,255,' + alpha + ')'; ctx.fill();
}
}
if(p){
for(let i=0;i<G.trail.length;i++){
const t = G.trail[i];
const a = t.life / 0.35;
ctx.beginPath(); ctx.arc(t.x, t.y, p.r * 0.85 * a, 0, TAU);
ctx.fillStyle = rgba(rgb, a*0.28); ctx.fill();
}
}
for(let i=0;i<G.eb.length;i++){
const b = G.eb[i];
if(b.kind === 'bone'){
const x = b.x - b.w/2, y = b.y - b.h/2;
ctx.fillStyle = 'rgba(255,255,255,.18)';
ctx.fillRect(x-3, y-3, b.w+6, b.h+6);
ctx.fillStyle = '#ffffff'; ctx.fillRect(x, y, b.w, b.h);
ctx.fillStyle = 'rgba(0,0,0,.55)';
ctx.fillRect(x, y, 4, b.h);
ctx.fillRect(x+b.w-4, y, 4, b.h);
} else if(b.kind === 'spear'){
const x = b.x - b.w/2, y = b.y - b.h/2;
ctx.fillStyle = rgba(hexToRgb(b.color), 0.35);
ctx.fillRect(x-3, y-3, b.w+6, b.h+6);
ctx.fillStyle = b.color; ctx.fillRect(x, y, b.w, b.h);
ctx.beginPath();
ctx.moveTo(b.x, y - 12);
ctx.lineTo(b.x - b.w/2 - 3, y + 4);
ctx.lineTo(b.x + b.w/2 + 3, y + 4);
ctx.closePath();
ctx.fillStyle = '#fff5cc'; ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,.4)';
ctx.fillRect(x, y + b.h*0.35, b.w, 2);
ctx.fillRect(x, y + b.h*0.65, b.w, 2);
} else {
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, TAU);
ctx.fillStyle = b.color; ctx.shadowColor = b.color; ctx.shadowBlur = 14;
ctx.fill(); ctx.shadowBlur = 0;
}
}
for(let i=0;i<G.enemies.length;i++){
const e = G.enemies[i];
const col = e.hitFlash > 0 ? '#ffffff' : e.color;
ctx.beginPath(); ctx.arc(e.x, e.y, e.r*1.7, 0, TAU);
ctx.fillStyle = rgba(hexToRgb(e.color), 0.12); ctx.fill();
drawPoly(e.x, e.y, e.r, e.sides, e.rot, col, 'rgba(255,255,255,.45)');
if(e.isBoss){
ctx.beginPath(); ctx.arc(e.x, e.y, e.r*0.45, 0, TAU);
ctx.fillStyle = '#fff'; ctx.globalAlpha = 0.85; ctx.fill(); ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(e.x + Math.cos(e.rot*2)*10, e.y + Math.sin(e.rot*2)*10, 5, 0, TAU);
ctx.fillStyle = '#ff3060'; ctx.fill();
ctx.beginPath();
ctx.arc(e.x, e.y, e.r*1.15, 0, TAU);
ctx.strokeStyle = 'rgba(255,176,32,.35)';
ctx.lineWidth = 2;
ctx.stroke();
}
if(!e.isBoss && e.hp < e.maxHp){
const w = e.r*2.2; const hpPct = clamp(e.hp/e.maxHp,0,1);
ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.fillRect(e.x-w/2, e.y-e.r-11, w, 4);
ctx.fillStyle = hpPct > 0.5 ? '#4cff9d' : (hpPct > 0.25 ? '#ffd23f' : '#ff4d6d');
ctx.fillRect(e.x-w/2, e.y-e.r-11, w*hpPct, 4);
}
}
for(let i=0;i<G.pb.length;i++){
const b = G.pb[i];
ctx.beginPath(); ctx.arc(b.x, b.y, b.r, 0, TAU);
ctx.fillStyle = '#eafcff'; ctx.shadowColor = '#4cd6ff'; ctx.shadowBlur = 16;
ctx.fill(); ctx.shadowBlur = 0;
}
for(let i=0;i<G.parts.length;i++){
const q = G.parts[i];
const a = clamp(q.life/q.max, 0, 1);
ctx.globalAlpha = a;
ctx.beginPath(); ctx.arc(q.x, q.y, q.r*a, 0, TAU);
ctx.fillStyle = q.color; ctx.fill();
}
ctx.globalAlpha = 1;
if(p && (G.scene === 'playing' || G.scene === 'paused' || G.scene === 'clear')){
const blink = p.invuln > 0 && Math.floor(G.time*22) % 2 === 0;
if(!blink){
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r*3.4);
g.addColorStop(0, rgba(rgb, sk.glow ? 0.6 : 0.34));
g.addColorStop(1, rgba(rgb, 0));
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(p.x, p.y, p.r*3.4, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(p.x, p.y, p.r, 0, TAU);
ctx.fillStyle = p.hurtFlash > 0 ? '#ff8fa3' : rgba(rgb, 1);
ctx.shadowColor = rgba(rgb, 1); ctx.shadowBlur = sk.glow ? 26 : 12;
ctx.fill(); ctx.shadowBlur = 0;
ctx.lineWidth = 2.5; ctx.strokeStyle = 'rgba(255,255,255,.9)'; ctx.stroke();
ctx.beginPath(); ctx.arc(p.x, p.y, p.r*0.42, 0, TAU);
ctx.fillStyle = 'rgba(255,255,255,.95)'; ctx.fill();
ctx.beginPath();
ctx.moveTo(p.x + p.aimX*p.r*0.7, p.y + p.aimY*p.r*0.7);
ctx.lineTo(p.x + p.aimX*(p.r+11), p.y + p.aimY*(p.r+11));
ctx.strokeStyle = rgba(rgb, 0.95); ctx.lineWidth = 5; ctx.lineCap = 'round'; ctx.stroke();
}
if(p.dashCd > 0){
const pct = 1 - p.dashCd/1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, p.r + 11, -Math.PI/2, -Math.PI/2 + TAU*pct);
ctx.strokeStyle = 'rgba(120,220,255,.55)'; ctx.lineWidth = 3; ctx.stroke();
}
}
if(inputMode === 'mouse' && G.scene === 'playing'){
const r = 12 + Math.sin(G.time*8)*2;
ctx.beginPath(); ctx.arc(mouseX, mouseY, r, 0, TAU);
ctx.strokeStyle = 'rgba(255,180,120,.85)'; ctx.lineWidth = 2; ctx.stroke();
ctx.beginPath(); ctx.arc(mouseX, mouseY, 3, 0, TAU);
ctx.fillStyle = 'rgba(255,180,120,.9)'; ctx.fill();
ctx.beginPath();
ctx.moveTo(mouseX-18, mouseY); ctx.lineTo(mouseX-6, mouseY);
ctx.moveTo(mouseX+6, mouseY); ctx.lineTo(mouseX+18, mouseY);
ctx.moveTo(mouseX, mouseY-18); ctx.lineTo(mouseX, mouseY-6);
ctx.moveTo(mouseX, mouseY+6); ctx.lineTo(mouseX, mouseY+18);
ctx.strokeStyle = 'rgba(255,180,120,.55)'; ctx.lineWidth = 1.5; ctx.stroke();
}
if(joy.active && G.scene === 'playing'){
ctx.beginPath(); ctx.arc(joy.ox, joy.oy, JOY_R, 0, TAU);
ctx.strokeStyle = 'rgba(120,180,255,.28)'; ctx.lineWidth = 2.5; ctx.stroke();
ctx.beginPath(); ctx.arc(joy.ox + joy.dx*JOY_R, joy.oy + joy.dy*JOY_R, 26, 0, TAU);
ctx.fillStyle = 'rgba(120,200,255,.28)'; ctx.fill();
ctx.strokeStyle = 'rgba(160,220,255,.7)'; ctx.lineWidth = 2; ctx.stroke();
}
if(G.boss && !G.boss.entering){
const bw = Math.min(W - 60, 460);
const bx = (W - bw)/2; const by = 76;
ctx.fillStyle = 'rgba(0,0,0,.6)'; ctx.fillRect(bx-3, by-3, bw+6, 20);
ctx.fillStyle = 'rgba(255,255,255,.1)'; ctx.fillRect(bx, by, bw, 14);
const pct = clamp(G.boss.hp/G.boss.maxHp, 0, 1);
const grd = ctx.createLinearGradient(bx, 0, bx+bw, 0);
grd.addColorStop(0, '#ff2f6e'); grd.addColorStop(1, '#ffb03a');
ctx.fillStyle = grd; ctx.fillRect(bx, by, bw*pct, 14);
ctx.strokeStyle = 'rgba(255,120,160,.8)'; ctx.lineWidth = 1.5; ctx.strokeRect(bx, by, bw, 14);
ctx.fillStyle = '#fff'; ctx.font = 'bold 11px system-ui, sans-serif';
ctx.textAlign = 'center';
ctx.fillText('БОСС ' + (G.boss.bossIndex || '') + ' — ' + Math.ceil(G.boss.hp) + ' / ' + G.boss.maxHp, W/2, by - 7);
ctx.fillStyle = 'rgba(255,176,32,.8)'; ctx.font = 'bold 10px system-ui, sans-serif';
ctx.fillText('⚠ прокачка урона не действует', W/2, by + 26);
}
ctx.restore();
}
/* ==========================================================
LOOP
========================================================== */
let lastT = performance.now();
function loop(t){
const dt = Math.min(0.05, (t - lastT) / 1000);
lastT = t;
if(G.scene === 'playing'){
update(dt);
if(btnDash) btnDash.classList.toggle('cool', !!(G.player && G.player.dashCd > 0));
} else if(G.scene === 'clear'){
G.clearTimer -= dt;
for(let i=G.parts.length-1;i>=0;i--){
const q = G.parts[i];
q.x += q.vx*dt; q.y += q.vy*dt;
q.vx *= 0.94; q.vy *= 0.94; q.life -= dt;
if(q.life <= 0) G.parts.splice(i,1);
}
if(G.shakeT > 0) G.shakeT -= dt;
G.time += dt;
if(G.clearTimer <= 0){
G.level++; startLevel();
G.scene = 'playing'; hideOverlay();
if(hud) hud.classList.remove('hidden');
if(btnDash) btnDash.classList.remove('hidden');
if(isTouchDevice && btnFire) btnFire.classList.remove('hidden');
banner('УРОВЕНЬ ' + G.level);
}
}
render();
requestAnimationFrame(loop);
}
setInterval(function(){
if(!currentUser) return;
checkCycle(); updateQuestTimer();
const qs = $('scr-quests');
if(qs && !qs.classList.contains('hidden')) renderQuests();
}, 1000);
setInterval(function(){
if(currentUser && (G.scene === 'playing' || G.scene === 'paused')) writeSave();
}, 5000);
document.addEventListener('visibilitychange', function(){
if(document.hidden && G.scene === 'playing') togglePause();
});
/* ==========================================================
BOOT
========================================================== */
function boot(){
try{
resize();
loadAccounts();
let stored = null;
try{ stored = localStorage.getItem(CURRENT_KEY); }catch(e){}
if(stored){
if(stored === 'guest'){ currentUser = 'guest'; }
else if(accounts[stored]){ currentUser = stored; }
else { currentUser = null; try{ localStorage.removeItem(CURRENT_KEY); }catch(e){} }
}
if(currentUser){
loadSave();
refreshAllUI();
goMenu();
} else {
save = defaultSave();
refreshAllUI();
showAuth();
}
updateMuteButtons();
requestAnimationFrame(loop);
}catch(err){
showFatal('BOOT: ' + (err && err.stack ? err.stack : err));
}
}
boot();
</script>
</body>
</html>Game Source: НЕОН: Арена
Creator: BlazingBear24
Libraries: none
Complexity: complex (2309 lines, 93.1 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: game-blazingbear24" to link back to the original. Then publish at arcadelab.ai/publish.