SNAKE CLASH.IO
by CosmicRanger903805 lines150.8 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="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0b120b">
<title>SNAKE CLASH.IO</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{width:100%;height:100%;overflow:hidden;background:#0b120b;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;
touch-action:none;user-select:none;-webkit-user-select:none;color:#fff;}
canvas{position:fixed;inset:0;width:100%;height:100%;display:block;}
#menu,#langScreen,#authorScreen{position:fixed;inset:0;z-index:100;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:14px;
background:radial-gradient(circle at 50% 30%,#16281a 0%,#080d08 70%);
padding:calc(20px + env(safe-area-inset-top)) 20px calc(20px + env(safe-area-inset-bottom));
overflow-y:auto;}
#menu.hidden,#skinsScreen.hidden,#hud.hidden,#deathScreen.hidden,#langScreen.hidden,#authorScreen.hidden{display:none;}
#title{font-size:32px;font-weight:900;letter-spacing:2px;text-align:center;
background:linear-gradient(90deg,#7fe07f,#f7d94a,#7fe07f);
-webkit-background-clip:text;background-clip:text;color:transparent;
text-shadow:0 0 40px rgba(120,240,120,.25);}
#subtitle{font-size:12px;color:#7a9a7a;margin-top:-8px;letter-spacing:1px;text-align:center;}
#coinBadge{font-size:13px;font-weight:900;color:#ffe45e;background:rgba(0,0,0,.5);
padding:6px 16px;border-radius:10px;border:2px solid #7a6a1a;letter-spacing:1px;}
.mbtn{width:min(260px,80vw);padding:14px 0;border-radius:14px;font-size:17px;font-weight:800;
text-align:center;letter-spacing:1px;cursor:pointer;pointer-events:auto;
background:linear-gradient(180deg,#3a8f3a,#245a24);
border:2px solid #58c058;box-shadow:0 4px 0 #143414,0 6px 18px rgba(0,0,0,.5);
transition:transform .08s;text-decoration:none;color:#fff;display:block;}
.mbtn:active{transform:translateY(3px);box-shadow:0 1px 0 #143414;}
.mbtn.alt{background:linear-gradient(180deg,#3a5f8f,#24385a);border-color:#5878c0;
box-shadow:0 4px 0 #142034,0 6px 18px rgba(0,0,0,.5);}
.mbtn.gold{background:linear-gradient(180deg,#b8860b,#7a5a08);border-color:#f7d94a;
box-shadow:0 4px 0 #3a2a04,0 6px 18px rgba(0,0,0,.5);}
.mbtn.small{padding:10px 0;font-size:15px;width:min(220px,70vw);}
#nickWrap,.nickWrap{display:flex;flex-direction:column;gap:6px;align-items:center;margin:4px 0;}
#nickWrap label,.nickWrap label{font-size:11px;color:#8fc08f;letter-spacing:1px;font-weight:800;}
#nickInput{width:min(260px,80vw);padding:12px 14px;font-size:15px;font-weight:700;
background:#0f1a0f;color:#fff;border:2px solid #2e4a2e;border-radius:12px;
outline:none;text-align:center;letter-spacing:1px;pointer-events:auto;}
#nickInput:focus{border-color:#5cc05c;box-shadow:0 0 12px rgba(90,200,90,.3);}
#langRow,#mapRow{display:flex;gap:8px;flex-wrap:wrap;justify-content:center;}
.chip{padding:8px 14px;border-radius:10px;background:#0f1a0f;border:2px solid #2e4a2e;
font-size:13px;font-weight:800;cursor:pointer;color:#cfe8cf;letter-spacing:.5px;pointer-events:auto;}
.chip.sel{background:#1e3a1e;border-color:#7fe07f;color:#fff;box-shadow:0 0 10px rgba(120,240,120,.35);}
.chip:active{transform:scale(.96);}
#skinBadge{font-size:10px;color:#8fb08f;background:rgba(0,0,0,.55);
padding:4px 12px;border-radius:8px;letter-spacing:1px;}
#langScreen h1{font-size:34px;font-weight:900;letter-spacing:3px;
background:linear-gradient(90deg,#7fe07f,#f7d94a,#7fe07f);
-webkit-background-clip:text;background-clip:text;color:transparent;}
#langScreen p{color:#9fc09f;font-size:13px;letter-spacing:1px;}
#authorScreen h1{font-size:26px;font-weight:900;letter-spacing:2px;color:#f7d94a;text-align:center;}
#authorBox{max-width:min(520px,92vw);display:flex;flex-direction:column;gap:16px;align-items:center;
background:rgba(15,26,15,.7);border:2px solid #3a5a3a;border-radius:16px;padding:20px 18px;}
#authorText{font-size:14px;line-height:1.65;color:#d8e8d8;text-align:center;letter-spacing:.3px;}
#skinsScreen{position:fixed;inset:0;z-index:110;display:flex;flex-direction:column;
background:linear-gradient(180deg,#0d160d,#081008);}
#skinsHeader{padding:calc(10px + env(safe-area-inset-top)) calc(14px + env(safe-area-inset-right)) 10px calc(14px + env(safe-area-inset-left));
display:flex;align-items:center;gap:12px;border-bottom:1px solid #1e321e;}
#backBtn{padding:9px 16px;border-radius:10px;background:#1e321e;border:1px solid #3a5a3a;
font-size:14px;font-weight:700;cursor:pointer;pointer-events:auto;}
#skinsHeader h2{font-size:15px;font-weight:800;letter-spacing:1px;flex:1;}
#skinsHeaderCoins{font-size:13px;font-weight:900;color:#ffe45e;background:rgba(0,0,0,.5);
padding:5px 10px;border-radius:8px;border:1px solid #7a6a1a;}
#skinsGrid{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;
padding:12px calc(12px + env(safe-area-inset-right)) calc(20px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));
display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;
touch-action:pan-y;}
.skinCard{background:#121d12;border:2px solid #24381f;border-radius:12px;padding:8px;
display:flex;flex-direction:column;gap:5px;cursor:pointer;transition:.15s;position:relative;}
.skinCard.sel{border-color:#7fe07f;box-shadow:0 0 14px rgba(120,240,120,.4);background:#182a18;}
.skinCard.locked{opacity:.78;}
.skinCard.locked::before{content:'🔒';position:absolute;top:4px;right:6px;font-size:18px;z-index:2;}
.skinCard.sel::after{content:'✓';position:absolute;top:4px;right:6px;color:#7fe07f;
font-size:18px;font-weight:900;z-index:3;}
.skinCard.sel.locked::before{display:none;}
.skinCard.cantafford{animation:cantflash .4s;}
@keyframes cantflash{0%,100%{border-color:#24381f}50%{border-color:#e04a3a}}
.skinPrev{height:52px;border-radius:9px;position:relative;overflow:hidden;}
.skinHead{position:absolute;width:30px;height:30px;border-radius:50%;
left:50%;top:50%;transform:translate(-50%,-50%);box-shadow:0 0 10px rgba(0,0,0,.6);}
.skinName{font-size:12px;font-weight:800;text-align:center;}
.skinPrice{font-size:12px;font-weight:900;text-align:center;color:#ffe45e;letter-spacing:.5px;}
.skinPrice.owned{color:#7fe07f;font-size:11px;}
.skinStats{font-size:9.5px;color:#8fb08f;text-align:center;line-height:1.4;}
.skinAb{font-size:10px;color:#d8c86a;text-align:center;font-weight:800;}
.skinDesc{font-size:9px;color:#a8c0a8;text-align:center;line-height:1.35;font-style:italic;}
.skinCard.legendary{border-color:#f7d94a;box-shadow:0 0 12px rgba(247,217,74,.35);}
#hud{position:fixed;inset:0;z-index:20;pointer-events:none;}
#topbar{position:absolute;top:0;left:0;right:0;
padding:calc(6px + env(safe-area-inset-top)) calc(6px + env(safe-area-inset-right)) 6px calc(6px + env(safe-area-inset-left));
display:flex;align-items:flex-start;gap:6px;}
#bars{display:flex;flex-direction:column;gap:4px;flex:1;max-width:190px;}
.bar{position:relative;height:14px;border-radius:8px;background:rgba(0,0,0,.55);
border:1px solid rgba(255,255,255,.16);overflow:hidden;}
.bar>i{position:absolute;left:0;top:0;bottom:0;width:100%;display:block;
transition:width .12s linear;border-radius:7px;}
#hpfill{background:linear-gradient(90deg,#e04a3a,#f7c948,#5fd85f);}
#shfill{background:linear-gradient(90deg,#2a7fd0,#63d8f0);}
#stfill{background:linear-gradient(90deg,#b0b0b0,#ffe45e,#f0a040);}
.bar>span{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
font-size:9px;font-weight:800;text-shadow:0 1px 2px #000;letter-spacing:.5px;}
#scorebox{font-size:11px;font-weight:800;background:rgba(0,0,0,.5);
padding:4px 10px;border-radius:9px;border:1px solid rgba(255,255,255,.14);
white-space:nowrap;}
#scorebox b{color:#8ef08e;font-size:14px;}
#menuBtnHud{width:34px;height:34px;border-radius:9px;background:rgba(0,0,0,.55);
border:1px solid rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;
font-size:18px;font-weight:900;pointer-events:auto;color:#dfe;flex-shrink:0;}
#lb{position:absolute;right:calc(6px + env(safe-area-inset-right));
top:calc(52px + env(safe-area-inset-top));
width:112px;background:rgba(4,12,4,.62);border:1px solid rgba(120,200,120,.22);
border-radius:9px;padding:4px 6px;font-size:9px;line-height:1.4;}
#lb .lbtitle{font-size:8.5px;font-weight:900;letter-spacing:1px;color:#8ee08e;
text-align:center;margin-bottom:2px;}
.lbrow{display:flex;justify-content:space-between;gap:3px;overflow:hidden;white-space:nowrap;}
.lbrow span:first-child{overflow:hidden;text-overflow:ellipsis;max-width:72px;}
.lbrow.me{color:#ffe45e;font-weight:800;}
.lbrow .sc{color:#9fd89f;}
#joyzone{position:absolute;left:0;bottom:0;width:52%;height:62%;
pointer-events:auto;touch-action:none;}
#joybase{position:absolute;width:108px;height:108px;margin-left:-54px;margin-top:-54px;
border-radius:50%;border:2px solid rgba(140,220,140,.35);
background:radial-gradient(circle,rgba(30,70,30,.45),rgba(10,25,10,.28));
opacity:.4;transition:opacity .15s;pointer-events:none;}
#joyknob{position:absolute;left:50%;top:50%;width:48px;height:48px;border-radius:50%;
background:radial-gradient(circle at 35% 30%,#a8efa8,#3d8a3d);
box-shadow:0 0 14px rgba(120,240,120,.4);transform:translate(-50%,-50%);
pointer-events:none;}
#btns{position:absolute;right:calc(12px + env(safe-area-inset-right));
bottom:calc(20px + env(safe-area-inset-bottom));
display:flex;flex-direction:column;gap:10px;pointer-events:none;align-items:flex-end;}
.abtn{width:64px;height:64px;border-radius:50%;pointer-events:auto;touch-action:none;
display:flex;align-items:center;justify-content:center;font-size:22px;
border:2px solid rgba(255,255,255,.28);position:relative;
background:radial-gradient(circle at 35% 30%,rgba(90,150,220,.75),rgba(20,45,90,.75));
box-shadow:0 3px 12px rgba(0,0,0,.5);transition:transform .06s;}
.abtn:active{transform:scale(.93);}
#btnAttack{background:radial-gradient(circle at 35% 30%,rgba(230,110,80,.8),rgba(110,25,15,.8));}
#btnSprint{background:radial-gradient(circle at 35% 30%,rgba(240,200,80,.8),rgba(120,80,10,.8));}
.abtn.disabled{opacity:.28;pointer-events:none;}
.abtn .cd{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
font-size:15px;font-weight:900;background:rgba(0,0,0,.62);border-radius:50%;
color:#ffd45e;text-shadow:0 1px 3px #000;}
.abtn .cd.hidden{display:none;}
.abtn .lbl{position:absolute;bottom:-14px;width:100%;text-align:center;font-size:8px;
font-weight:800;color:#cfe8cf;letter-spacing:.5px;text-shadow:0 1px 3px #000;white-space:nowrap;}
#deathScreen{position:fixed;inset:0;z-index:90;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:14px;background:rgba(20,0,0,.72);
backdrop-filter:blur(4px);padding:20px;}
#deathScreen h1{font-size:28px;font-weight:900;color:#ff6b5b;letter-spacing:2px;
text-shadow:0 0 24px rgba(255,80,60,.6);}
#deathInfo{font-size:13px;color:#e8c0b0;text-align:center;line-height:1.7;}
#deathInfo b{color:#ffe45e;font-size:19px;}
@media (orientation:landscape){
#topbar{padding-top:calc(4px + env(safe-area-inset-top));}
#lb{top:calc(42px + env(safe-area-inset-top));width:96px;font-size:8.5px;}
#bars{max-width:150px;}
.bar{height:12px;}
#joybase{width:92px;height:92px;margin-left:-46px;margin-top:-46px;}
#joyknob{width:42px;height:42px;}
#btns{bottom:calc(10px + env(safe-area-inset-bottom));gap:8px;}
.abtn{width:56px;height:56px;font-size:19px;}
.abtn .lbl{font-size:7.5px;bottom:-12px;}
#lb .lbrow span:first-child{max-width:58px;}
#title{font-size:26px;}
#menu,#langScreen,#authorScreen{gap:10px;}
.mbtn{padding:11px 0;font-size:15px;}
}
@media (max-width:380px){
#scorebox{font-size:10px;padding:3px 7px;}
#scorebox b{font-size:12px;}
}
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="hud" class="hidden">
<div id="topbar">
<div id="bars">
<div class="bar"><i id="hpfill"></i><span id="hptext">100</span></div>
<div class="bar"><i id="shfill"></i><span id="shtext">100</span></div>
<div class="bar"><i id="stfill"></i><span id="sttext">100</span></div>
</div>
<div id="scorebox">Score: <b id="scoreval">0</b></div>
<div id="menuBtnHud">≡</div>
</div>
<div id="lb"><div class="lbtitle" id="lbTitle">TOP 5</div><div id="lblist"></div></div>
<div id="joyzone"><div id="joybase"><div id="joyknob"></div></div></div>
<div id="btns">
<div class="abtn" id="btnShield">🛡<div class="lbl" id="shieldLbl">SHIELD</div></div>
<div class="abtn" id="btnSprint">💨<div class="lbl" id="sprintLbl">SPRINT</div></div>
<div class="abtn" id="btnAttack">⚔<div class="cd hidden" id="atkCd">0</div><div class="lbl" id="atkLbl">ATTACK</div></div>
</div>
</div>
<div id="langScreen" class="hidden">
<h1>SNAKE CLASH.IO</h1>
<p id="langPrompt">Choose your language · Выберите язык</p>
<div class="mbtn" id="langRu">🇷🇺 РУССКИЙ</div>
<div class="mbtn alt" id="langEn">🇬🇧 ENGLISH</div>
</div>
<div id="menu" class="hidden">
<div id="title">SNAKE CLASH.IO</div>
<div id="subtitle" data-i18n="subtitle">31 змея · арена на выживание</div>
<div id="coinBadge">🪙 <span id="coinVal">0</span></div>
<div id="nickWrap">
<label data-i18n="nickLabel">ТВОЙ НИК</label>
<input id="nickInput" type="text" maxlength="14" placeholder="—" autocomplete="off">
</div>
<div class="nickWrap" style="gap:8px">
<label data-i18n="mapLabel">КАРТА</label>
<div id="mapRow">
<div class="chip sel" data-map="jungle" id="mapJungle">🌴 <span data-i18n="mapJungle">Джунгли</span></div>
<div class="chip" data-map="winter" id="mapWinter">❄️ <span data-i18n="mapWinter">Зимняя</span></div>
</div>
</div>
<div class="mbtn" id="playBtn" data-i18n="play">ИГРАТЬ</div>
<div class="mbtn alt" id="skinsBtn" data-i18n="skins">СКИНЫ</div>
<div class="mbtn gold" id="authorBtn" data-i18n="authorBtn">💚 ПОМОЩЬ АВТОРУ</div>
<div class="mbtn small" id="langBtn" data-i18n="langBtn">🌐 СМЕНИТЬ ЯЗЫК</div>
<div id="skinBadge">—</div>
</div>
<div id="authorScreen" class="hidden">
<div id="authorBox">
<h1 id="authorTitle">💚 Помощь автору</h1>
<div id="authorText"></div>
<a href="https://dalink.to/snakeclashio" target="_blank" rel="noopener" class="mbtn gold" id="authorLink">💚 Поддержать</a>
<div class="mbtn alt" id="authorBackBtn" data-i18n="back">← НАЗАД</div>
</div>
</div>
<div id="skinsScreen" class="hidden">
<div id="skinsHeader">
<div id="backBtn" data-i18n="back">← НАЗАД</div>
<h2 id="skinsTitle">СКИНЫ</h2>
<div id="skinsHeaderCoins">🪙 <span id="skinsCoins">0</span></div>
</div>
<div id="skinsGrid"></div>
</div>
<div id="deathScreen" class="hidden">
<h1 id="deathTitle">ТЫ ПОГИБ</h1>
<div id="deathInfo"></div>
<div class="mbtn" id="respawnBtn" data-i18n="respawn">ВОЗРОДИТЬСЯ</div>
<div class="mbtn alt" id="toMenuBtn" data-i18n="toMenu">В МЕНЮ</div>
</div>
<script>
"use strict";
(function(){
/* ============================================================
SNAKE CLASH.IO — UPDATE v2.2
Changes:
- Полностью удалена система погоды (дождь/снег/звуки).
- Автосохранение монет/скинов мгновенное (saveProgress → doSaveProgress).
- Звуки атак у всех 31 змеи (уникальные + боты слышны рядом).
- Анкилозавр: звук punch (удар кулаком/пощёчина).
- Бургер: визуал под бургер (кунжут, салат, помидор, сыр).
- Пул ников используется полностью (без обрезки).
- Fix: handleCollisions учитывает invuln.
- Fix: killSnake защищён от nBody<=0 (NaN-еда).
- Fix: selectedSkin сохраняется вместе с монетами.
- Fix: respawn/teleport/burrow чистят нулевые сегменты пути.
============================================================ */
const I18N = {
ru:{
subtitle:'31 змея · арена на выживание',
nickLabel:'ТВОЙ НИК', mapLabel:'КАРТА', mapJungle:'Джунгли', mapWinter:'Зимняя',
play:'ИГРАТЬ', skins:'СКИНЫ', langBtn:'🌐 СМЕНИТЬ ЯЗЫК',
authorBtn:'💚 ПОМОЩЬ АВТОРУ',
back:'← НАЗАД', skinsTitle:'ВСЕ СКИНЫ', top:'ТОП 5',
respawn:'ВОЗРОДИТЬСЯ', toMenu:'В МЕНЮ',
death:'ТЫ ПОГИБ', score:'Длина', killer:'Убийца',
shield:'ЩИТ', attack:'АТАКА', sprint:'СПРИНТ', scoreHud:'Длина',
nickPlaceholder:'ВВЕДИ ИМЯ', nickDefault:'Игрок',
langPrompt:'Выберите язык · Choose your language',
authorTitle:'💚 Помощь автору',
authorText:'Мне 14, я в 8 классе. Мне надо готовиться к ВПР, постоянно делать уроки, и мой единственный помощник в создании игры — это ИИ. Если бы ты мог помочь финансовой суммой, я был бы очень рад — это даёт мне мотивацию, что кому-то реально нравится моя простая игра и кто-то реально ждёт обновлений. Я был бы рад даже 10 рублям.',
authorLink:'💚 Поддержать',
owned:'Куплено', buy:'Купить', notEnough:'Не хватает монет!'
},
en:{
subtitle:'31 snakes · survival arena',
nickLabel:'YOUR NICK', mapLabel:'MAP', mapJungle:'Jungle', mapWinter:'Winter',
play:'PLAY', skins:'SKINS', langBtn:'🌐 CHANGE LANGUAGE',
authorBtn:'💚 SUPPORT AUTHOR',
back:'← BACK', skinsTitle:'ALL SKINS', top:'TOP 5',
respawn:'RESPAWN', toMenu:'TO MENU',
death:'YOU DIED', score:'Length', killer:'Killer',
shield:'SHIELD', attack:'ATTACK', sprint:'SPRINT', scoreHud:'Length',
nickPlaceholder:'ENTER NAME', nickDefault:'Player',
langPrompt:'Choose your language',
authorTitle:'💚 Support Author',
authorText:"I'm 14, in 8th grade. I have exams to prepare for, homework all the time, and my only helper in making games is AI. If you could help with a small donation, I'd be super happy — it gives me motivation that someone really likes my simple game. Even 10 rubles would make my day.",
authorLink:'💚 Support',
owned:'Owned', buy:'Buy', notEnough:'Not enough coins!'
}
};
let LANG = 'ru';
function t(k){ return (I18N[LANG] && I18N[LANG][k]) || k; }
const NICKS_RU = ["Тень","Волк","ЗмейГорыныч","Патсан","Братан","КорольДвора","СерыйВолк","КрасныйЗмей","ЧёрныйКот","БелыйТигр","ЗолотойДракон","СеребряныйЗмей","Влад_А4","МаксОН","ЛёхаТоп","ГлебПро","СтёпаГГ","БорисКилл","ОлегВин","ИванYT","Пётр777","ФёдорX","Димасик","Ярик","Тимоха","Мишаня","Пашка","Костян","Саня","Никитос","КириллУТ","АртёмГГ","ЕгорBest","Рома_777","ЖеняПро","ИльяТоп","АндрейВин","Серёга","Данила","Денчик","ЛёхаЮТ","ТохаПро","ВоваПро","Костик","Славик","Гриша","Валера","Стас","ЕгорПро","НикитаТоп","Максон","Даниил","Игорёк","Серж","Ден","СлавикПРО","Шкибиди","СигмаБой","СкибидиТуалет","Риззлорд","ГошаМогучий","БогданДруг","Нагибатор","Хацкер","Терминатор","КрутойПерец","МегаБосс","СуперКиллер","Псих","Маньяк","Безумный","Дикий","Злой","Хитрый","Ловкий","Быстрый","Молниеносный","Тихий","Громкий","Тёмный","Светлый","Огненный","Ледяной","Громовой","Змеиный","ЗмеиныйКороль","ЗмейБог","ЗмейЛорд","ЗмейБосс","ЗмейПро","КобраКинг","ПитонПро","УдавГГ","МамбаТоп","ГадюкаПро","Анаконда","ЧернаяМамба","БелаяКобра","КрасныйПитон","СинийУдав","ЗеленыйЗмей","ЖелтаяГадюка","ОгненнаяМамба","ЛедянойПитон","ГромовойУдав","ПустотныйЗмей","Космический","Звёздный","Галактический","ТёмнаяМатерия","ЧёрнаяДыра","Сверхновая","Метеор","Астероид","Комета","Планета","Вселенная","Бесконечность","ВечныйЗмей","Бессмертный","Нежить","Призрак","Фантом","Дух","Демон","Ангел","Дьявол","БогЗмей","ЦарьЗмей","Император","Король","Принц","Барон","Граф","Герцог","Рыцарь","Воин","Маг","Волшебник","Чародей","Колдун","Шаман","Друид","Варвар","Паладин","Рейнджер","Ассасин","Разбойник","Пират","Ниндзя","Самурай","Викинг","Спартанец","Гладиатор","Легионер","Титан","Гигант","Колосс","Атлант","Геркулес","Зевс","Посейдон","Аид","Арес","Аполлон","Гермес","Гефест","Дионис","Пан","Хаос","Тартар","Олимп","Кронос","Уран","Атлас","Прометей","Эпиметей","Тифон","Цербер","Химера","Горгона","Медуза","Циклоп","Минотавр","Кентавр","Сатир","Нимфа","Сирена","Харибда","Сцилла","Гарпия","Феникс","Грифон","Дракон","Виверна","Василиск","Кракен","Левиафан","Бегемот"];
const NICKS_EN = ["ShadowWolf","DarkSlayer","ProGamer2010","xX_Killer_Xx","SnakeMaster","DragonFire","NightHunter","GhostRider","IronFist","StormBreaker","FrostBite","VenomStrike","ThunderBolt","BlazeKing","ShadowNinja","SkullCrusher","DarkPhoenix","SilverArrow","RedViper","BlackMamba","CrazyFrog","MegaTron","UltimaWolf","DeadPool2011","SuperSnake","GamerPro","NoobSlayer","HeadShot","SniperKing","KillerBee","WildCat","AlphaWolf","LoneWolf","BloodMoon","DeathWish","NightFury","FireStorm","IceDragon","SkyWalker","EarthShaker","GreenAnaconda","CobraKing","PythonPro","Rattlesnake","BoaConstrictor","ToxicSlime","AcidBurn","PoisonIvy","Venomous","ToxicKing","xXx_Dark_xXx","_Pro_Player_","Mr_Snake","TheBeast","MonsterKill","RageQuit","LuckyShot","FastAndDead","SpeedDemon","TurboSnake","KingOfSnakes","QueenBee","LordVader","DarkKnight","WhiteWolf","GreyFox","RedDragon","BlueThunder","GreenGiant","PurpleHaze","Sasha2010","Dima_Pro","Maximka","VDanyaTop","Kirill_YT","ArtemGG","Nikitos","Vladislav","EgorBest","Romaragon_777","Zhenya_Pro","Ilya_TOPRed","Andrey_Win","DSergey_Kill","Pasha_2009","Kostya_GG","Denchik","Sanek_777","Lyoha_YT","Toha_Pro","ShadowKiller","DarkHunter","NightStalker","GhostWolf","PhantomLord","DemonSlayer","AngelOfDeath","DevilInside","HeavenFall","HellFire","SupaHot","MinecraftPro","FortniteKing","CSGO_Pro","RobloxGamer","BrawlStars","PubgMobile","FreeFire","GenshinPro","DotaLord","MrNobody","UnknownSoldier","SilentDeath","QuietStorm","LoudNoise","BigBoss","LittleDevil","CrazyWolf","MadDog","WildBoar","SnakeEater","SnakeHunter","SnakeKing","SnakeGod","SnakeLord","SnakeBoss","SnakePro","SnakeYT","SnakeX","GoldenSnake","SilverSnake","DiamondSnake","RubySnake","EmeraldSnake","CrystalSnake","Titanium","Platinum","Obsidian","Netherite","FireDragon","IceDragon","EarthDragon","WindDragon","WaterDragon","ThunderDragon","ShadowDragon","LightDragon","ChaosDragon","VoidDragon","xX_Shadow_Xx","xX_Dark_Xx","xX_Pro_Xx","xX_King_Xx","xX_Boss_Xx","xX_God_Xx","xX_Snake_Xx","xX_Wolf_Xx","xX_Kill_Xx","xX_YT_Xx","MegaKiller","UltraPro","SuperBoss","HyperWolf","GigaSnake","NanoBot","CyberSnake","RoboWolf","TechnoKing","DigitalGod","AnimeLover","MangaFan","NarutoFan","GokuSSJ","LuffyKing","ZoroPro","SasukeUchiha","ItachiSan","LeviAckerman","ErenYeager","BulletProof","IronMan2010","SpiderMan","BatmanPro","Superman","HulkSmash","ThorHammer","LokiTrick","DeadpoolX","Wolverine","PandaKing","TigerClaw","LionHeart","BearStrength","EagleEye","FalconWing","HawkEye","SharkBite","Crocodile","Alligator","MrBeastFan","SigmaBoy","SkibidiKing","RizzLord","GyattMaster","OhayoOni","FanumTax","SusAmogus","BabyShark","GojoSatoru","NekitPro","Vovchik","Serega","Dimasik","Yarik","Timoha","Mishanya","Pashka","Kostyan","Sanya","ShadowYT","DarkYT","ProYT","KingYT","BossYT","SnakeYT","WolfYT","KillerYT","GamerYT","NoobYT","MegaSnake228","Killer228","Pro228","Boss228","Wolf228","Dark228","Shadow228","King228","YT228","GG228","RainbowSnake","NeonSnake","GlowSnake","FireSnake","IceSnake","StormSnake","ThunderSnake","LightningSnake","BlizzardSnake","InfernoSnake","BlackDragon","WhiteDragonX","BlueDragon","GreenDragon","GoldDragon","SilverDragon","PurpleDragon","CrimsonDragon","AzureDragon","SeaSerpent","SerpentKing","FrostGiant","FireGiant","StormLord","FrostLord","FlameLord","ShadowLord","InfinityPro","EternalSnake","ImmortalKing","UndeadWolf","ZombiePro","SkeletonKing","GhostKing","PhantomX","WraithLord","Reaper","SonicBoom","FlashSpeed","QuickSilver","RapidFire","SwiftSnake","FastWolf","SpeedyGonzo","TurboKing","NitroBoost","HyperSpeed","MegaBoss","GigaChad","AlphaMale","SigmaWolf","OmegaSnake","DeltaForce","BetaTester","GammaRay","ZetaPro","ThetaKing","MrSnake2010","MrsSnake","DrSnake","ProfSnake","SirSnake","LadySnake","PrinceSnake","DukeSnake","BaronSnake","CraftMaster","BuildKing","MinePro","BlockBuster","PixelGamer","RetroBoy","ArcadeKing","GameOver","RespawnGod","Checkpoint","NinjaKing","SamuraiX","VikingPro","SpartanGG","Gladiator","LegionX","TitanLord","ColossusX","AtlasKing","ZeusBolt","PoseidonX","HadesKing","AresGod","ApolloX","HermesRun","Hephaestus","Dionysus","ChaosLord","TartarusX","OlympusX","KronosX","UranusX","PrometheusX","CerberusX","ChimeraX","GorgonX","MedusaX","CyclopsX","MinotaurX","CentaurX","SatyrX","NymphX","SirenX","Charybdis","ScyllaX","HarpyX","PhoenixX","GriffinX","WyvernX","BasiliskX","KrakenX","LeviathanX","BehemothX"];
/* ============ SKINS (each has unique attack sound) ============ */
const SKINS = [
{ n:{ru:'Дракон',en:'Dragon'}, b:'#c0392b', b2:'#3a0a04', p:'#f7b733', h:'#e8543f',
head:{shape:'long',eyes:'angry',eyeColor:'#ffee55',pupilColor:'#000',pupil:'slit',horns:4,teeth:'fangs'},
sideSpikes:{count:3,style:'sharp',len:1.0}, tail:'spikes', crest:'ridge',
scale:'diamond', ab:'fire', abn:{ru:'Огненное дыхание',en:'Fire Breath'},
desc:{ru:'Конус огня 2.6с, поджигает (3-6с).',en:'Fire cone 2.6s, burns (3-6s).'},
sound:'fire', hp:130, sh:70, sp:1.00, atk:1.20, cd:6.0, abDur:2.6, shieldDur:16, shieldRes:1.0,
maxS:90, sprintSpd:2.0, drain:35, regen:16, price:545 },
{ n:{ru:'Анкилозавр',en:'Ankylosaurus'}, b:'#6b7a4a', b2:'#1f2410', p:'#a8a06a', h:'#8a9a5a',
head:{shape:'broad',eyes:'small',eyeColor:'#c8e050',pupilColor:'#000',pupil:'round',horns:'curve',teeth:'rows'},
sideSpikes:{count:5,style:'round',len:0.9}, tail:'club', crest:'plate',
scale:'armor', ab:'tail', abn:{ru:'Удар булавой',en:'Tail Smash'},
desc:{ru:'Хвост всегда бьёт сзади, где булава.',en:'Tail always strikes behind where the club is.'},
sound:'punch', hp:175, sh:150, sp:0.90, atk:1.05, cd:5.0, abDur:0.8, shieldDur:9, shieldRes:0.6,
maxS:130, sprintSpd:1.7, drain:28, regen:22, price:285 },
{ n:{ru:'Гадюка',en:'Viper'}, b:'#4a6b3a', b2:'#152208', p:'#8fbf4a', h:'#5c8248',
head:{shape:'flat',eyes:'slit',eyeColor:'#e0ff50',pupilColor:'#000',pupil:'slit',horns:0,teeth:'fangs'},
sideSpikes:{count:0,style:'sharp',len:0}, tail:'plain', crest:'none',
scale:'spots', ab:'vdash', abn:{ru:'Ядовитый рывок',en:'Venom Dash'},
desc:{ru:'Рывок + ядовитый след.',en:'Dash + poison trail.'},
sound:'venom', hp:110, sh:60, sp:1.05, atk:1.10, cd:3.4, abDur:1.6, shieldDur:18, shieldRes:1.2,
maxS:100, sprintSpd:2.3, drain:40, regen:14, price:150 },
{ n:{ru:'Кобра',en:'Cobra'}, b:'#8a6d3b', b2:'#3a2a10', p:'#d9b36c', h:'#a88549',
head:{shape:'long',eyes:'angry',eyeColor:'#ff5533',pupilColor:'#000',pupil:'slit',horns:0,teeth:'fangs',frill:'hood'},
sideSpikes:{count:0,style:'sharp',len:0}, tail:'plain', crest:'hood',
scale:'stripes', ab:'spit', abn:{ru:'Плевок ядом',en:'Venom Spit'},
desc:{ru:'Снаряд яда, замедляет.',en:'Venom projectile, slows.'},
sound:'spit', hp:105, sh:55, sp:1.05, atk:1.15, cd:2.8, abDur:1.6, shieldDur:22, shieldRes:1.3,
maxS:80, sprintSpd:2.4, drain:42, regen:13, price:300 },
{ n:{ru:'Питон',en:'Python'}, b:'#7a6a4a', b2:'#2a2210', p:'#b09a70', h:'#93815b',
head:{shape:'broad',eyes:'slit',eyeColor:'#a0e050',pupilColor:'#000',pupil:'slit',horns:0,teeth:'rows'},
sideSpikes:{count:2,style:'round',len:0.5}, tail:'whip', crest:'none',
scale:'diamond', ab:'constrict', abn:{ru:'Сжатие',en:'Constrict'},
desc:{ru:'Тянет врагов, урон по времени.',en:'Pulls enemies, deals DoT.'},
sound:'hiss', hp:150, sh:90, sp:0.92, atk:1.00, cd:6.5, abDur:2.4, shieldDur:12, shieldRes:0.85,
maxS:140, sprintSpd:1.6, drain:30, regen:24, price:255 },
{ n:{ru:'Гремучник',en:'Rattlesnake'}, b:'#9a8a5a', b2:'#3a3018', p:'#e0d090', h:'#b3a26c',
head:{shape:'flat',eyes:'small',eyeColor:'#ffe050',pupilColor:'#000',pupil:'slit',horns:0,teeth:'fangs'},
sideSpikes:{count:3,style:'round',len:0.6}, tail:'spikes', crest:'none',
scale:'armor', ab:'shock', abn:{ru:'Ударная волна',en:'Shockwave'},
desc:{ru:'Взрыв вокруг + замедление.',en:'AoE blast + slow.'},
sound:'rattle', hp:118, sh:65, sp:1.00, atk:1.15, cd:5.0, abDur:1.0, shieldDur:20, shieldRes:1.0,
maxS:110, sprintSpd:2.0, drain:32, regen:18, price:320 },
{ n:{ru:'Мамба',en:'Mamba'}, b:'#2e5a2e', b2:'#061206', p:'#7fd97f', h:'#3d7a3d',
head:{shape:'pointed',eyes:'slit',eyeColor:'#c8ff50',pupilColor:'#000',pupil:'slit',horns:0,teeth:'fangs'},
sideSpikes:{count:0,style:'sharp',len:0}, tail:'plain', crest:'none',
scale:'belly', ab:'sprint', abn:{ru:'Ускорение',en:'Speed Burst'},
desc:{ru:'Сверхбыстрый рывок 1.8с.',en:'Ultra-fast dash 1.8s.'},
sound:'whoosh', hp:100, sh:50, sp:1.18, atk:1.15, cd:3.5, abDur:1.8, shieldDur:24, shieldRes:1.4,
maxS:70, sprintSpd:2.7, drain:50, regen:11, price:360 },
{ n:{ru:'Василиск',en:'Basilisk'}, b:'#3a6b6b', b2:'#0a1e1e', p:'#7fd9d9', h:'#4e8a8a',
head:{shape:'long',eyes:'glow',eyeColor:'#7fffff',pupilColor:'#003030',pupil:'slit',horns:'curve',teeth:'rows'},
sideSpikes:{count:4,style:'blade',len:0.8}, tail:'blade', crest:'ridge',
scale:'glow', ab:'freeze', abn:{ru:'Окаменение',en:'Petrify'},
desc:{ru:'Ледяное поле 3с, сильное замедление.',en:'Ice field 3s, heavy slow.'},
sound:'ice', hp:120, sh:75, sp:0.98, atk:1.00, cd:7.0, abDur:3.0, shieldDur:16, shieldRes:0.9,
maxS:95, sprintSpd:1.9, drain:30, regen:19, price:440 },
{ n:{ru:'Гидра',en:'Hydra'}, b:'#4a3a6b', b2:'#150a2a', p:'#a97fd9', h:'#634d8a',
head:{shape:'broad',eyes:'multiple',eyeColor:'#ff33aa',pupilColor:'#000',pupil:'round',horns:4,teeth:'fangs'},
sideSpikes:{count:4,style:'sharp',len:0.9}, tail:'spikes', crest:'none',
scale:'spots', ab:'triple', abn:{ru:'Тройной плевок',en:'Triple Spit'},
desc:{ru:'Три снаряда веером с ядом.',en:'Three venom projectiles.'},
sound:'spit', hp:125, sh:70, sp:0.98, atk:1.20, cd:4.5, abDur:1.8, shieldDur:18, shieldRes:1.1,
maxS:100, sprintSpd:2.0, drain:36, regen:16, price:460 },
{ n:{ru:'Левиафан',en:'Leviathan'}, b:'#1f4a6b', b2:'#04101c', p:'#5fa8d9', h:'#2d6288',
head:{shape:'pointed',eyes:'glow',eyeColor:'#5fd8ff',pupilColor:'#001820',pupil:'slit',horns:'curve',teeth:'rows'},
sideSpikes:{count:5,style:'blade',len:1.0}, tail:'fan', crest:'ridge',
scale:'glow', ab:'wave', abn:{ru:'Волна',en:'Tsunami Wave'},
desc:{ru:'Мощная отбрасывающая волна.',en:'Powerful knockback wave.'},
sound:'splash', hp:160, sh:100, sp:0.88, atk:1.05, cd:6.0, abDur:1.4, shieldDur:11, shieldRes:0.75,
maxS:150, sprintSpd:1.55, drain:30, regen:26, price:570 },
{ n:{ru:'Виверна',en:'Wyvern'}, b:'#a83a2a', b2:'#3d0e08', p:'#f0a050', h:'#c95236',
head:{shape:'long',eyes:'angry',eyeColor:'#ffcc00',pupilColor:'#000',pupil:'slit',horns:2,teeth:'fangs'},
sideSpikes:{count:2,style:'blade',len:1.1}, tail:'blade', crest:'none',
scale:'diamond', ab:'fireball', abn:{ru:'Огненный шар',en:'Fireball'},
desc:{ru:'Снаряд с взрывом по площади.',en:'AoE explosive projectile.'},
sound:'fire', hp:120, sh:65, sp:1.02, atk:1.20, cd:4.2, abDur:2.0, shieldDur:18, shieldRes:1.05,
maxS:90, sprintSpd:2.1, drain:38, regen:15, price:500 },
{ n:{ru:'Крайт',en:'Krait'}, b:'#2a2a5a', b2:'#0a0a20', p:'#6a6ad9', h:'#3d3d7a',
head:{shape:'flat',eyes:'slit',eyeColor:'#dadaff',pupilColor:'#000',pupil:'slit',horns:0,teeth:'fangs'},
sideSpikes:{count:0,style:'sharp',len:0}, tail:'whip', crest:'none',
scale:'stripes', ab:'trail', abn:{ru:'Ядовитый след',en:'Poison Trail'},
desc:{ru:'Оставляет долгое облако яда.',en:'Leaves lingering poison.'},
sound:'venom', hp:110, sh:60, sp:1.03, atk:1.00, cd:5.5, abDur:5.0, shieldDur:20, shieldRes:1.1,
maxS:105, sprintSpd:2.15, drain:34, regen:17, price:180 },
{ n:{ru:'Удав',en:'Boa'}, b:'#5a4a3a', b2:'#1f1810', p:'#8a7a5a', h:'#73604b',
head:{shape:'broad',eyes:'sleepy',eyeColor:'#c8a060',pupilColor:'#000',pupil:'slit',horns:0,teeth:'rows'},
sideSpikes:{count:0,style:'round',len:0}, tail:'whip', crest:'none',
scale:'belly', ab:'crush', abn:{ru:'Удушение',en:'Crush'},
desc:{ru:'Мощное притяжение + сильный DoT.',en:'Strong pull + heavy DoT.'},
sound:'crush', hp:155, sh:95, sp:0.90, atk:0.95, cd:7.0, abDur:2.6, shieldDur:12, shieldRes:0.8,
maxS:145, sprintSpd:1.6, drain:28, regen:25, price:225 },
{ n:{ru:'Коралловый',en:'Coral'}, b:'#c0392b', b2:'#3a0d06', p:'#f0e050', h:'#d9503a',
head:{shape:'flat',eyes:'crazy',eyeColor:'#ffee00',pupilColor:'#000',pupil:'star',horns:0,teeth:'rows'},
sideSpikes:{count:4,style:'round',len:0.6}, tail:'fan', crest:'plate',
scale:'stripes', ab:'confuse', abn:{ru:'Дезориентация',en:'Confusion'},
desc:{ru:'Поле дезориентации 3.5с.',en:'Confusion field 3.5s.'},
sound:'hiss', hp:108, sh:60, sp:1.05, atk:0.95, cd:7.5, abDur:3.5, shieldDur:20, shieldRes:1.15,
maxS:100, sprintSpd:2.05, drain:34, regen:18, price:165 },
{ n:{ru:'Тайпан',en:'Taipan'}, b:'#3a3a2a', b2:'#0a0a05', p:'#e0e050', h:'#54543c',
head:{shape:'pointed',eyes:'glow',eyeColor:'#ffff80',pupilColor:'#000',pupil:'slit',horns:0,teeth:'fangs'},
sideSpikes:{count:2,style:'sharp',len:0.7}, tail:'blade', crest:'none',
scale:'belly', ab:'lightning', abn:{ru:'Молния',en:'Lightning'},
desc:{ru:'Цепь молний по 4 врагам.',en:'Chain lightning to 4.'},
sound:'zap', hp:110, sh:55, sp:1.08, atk:1.30, cd:4.8, abDur:0.4, shieldDur:22, shieldRes:1.3,
maxS:80, sprintSpd:2.4, drain:45, regen:12, price:600 },
{ n:{ru:'Медноголовый',en:'Copperhead'}, b:'#8a5a3a', b2:'#2e1810', p:'#d9a070', h:'#a87049',
head:{shape:'broad',eyes:'slit',eyeColor:'#e0c060',pupilColor:'#000',pupil:'slit',horns:'curve',teeth:'fangs'},
sideSpikes:{count:2,style:'round',len:0.55}, tail:'plain', crest:'none',
scale:'spots', ab:'poison', abn:{ru:'Облако яда',en:'Poison Cloud'},
desc:{ru:'Стоячее облако яда 5с.',en:'Standing poison cloud 5s.'},
sound:'venom', hp:115, sh:62, sp:1.00, atk:1.10, cd:6.0, abDur:5.0, shieldDur:18, shieldRes:1.05,
maxS:95, sprintSpd:2.0, drain:34, regen:17, price:270 },
{ n:{ru:'Боковой',en:'Sidewinder'}, b:'#b08040', b2:'#3d2410', p:'#f0d090', h:'#cc9a52',
head:{shape:'flat',eyes:'small',eyeColor:'#ffdd44',pupilColor:'#000',pupil:'round',horns:0,teeth:'rows'},
sideSpikes:{count:3,style:'round',len:0.5}, tail:'fan', crest:'none',
scale:'stripes', ab:'zigzag', abn:{ru:'Зигзаг',en:'Zigzag'},
desc:{ru:'Много зигзагов, малый откат.',en:'Fast zigzag dash.'},
sound:'whoosh', hp:105, sh:58, sp:1.12, atk:1.15, cd:2.8, abDur:2.0, shieldDur:22, shieldRes:1.35,
maxS:115, sprintSpd:2.25, drain:36, regen:20, price:195 },
{ n:{ru:'Морской змей',en:'Sea Serpent'}, b:'#1a6b6b', b2:'#021414', p:'#50d0c0', h:'#268f8f',
head:{shape:'long',eyes:'glow',eyeColor:'#40ffd0',pupilColor:'#001820',pupil:'slit',horns:0,teeth:'fangs'},
sideSpikes:{count:4,style:'blade',len:0.85}, tail:'fan', crest:'ridge',
scale:'glow', ab:'maelstrom', abn:{ru:'Прилив',en:'Maelstrom'},
desc:{ru:'Водоворот тянет врагов внутрь.',en:'Vortex pulls enemies in.'},
sound:'splash', hp:140, sh:85, sp:0.95, atk:1.05, cd:6.5, abDur:2.5, shieldDur:14, shieldRes:0.9,
maxS:125, sprintSpd:1.85, drain:30, regen:21, price:520 },
{ n:{ru:'Громовой',en:'Thunder'}, b:'#4a4a7a', b2:'#101026', p:'#e0e0ff', h:'#6666a3',
head:{shape:'pointed',eyes:'glow',eyeColor:'#e0e0ff',pupilColor:'#000060',pupil:'slit',horns:2,teeth:'fangs'},
sideSpikes:{count:3,style:'blade',len:0.9}, tail:'spikes', crest:'ridge',
scale:'glow', ab:'thunder', abn:{ru:'Гром',en:'Thunder Strike'},
desc:{ru:'Сразу 5 молний по врагам.',en:'5 lightning bolts on enemies.'},
sound:'thunder', hp:125, sh:70, sp:1.00, atk:1.25, cd:5.0, abDur:0.5, shieldDur:16, shieldRes:1.0,
maxS:105, sprintSpd:2.1, drain:38, regen:15, price:650 },
{ n:{ru:'Пустотный',en:'Void'}, b:'#1a1a2a', b2:'#000000', p:'#a050f0', h:'#2e2e4a',
head:{shape:'skull',eyes:'glow',eyeColor:'#c070ff',pupilColor:'#000000',pupil:'star',horns:'curve',teeth:'fangs'},
sideSpikes:{count:5,style:'sharp',len:1.0}, tail:'blade', crest:'none',
scale:'glow', ab:'teleport', abn:{ru:'Прыжок в пустоту',en:'Void Jump'},
desc:{ru:'Телепорт + имплозия на выходе.',en:'Teleport + implosion.'},
sound:'teleport', hp:115, sh:65, sp:1.05, atk:1.25, cd:5.5, abDur:1.2, shieldDur:18, shieldRes:1.0,
maxS:100, sprintSpd:2.15, drain:40, regen:14, price:770 },
{ n:{ru:'Скорпион',en:'Scorpion'}, b:'#5a3a1a', b2:'#1a0e04', p:'#f0c060', h:'#7a5030',
head:{shape:'broad',eyes:'angry',eyeColor:'#ff8800',pupilColor:'#000',pupil:'slit',horns:2,teeth:'fangs'},
sideSpikes:{count:6,style:'sharp',len:0.7}, tail:'blade', crest:'plate',
scale:'armor', ab:'sting', abn:{ru:'Жало',en:'Sting'},
desc:{ru:'Дальний удар жалом (конус).',en:'Long tail sting (cone).'},
sound:'sting', hp:120, sh:75, sp:1.00, atk:1.20, cd:3.8, abDur:0.6, shieldDur:17, shieldRes:1.0,
maxS:100, sprintSpd:2.05, drain:36, regen:17, price:400 },
{ n:{ru:'Скунс',en:'Skunk'}, b:'#2a2a2a', b2:'#000000', p:'#f0f0f0', h:'#3a3a3a',
head:{shape:'round',eyes:'small',eyeColor:'#ffee55',pupilColor:'#000',pupil:'round',horns:0,teeth:'none'},
sideSpikes:{count:0,style:'sharp',len:0}, tail:'fan', crest:'none',
scale:'stripes', ab:'stink', abn:{ru:'Вонь',en:'Stink Cloud'},
desc:{ru:'Облако вони: замедление + дезориентация.',en:'Stink: slow + confuse.'},
sound:'puff', hp:115, sh:65, sp:1.02, atk:0.95, cd:6.0, abDur:4.0, shieldDur:18, shieldRes:1.1,
maxS:110, sprintSpd:2.1, drain:32, regen:20, price:210 },
{ n:{ru:'Дикобраз',en:'Porcupine'}, b:'#6b5030', b2:'#20180c', p:'#f0e0c0', h:'#8a6a42',
head:{shape:'round',eyes:'small',eyeColor:'#e0c080',pupilColor:'#000',pupil:'round',horns:0,teeth:'rows'},
sideSpikes:{count:8,style:'sharp',len:0.7}, tail:'spikes', crest:'none',
scale:'armor', ab:'spikeburst', abn:{ru:'Взрыв игл',en:'Spike Burst'},
desc:{ru:'Взрыв игл вокруг + отброс.',en:'Spike burst + knockback.'},
sound:'stab', hp:145, sh:100, sp:0.94, atk:1.15, cd:5.5, abDur:0.5, shieldDur:11, shieldRes:0.7,
maxS:140, sprintSpd:1.7, drain:26, regen:24, price:340 },
{ n:{ru:'Электроугорь',en:'Electric Eel'}, b:'#1a3a4a', b2:'#02101a', p:'#5fd0ff', h:'#2a5a70',
head:{shape:'long',eyes:'glow',eyeColor:'#a0f0ff',pupilColor:'#000820',pupil:'slit',horns:0,teeth:'rows'},
sideSpikes:{count:0,style:'sharp',len:0}, tail:'whip', crest:'ridge',
scale:'glow', ab:'efield', abn:{ru:'Электрополе',en:'Electric Field'},
desc:{ru:'Электрополе вокруг, DoT + замедление.',en:'Field around, DoT + slow.'},
sound:'zap', hp:125, sh:70, sp:1.02, atk:1.10, cd:5.0, abDur:2.5, shieldDur:15, shieldRes:0.95,
maxS:105, sprintSpd:2.1, drain:36, regen:17, price:420 },
{ n:{ru:'Огненный муравей',en:'Fire Ant'}, b:'#7a1a0a', b2:'#2a0602', p:'#ff8030', h:'#a02a10',
head:{shape:'round',eyes:'angry',eyeColor:'#ffcc00',pupilColor:'#000',pupil:'round',horns:2,teeth:'fangs'},
sideSpikes:{count:4,style:'sharp',len:0.5}, tail:'spikes', crest:'none',
scale:'armor', ab:'acid', abn:{ru:'Кислота',en:'Acid Spray'},
desc:{ru:'Кислотный конус, поджигает.',en:'Acid cone, ignites.'},
sound:'acid', hp:110, sh:60, sp:1.10, atk:1.20, cd:4.0, abDur:2.0, shieldDur:20, shieldRes:1.2,
maxS:90, sprintSpd:2.35, drain:44, regen:13, price:380 },
{ n:{ru:'Ледяной червь',en:'Ice Worm'}, b:'#8fbfd9', b2:'#0a2030', p:'#e0f8ff', h:'#a8d0e0',
head:{shape:'round',eyes:'sleepy',eyeColor:'#a0e8ff',pupilColor:'#002030',pupil:'round',horns:0,teeth:'rows'},
sideSpikes:{count:2,style:'blade',len:0.6}, tail:'blade', crest:'none',
scale:'glow', ab:'icetrail', abn:{ru:'Ледяной след',en:'Ice Trail'},
desc:{ru:'Оставляет ледяной след (slow).',en:'Leaves ice trail (slow).'},
sound:'ice', hp:135, sh:80, sp:0.98, atk:1.05, cd:5.5, abDur:4.5, shieldDur:16, shieldRes:1.0,
maxS:125, sprintSpd:1.85, drain:30, regen:22, price:240 },
{ n:{ru:'Песчаный червь',en:'Sandworm'}, b:'#b09060', b2:'#3a2a14', p:'#f0d8a8', h:'#d0b080',
head:{shape:'round',eyes:'multiple',eyeColor:'#a06020',pupilColor:'#000',pupil:'round',horns:0,teeth:'rows'},
sideSpikes:{count:6,style:'round',len:0.5}, tail:'spikes', crest:'none',
scale:'belly', ab:'burrow', abn:{ru:'Норка',en:'Burrow'},
desc:{ru:'Уход под землю + мощный удар.',en:'Burrow + powerful strike.'},
sound:'burrow', hp:160, sh:95, sp:0.92, atk:1.10, cd:6.0, abDur:2.0, shieldDur:12, shieldRes:0.85,
maxS:140, sprintSpd:1.7, drain:28, regen:24, price:480 },
{ n:{ru:'Феникс',en:'Phoenix'}, b:'#c04020', b2:'#3a0a04', p:'#ffd050', h:'#f07030',
head:{shape:'pointed',eyes:'glow',eyeColor:'#ffcc00',pupilColor:'#800000',pupil:'star',horns:'curve',teeth:'fangs'},
sideSpikes:{count:4,style:'blade',len:1.1}, tail:'fan', crest:'ridge',
scale:'glow', ab:'rebirth', abn:{ru:'Возрождение',en:'Rebirth'},
desc:{ru:'Лечение + усиленный щит + огонь вокруг.',en:'Heal + shield + fire ring.'},
sound:'fire', hp:120, sh:80, sp:1.05, atk:1.10, cd:8.0, abDur:3.0, shieldDur:14, shieldRes:0.9,
maxS:115, sprintSpd:2.1, drain:34, regen:18, price:0 },
{ n:{ru:'Богдан',en:'Bogdan'}, b:'#7fd97f', b2:'#1a3a1a', p:'#d0ffd0', h:'#a8efa8',
head:{shape:'round',eyes:'heart',eyeColor:'#ff5588',pupilColor:'#000',pupil:'round',horns:0,teeth:'none'},
sideSpikes:{count:0,style:'sharp',len:0}, tail:'plain', crest:'none',
scale:'smooth', ab:'none', abn:{ru:'Нет атаки',en:'No attack'},
desc:{ru:'Друг разработчика. Растёт быстрее всех от еды!',en:"Dev's friend. Grows fastest from food!"},
sound:'none', hp:200, sh:120, sp:1.00, atk:0, cd:99, abDur:0, shieldDur:14, shieldRes:1.0,
maxS:160, sprintSpd:1.7, drain:22, regen:26, noAttack:true, growMul:2.5, price:0 },
{ n:{ru:'Бургер',en:'Burger'}, b:'#c88040', b2:'#3a1c08', p:'#e8b060', h:'#e0a050',
head:{shape:'round',eyes:'sleepy',eyeColor:'#3a1c08',pupilColor:'#000',pupil:'round',horns:0,teeth:'none'},
sideSpikes:{count:0,style:'sharp',len:0}, tail:'plain', crest:'none',
scale:'armor', ab:'none', abn:{ru:'Нет атаки',en:'No attack'},
desc:{ru:'Друг Богдана. Из него выпадает куча еды!',en:"Bogdan's friend. Drops lots of food!"},
sound:'none', hp:180, sh:100, sp:0.95, atk:0, cd:99, abDur:0, shieldDur:14, shieldRes:1.0,
maxS:130, sprintSpd:1.75, drain:26, regen:22, noAttack:true, foodDropMul:3.5, price:0,
visual:'burger' },
{ n:{ru:'Титанобоа',en:'Titanoboa'}, b:'#4a2a1a', b2:'#140703', p:'#f0a050', h:'#6b3d24',
head:{shape:'broad',eyes:'angry',eyeColor:'#ff5500',pupilColor:'#000',pupil:'slit',horns:'curve',teeth:'fangs'},
sideSpikes:{count:6,style:'sharp',len:1.2}, tail:'whip', crest:'ridge',
scale:'diamond', ab:'roar', abn:{ru:'Сокрушительный рёв',en:'Devastating Roar'},
desc:{ru:'Рёв на дистанции: оглушает и наносит урон по площади. Спавнится с 600 длины, еда ×1.7.',en:'Roar at range: stuns and AoE damage. Spawns at 600 length, food ×1.7.'},
sound:'roar', hp:200, sh:110, sp:0.98, atk:1.15, cd:9.0, abDur:1.4, shieldDur:14, shieldRes:0.95,
maxS:180, sprintSpd:1.75, drain:28, regen:24,
price:1890, playerOnly:true, bigStart:600, growMul:1.7 }
];
/* ====== WORLD / CONST ====== */
const WORLD = 7700;
const MAX_SNAKES = 55;
const BOT_COUNT = MAX_SNAKES - 1;
const BASE_SPEED = 168;
const FOOD_COUNT = 3600;
const FOOD_TARGET = 3600;
const BODY_CELL = 130;
const FOOD_CELL = 150;
const BODY_GW = Math.ceil(WORLD / BODY_CELL) + 2;
const FOOD_GW = Math.ceil(WORLD / FOOD_CELL) + 2;
const MAX_BODY = 130;
const TAU = Math.PI*2;
const SOUND_RADIUS = 1100;
const rand=(a,b)=>a+Math.random()*(b-a);
const randInt=(a,b)=>Math.floor(a+Math.random()*(b-a+1));
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
function angDiff(a,b){let d=a-b;while(d>Math.PI)d-=TAU;while(d<-Math.PI)d+=TAU;return d;}
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;}
/* ====== MAPS (weather removed) ====== */
const MAPS = {
jungle:{
name:{ru:'Джунгли',en:'Jungle'},
ground:['#182613','#1a2a15','#162210','#1c2d17','#152009'],
border:'#5a1a1a', border2:'#a83030',
decoTree:'#1e3a16', decoTree2:'#2a4d1e', decoTreeHi:'rgba(90,150,60,0.35)',
pond1:'#12303a', pond2:'#1a4450',
rock1:'#3a3a34', rock2:'#4a4a42',
bush1:'#2d4a1e', bush2:'#3a5a28', bushHi:'#5c9a3a',
treeCount:640, pondCount:36, rockCount:260, bushCount:280, smallPondCount:38
},
winter:{
name:{ru:'Зимняя',en:'Winter'},
ground:['#d8e4ec','#cfdce6','#dde8ee','#c8d6e0','#d2dee6'],
border:'#2a4a6a', border2:'#5a8ab0',
decoTree:'#2a4a3a', decoTree2:'#1a3028', decoTreeHi:'rgba(255,255,255,0.6)',
pond1:'#4a7a9a', pond2:'#6aa0c0',
rock1:'#a8b0b8', rock2:'#c0c8d0',
bush1:'#3a5a6a', bush2:'#4a6a7a', bushHi:'#e0f0ff',
ice1:'#a8d0e8', ice2:'#c8e4f4', ice3:'#dff0fa',
treeCount:440, pondCount:26, rockCount:300, snowmanCount:70, iceRiverCount:24
}
};
let currentMapKey = 'jungle';
let currentMap = MAPS.jungle;
/* ====== CANVAS ====== */
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d',{alpha:false});
let cw=0,ch=0,dpr=1;
const cam = { x:WORLD/2, y:WORLD/2, scale:1, baseScale:1, zoom:1, zoomCur:1 };
function resize(){
dpr = Math.min(window.devicePixelRatio||1, 2);
cw = window.innerWidth; ch = window.innerHeight;
canvas.width = Math.floor(cw*dpr);
canvas.height = Math.floor(ch*dpr);
canvas.style.width = cw+'px'; canvas.style.height = ch+'px';
const base = Math.min(cw, ch);
cam.baseScale = base / 620;
render._vg = null;
placeJoyDefault();
}
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', ()=>setTimeout(resize,250));
/* ====== STATE ====== */
let state = 'lang';
let snakes = [];
let snakeById = new Map();
let player = null;
let nextId = 1;
let effects=[], particles=[], decos=[];
let textPops = [];
let time=0, hudTimer=0;
let respawnQueue=[];
let usedNicks=[];
let playerDeathInfo={score:0,killer:''};
let selectedSkin = 0;
let audioCtx = null;
/* ====== ECONOMY ====== */
let coins = 0;
let ownedSkins = new Set();
function isSkinOwned(idx){
const s = SKINS[idx];
if (!s) return false;
if (s.price === 0) return true;
return ownedSkins.has(idx);
}
function isSkinAvailableForBots(idx){
const s = SKINS[idx];
return s && !s.playerOnly;
}
/* Полное мгновенное сохранение — монеты + купленные скины + выбранный скин */
function doSaveProgress(){
try{
localStorage.setItem('snakeCoins', String(coins));
localStorage.setItem('snakeOwnedSkins', JSON.stringify([...ownedSkins]));
localStorage.setItem('snakeSkin', String(selectedSkin));
}catch(e){}
}
function saveProgress(){ doSaveProgress(); }
function loadProgress(){
try{
const c = localStorage.getItem('snakeCoins');
if (c !== null){ const v = parseInt(c,10); if (Number.isFinite(v) && v>=0) coins = v; }
const o = localStorage.getItem('snakeOwnedSkins');
if (o){
const arr = JSON.parse(o);
if (Array.isArray(arr)) ownedSkins = new Set(arr.filter(n => Number.isInteger(n) && n>=0 && n<SKINS.length));
}
}catch(e){}
for (let i=0;i<SKINS.length;i++){ if (SKINS[i].price === 0) ownedSkins.add(i); }
if (!isSkinOwned(selectedSkin)) selectedSkin = 27;
}
function updateCoinDisplay(){
const el = document.getElementById('coinVal');
if (el) el.textContent = coins;
const el2 = document.getElementById('skinsCoins');
if (el2) el2.textContent = coins;
}
/* ====== LOAD SAVED ====== */
try{
const savedNick = localStorage.getItem('snakeNick');
const savedSkin = localStorage.getItem('snakeSkin');
const savedLang = localStorage.getItem('snakeLang');
const savedMap = localStorage.getItem('snakeMap');
if (savedNick) document.getElementById('nickInput').value = savedNick;
if (savedSkin !== null) selectedSkin = clamp(parseInt(savedSkin,10)||0, 0, SKINS.length-1);
if (savedLang === 'ru' || savedLang === 'en') LANG = savedLang;
if (savedMap && MAPS[savedMap]) currentMapKey = savedMap;
}catch(e){}
loadProgress();
currentMap = MAPS[currentMapKey];
/* ====== GRIDS ====== */
const bodyGrid = new Array(BODY_GW*BODY_GW);
for (let i=0;i<bodyGrid.length;i++) bodyGrid[i]=[];
const cellMark = new Int32Array(BODY_GW*BODY_GW);
let frameId = 0;
const foodGrid = new Array(FOOD_GW*FOOD_GW).fill(null);
function foodCellIndex(x,y){ return ((x/FOOD_CELL)|0)*FOOD_GW + ((y/FOOD_CELL)|0); }
/* ====== FOOD ====== */
const FOOD_COLORS = ['#e74c3c','#f39c12','#f1c40f','#2ecc71','#1abc9c','#3498db','#9b59b6','#e84393','#ff7675','#74b9ff','#55efc4','#ffeaa7','#fd79a8','#a29bfe','#81ecec','#fab1a0'];
function spawnFood(x,y,r,v,c){
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
x=clamp(x,15,WORLD-15); y=clamp(y,15,WORLD-15);
const ci = foodCellIndex(x,y);
let arr = foodGrid[ci];
if (!arr){ arr=[]; foodGrid[ci]=arr; }
const f = {x,y,r,v,c,alive:true,cd:0};
arr.push(f);
return f;
}
function spawnRandomFood(){
const roll = Math.random();
let r,v;
if (roll<0.78){ r=rand(4.5,6.5); v=2; }
else if (roll<0.96){ r=rand(7.5,9.5); v=6; }
else { r=rand(12,15); v=16; }
spawnFood(rand(20,WORLD-20), rand(20,WORLD-20), r, v, FOOD_COLORS[randInt(0,FOOD_COLORS.length-1)]);
}
function generateFood(){
for (let i=0;i<foodGrid.length;i++) foodGrid[i] = null;
for (let i=0;i<FOOD_COUNT;i++) spawnRandomFood();
}
function countAndCompactFood(){
let count = 0;
for (let i=0;i<foodGrid.length;i++){
const arr = foodGrid[i];
if (!arr) continue;
if (arr.length > 64){
const alive = arr.filter(f => f.alive);
foodGrid[i] = alive.length ? alive : null;
count += alive.length;
} else {
for (let k=arr.length-1;k>=0;k--){ if (!arr[k].alive) arr.splice(k,1); }
if (arr.length === 0) foodGrid[i] = null;
count += arr.length;
}
}
return count;
}
let foodRespawnTimer = 0;
function updateFoodRespawn(dt){
foodRespawnTimer -= dt;
if (foodRespawnTimer > 0) return;
foodRespawnTimer = 0.6;
const count = countAndCompactFood();
const toSpawn = Math.min(40, Math.max(0, FOOD_TARGET - count));
for (let i=0;i<toSpawn;i++) spawnRandomFood();
}
/* ====== DECOS ====== */
function generateDecos(){
decos = [];
const M = currentMap;
for (let i=0;i<M.treeCount;i++) decos.push({t:'tree',x:rand(40,WORLD-40),y:rand(40,WORLD-40),r:rand(34,82),sh:Math.random()});
for (let i=0;i<M.pondCount;i++) decos.push({t:'pond',x:rand(300,WORLD-300),y:rand(300,WORLD-300),rx:rand(160,420),ry:rand(120,340),rot:rand(0,Math.PI)});
for (let i=0;i<M.rockCount;i++) decos.push({t:'rock',x:rand(30,WORLD-30),y:rand(30,WORLD-30),r:rand(9,22)});
if (M.bushCount){
for (let i=0;i<M.bushCount;i++){
decos.push({t:'bush',x:rand(30,WORLD-30),y:rand(30,WORLD-30),r:rand(16,36),sh:Math.random()});
}
}
if (M.smallPondCount){
for (let i=0;i<M.smallPondCount;i++){
decos.push({t:'smallpond',x:rand(100,WORLD-100),y:rand(100,WORLD-100),r:rand(28,60)});
}
}
if (M.snowmanCount){
for (let i=0;i<M.snowmanCount;i++){
decos.push({t:'snowman',x:rand(40,WORLD-40),y:rand(40,WORLD-40),r:rand(20,34)});
}
}
if (M.iceRiverCount){
for (let i=0;i<M.iceRiverCount;i++){
const ang = rand(0,Math.PI);
decos.push({t:'iceriver',x:rand(200,WORLD-200),y:rand(200,WORLD-200),
len:rand(180,420), wid:rand(24,52), rot:ang});
}
}
}
/* ====== SNAKE ====== */
function makeSnake(name, skinIdx, isPlayer, x, y){
const skin = SKINS[skinIdx];
const startScore = skin.bigStart || 22;
const s = {
id:nextId++, name, skin, skinIdx, isPlayer,
x, y, angle:rand(0,TAU), targetAngle:0,
score:startScore, hp:skin.hp, hpMax:skin.hp,
shMax:skin.sh, shield:skin.sh, shieldOn:false,
stMax:skin.maxS, stamina:skin.maxS, sprinting:false,
alive:true,
path:[{x,y}], pathLen:0,
body:new Float32Array(MAX_BODY*2),
maxBody:12, nBody:1, r:10, dStep:5.5,
acd:0, slowT:0, confuseT:0, dashT:0, burnT:0, burnDps:0, zigT:0, zigAngle:0,
invuln:0, hitFlash:0, speed:BASE_SPEED,
aiTimer:rand(0,0.2), aiState:'farm', aiTarget:null, aiFood:null,
aiWanderAng:rand(0,TAU), aiWanderT:0,
aiPreferredSide:(Math.random()<0.5?1:-1),
aiLastTargetPos:null, aiInterceptT:0, aiSprintT:0, aiShieldT:0,
personality:{
aggr:rand(0.25,0.95),
caution:rand(0.2,0.9),
greed:rand(0.3,0.9),
skill:rand(0.6,1.0),
reaction:rand(0.05,0.16),
predict:rand(0.6,1.0)
}
};
if (skin.bigStart){
for (let i=0;i<40;i++){
s.path.push({x: x - Math.cos(s.angle)*(i+1)*2, y: y - Math.sin(s.angle)*(i+1)*2});
}
}
updateSize(s);
return s;
}
function growthMul(s){
return Math.min(1.55, 1 + Math.sqrt(s.score) * 0.011);
}
function rangeMul(s){
return Math.min(1.25, 1 + Math.sqrt(s.score) * 0.0055);
}
function updateSize(s){
const prevHpMax = s.hpMax;
s.r = Math.min(52, 9 + Math.sqrt(s.score)*0.38);
s.maxBody = Math.min(MAX_BODY, Math.floor(10 + Math.pow(s.score,0.55)*1.25));
s.dStep = s.r*0.56;
s.totalLen = (s.maxBody-1)*s.dStep;
s.hpMax = Math.round(s.skin.hp + Math.sqrt(s.score)*4.5);
if (s.hpMax > prevHpMax) s.hp += (s.hpMax-prevHpMax);
s.hp = Math.min(s.hp, s.hpMax);
s.shMax = Math.round(s.skin.sh + Math.sqrt(s.score)*2.2);
s.shield = Math.min(s.shMax, s.shield);
}
function addSnake(s){ snakes.push(s); snakeById.set(s.id,s); }
function removeSnake(s){
const i = snakes.indexOf(s);
if (i>=0) snakes.splice(i,1);
snakeById.delete(s.id);
}
/* ====== BODY ====== */
function computeBody(s){
const out = s.body;
const maxC = s.maxBody;
out[0]=s.x; out[1]=s.y;
let count=1;
if (maxC<=1){ s.nBody=1; return; }
let px=s.x, py=s.y, acc=0, target=s.dStep;
const path = s.path;
for (let i=path.length-1;i>=0 && count<maxC;i--){
const p = path[i];
const dx=p.x-px, dy=p.y-py;
const seg = Math.sqrt(dx*dx+dy*dy);
if (seg>1e-6){
while (acc+seg>=target && count<maxC){
const t = (target-acc)/seg;
out[count*2] = px+dx*t;
out[count*2+1] = py+dy*t;
count++;
target += s.dStep;
}
acc += seg;
px=p.x; py=p.y;
}
}
s.nBody = count;
}
function buildBodyGrid(){
frameId++;
for (const s of snakes){
if (!s.alive) continue;
const n = s.nBody;
const stride = n>60?2:1;
for (let i=0;i<n;i+=stride){
const x=s.body[i*2], y=s.body[i*2+1];
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
const cx=(x/BODY_CELL)|0, cy=(y/BODY_CELL)|0;
const k = cx*BODY_GW + cy;
if (k<0||k>=bodyGrid.length) continue;
if (cellMark[k]!==frameId){ cellMark[k]=frameId; bodyGrid[k].length=0; }
bodyGrid[k].push(x, y, s.r*0.92, s.id);
}
}
}
function pointBlocked(x,y,pr,ignoreId){
const cx=(x/BODY_CELL)|0, cy=(y/BODY_CELL)|0;
for (let i=cx-1;i<=cx+1;i++){
if (i<0||i>=BODY_GW) continue;
for (let j=cy-1;j<=cy+1;j++){
if (j<0||j>=BODY_GW) continue;
const k=i*BODY_GW+j;
if (cellMark[k]!==frameId) continue;
const arr=bodyGrid[k];
for (let m=0;m<arr.length;m+=4){
if (arr[m+3]===ignoreId) continue;
const dx=arr[m]-x, dy=arr[m+1]-y;
const rr=arr[m+2]+pr;
if (dx*dx+dy*dy<rr*rr) return true;
}
}
}
return false;
}
function pointHit(x,y,pr,ignoreId){
const cx=(x/BODY_CELL)|0, cy=(y/BODY_CELL)|0;
for (let i=cx-1;i<=cx+1;i++){
if (i<0||i>=BODY_GW) continue;
for (let j=cy-1;j<=cy+1;j++){
if (j<0||j>=BODY_GW) continue;
const k=i*BODY_GW+j;
if (cellMark[k]!==frameId) continue;
const arr=bodyGrid[k];
for (let m=0;m<arr.length;m+=4){
if (arr[m+3]===ignoreId) continue;
const dx=arr[m]-x, dy=arr[m+1]-y;
const rr=arr[m+2]+pr;
if (dx*dx+dy*dy<rr*rr) return arr[m+3];
}
}
}
return -1;
}
function snakeHitTest(s, x, y, radius){
const dx0 = s.x - x, dy0 = s.y - y;
const rrH = radius + s.r;
if (dx0*dx0 + dy0*dy0 < rrH*rrH) return true;
const n = s.nBody;
if (n <= 1) return false;
const stride = n > 55 ? 2 : 1;
const bodyR = s.r * 0.92;
const rrB = radius + bodyR;
const rrB2 = rrB * rrB;
for (let i = stride; i < n; i += stride){
const dx = s.body[i*2] - x;
const dy = s.body[i*2+1] - y;
if (dx*dx + dy*dy < rrB2) return true;
}
const ti = (n-1)*2;
const tdx = s.body[ti] - x;
const tdy = s.body[ti+1] - y;
if (tdx*tdx + tdy*tdy < rrB2) return true;
return false;
}
function snakeDistanceApprox(s, x, y){
let minD = Math.hypot(s.x - x, s.y - y) - s.r;
const n = s.nBody;
const stride = n > 55 ? 2 : 1;
const bodyR = s.r * 0.92;
for (let i = stride; i < n; i += stride){
const dx = s.body[i*2] - x;
const dy = s.body[i*2+1] - y;
const d = Math.hypot(dx, dy) - bodyR;
if (d < minD) minD = d;
}
return minD;
}
/* ====== PARTICLES / EFFECTS / TEXT ====== */
function addParticle(x,y,vx,vy,life,r,c){
if (particles.length>680) return;
particles.push({x,y,vx,vy,life,max:life,r,c});
}
function burst(x,y,n,c,spd,r){
for (let i=0;i<n;i++){
const a=rand(0,TAU), sp=rand(spd*0.4,spd);
addParticle(x,y,Math.cos(a)*sp,Math.sin(a)*sp,rand(0.3,0.8),rand(r*0.5,r),c);
}
}
function addTextPop(x, y, text, color){
textPops.push({x, y, text, color, life:1.4, max:1.4, vy:-55});
}
function updateTextPops(dt){
for (let i=textPops.length-1;i>=0;i--){
const p = textPops[i];
p.life -= dt;
p.y += p.vy * dt;
p.vy *= 0.96;
if (p.life <= 0) textPops.splice(i,1);
}
}
function drawTextPops(){
if (!textPops.length) return;
for (const p of textPops){
const a = Math.min(1, p.life/p.max * 1.6);
ctx.globalAlpha = a;
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineWidth = 4;
ctx.strokeStyle = 'rgba(0,0,0,0.85)';
ctx.strokeText(p.text, p.x, p.y);
ctx.fillStyle = p.color;
ctx.fillText(p.text, p.x, p.y);
}
ctx.globalAlpha = 1;
}
function damageSnake(s, dmg, attacker){
if (!s.alive || s.invuln>0) return;
if (s.shieldOn && s.shield>0){
const resMul = s.skin.shieldRes || 1.0;
s.shield -= dmg*(1.5/Math.max(0.4,resMul));
dmg *= 0.28/resMul;
if (s.shield<=0){ s.shield=0; s.shieldOn=false; }
}
s.hp -= dmg;
s.hitFlash = 0.16;
if (s.hp<=0) killSnake(s, attacker);
}
function killSnake(s, killer){
if (!s.alive) return;
s.alive = false;
const dropMul = s.skin.foodDropMul || 1;
const dropN = Math.min(90, Math.floor((8 + Math.floor(s.score/10)) * dropMul));
const perVal = Math.max(2.5, s.score/dropN*0.7);
const safeN = Math.max(1, s.nBody);
for (let i=0;i<dropN;i++){
const idx = Math.min(safeN-1, Math.floor(Math.random()*safeN));
const fx = s.body[idx*2];
const fy = s.body[idx*2+1];
if (!Number.isFinite(fx) || !Number.isFinite(fy)) continue;
const f = spawnFood(fx+rand(-14,14), fy+rand(-14,14), s.r*0.45, perVal, s.skin.p);
if (f) f.cd = 0.35; // short delay before being eaten so traces stay visible
}
for (let i=0;i<22;i++){
const idx = Math.min(safeN-1, Math.floor(Math.random()*safeN));
const fx = s.body[idx*2], fy = s.body[idx*2+1];
if (!Number.isFinite(fx) || !Number.isFinite(fy)) continue;
burst(fx, fy, 2, s.skin.p, 140, 5);
}
if (killer && killer.alive && killer !== s) killer.score += s.score*0.32;
if (killer && killer.isPlayer && s !== killer){
let reward = 10;
if (s.score >= 300) reward = 20;
else if (s.score >= 100) reward = 15;
coins += reward;
saveProgress();
updateCoinDisplay();
addTextPop(s.x, s.y - 30, '+'+reward+' 🪙', '#ffe45e');
}
if (s.isPlayer){
playerDeathInfo = { score:Math.floor(s.score), killer:killer?killer.name:'—' };
player = null;
setTimeout(showDeath, 480);
}
removeSnake(s);
respawnQueue.push(rand(1.4,3.2));
}
/* ====== SOUND ====== */
function initAudio(){
if (!audioCtx){
try{ audioCtx = new (window.AudioContext||window.webkitAudioContext)(); }catch(e){}
} else if (audioCtx.state === 'suspended'){
audioCtx.resume();
}
}
function sfx(type, dur, vol){
if (!type || type === 'none') return;
initAudio();
if (!audioCtx) return;
dur = dur || 0.35;
const v = (vol === undefined) ? 0.13 : vol;
const t0 = audioCtx.currentTime;
const makeOut = (peak) => {
const out = audioCtx.createGain();
out.connect(audioCtx.destination);
out.gain.setValueAtTime(0.0001, t0);
out.gain.linearRampToValueAtTime(peak, t0+0.015);
out.gain.linearRampToValueAtTime(peak*0.75, t0+dur*0.6);
out.gain.linearRampToValueAtTime(0.0001, t0+dur);
return out;
};
const makeNoise = (len) => {
const buf = audioCtx.createBuffer(1, Math.floor(audioCtx.sampleRate*len), audioCtx.sampleRate);
const d = buf.getChannelData(0);
for (let i=0;i<d.length;i++) d[i] = Math.random()*2-1;
const src = audioCtx.createBufferSource();
src.buffer = buf;
return src;
};
// === NOISE-BASED ===
if (type === 'fire' || type === 'spit' || type === 'hiss' || type === 'ice' || type === 'venom' || type === 'splash' || type === 'puff'){
const out = makeOut(v);
const src = makeNoise(dur);
const filt = audioCtx.createBiquadFilter();
filt.type = 'bandpass';
if (type === 'fire'){ filt.frequency.value = 750; filt.Q.value = 0.6; }
else if (type === 'spit'){ filt.frequency.value = 2200; filt.Q.value = 3.5; }
else if (type === 'hiss'){ filt.frequency.value = 4800; filt.Q.value = 1.1; }
else if (type === 'ice'){ filt.frequency.value = 3600; filt.Q.value = 6; }
else if (type === 'venom'){ filt.frequency.value = 1500; filt.Q.value = 1.8; }
else if (type === 'splash'){ filt.frequency.value = 900; filt.Q.value = 0.7; }
else if (type === 'puff'){ filt.frequency.value = 500; filt.Q.value = 0.5; }
src.connect(filt); filt.connect(out);
src.start(t0); src.stop(t0+dur);
// For fire add a subtle low crackle layer
if (type === 'fire'){
const src2 = makeNoise(dur);
const f2 = audioCtx.createBiquadFilter();
f2.type = 'lowpass'; f2.frequency.value = 400;
const g2 = audioCtx.createGain();
g2.gain.setValueAtTime(0.0001, t0);
g2.gain.linearRampToValueAtTime(v*0.7, t0+0.05);
g2.gain.linearRampToValueAtTime(0.0001, t0+dur);
src2.connect(f2); f2.connect(g2); g2.connect(audioCtx.destination);
src2.start(t0); src2.stop(t0+dur);
}
return;
}
// === TONE-BASED ===
const out = makeOut(v);
if (type === 'zap'){
const o = audioCtx.createOscillator();
o.type = 'sawtooth';
o.frequency.setValueAtTime(1400, t0);
o.frequency.exponentialRampToValueAtTime(70, t0+dur);
o.connect(out);
o.start(t0); o.stop(t0+dur);
} else if (type === 'thunder'){
// deep rumble + fast zap
const o = audioCtx.createOscillator();
o.type = 'sawtooth';
o.frequency.setValueAtTime(2200, t0);
o.frequency.exponentialRampToValueAtTime(120, t0+0.08);
const outA = makeOut(v*0.9);
o.connect(outA);
o.start(t0); o.stop(t0+0.12);
const o2 = audioCtx.createOscillator();
o2.type = 'sine';
o2.frequency.setValueAtTime(70, t0+0.05);
o2.frequency.exponentialRampToValueAtTime(35, t0+dur);
const outB = makeOut(v*1.2);
o2.connect(outB);
o2.start(t0+0.05); o2.stop(t0+dur);
} else if (type === 'boom'){
const o = audioCtx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(130, t0);
o.frequency.exponentialRampToValueAtTime(28, t0+dur);
o.connect(out);
o.start(t0); o.stop(t0+dur);
} else if (type === 'punch'){
// slap-like: short thump + short noise click
const o = audioCtx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(200, t0);
o.frequency.exponentialRampToValueAtTime(55, t0+dur*0.5);
const g = audioCtx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.linearRampToValueAtTime(v*1.4, t0+0.005);
g.gain.linearRampToValueAtTime(0.0001, t0+dur);
o.connect(g); g.connect(audioCtx.destination);
o.start(t0); o.stop(t0+dur);
const src = makeNoise(0.09);
const f = audioCtx.createBiquadFilter();
f.type = 'bandpass'; f.frequency.value = 1300; f.Q.value = 1.6;
const g2 = audioCtx.createGain();
g2.gain.setValueAtTime(v*1.1, t0);
g2.gain.exponentialRampToValueAtTime(0.0001, t0+0.09);
src.connect(f); f.connect(g2); g2.connect(audioCtx.destination);
src.start(t0); src.stop(t0+0.1);
} else if (type === 'teleport'){
const o = audioCtx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(180, t0);
o.frequency.exponentialRampToValueAtTime(1400, t0+dur*0.5);
o.frequency.exponentialRampToValueAtTime(120, t0+dur);
o.connect(out);
o.start(t0); o.stop(t0+dur);
} else if (type === 'whoosh'){
const src = makeNoise(dur);
const filt = audioCtx.createBiquadFilter();
filt.type = 'bandpass';
filt.frequency.setValueAtTime(300, t0);
filt.frequency.exponentialRampToValueAtTime(2600, t0+dur*0.5);
filt.frequency.exponentialRampToValueAtTime(400, t0+dur);
filt.Q.value = 1.2;
src.connect(filt); filt.connect(out);
src.start(t0); src.stop(t0+dur);
} else if (type === 'sting'){
const o = audioCtx.createOscillator();
o.type = 'sawtooth';
o.frequency.setValueAtTime(2000, t0);
o.frequency.exponentialRampToValueAtTime(180, t0+dur);
o.connect(out);
o.start(t0); o.stop(t0+dur);
const src = makeNoise(0.06);
const f = audioCtx.createBiquadFilter();
f.type = 'highpass'; f.frequency.value = 2000;
const g = audioCtx.createGain();
g.gain.setValueAtTime(v*0.9, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0+0.06);
src.connect(f); f.connect(g); g.connect(audioCtx.destination);
src.start(t0); src.stop(t0+0.08);
} else if (type === 'stab'){
const o = audioCtx.createOscillator();
o.type = 'triangle';
o.frequency.setValueAtTime(3000, t0);
o.frequency.exponentialRampToValueAtTime(600, t0+dur*0.4);
o.connect(out);
o.start(t0); o.stop(t0+dur);
} else if (type === 'crush'){
// Low grindy noise
const src = makeNoise(dur);
const filt = audioCtx.createBiquadFilter();
filt.type = 'lowpass';
filt.frequency.setValueAtTime(600, t0);
filt.frequency.exponentialRampToValueAtTime(120, t0+dur);
const g = audioCtx.createGain();
g.gain.setValueAtTime(v*0.9, t0);
g.gain.linearRampToValueAtTime(0.0001, t0+dur);
src.connect(filt); filt.connect(g); g.connect(audioCtx.destination);
src.start(t0); src.stop(t0+dur);
} else if (type === 'burrow'){
const o = audioCtx.createOscillator();
o.type = 'sawtooth';
o.frequency.setValueAtTime(80, t0);
o.frequency.exponentialRampToValueAtTime(40, t0+dur);
const filt = audioCtx.createBiquadFilter();
filt.type = 'lowpass'; filt.frequency.value = 250;
const g = audioCtx.createGain();
g.gain.setValueAtTime(0.0001, t0);
g.gain.linearRampToValueAtTime(v*0.95, t0+0.05);
g.gain.linearRampToValueAtTime(0.0001, t0+dur);
o.connect(filt); filt.connect(g); g.connect(audioCtx.destination);
o.start(t0); o.stop(t0+dur);
} else if (type === 'acid'){
// bubbling
for (let i=0;i<6;i++){
const it = t0 + i*0.06;
const o = audioCtx.createOscillator();
o.type = 'sine';
o.frequency.setValueAtTime(rand(180,320), it);
o.frequency.exponentialRampToValueAtTime(rand(60,120), it+0.09);
const g = audioCtx.createGain();
g.gain.setValueAtTime(0.0001, it);
g.gain.linearRampToValueAtTime(v*0.55, it+0.01);
g.gain.linearRampToValueAtTime(0.0001, it+0.1);
o.connect(g); g.connect(audioCtx.destination);
o.start(it); o.stop(it+0.12);
}
} else if (type === 'rattle'){
// rapid clicks
for (let i=0;i<10;i++){
const it = t0 + i*0.032;
const o = audioCtx.createOscillator();
o.type = 'square';
o.frequency.value = 2400 + rand(-250, 250);
const g = audioCtx.createGain();
g.gain.setValueAtTime(0.0001, it);
g.gain.linearRampToValueAtTime(v*0.4, it+0.003);
g.gain.linearRampToValueAtTime(0.0001, it+0.028);
o.connect(g); g.connect(audioCtx.destination);
o.start(it); o.stop(it+0.035);
}
} else if (type === 'roar'){
const roarDur = 1.5;
const outR = audioCtx.createGain();
outR.connect(audioCtx.destination);
outR.gain.setValueAtTime(0.0001, t0);
outR.gain.linearRampToValueAtTime(v*1.8, t0+0.12);
outR.gain.linearRampToValueAtTime(v*1.5, t0+0.85);
outR.gain.linearRampToValueAtTime(0.0001, t0+roarDur);
const o1 = audioCtx.createOscillator();
o1.type = 'sawtooth';
o1.frequency.setValueAtTime(90, t0);
o1.frequency.linearRampToValueAtTime(65, t0+roarDur*0.7);
o1.frequency.exponentialRampToValueAtTime(42, t0+roarDur);
const f1 = audioCtx.createBiquadFilter();
f1.type = 'lowpass'; f1.frequency.value = 420; f1.Q.value = 3;
o1.connect(f1); f1.connect(outR);
o1.start(t0); o1.stop(t0+roarDur);
const src = makeNoise(roarDur);
const d = src.buffer.getChannelData(0);
for (let i=0;i<d.length;i++) d[i] *= (1 - i/d.length) * 0.5;
const f2 = audioCtx.createBiquadFilter();
f2.type = 'bandpass'; f2.frequency.value = 180; f2.Q.value = 0.9;
src.connect(f2); f2.connect(outR);
src.start(t0); src.stop(t0+roarDur);
}
}
/* Helper: play ability sound for snake s, if player or near player */
function playAttackSound(s, dur){
if (!s || !s.alive) return;
const snd = s.skin.sound;
if (!snd || snd === 'none') return;
if (s.isPlayer){
sfx(snd, dur);
} else if (player && player.alive){
const dx = player.x - s.x, dy = player.y - s.y;
if (dx*dx + dy*dy < SOUND_RADIUS*SOUND_RADIUS){
// Fade volume by distance so far ones are quieter
const d = Math.sqrt(dx*dx + dy*dy);
const vol = 0.13 * Math.max(0.15, 1 - d/SOUND_RADIUS);
sfx(snd, dur, vol);
}
}
}
/* ====== ABILITIES ====== */
function useAbility(s){
if (!s.alive || s.skin.noAttack || s.acd>0 || s.skin.ab==='none') return;
if (state !== 'playing' && s.isPlayer) return;
const ab = s.skin.ab;
const atk = s.skin.atk * growthMul(s);
const rm = rangeMul(s);
const r = s.r;
const a = s.angle;
const hx = s.x + Math.cos(a)*r*0.6;
const hy = s.y + Math.sin(a)*r*0.6;
const oid = s.id;
const dur = s.skin.abDur || 1.0;
switch(ab){
case 'fire': {
const range = 220*rm;
effects.push({k:'cone',o:oid,x:hx,y:hy,ang:a,spread:0.55,range,
life:dur,max:dur,dmg:36*atk,c:'#ff8c1a',head:true,burn:true});
s.acd = s.skin.cd;
burst(hx,hy,14,'#ffb340',140,7);
playAttackSound(s, dur);
break;
}
case 'tail': {
const tailAng = a + Math.PI;
const range = 175*rm;
const ti = (s.nBody - 1) * 2;
const tx = s.body[ti] !== undefined ? s.body[ti] : s.x;
const ty = s.body[ti+1] !== undefined ? s.body[ti+1] : s.y;
effects.push({k:'cone',o:oid,x:tx,y:ty,ang:tailAng,spread:0.9,range,
life:dur,max:dur,dmg:50*atk,c:'#d8c878',once:true,slow:0.9,knock:280});
s.acd = s.skin.cd;
burst(tx+Math.cos(tailAng)*70, ty+Math.sin(tailAng)*70, 18, '#d8c878', 200, 7);
playAttackSound(s, dur);
break;
}
case 'vdash':
s.dashT = dur;
effects.push({k:'cloud',o:oid,x:s.x,y:s.y,r:55*rm,life:dur+2,max:dur+2,
dps:18*atk,slow:0.55,c:'#9ee04a',owner:oid,move:true});
s.acd = s.skin.cd;
playAttackSound(s, 0.3);
break;
case 'spit': {
const sp = 420;
effects.push({k:'proj',o:oid,x:hx,y:hy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,
r:10,life:1.9,dmg:28*atk,c:'#9ee04a',slow:1.6,aoe:0});
s.acd = s.skin.cd;
playAttackSound(s, 0.25);
break;
}
case 'triple': {
const sp = 400;
for (let i=-1;i<=1;i++){
const ang = a + i*0.24;
effects.push({k:'proj',o:oid,x:hx,y:hy,vx:Math.cos(ang)*sp,vy:Math.sin(ang)*sp,
r:9,life:1.9,dmg:20*atk,c:'#c07fe0',slow:1.3,aoe:0});
}
s.acd = s.skin.cd;
playAttackSound(s, 0.28);
break;
}
case 'fireball': {
const sp = 320;
effects.push({k:'proj',o:oid,x:hx,y:hy,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,
r:16,life:2.0,dmg:42*atk,c:'#ff7a20',aoe:110*rm,burn:true});
s.acd = s.skin.cd;
playAttackSound(s, 0.4);
break;
}
case 'shock':
effects.push({k:'ring',o:oid,x:s.x,y:s.y,r0:r,r1:270*rm,life:dur,max:dur,
dmg:30*atk,slow:2.6,knock:180,c:'#ffe066'});
s.acd = s.skin.cd;
playAttackSound(s, 0.35);
break;
case 'freeze':
effects.push({k:'ring',o:oid,x:s.x,y:s.y,r0:r,r1:300*rm,life:dur,max:dur,
dmg:5*atk,slow:3.6,knock:0,c:'#8fe8f0',field:true,radius:300*rm});
s.acd = s.skin.cd;
playAttackSound(s, 0.5);
break;
case 'sprint':
s.dashT = dur; s.speedBoost = 2.8;
burst(s.x,s.y,16,'#7fd97f',220,7);
s.acd = s.skin.cd;
playAttackSound(s, 0.3);
break;
case 'wave':
effects.push({k:'ring',o:oid,x:s.x,y:s.y,r0:r,r1:320*rm,life:dur,max:dur,
dmg:26*atk,slow:1.4,knock:440,c:'#5fd8f0'});
s.acd = s.skin.cd;
playAttackSound(s, 0.5);
break;
case 'constrict':
effects.push({k:'pull',o:oid,x:s.x,y:s.y,radius:240*rm,life:dur,max:dur,
dps:22*atk,force:340,c:'#a89070'});
s.acd = s.skin.cd;
playAttackSound(s, 0.6);
break;
case 'crush':
effects.push({k:'pull',o:oid,x:s.x,y:s.y,radius:260*rm,life:dur,max:dur,
dps:32*atk,force:400,c:'#8a7a5a'});
s.acd = s.skin.cd;
playAttackSound(s, 0.6);
break;
case 'trail':
effects.push({k:'cloud',o:oid,x:s.x,y:s.y,r:80*rm,life:dur,max:dur,
dps:14*atk,slow:0.6,c:'#7a6ad9',owner:oid,move:true});
s.acd = s.skin.cd;
playAttackSound(s, 0.3);
break;
case 'confuse':
effects.push({k:'ring',o:oid,x:s.x,y:s.y,r0:r,r1:280*rm,life:dur,max:dur,
dmg:8*atk,confuse:3.0,knock:0,c:'#f0e050',field:true,radius:280*rm});
s.acd = s.skin.cd;
playAttackSound(s, 0.4);
break;
case 'lightning': {
const targets = [];
for (const o of snakes){
if (o===s || !o.alive) continue;
const d = Math.hypot(o.x-s.x, o.y-s.y);
if (d < 420*rm) targets.push({s:o,d});
}
targets.sort((p,q)=>p.d-q.d);
const hit = targets.slice(0,4);
let px=s.x, py=s.y;
for (const tg of hit){
effects.push({k:'beam',x1:px,y1:py,x2:tg.s.x,y2:tg.s.y,life:0.35,max:0.35,c:'#e8e8ff'});
damageSnake(tg.s, 32*atk, s);
burst(tg.s.x,tg.s.y,12,'#e8e8ff',200,6);
px=tg.s.x; py=tg.s.y;
}
s.acd = s.skin.cd;
playAttackSound(s, 0.25);
break;
}
case 'thunder': {
const targets = [];
for (const o of snakes){
if (o===s || !o.alive) continue;
const d = Math.hypot(o.x-s.x, o.y-s.y);
if (d < 480*rm) targets.push({s:o,d});
}
targets.sort((p,q)=>p.d-q.d);
const hit = targets.slice(0,5);
for (const tg of hit){
effects.push({k:'bolt',x:tg.s.x,y:tg.s.y,life:0.4,max:0.4,c:'#e0e0ff'});
damageSnake(tg.s, 28*atk, s);
burst(tg.s.x,tg.s.y,14,'#e0e0ff',220,6);
}
s.acd = s.skin.cd;
playAttackSound(s, 0.35);
break;
}
case 'poison':
effects.push({k:'cloud',o:oid,x:hx,y:hy,r:120*rm,life:dur,max:dur,
dps:15*atk,slow:0.65,c:'#7fd94a',owner:oid});
s.acd = s.skin.cd;
playAttackSound(s, 0.4);
break;
case 'zigzag':
s.dashT = dur; s.zigT = 0.28; s.zigAngle = 1; s.speedBoost = 2.0;
s.acd = s.skin.cd;
playAttackSound(s, 0.3);
break;
case 'maelstrom':
effects.push({k:'pull',o:oid,x:s.x,y:s.y,radius:280*rm,life:dur,max:dur,
dps:18*atk,force:360,c:'#50d0c0',spin:true});
s.acd = s.skin.cd;
playAttackSound(s, 0.6);
break;
case 'teleport': {
const d = 280*Math.min(1.25, rm);
const tx = clamp(s.x+Math.cos(a)*d, 40, WORLD-40);
const ty = clamp(s.y+Math.sin(a)*d, 40, WORLD-40);
const steps = 12;
const sx0 = s.x, sy0 = s.y;
for (let i=1;i<=steps;i++){
const px = sx0 + (tx-sx0)*i/steps;
const py = sy0 + (ty-sy0)*i/steps;
aoeDamage(px, py, s.r+28, oid, 18*atk, null);
burst(px, py, 2, '#b070ff', 80, 6);
}
// translate path
const dx = tx - s.x, dy = ty - s.y;
for (const p of s.path){ p.x += dx; p.y += dy; }
s.x = tx; s.y = ty;
// push, but skip if identical to last point
const lastP = s.path[s.path.length-1];
if (!lastP || Math.hypot(lastP.x-tx, lastP.y-ty) > 0.5) s.path.push({x:tx,y:ty});
computeBody(s);
effects.push({k:'ring',o:oid,x:tx,y:ty,r0:10,r1:160*rm,life:dur,max:dur,
dmg:28*atk,slow:1.5,knock:180,c:'#b070ff'});
burst(tx,ty,18,'#b070ff',200,7);
s.acd = s.skin.cd;
playAttackSound(s, 0.4);
break;
}
case 'sting': {
const range = 240*rm;
effects.push({k:'cone',o:oid,x:hx,y:hy,ang:a,spread:0.35,range,
life:0.5,max:0.5,dmg:52*atk,c:'#ffbb40',once:true,slow:1.5,knock:200});
s.acd = s.skin.cd;
playAttackSound(s, 0.25);
break;
}
case 'stink':
effects.push({k:'ring',o:oid,x:s.x,y:s.y,r0:r,r1:240*rm,life:dur,max:dur,
dmg:5*atk,confuse:3.5,slow:2.0,knock:0,c:'#c0e0a0',field:true,radius:240*rm});
s.acd = s.skin.cd;
playAttackSound(s, 0.5);
break;
case 'spikeburst': {
const rad = 200*rm;
effects.push({k:'ring',o:oid,x:s.x,y:s.y,r0:r,r1:rad,life:0.5,max:0.5,
dmg:48*atk,slow:0.8,knock:460,c:'#f0e0c0'});
for (let i=0;i<24;i++){
const aa = (i/24)*TAU;
addParticle(s.x, s.y, Math.cos(aa)*rand(180,340), Math.sin(aa)*rand(180,340), 0.5, 5, '#f0e0c0');
}
s.acd = s.skin.cd;
playAttackSound(s, 0.35);
break;
}
case 'efield': {
effects.push({k:'cloud',o:oid,x:s.x,y:s.y,r:150*rm,life:dur,max:dur,
dps:17*atk,slow:0.75,c:'#7fd8ff',owner:oid,move:true,shock:true});
s.acd = s.skin.cd;
playAttackSound(s, 0.4);
break;
}
case 'acid': {
const range = 220*rm;
effects.push({k:'cone',o:oid,x:hx,y:hy,ang:a,spread:0.5,range,
life:dur,max:dur,dmg:26*atk,c:'#ffcc30',head:true,burn:true});
s.acd = s.skin.cd;
playAttackSound(s, 0.4);
break;
}
case 'icetrail':
effects.push({k:'cloud',o:oid,x:s.x,y:s.y,r:75*rm,life:dur,max:dur,
dps:10*atk,slow:0.5,c:'#a0e8ff',owner:oid,move:true});
s.acd = s.skin.cd;
playAttackSound(s, 0.3);
break;
case 'burrow': {
const d = 300*Math.min(1.25, rm);
const tx = clamp(s.x+Math.cos(a)*d, 40, WORLD-40);
const ty = clamp(s.y+Math.sin(a)*d, 40, WORLD-40);
const steps = 10;
const sx0=s.x, sy0=s.y;
for (let i=1;i<=steps;i++){
const px = sx0 + (tx-sx0)*i/steps;
const py = sy0 + (ty-sy0)*i/steps;
aoeDamage(px, py, s.r+32, oid, 16*atk, null);
burst(px, py, 3, '#b09060', 100, 7);
}
const dx = tx - s.x, dy = ty - s.y;
for (const p of s.path){ p.x += dx; p.y += dy; }
s.x = tx; s.y = ty;
const lastP = s.path[s.path.length-1];
if (!lastP || Math.hypot(lastP.x-tx, lastP.y-ty) > 0.5) s.path.push({x:tx,y:ty});
computeBody(s);
effects.push({k:'ring',o:oid,x:tx,y:ty,r0:10,r1:200*rm,life:0.6,max:0.6,
dmg:38*atk,slow:1.2,knock:300,c:'#d0b080'});
burst(tx,ty,20,'#d0b080',240,8);
s.acd = s.skin.cd;
playAttackSound(s, 0.45);
break;
}
case 'rebirth': {
s.hp = Math.min(s.hpMax, s.hp + s.hpMax*0.4);
s.shield = s.shMax;
s.shieldOn = true;
effects.push({k:'ring',o:oid,x:s.x,y:s.y,r0:r,r1:220*rm,life:dur,max:dur,
dmg:14*atk,slow:1.0,knock:80,c:'#ffb040',burn:true,field:true,radius:200*rm});
burst(s.x,s.y,30,'#ffcc50',240,9);
s.acd = s.skin.cd;
playAttackSound(s, 0.6);
break;
}
case 'roar': {
const roarRange = 480*rm;
effects.push({k:'ring',o:oid,x:s.x,y:s.y,r0:s.r,r1:roarRange,life:dur,max:dur,
dmg:70*atk,slow:2.5,knock:520,c:'#f0a050'});
burst(s.x,s.y,60,'#ffcc80',420,10);
burst(s.x,s.y,30,'#ff8030',260,14);
s.acd = s.skin.cd;
playAttackSound(s, 1.5);
break;
}
}
}
function aoeDamage(x,y,rad,ownerId,dmg,opts){
const list = snakes.slice();
const attacker = snakeById.get(ownerId);
for (const s of list){
if (!s.alive || s.id===ownerId) continue;
if (!snakeHitTest(s, x, y, rad)) continue;
damageSnake(s, dmg, attacker||null);
if (opts && s.alive){
if (opts.slow) s.slowT = Math.max(s.slowT, opts.slow);
if (opts.confuse) s.confuseT = Math.max(s.confuseT, opts.confuse);
if (opts.knock){
const dx=s.x-x, dy=s.y-y;
const d=Math.hypot(dx,dy)||1;
s.x = clamp(s.x + dx/d*opts.knock*0.08, 30, WORLD-30);
s.y = clamp(s.y + dy/d*opts.knock*0.08, 30, WORLD-30);
}
}
}
}
function updateEffects(dt){
const activeSnakes = snakes.slice();
for (let i=effects.length-1;i>=0;i--){
const e = effects[i];
e.life -= dt;
if (e.life<=0){ effects.splice(i,1); continue; }
switch(e.k){
case 'cone': {
const o = snakeById.get(e.o);
if (e.head){
if (!o || !o.alive){ effects.splice(i,1); continue; }
e.x = o.x; e.y = o.y; e.ang = o.angle;
e.range = 220 * rangeMul(o);
}
const pointInCone = (px, py, s) => {
const dx=px-e.x, dy=py-e.y;
const d = Math.hypot(dx,dy);
if (d > e.range + s.r) return false;
const aa = Math.atan2(dy,dx);
if (Math.abs(angDiff(aa, e.ang)) > e.spread) return false;
return true;
};
const coneHits = (s) => {
if (pointInCone(s.x, s.y, s)) return true;
const stride = s.nBody > 40 ? 2 : 1;
for (let k=0;k<s.nBody;k+=stride){
if (pointInCone(s.body[k*2], s.body[k*2+1], s)) return true;
}
return false;
};
if (e.once){
e.once = false;
const attacker = snakeById.get(e.o);
for (const s of activeSnakes){
if (!s.alive || s.id===e.o) continue;
if (!coneHits(s)) continue;
damageSnake(s, e.dmg, attacker||null);
if (e.slow && s.alive) s.slowT = Math.max(s.slowT, e.slow);
if (e.knock && s.alive){
const ddx=s.x-e.x, ddy=s.y-e.y;
const dd=Math.hypot(ddx,ddy)||1;
s.x = clamp(s.x + ddx/dd*e.knock*0.09, 30, WORLD-30);
s.y = clamp(s.y + ddy/dd*e.knock*0.09, 30, WORLD-30);
}
}
continue;
}
const dmg = e.dmg * dt;
for (const s of activeSnakes){
if (!s.alive || s.id===e.o) continue;
if (!coneHits(s)) continue;
damageSnake(s, dmg, o||null);
if (e.burn && s.alive){
s.burnT = Math.max(s.burnT, rand(3.0,6.0));
s.burnDps = 10 * (o?o.skin.atk:1);
}
if (Math.random()<0.4) addParticle(s.x,s.y,rand(-40,40),rand(-40,40),0.4,5,'#ffb340');
}
if (Math.random()<0.9){
const aa = e.ang + rand(-e.spread,e.spread);
const d = rand(20,e.range);
addParticle(e.x+Math.cos(aa)*d, e.y+Math.sin(aa)*d,
Math.cos(aa)*rand(40,160), Math.sin(aa)*rand(40,160),
rand(0.25,0.55), rand(4,10), Math.random()<0.5?'#ff8c1a':'#ffd24a');
}
break;
}
case 'ring': {
const prog = 1 - e.life/e.max;
const rad = e.r0 + (e.r1-e.r0)*prog;
e.curR = rad;
if (!e.hitSet) e.hitSet = new Set();
const attacker = snakeById.get(e.o);
for (const s of activeSnakes){
if (!s.alive || s.id===e.o) continue;
if (e.hitSet.has(s.id)) continue;
const d = snakeDistanceApprox(s, e.x, e.y);
if (d > rad) continue;
e.hitSet.add(s.id);
damageSnake(s, e.dmg, attacker||null);
if (!s.alive) continue;
if (e.slow) s.slowT = Math.max(s.slowT, e.slow);
if (e.confuse) s.confuseT = Math.max(s.confuseT, e.confuse);
if (e.knock){
const dx=s.x-e.x, dy=s.y-e.y;
const dd=Math.hypot(dx,dy)||1;
s.x = clamp(s.x + dx/dd*e.knock*0.09, 30, WORLD-30);
s.y = clamp(s.y + dy/dd*e.knock*0.09, 30, WORLD-30);
}
if (e.burn && attacker){
s.burnT = Math.max(s.burnT, rand(3.0,5.0));
s.burnDps = 10*attacker.skin.atk;
}
}
if (e.field){
for (const s of activeSnakes){
if (!s.alive || s.id===e.o) continue;
if (!snakeHitTest(s, e.x, e.y, e.radius)) continue;
s.slowT = Math.max(s.slowT, 0.4);
if (e.confuse) s.confuseT = Math.max(s.confuseT, 0.5);
}
}
break;
}
case 'proj': {
e.x += e.vx*dt; e.y += e.vy*dt;
if (e.x<10||e.y<10||e.x>WORLD-10||e.y>WORLD-10){ effects.splice(i,1); continue; }
let hitSomething=false;
for (const s of activeSnakes){
if (!s.alive || s.id===e.o) continue;
if (!snakeHitTest(s, e.x, e.y, e.r)) continue;
const atk = snakeById.get(e.o);
if (e.aoe>0){
aoeDamage(e.x, e.y, e.aoe, e.o, e.dmg, {slow:1.0});
burst(e.x,e.y,22,e.c,220,9);
if (e.burn){
for (const o of snakes.slice()){
if (!o.alive || o.id===e.o) continue;
if (snakeHitTest(o, e.x, e.y, e.aoe)){
o.burnT = Math.max(o.burnT, rand(3.0,5.0));
o.burnDps = 10*(atk?atk.skin.atk:1);
}
}
}
} else {
damageSnake(s, e.dmg, atk||null);
if (e.slow && s.alive) s.slowT = Math.max(s.slowT, e.slow);
burst(e.x,e.y,10,e.c,170,7);
}
hitSomething=true;
break;
}
if (hitSomething){ effects.splice(i,1); continue; }
if (Math.random()<0.6) addParticle(e.x,e.y,rand(-20,20),rand(-20,20),0.3,e.r*0.6,e.c);
break;
}
case 'cloud': case 'trail': {
const atk = snakeById.get(e.o);
if (e.move && atk && atk.alive){
e.x = atk.x; e.y = atk.y;
}
const age = e.max - e.life;
const growT = Math.min(1, age / 0.35);
const effR = e.r * growT;
e.curR = effR;
const dmg = e.dps*dt;
for (const s of activeSnakes){
if (!s.alive || s.id===e.o) continue;
if (!snakeHitTest(s, e.x, e.y, effR)) continue;
damageSnake(s, dmg, atk||null);
if (s.alive && e.slow) s.slowT = Math.max(s.slowT, e.slow);
}
if (Math.random()<0.5){
const aa=rand(0,TAU), d=rand(0,effR);
addParticle(e.x+Math.cos(aa)*d, e.y+Math.sin(aa)*d, rand(-15,15), rand(-25,-5), rand(0.5,1.1), rand(5,10), e.c);
}
break;
}
case 'pull': {
const atk = snakeById.get(e.o);
const dmg = e.dps*dt;
for (const s of activeSnakes){
if (!s.alive || s.id===e.o) continue;
if (!snakeHitTest(s, e.x, e.y, e.radius)) continue;
damageSnake(s, dmg, atk||null);
if (!s.alive) continue;
const dx=e.x-s.x, dy=e.y-s.y;
const d=Math.hypot(dx,dy);
if (d > 1){
s.x += dx/d*e.force*dt;
s.y += dy/d*e.force*dt;
}
s.slowT = Math.max(s.slowT, 0.3);
}
break;
}
case 'beam': case 'bolt': break;
}
}
}
function updateParticles(dt){
for (let i=particles.length-1;i>=0;i--){
const p = particles[i];
p.life -= dt;
if (p.life<=0){ particles.splice(i,1); continue; }
p.x += p.vx*dt; p.y += p.vy*dt;
p.vx *= 0.94; p.vy *= 0.94;
}
}
/* ====== PHYSICS ====== */
function physics(s, dt){
if (!s.alive) return;
if (s.acd>0) s.acd -= dt;
if (s.slowT>0) s.slowT -= dt;
if (s.confuseT>0) s.confuseT -= dt;
if (s.invuln>0) s.invuln -= dt;
if (s.hitFlash>0) s.hitFlash -= dt;
if (s.burnT>0){
s.burnT -= dt;
damageSnake(s, s.burnDps*dt, null);
if (!s.alive) return;
if (Math.random()<0.6) addParticle(s.x+rand(-s.r,s.r), s.y+rand(-s.r,s.r),
rand(-10,10), -rand(20,50), rand(0.4,0.8), rand(4,9),
Math.random()<0.5?'#ff7a20':'#ffd24a');
if (s.burnT<=0) s.burnDps=0;
}
if (s.shieldOn){
const durRate = s.skin.shieldDur || 16;
s.shield -= durRate*dt;
if (s.shield<=0){ s.shield=0; s.shieldOn=false; }
} else {
s.shield = Math.min(s.shMax, s.shield + 9*dt);
}
if (s.sprinting && s.stamina > 0){
s.stamina -= s.skin.drain * dt;
if (s.stamina < 0) s.stamina = 0;
} else {
s.sprinting = false;
s.stamina = Math.min(s.stMax, s.stamina + s.skin.regen * dt);
}
let turnRate = 4.6 - Math.min(2.2, s.r/26);
if (s.dashT>0) turnRate *= 0.55;
if (s.zigT>0 && s.dashT>0){
s.zigT -= dt;
if (s.zigT<=0){
s.zigAngle = -s.zigAngle;
s.zigT = 0.28;
s.targetAngle = s.angle + s.zigAngle*0.8;
}
}
const diff = angDiff(s.targetAngle, s.angle);
const maxTurn = turnRate*dt;
s.angle += clamp(diff, -maxTurn, maxTurn);
if (s.angle > Math.PI) s.angle -= TAU;
if (s.angle < -Math.PI) s.angle += TAU;
let spd = BASE_SPEED * s.skin.sp;
if (s.slowT>0) spd *= 0.52;
if (s.sprinting) spd *= s.skin.sprintSpd;
if (s.dashT>0){
const boost = s.speedBoost || 2.15;
spd *= boost;
s.dashT -= dt;
if (s.dashT<=0) s.speedBoost = 0;
}
s.speed = spd;
s.x += Math.cos(s.angle)*spd*dt;
s.y += Math.sin(s.angle)*spd*dt;
const pad = s.r + 4;
let hitWall = false;
if (s.x < pad){ s.x = pad; hitWall = true; }
if (s.x > WORLD-pad){ s.x = WORLD-pad; hitWall = true; }
if (s.y < pad){ s.y = pad; hitWall = true; }
if (s.y > WORLD-pad){ s.y = WORLD-pad; hitWall = true; }
if (hitWall){
const toCenter = Math.atan2(WORLD/2-s.y, WORLD/2-s.x);
s.targetAngle = toCenter;
s.angle = toCenter;
}
// Safety: if somehow we got NaN, snap to center-ish and reset path
if (!Number.isFinite(s.x) || !Number.isFinite(s.y)){
s.x = rand(400, WORLD-400);
s.y = rand(400, WORLD-400);
s.path = [{x:s.x, y:s.y}];
s.pathLen = 0;
}
const last = s.path[s.path.length-1];
const dx = s.x-last.x, dy = s.y-last.y;
const seg = Math.sqrt(dx*dx+dy*dy);
s.pathLen += seg;
if (seg > 0.4) s.path.push({x:s.x,y:s.y});
const need = s.totalLen + s.r*3 + 60;
while (s.path.length>3){
const a = s.path[0], b = s.path[1];
const d = Math.hypot(b.x-a.x, b.y-a.y);
if (s.pathLen - d > need){ s.pathLen -= d; s.path.shift(); }
else break;
}
computeBody(s);
if (s.dashT>0){
for (const o of snakes.slice()){
if (o===s || !o.alive) continue;
const ddx=o.x-s.x, ddy=o.y-s.y;
const dd=Math.hypot(ddx,ddy);
if (dd < s.r+o.r+6){
damageSnake(o, 42 * s.skin.atk * growthMul(s) * dt * 3, s);
if (o.alive) o.slowT = Math.max(o.slowT, 0.5);
}
}
}
eatFood(s);
}
function eatFood(s){
const rad = s.r + 16;
const cx = (s.x/FOOD_CELL)|0, cy = (s.y/FOOD_CELL)|0;
const growMul = s.skin.growMul || 1;
for (let i=cx-1;i<=cx+1;i++){
if (i<0||i>=FOOD_GW) continue;
for (let j=cy-1;j<=cy+1;j++){
if (j<0||j>=FOOD_GW) continue;
const arr = foodGrid[i*FOOD_GW+j];
if (!arr) continue;
for (let k=0;k<arr.length;k++){
const f = arr[k];
if (!f.alive) continue;
if (f.cd > 0){ f.cd -= 1/60; continue; }
const dx=f.x-s.x, dy=f.y-s.y;
const rr=rad+f.r;
if (dx*dx+dy*dy < rr*rr){
f.alive = false;
s.score += f.v * growMul;
s.hp = Math.min(s.hpMax, s.hp + f.v*0.5);
updateSize(s);
if (Math.random()<0.3) addParticle(f.x,f.y,rand(-30,30),rand(-30,30),0.35,f.r,f.c);
}
}
}
}
}
/* ====== AI ====== */
function pathClear(s, ang, look){
const cs = Math.cos(ang), sn = Math.sin(ang);
const step = 50;
for (let d=44; d<=look; d+=step){
const x = s.x+cs*d, y = s.y+sn*d;
if (x<60||y<60||x>WORLD-60||y>WORLD-60) return false;
if (pointBlocked(x,y,s.r*0.9,s.id)) return false;
}
return true;
}
function aiSteer(s, desired){
const look = 150 + s.r*2.8 + s.speed*0.6;
if (pathClear(s, desired, look)) return desired;
const offsets = [0.28,-0.28,0.55,-0.55,0.9,-0.9,1.3,-1.3,1.75,-1.75,2.3,-2.3,2.9,-2.9,3.14];
if (s.aiPreferredSide === -1) {
for (let i=0;i<offsets.length;i++) offsets[i] = -offsets[i];
}
for (let i=0;i<offsets.length;i++){
const a = desired + offsets[i];
if (pathClear(s, a, look*0.92)) return a;
}
return desired + Math.PI + rand(-0.6,0.6);
}
function predictPos(target, s, mySpeed){
const dist = Math.hypot(target.x-s.x, target.y-s.y);
const t = dist / Math.max(60, mySpeed);
const skill = s.personality.predict;
const lx = target.x + Math.cos(target.angle) * target.speed * t * skill;
const ly = target.y + Math.sin(target.angle) * target.speed * t * skill;
const turn = angDiff(target.targetAngle, target.angle);
const futureAngle = target.angle + turn * Math.min(1, t*1.5);
const lx2 = target.x + Math.cos(futureAngle) * target.speed * t;
const ly2 = target.y + Math.sin(futureAngle) * target.speed * t;
return { x:(lx+lx2)*0.5, y:(ly+ly2)*0.5 };
}
function threatAhead(s, tx, ty){
const dx = tx - s.x, dy = ty - s.y;
const d = Math.hypot(dx,dy) || 1;
const ux = dx/d, uy = dy/d;
const step = 60;
for (let dist=80; dist<=d && dist<=600; dist+=step){
const px = s.x + ux*dist, py = s.y + uy*dist;
if (pointBlocked(px, py, s.r*1.05, s.id)) return dist;
}
return 0;
}
function aiUpdate(s, dt){
s.aiTimer -= dt;
const P = s.personality;
const mySpeed = BASE_SPEED * s.skin.sp * (s.sprinting ? s.skin.sprintSpd : 1);
const ahead = 70 + s.r*1.7 + s.speed*0.4;
const ax = s.x + Math.cos(s.angle)*ahead;
const ay = s.y + Math.sin(s.angle)*ahead;
const nearEdge = ax<130 || ay<130 || ax>WORLD-130 || ay>WORLD-130;
if (nearEdge || pointBlocked(ax, ay, s.r*0.95, s.id)){
const away = Math.atan2(WORLD/2-s.y, WORLD/2-s.x);
let best = away, bestScore = -1e9;
for (let i=0;i<20;i++){
const a = s.angle + (i-10)*0.28;
if (pathClear(s, a, 200 + s.r*2.6)){
const sc = Math.cos(angDiff(a, away))*100 - Math.abs(i-10)*2.2;
if (sc > bestScore){ bestScore=sc; best=a; }
}
}
s.targetAngle = best;
s.aiTimer = 0;
s.sprinting = false;
return;
}
if (s.aiTimer > 0) return;
s.aiTimer = P.reaction + Math.random()*0.04;
const percept = 1100 * (0.65 + P.skill*0.55);
let prey = null, preyD = 1e9, preyScore = -1e9;
let threat = null, threatD = 1e9;
let nearest = null, nearestD = 1e9;
for (const o of snakes){
if (o===s || !o.alive) continue;
const dx = o.x-s.x, dy = o.y-s.y;
const d = Math.sqrt(dx*dx+dy*dy);
if (d < nearestD){ nearestD = d; nearest = o; }
if (d > percept) continue;
const ratio = o.score / Math.max(1, s.score);
if (ratio < 0.8){
const sc = 900 - d + (1-ratio)*600;
if (sc > preyScore){ preyScore=sc; prey = o; preyD = d; }
}
if (ratio > 1.25){
if (d < threatD){ threatD = d; threat = o; }
}
}
const hpFrac = s.hp / s.hpMax;
const fleeRadius = 280 + s.r*4 + P.caution*380;
const danger = threat && threatD < fleeRadius && (hpFrac < 0.85 || threat.score > s.score*1.8);
if (danger){
s.aiState = 'flee';
let away = Math.atan2(s.y-threat.y, s.x-threat.x);
if (s.x<700 || s.y<700 || s.x>WORLD-700 || s.y>WORLD-700){
away = Math.atan2(WORLD/2-s.y, WORLD/2-s.x);
}
s.targetAngle = aiSteer(s, away);
if (s.shield > s.shMax*0.4 && threatD < 400) s.shieldOn = true;
else s.shieldOn = false;
if (threatD < 380 && s.stamina > s.stMax*0.25) s.sprinting = true;
else s.sprinting = false;
return;
}
s.shieldOn = false;
const huntRange = P.aggr*900 + 150;
const wantHunt = prey && preyD < huntRange && P.aggr > 0.28;
if (wantHunt){
s.aiState = 'hunt';
const predicted = predictPos(prey, s, mySpeed);
let ang = Math.atan2(predicted.y-s.y, predicted.x-s.x);
if (preyD > 200 && preyD < 700 && P.skill > 0.7){
const perp = Math.atan2(prey.y-s.y, prey.x-s.x) + Math.PI/2 * s.aiPreferredSide;
const blend = 0.25;
ang = Math.atan2(
Math.sin(ang)*(1-blend) + Math.sin(perp)*blend,
Math.cos(ang)*(1-blend) + Math.cos(perp)*blend
);
}
const dangerDist = threatAhead(s, predicted.x, predicted.y);
if (dangerDist && dangerDist < 220){
const perp = ang + Math.PI/2 * s.aiPreferredSide;
s.targetAngle = aiSteer(s, perp);
} else {
s.targetAngle = aiSteer(s, ang);
}
if (preyD > 260 && preyD < 700 && s.stamina > s.stMax*0.35){
s.sprinting = true;
} else if (preyD < 180){
s.sprinting = false;
}
if (s.acd <= 0 && !s.skin.noAttack){
const angToPrey = Math.atan2(prey.y-s.y, prey.x-s.x);
const facing = Math.abs(angDiff(s.angle, angToPrey)) < 0.55;
const abKind = s.skin.ab;
const rm = rangeMul(s);
const isMelee = abKind==='vdash'||abKind==='sprint'||abKind==='zigzag'||abKind==='tail'||abKind==='shock'||abKind==='wave'||abKind==='maelstrom'||abKind==='constrict'||abKind==='crush'||abKind==='sting'||abKind==='spikeburst'||abKind==='burrow'||abKind==='rebirth'||abKind==='stink';
const isRanged = abKind==='spit'||abKind==='fireball'||abKind==='triple'||abKind==='fire'||abKind==='lightning'||abKind==='thunder'||abKind==='acid';
const dist = preyD;
if (facing){
if (isMelee && dist < 200*rm && dist > 55) useAbility(s);
else if (isRanged && dist < 360*rm) useAbility(s);
else if (!isMelee && !isRanged && dist < 300*rm) useAbility(s);
}
}
return;
}
s.aiState = 'farm';
s.sprinting = false;
let target = null, bestSc = -1e9;
const frad = 640 + P.greed*440;
const cx = (s.x/FOOD_CELL)|0, cy = (s.y/FOOD_CELL)|0;
const span = Math.ceil(frad/FOOD_CELL);
for (let i=cx-span;i<=cx+span;i++){
if (i<0||i>=FOOD_GW) continue;
for (let j=cy-span;j<=cy+span;j++){
if (j<0||j>=FOOD_GW) continue;
const arr = foodGrid[i*FOOD_GW+j];
if (!arr) continue;
for (let k=0;k<arr.length;k++){
const f = arr[k];
if (!f.alive) continue;
const dx=f.x-s.x, dy=f.y-s.y;
const d2 = dx*dx+dy*dy;
if (d2 > frad*frad) continue;
const sc = f.v*60 - Math.sqrt(d2);
if (sc > bestSc){ bestSc=sc; target=f; }
}
}
}
let ang;
if (target){
ang = Math.atan2(target.y-s.y, target.x-s.x);
} else {
s.aiWanderT -= 0.1;
if (s.aiWanderT <= 0){ s.aiWanderAng = rand(0,TAU); s.aiWanderT = rand(1.6,3.5); }
ang = s.aiWanderAng;
}
if (threat && threatD < 480){
const away = Math.atan2(s.y-threat.y, s.x-threat.x);
ang = Math.atan2(
Math.sin(ang)*0.55 + Math.sin(away)*0.45,
Math.cos(ang)*0.55 + Math.cos(away)*0.45
);
}
const edgeMargin = 650;
if (s.x < edgeMargin || s.y < edgeMargin || s.x > WORLD-edgeMargin || s.y > WORLD-edgeMargin){
const toCenter = Math.atan2(WORLD/2-s.y, WORLD/2-s.x);
ang = Math.atan2(
Math.sin(ang)*0.35 + Math.sin(toCenter)*0.65,
Math.cos(ang)*0.35 + Math.cos(toCenter)*0.65
);
}
if (nearestD < 60 && nearest){
ang += 0.4 * s.aiPreferredSide;
}
s.targetAngle = aiSteer(s, ang);
}
/* ====== COLLISIONS ====== */
function handleCollisions(){
const dead = [];
for (const s of snakes){
if (!s.alive) continue;
if (s.invuln > 0) continue; // fresh spawn protection
const hitId = pointHit(s.x, s.y, s.r*0.82, s.id);
if (hitId !== -1){
if (s.shieldOn && s.shield > 0){
s.shield -= 34;
if (s.shield < 0) s.shield = 0;
const other = snakeById.get(hitId);
if (other){
const dx=s.x-other.x, dy=s.y-other.y;
const d=Math.hypot(dx,dy)||1;
s.x += dx/d*6; s.y += dy/d*6;
}
if (Math.random()<0.3) burst(s.x,s.y,3,'#7fd8f0',90,5);
} else {
dead.push([s, snakeById.get(hitId)]);
}
}
}
for (const [s, killer] of dead){
if (s.alive) killSnake(s, killer);
}
}
/* ====== CAMERA ====== */
function updateCamera(dt){
if (!player || !player.alive) return;
const lead = 70;
const tx = player.x + Math.cos(player.angle)*lead;
const ty = player.y + Math.sin(player.angle)*lead;
const k = Math.min(1, dt*6.5);
cam.x += (tx-cam.x)*k;
cam.y += (ty-cam.y)*k;
cam.zoomCur += (cam.zoom-cam.zoomCur)*Math.min(1, dt*9);
cam.scale = cam.baseScale * cam.zoomCur;
}
/* ====== SPAWN ====== */
function getPlayerNick(){
const v = document.getElementById('nickInput').value.trim();
if (v) return v.slice(0,14);
return t('nickDefault');
}
function spawnPlayer(){
const skinIdx = selectedSkin;
const name = getPlayerNick();
let x, y, tries=0;
do {
x = rand(600, WORLD-600);
y = rand(600, WORLD-600);
tries++;
} while (tries<40 && pointBlocked(x, y, 60, -1));
const s = makeSnake(name, skinIdx, true, x, y);
s.invuln = 2.5;
addSnake(s);
player = s;
cam.x = x; cam.y = y;
cam.zoom = 1; cam.zoomCur = 1;
cam.scale = cam.baseScale;
}
function spawnBot(){
let skinIdx;
let tries = 0;
do {
skinIdx = randInt(0, SKINS.length-1);
tries++;
} while (tries < 30 && !isSkinAvailableForBots(skinIdx));
if (!isSkinAvailableForBots(skinIdx)) skinIdx = 0;
const pool = (LANG==='ru') ? NICKS_RU : NICKS_EN;
const name = usedNicks.length ? usedNicks.pop() : pool[randInt(0, pool.length-1)];
let x, y, tries2 = 0;
do {
x = rand(180, WORLD-180);
y = rand(180, WORLD-180);
tries2++;
} while (tries2 < 30 && pointBlocked(x, y, 40, -1));
const s = makeSnake(name, skinIdx, false, x, y);
s.invuln = 1.5;
addSnake(s);
}
function setupMatch(){
snakes = [];
snakeById.clear();
effects = [];
particles = [];
textPops = [];
respawnQueue = [];
nextId = 1;
// Use FULL shuffled nick pool for the language
const pool = shuffle((LANG==='ru'?NICKS_RU:NICKS_EN).slice());
usedNicks = pool.slice();
spawnPlayer();
for (let i=0;i<BOT_COUNT;i++) spawnBot();
}
/* ====== RENDER ====== */
function render(){
ctx.setTransform(dpr,0,0,dpr,0,0);
if (state !== 'playing' && state !== 'dead'){
drawMenuBg();
return;
}
ctx.fillStyle = currentMap.ground[0];
ctx.fillRect(0,0,cw,ch);
ctx.save();
ctx.translate(cw/2, ch/2);
ctx.scale(cam.scale, cam.scale);
ctx.translate(-cam.x, -cam.y);
const vw = cw/cam.scale, vh = ch/cam.scale;
const vx0 = cam.x - vw/2 - 60, vy0 = cam.y - vh/2 - 60;
const vx1 = cam.x + vw/2 + 60, vy1 = cam.y + vh/2 + 60;
drawWorld(vx0, vy0, vx1, vy1);
drawFood(vx0, vy0, vx1, vy1);
drawEffects(false);
drawSnakes(vx0, vy0, vx1, vy1);
drawEffects(true);
drawParticles();
drawTextPops();
drawBorder();
ctx.restore();
if (!render._vg){
render._vg = ctx.createRadialGradient(cw/2, ch/2, Math.min(cw,ch)*0.35, cw/2, ch/2, Math.max(cw,ch)*0.75);
render._vg.addColorStop(0, 'rgba(0,0,0,0)');
render._vg.addColorStop(1, 'rgba(0,0,0,0.5)');
}
ctx.fillStyle = render._vg;
ctx.fillRect(0,0,cw,ch);
}
function drawMenuBg(){
const isWinter = currentMapKey === 'winter';
const g = ctx.createRadialGradient(cw/2, ch*0.35, 20, cw/2, ch*0.5, Math.max(cw,ch)*0.8);
if (isWinter){ g.addColorStop(0,'#1a2c3a'); g.addColorStop(1,'#070c12'); }
else { g.addColorStop(0,'#182a18'); g.addColorStop(1,'#070c07'); }
ctx.fillStyle = g;
ctx.fillRect(0,0,cw,ch);
for (let i=0;i<14;i++){
const a = time*0.22 + i*1.7;
const x = cw/2 + Math.cos(a*0.7+i)*(cw*0.45);
const y = ch/2 + Math.sin(a+i*2.1)*(ch*0.4);
const r = 30 + (i%4)*24;
ctx.beginPath();
ctx.arc(x,y,r,0,TAU);
if (isWinter) ctx.fillStyle = `rgba(${180+i*3},${200+i*3},${220+i*2},0.12)`;
else ctx.fillStyle = `rgba(${40+i*6},${90+i*4},${40+i*3},0.14)`;
ctx.fill();
}
}
function drawWorld(vx0, vy0, vx1, vy1){
const M = currentMap;
const gs = 220;
const sx = Math.max(0, Math.floor(vx0/gs));
const sy = Math.max(0, Math.floor(vy0/gs));
const ex = Math.min(Math.ceil(WORLD/gs), Math.ceil(vx1/gs));
const ey = Math.min(Math.ceil(WORLD/gs), Math.ceil(vy1/gs));
for (let i=sx;i<ex;i++){
for (let j=sy;j<ey;j++){
const h = ((i*73856093) ^ (j*19349663)) >>> 0;
ctx.fillStyle = M.ground[h%5];
ctx.fillRect(i*gs, j*gs, gs+1, gs+1);
}
}
for (let i=0;i<decos.length;i++){
const d = decos[i];
if (d.x < vx0-300 || d.x > vx1+300 || d.y < vy0-300 || d.y > vy1+300) continue;
if (d.t==='pond'){
ctx.save();
ctx.translate(d.x, d.y);
ctx.rotate(d.rot);
ctx.beginPath(); ctx.ellipse(0,0,d.rx,d.ry,0,0,TAU);
ctx.fillStyle = M.pond1; ctx.fill();
ctx.beginPath(); ctx.ellipse(0,0,d.rx*0.86,d.ry*0.86,0,0,TAU);
ctx.fillStyle = M.pond2; ctx.fill();
ctx.restore();
} else if (d.t==='smallpond'){
ctx.beginPath(); ctx.arc(d.x, d.y, d.r, 0, TAU);
ctx.fillStyle = M.pond2; ctx.fill();
ctx.beginPath(); ctx.arc(d.x, d.y, d.r*0.7, 0, TAU);
ctx.fillStyle = M.pond1; ctx.fill();
ctx.beginPath(); ctx.arc(d.x + d.r*0.3, d.y - d.r*0.2, d.r*0.28, 0, TAU);
ctx.fillStyle = 'rgba(60,120,60,0.7)'; ctx.fill();
} else if (d.t==='tree'){
const r = d.r;
ctx.beginPath(); ctx.arc(d.x+r*0.12, d.y+r*0.16, r, 0, TAU);
ctx.fillStyle = 'rgba(0,0,0,0.32)'; ctx.fill();
ctx.beginPath(); ctx.arc(d.x, d.y, r, 0, TAU);
ctx.fillStyle = d.sh>0.5?M.decoTree:M.decoTree2; ctx.fill();
ctx.beginPath(); ctx.arc(d.x-r*0.28, d.y-r*0.3, r*0.56, 0, TAU);
ctx.fillStyle = M.decoTree2; ctx.fill();
ctx.beginPath(); ctx.arc(d.x-r*0.36, d.y-r*0.4, r*0.3, 0, TAU);
ctx.fillStyle = M.decoTreeHi; ctx.fill();
} else if (d.t==='bush'){
const r = d.r;
for (let k=0;k<3;k++){
const ax = d.x + (k-1)*r*0.55;
const ay = d.y + (k%2 ? -r*0.15 : r*0.15);
ctx.beginPath(); ctx.arc(ax, ay, r, 0, TAU);
ctx.fillStyle = d.sh>0.5 ? M.bush1 : M.bush2; ctx.fill();
}
ctx.beginPath(); ctx.arc(d.x - r*0.5, d.y - r*0.4, r*0.35, 0, TAU);
ctx.fillStyle = M.bushHi; ctx.globalAlpha = 0.6; ctx.fill(); ctx.globalAlpha = 1;
} else if (d.t==='snowman'){
const r = d.r;
ctx.beginPath(); ctx.arc(d.x, d.y + r*0.7, r*0.7, 0, TAU);
ctx.fillStyle = '#eef4fa'; ctx.fill();
ctx.beginPath(); ctx.arc(d.x, d.y + r*0.05, r*0.5, 0, TAU);
ctx.fillStyle = '#f8fbff'; ctx.fill();
ctx.beginPath(); ctx.arc(d.x, d.y - r*0.45, r*0.38, 0, TAU);
ctx.fillStyle = '#ffffff'; ctx.fill();
ctx.fillStyle = '#222';
ctx.beginPath(); ctx.arc(d.x-r*0.14, d.y-r*0.5, r*0.06, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(d.x+r*0.14, d.y-r*0.5, r*0.06, 0, TAU); ctx.fill();
ctx.fillStyle = '#ff8030';
ctx.beginPath();
ctx.moveTo(d.x, d.y-r*0.42);
ctx.lineTo(d.x+r*0.3, d.y-r*0.36);
ctx.lineTo(d.x, d.y-r*0.32);
ctx.closePath(); ctx.fill();
ctx.fillStyle = '#3a3a3a';
ctx.fillRect(d.x-r*0.35, d.y-r*0.75, r*0.7, r*0.08);
ctx.fillRect(d.x-r*0.22, d.y-r*1.05, r*0.44, r*0.32);
} else if (d.t==='iceriver'){
ctx.save();
ctx.translate(d.x, d.y);
ctx.rotate(d.rot);
const grad = ctx.createLinearGradient(0, -d.wid, 0, d.wid);
grad.addColorStop(0, 'rgba(200,230,250,0.75)');
grad.addColorStop(0.5, 'rgba(150,200,235,0.9)');
grad.addColorStop(1, 'rgba(180,215,240,0.75)');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.ellipse(0, 0, d.len/2, d.wid/2, 0, 0, TAU);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 2;
for (let k=-1;k<=1;k++){
ctx.beginPath();
ctx.moveTo(-d.len/2 + 10, k*d.wid*0.25);
ctx.lineTo(d.len/2 - 10, k*d.wid*0.25);
ctx.stroke();
}
ctx.restore();
} else {
ctx.beginPath(); ctx.arc(d.x, d.y, d.r, 0, TAU);
ctx.fillStyle = M.rock1; ctx.fill();
ctx.beginPath(); ctx.arc(d.x-d.r*0.25, d.y-d.r*0.25, d.r*0.55, 0, TAU);
ctx.fillStyle = M.rock2; ctx.fill();
}
}
}
function drawBorder(){
ctx.lineWidth = 16;
ctx.strokeStyle = currentMap.border;
ctx.strokeRect(0,0,WORLD,WORLD);
ctx.lineWidth = 5;
ctx.strokeStyle = currentMap.border2;
ctx.strokeRect(8,8,WORLD-16,WORLD-16);
}
function drawFood(vx0, vy0, vx1, vy1){
const i0 = Math.max(0, Math.floor(vx0/FOOD_CELL));
const i1 = Math.min(FOOD_GW-1, Math.ceil(vx1/FOOD_CELL));
const j0 = Math.max(0, Math.floor(vy0/FOOD_CELL));
const j1 = Math.min(FOOD_GW-1, Math.ceil(vy1/FOOD_CELL));
const pulse = 1 + Math.sin(time*5)*0.08;
for (let i=i0;i<=i1;i++){
for (let j=j0;j<=j1;j++){
const arr = foodGrid[i*FOOD_GW+j];
if (!arr) continue;
for (let k=0;k<arr.length;k++){
const f = arr[k];
if (!f.alive) continue;
if (f.x<vx0||f.x>vx1||f.y<vy0||f.y>vy1) continue;
const r = f.v>=16 ? f.r*pulse : f.r;
if (f.v>=16){
ctx.beginPath(); ctx.arc(f.x, f.y, r*2.0, 0, TAU);
ctx.fillStyle = f.c; ctx.globalAlpha = 0.16; ctx.fill(); ctx.globalAlpha = 1;
} else if (f.v>=6){
ctx.beginPath(); ctx.arc(f.x, f.y, r*1.6, 0, TAU);
ctx.fillStyle = f.c; ctx.globalAlpha = 0.13; ctx.fill(); ctx.globalAlpha = 1;
}
ctx.beginPath(); ctx.arc(f.x, f.y, r, 0, TAU);
ctx.fillStyle = f.c; ctx.fill();
ctx.beginPath(); ctx.arc(f.x-r*0.28, f.y-r*0.3, r*0.34, 0, TAU);
ctx.fillStyle = 'rgba(255,255,255,0.5)'; ctx.fill();
}
}
}
}
function drawSnakes(vx0, vy0, vx1, vy1){
for (const s of snakes){
if (!s.alive) continue;
if (s.x < vx0-400 || s.x > vx1+400 || s.y < vy0-400 || s.y > vy1+400) continue;
drawSnake(s);
}
}
function drawSnake(s){
const n = s.nBody;
if (n<1) return;
const r = s.r;
const b = s.body;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(b[0], b[1]);
for (let i=1;i<n;i++) ctx.lineTo(b[i*2], b[i*2+1]);
if (n===1) ctx.lineTo(b[0]+0.01, b[1]);
ctx.strokeStyle = s.skin.b2;
ctx.lineWidth = r*2 + Math.max(3, r*0.34);
ctx.stroke();
ctx.strokeStyle = s.hitFlash>0 ? '#ffffff' : s.skin.b;
ctx.lineWidth = r*2;
ctx.stroke();
drawSideSpikes(s, r, n);
drawScalePattern(s, r, n);
drawTail(s, r, n);
// Burger-body decorations (lettuce/tomato/cheese stripes)
if (s.skin.visual === 'burger'){
ctx.save();
// green lettuce line
ctx.beginPath();
ctx.moveTo(b[0], b[1]);
for (let i=1;i<n;i++){
const t = i/n;
const px = b[i*2], py = b[i*2+1];
const off = Math.sin(i*0.7)*r*0.25;
ctx.lineTo(px + off*0.3, py + off*0.3);
}
ctx.strokeStyle = '#6fbf3a';
ctx.lineWidth = r*0.4;
ctx.globalAlpha = 0.75;
ctx.stroke();
// red tomato line
ctx.beginPath();
ctx.moveTo(b[0], b[1]);
for (let i=1;i<n;i++){
const px = b[i*2], py = b[i*2+1];
const off = Math.sin(i*0.7 + 1.2)*r*0.35;
ctx.lineTo(px + off*0.2, py + off*0.2);
}
ctx.strokeStyle = '#d6382a';
ctx.lineWidth = r*0.28;
ctx.globalAlpha = 0.8;
ctx.stroke();
ctx.globalAlpha = 1;
ctx.restore();
}
ctx.beginPath();
ctx.moveTo(b[0], b[1]);
for (let i=1;i<n;i++) ctx.lineTo(b[i*2], b[i*2+1]);
ctx.strokeStyle = s.skin.p;
ctx.globalAlpha = 0.45;
ctx.lineWidth = r*0.55;
ctx.stroke();
ctx.globalAlpha = 1;
if (s.burnT > 0){
ctx.beginPath();
ctx.moveTo(b[0], b[1]);
for (let i=1;i<n;i++) ctx.lineTo(b[i*2], b[i*2+1]);
ctx.strokeStyle = `rgba(255,140,40,${0.4+Math.sin(time*20)*0.15})`;
ctx.lineWidth = r*2.3;
ctx.stroke();
}
if (s.shieldOn && s.shield>0){
ctx.beginPath();
ctx.moveTo(b[0], b[1]);
for (let i=1;i<n;i++) ctx.lineTo(b[i*2], b[i*2+1]);
ctx.strokeStyle = 'rgba(110,220,255,' + (0.25+0.18*Math.sin(time*12)) + ')';
ctx.lineWidth = r*2.55;
ctx.stroke();
}
if (s.sprinting){
ctx.beginPath();
ctx.arc(s.x, s.y, r*1.9, 0, TAU);
ctx.strokeStyle = 'rgba(255,220,120,0.5)';
ctx.lineWidth = 3;
ctx.stroke();
}
if (s.slowT > 0){
ctx.beginPath();
ctx.arc(s.x, s.y, r*1.7, 0, TAU);
ctx.strokeStyle = 'rgba(140,230,245,0.55)';
ctx.lineWidth = 3;
ctx.stroke();
}
if (s.confuseT > 0){
ctx.beginPath();
ctx.arc(s.x, s.y, r*2.0, time*4, time*4+1.6);
ctx.strokeStyle = 'rgba(240,225,80,0.75)';
ctx.lineWidth = 4;
ctx.stroke();
}
drawHead(s);
}
function drawTail(s, r, n){
if (n<2) return;
const b = s.body;
const tailType = s.skin.tail;
if (tailType === 'plain') return;
const ix = (n-1);
const tx = b[ix*2], ty = b[ix*2+1];
const px = b[(ix-1)*2], py = b[(ix-1)*2+1];
const ang = Math.atan2(ty-py, tx-px);
ctx.save();
ctx.translate(tx, ty);
ctx.rotate(ang);
if (tailType === 'club'){
ctx.beginPath();
ctx.arc(0, 0, r*1.35, 0, TAU);
ctx.fillStyle = s.skin.b2; ctx.fill();
ctx.strokeStyle = s.skin.p; ctx.lineWidth = Math.max(2, r*0.2); ctx.stroke();
ctx.fillStyle = s.skin.p;
for (let i=0;i<6;i++){
const aa = i/6*TAU;
ctx.beginPath();
ctx.moveTo(Math.cos(aa)*r*1.2, Math.sin(aa)*r*1.2);
ctx.lineTo(Math.cos(aa)*r*1.85, Math.sin(aa)*r*1.85);
ctx.lineTo(Math.cos(aa+0.3)*r*1.15, Math.sin(aa+0.3)*r*1.15);
ctx.closePath();
ctx.fill();
}
} else if (tailType === 'blade'){
ctx.beginPath();
ctx.moveTo(0, -r*0.6);
ctx.lineTo(r*2.0, 0);
ctx.lineTo(0, r*0.6);
ctx.closePath();
ctx.fillStyle = s.skin.p; ctx.fill();
ctx.strokeStyle = s.skin.b2; ctx.lineWidth = 2; ctx.stroke();
} else if (tailType === 'fan'){
for (let i=-3;i<=3;i++){
const a = i*0.18;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(a)*r*1.9, Math.sin(a)*r*1.9);
ctx.lineTo(Math.cos(a+0.15)*r*1.4, Math.sin(a+0.15)*r*1.4);
ctx.closePath();
ctx.fillStyle = s.skin.p; ctx.fill();
}
} else if (tailType === 'whip'){
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(r*1.8, -r*0.7, r*3.4, 0);
ctx.strokeStyle = s.skin.p;
ctx.lineWidth = Math.max(2, r*0.35);
ctx.stroke();
} else if (tailType === 'spikes'){
ctx.fillStyle = s.skin.p;
for (let i=0;i<3;i++){
const x = i*r*0.5;
for (const sg of [-1,1]){
ctx.beginPath();
ctx.moveTo(x, sg*r*0.4);
ctx.lineTo(x + r*0.3, sg*r*1.2);
ctx.lineTo(x + r*0.6, sg*r*0.4);
ctx.closePath();
ctx.fill();
}
}
}
ctx.restore();
}
function drawSideSpikes(s, r, n){
const sp = s.skin.sideSpikes;
if (!sp || !sp.count || !sp.len) return;
const b = s.body;
const stride = Math.max(2, Math.floor(n / (sp.count*3)));
for (let i=stride; i<n-1; i+=stride){
const nx = b[i*2+2] - b[i*2-2];
const ny = b[i*2+3] - b[i*2-1];
const len = Math.hypot(nx,ny)||1;
const perpX = -ny/len, perpY = nx/len;
for (const sg of [-1,1]){
const bx = b[i*2] + perpX*sg*r*0.85;
const by = b[i*2+1] + perpY*sg*r*0.85;
const ex = b[i*2] + perpX*sg*(r + r*sp.len);
const ey = b[i*2+1] + perpY*sg*(r + r*sp.len);
let w;
if (sp.style === 'round') w = r*0.22;
else if (sp.style === 'blade') w = r*0.32;
else w = r*0.26;
ctx.beginPath();
ctx.moveTo(bx + (nx/len)*w, by + (ny/len)*w);
if (sp.style === 'round'){
ctx.quadraticCurveTo(ex, ey, bx - (nx/len)*w, by - (ny/len)*w);
} else {
ctx.lineTo(ex, ey);
ctx.lineTo(bx - (nx/len)*w, by - (ny/len)*w);
}
ctx.closePath();
ctx.fillStyle = s.skin.p;
ctx.strokeStyle = s.skin.b2;
ctx.lineWidth = 1.5;
ctx.fill();
ctx.stroke();
}
}
}
function drawScalePattern(s, r, n){
const style = s.skin.scale;
const b = s.body;
if (style === 'smooth') return;
ctx.save();
const step = style === 'diamond' ? 6 : (style === 'spots' ? 7 : (style === 'glow' ? 5 : 8));
for (let i=0;i<n;i+=step){
const x = b[i*2], y = b[i*2+1];
if (style === 'stripes'){
ctx.beginPath(); ctx.arc(x, y, r*0.75, 0, TAU);
ctx.fillStyle = s.skin.b2; ctx.globalAlpha = 0.5; ctx.fill(); ctx.globalAlpha = 1;
} else if (style === 'spots'){
ctx.beginPath(); ctx.arc(x, y, r*0.35, 0, TAU);
ctx.fillStyle = s.skin.p; ctx.globalAlpha = 0.7; ctx.fill(); ctx.globalAlpha = 1;
} else if (style === 'diamond'){
ctx.save(); ctx.translate(x, y);
ctx.beginPath();
ctx.moveTo(0, -r*0.55); ctx.lineTo(r*0.42, 0);
ctx.lineTo(0, r*0.55); ctx.lineTo(-r*0.42, 0);
ctx.closePath();
ctx.fillStyle = s.skin.p; ctx.globalAlpha = 0.35; ctx.fill();
ctx.restore(); ctx.globalAlpha = 1;
} else if (style === 'armor'){
ctx.beginPath(); ctx.arc(x, y, r*0.85, 0, TAU);
ctx.strokeStyle = s.skin.b2; ctx.lineWidth = 2;
ctx.globalAlpha = 0.6; ctx.stroke(); ctx.globalAlpha = 1;
} else if (style === 'belly'){
if (i % 2 === 0){
ctx.beginPath(); ctx.arc(x, y, r*0.5, 0, TAU);
ctx.fillStyle = 'rgba(255,255,255,0.12)'; ctx.fill();
}
} else if (style === 'glow'){
const pulse = 0.5 + Math.sin(time*3 + i)*0.3;
ctx.beginPath(); ctx.arc(x, y, r*0.5, 0, TAU);
ctx.fillStyle = s.skin.p; ctx.globalAlpha = pulse*0.7; ctx.fill(); ctx.globalAlpha = 1;
}
}
ctx.restore();
}
function drawHead(s){
const r = s.r;
const a = s.angle;
const ex = Math.cos(a), ey = Math.sin(a);
const px = -ey, py = ex;
const H = s.skin.head;
const skin = s.skin;
const isBurger = (skin.visual === 'burger');
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(a);
if (H.frill === 'hood'){
ctx.beginPath();
ctx.ellipse(-r*0.4, 0, r*1.35, r*1.55, 0, 0, TAU);
ctx.fillStyle = skin.b2;
ctx.fill();
ctx.strokeStyle = skin.p;
ctx.lineWidth = Math.max(1.5, r*0.1);
ctx.stroke();
}
let hrx = r*1.1, hry = r*1.1;
if (H.shape === 'long'){ hrx = r*1.4; hry = r*0.95; }
else if (H.shape === 'broad'){ hrx = r*1.2; hry = r*1.28; }
else if (H.shape === 'pointed'){ hrx = r*1.45; hry = r*0.9; }
else if (H.shape === 'flat'){ hrx = r*1.28; hry = r*0.88; }
else if (H.shape === 'skull'){ hrx = r*1.18; hry = r*1.08; }
else if (H.shape === 'round'){ hrx = r*1.05; hry = r*1.05; }
ctx.beginPath();
ctx.ellipse(0, 0, hrx, hry, 0, 0, TAU);
ctx.fillStyle = isBurger ? '#c88040' : skin.h;
ctx.fill();
ctx.strokeStyle = isBurger ? '#8a5a20' : skin.b2;
ctx.lineWidth = Math.max(2, r*0.15);
ctx.stroke();
// Burger: lettuce fringe + cheese + tomato + sesame seeds
if (isBurger){
// Lettuce (wavy green line)
ctx.beginPath();
for (let k=-3;k<=3;k++){
const ang = k*0.45;
const lx = Math.cos(ang)*hrx*1.02;
const ly = Math.sin(ang)*hry*1.02;
if (k===-3) ctx.moveTo(lx, ly); else ctx.lineTo(lx, ly);
ctx.lineTo(lx*0.85, ly*0.85);
}
ctx.strokeStyle = '#6fbf3a';
ctx.lineWidth = Math.max(2, r*0.15);
ctx.stroke();
// Tomato circle at back
ctx.beginPath();
ctx.ellipse(-hrx*0.55, 0, r*0.35, r*0.55, 0, 0, TAU);
ctx.fillStyle = '#d6382a';
ctx.globalAlpha = 0.9;
ctx.fill();
ctx.globalAlpha = 1;
// Cheese drip
ctx.beginPath();
ctx.moveTo(-hrx*0.2, hry*0.4);
ctx.lineTo(hrx*0.1, hry*0.85);
ctx.lineTo(hrx*0.35, hry*0.4);
ctx.closePath();
ctx.fillStyle = '#ffd24a';
ctx.fill();
// Sesame seeds on top
ctx.fillStyle = '#fff8e0';
const seeds = [[-0.55,-0.35],[0.0,-0.55],[0.55,-0.35],[-0.35,0.0],[0.35,0.0],[-0.15,0.35],[0.35,0.35],[-0.6,0.15]];
for (const [sx, sy] of seeds){
ctx.beginPath();
ctx.ellipse(sx*hrx, sy*hry, r*0.14, r*0.08, sx*0.6, 0, TAU);
ctx.fill();
}
}
if (H.crest === 'ridge'){
ctx.fillStyle = skin.p;
for (let i=0;i<4;i++){
const ax = -r*0.6 + i*r*0.32;
const hh = r*(0.85 - Math.abs(i-1.5)*0.15);
ctx.beginPath();
ctx.moveTo(ax-r*0.1, -hry*0.6);
ctx.lineTo(ax, -hry*0.6-hh);
ctx.lineTo(ax+r*0.1, -hry*0.6);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(ax-r*0.1, hry*0.6);
ctx.lineTo(ax, hry*0.6+hh);
ctx.lineTo(ax+r*0.1, hry*0.6);
ctx.closePath(); ctx.fill();
}
} else if (H.crest === 'plate'){
ctx.fillStyle = skin.p;
ctx.beginPath();
ctx.moveTo(-r*0.6, -hry*0.7);
ctx.lineTo(-r*0.2, -hry*1.3);
ctx.lineTo(r*0.2, -hry*0.7);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(-r*0.6, hry*0.7);
ctx.lineTo(-r*0.2, hry*1.3);
ctx.lineTo(r*0.2, hry*0.7);
ctx.closePath(); ctx.fill();
}
if (H.horns === 2){
ctx.fillStyle = '#e8e0c0';
for (const sg of [-1,1]){
ctx.beginPath();
ctx.moveTo(-r*0.15, sg*r*0.7);
ctx.lineTo(-r*0.95, sg*r*1.2);
ctx.lineTo(-r*0.05, sg*r*0.95);
ctx.closePath(); ctx.fill();
}
} else if (H.horns === 4){
ctx.fillStyle = '#e8e0c0';
for (const sg of [-1,1]){
ctx.beginPath();
ctx.moveTo(r*0.15, sg*r*0.55);
ctx.lineTo(r*0.6, sg*r*1.05);
ctx.lineTo(r*0.35, sg*r*0.5);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(-r*0.5, sg*r*0.6);
ctx.lineTo(-r*1.0, sg*r*1.05);
ctx.lineTo(-r*0.3, sg*r*0.6);
ctx.closePath(); ctx.fill();
}
} else if (H.horns === 'curve'){
ctx.strokeStyle = '#e8e0c0';
ctx.lineWidth = Math.max(3, r*0.18);
ctx.lineCap = 'round';
for (const sg of [-1,1]){
ctx.beginPath();
ctx.moveTo(-r*0.1, sg*r*0.55);
ctx.quadraticCurveTo(-r*0.9, sg*r*1.35, -r*1.2, sg*r*0.65);
ctx.stroke();
}
} else if (H.horns === 'straight'){
ctx.strokeStyle = '#e8e0c0';
ctx.lineWidth = Math.max(3, r*0.16);
for (const sg of [-1,1]){
ctx.beginPath();
ctx.moveTo(-r*0.15, sg*r*0.7);
ctx.lineTo(-r*1.1, sg*r*0.7);
ctx.stroke();
}
}
if (H.teeth === 'fangs' && !isBurger){
ctx.fillStyle = '#ffffff';
for (const sg of [-1,1]){
ctx.beginPath();
ctx.moveTo(hrx*0.65, sg*r*0.35);
ctx.lineTo(hrx*1.05, sg*r*0.15);
ctx.lineTo(hrx*0.7, sg*r*0.5);
ctx.closePath(); ctx.fill();
}
} else if (H.teeth === 'rows' && !isBurger){
ctx.fillStyle = '#f0f0f0';
for (const sg of [-1,1]){
for (let i=0;i<3;i++){
const tx = hrx*0.4 + i*hrx*0.18;
ctx.beginPath();
ctx.moveTo(tx, sg*r*0.35);
ctx.lineTo(tx+r*0.06, sg*r*0.52);
ctx.lineTo(tx+r*0.12, sg*r*0.35);
ctx.closePath(); ctx.fill();
}
}
}
ctx.restore();
drawEyes(s, r, ex, ey, px, py);
if (s.isPlayer || cam.scale * r > 9){
ctx.font = 'bold ' + Math.max(10, r*0.75) + 'px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.lineWidth = Math.max(2, r*0.18);
ctx.strokeStyle = 'rgba(0,0,0,0.75)';
ctx.strokeText(s.name, s.x, s.y - r*1.8);
ctx.fillStyle = s.isPlayer ? '#ffe45e' : '#e8f0e8';
ctx.fillText(s.name, s.x, s.y - r*1.8);
}
}
function drawEyes(s, r, ex, ey, px, py){
const H = s.skin.head;
const eyeOff = r*0.58;
const eyeR = Math.max(2, r*0.30);
const a2 = s.angle;
const eType = H.eyes;
const eyeCol = H.eyeColor || '#ffee55';
const pupCol = H.pupilColor || '#000';
const pupShape = H.pupil || 'round';
for (const sg of [-1,1]){
const cx = s.x + ex*r*0.42 + px*eyeOff*sg;
const cy = s.y + ey*r*0.42 + py*eyeOff*sg;
if (eType === 'angry'){
ctx.save();
ctx.translate(cx, cy); ctx.rotate(a2);
ctx.beginPath(); ctx.ellipse(0, 0, eyeR*1.15, eyeR*0.8, 0, 0, TAU);
ctx.fillStyle = eyeCol; ctx.fill();
ctx.strokeStyle = '#200'; ctx.lineWidth = Math.max(1, r*0.1); ctx.stroke();
ctx.beginPath();
ctx.moveTo(-eyeR*1.2, -eyeR*sg*1.2);
ctx.lineTo(eyeR*1.1, -eyeR*sg*0.4);
ctx.strokeStyle = s.skin.b2;
ctx.lineWidth = Math.max(3, r*0.2); ctx.stroke();
drawPupil(eyeR*0.15, 0, eyeR, pupShape, pupCol);
ctx.restore();
} else if (eType === 'slit'){
ctx.beginPath(); ctx.arc(cx, cy, eyeR, 0, TAU);
ctx.fillStyle = eyeCol; ctx.fill();
ctx.save(); ctx.translate(cx, cy); ctx.rotate(a2);
drawPupil(ex*eyeR*0.15, ey*eyeR*0.15, eyeR, pupShape, pupCol);
ctx.restore();
} else if (eType === 'glow'){
ctx.beginPath(); ctx.arc(cx, cy, eyeR*1.4, 0, TAU);
ctx.fillStyle = eyeCol;
ctx.globalAlpha = 0.35 + Math.sin(time*6+sg)*0.15;
ctx.fill(); ctx.globalAlpha = 1;
ctx.beginPath(); ctx.arc(cx, cy, eyeR, 0, TAU);
ctx.fillStyle = '#ffffff'; ctx.fill();
ctx.beginPath(); ctx.arc(cx+ex*eyeR*0.2, cy+ey*eyeR*0.2, eyeR*0.5, 0, TAU);
ctx.fillStyle = pupCol; ctx.fill();
} else if (eType === 'small'){
ctx.beginPath(); ctx.arc(cx, cy, eyeR*0.7, 0, TAU);
ctx.fillStyle = eyeCol; ctx.fill();
ctx.beginPath(); ctx.arc(cx+ex*eyeR*0.15, cy+ey*eyeR*0.15, eyeR*0.35, 0, TAU);
ctx.fillStyle = pupCol; ctx.fill();
} else if (eType === 'multiple'){
for (let m=0;m<3;m++){
const ang = (m-1)*0.35;
const cx2 = cx + Math.cos(a2+Math.PI/2)*r*0.2*ang*2;
const cy2 = cy + Math.sin(a2+Math.PI/2)*r*0.2*ang*2;
ctx.beginPath(); ctx.arc(cx2, cy2, eyeR*0.5, 0, TAU);
ctx.fillStyle = eyeCol; ctx.fill();
ctx.beginPath(); ctx.arc(cx2, cy2, eyeR*0.22, 0, TAU);
ctx.fillStyle = pupCol; ctx.fill();
}
} else if (eType === 'sleepy'){
ctx.save();
ctx.translate(cx, cy); ctx.rotate(a2);
ctx.beginPath(); ctx.ellipse(0, 0, eyeR*1.1, eyeR*0.7, 0, 0, TAU);
ctx.fillStyle = eyeCol; ctx.fill();
ctx.beginPath(); ctx.ellipse(0, -eyeR*0.4, eyeR*1.2, eyeR*0.55, 0, 0, TAU);
ctx.fillStyle = s.skin.h; ctx.fill();
ctx.strokeStyle = s.skin.b2; ctx.lineWidth = Math.max(1.5, r*0.12); ctx.stroke();
ctx.restore();
} else if (eType === 'crazy'){
ctx.beginPath(); ctx.arc(cx, cy, eyeR*1.3, 0, TAU);
ctx.fillStyle = '#ffffff'; ctx.fill();
ctx.beginPath(); ctx.arc(cx, cy, eyeR*0.9, 0, TAU);
ctx.fillStyle = eyeCol; ctx.fill();
ctx.beginPath(); ctx.arc(cx+ex*eyeR*0.1, cy+ey*eyeR*0.1, eyeR*0.28, 0, TAU);
ctx.fillStyle = pupCol; ctx.fill();
} else if (eType === 'heart'){
ctx.save();
ctx.translate(cx, cy); ctx.rotate(a2);
ctx.fillStyle = eyeCol;
ctx.beginPath();
ctx.arc(-eyeR*0.4, -eyeR*0.3, eyeR*0.6, 0, TAU); ctx.fill();
ctx.beginPath();
ctx.arc(eyeR*0.4, -eyeR*0.3, eyeR*0.6, 0, TAU); ctx.fill();
ctx.beginPath();
ctx.moveTo(-eyeR*0.95, -eyeR*0.05);
ctx.lineTo(eyeR*0.95, -eyeR*0.05);
ctx.lineTo(0, eyeR*1.0);
ctx.closePath(); ctx.fill();
ctx.restore();
} else {
ctx.beginPath(); ctx.arc(cx, cy, eyeR, 0, TAU);
ctx.fillStyle = eyeCol; ctx.fill();
ctx.beginPath(); ctx.arc(cx+ex*eyeR*0.15, cy+ey*eyeR*0.15, eyeR*0.45, 0, TAU);
ctx.fillStyle = pupCol; ctx.fill();
}
}
}
function drawPupil(x, y, eyeR, shape, color){
ctx.fillStyle = color;
if (shape === 'slit'){
ctx.beginPath();
ctx.ellipse(x, y, eyeR*0.22, eyeR*0.85, 0, 0, TAU);
ctx.fill();
} else if (shape === 'star'){
const R = eyeR*0.6, r2 = eyeR*0.28;
ctx.beginPath();
for (let i=0;i<10;i++){
const rad = i%2===0 ? R : r2;
const aa = -Math.PI/2 + i*Math.PI/5;
const xx = x + Math.cos(aa)*rad;
const yy = y + Math.sin(aa)*rad;
if (i===0) ctx.moveTo(xx, yy); else ctx.lineTo(xx, yy);
}
ctx.closePath(); ctx.fill();
} else if (shape === 'cross'){
ctx.fillRect(x-eyeR*0.15, y-eyeR*0.55, eyeR*0.3, eyeR*1.1);
ctx.fillRect(x-eyeR*0.55, y-eyeR*0.15, eyeR*1.1, eyeR*0.3);
} else {
ctx.beginPath(); ctx.arc(x, y, eyeR*0.45, 0, TAU); ctx.fill();
}
}
function drawEffects(above){
for (const e of effects){
if (!e.k) continue;
const isAbove = (e.k === 'beam' || e.k === 'bolt');
if (isAbove !== above) continue;
const alpha = Math.max(0, e.life/e.max);
if (e.k === 'ring'){
let rad;
if (e.curR !== undefined) rad = e.curR;
else {
const prog = 1 - e.life/e.max;
rad = e.r0 + (e.r1-e.r0)*prog;
}
ctx.beginPath(); ctx.arc(e.x, e.y, rad, 0, TAU);
ctx.strokeStyle = e.c; ctx.globalAlpha = alpha*0.8;
ctx.lineWidth = Math.max(4, rad*0.12); ctx.stroke();
ctx.globalAlpha = alpha*0.14;
ctx.fillStyle = e.c; ctx.fill();
ctx.globalAlpha = 1;
} else if (e.k === 'cone'){
const g = ctx.createRadialGradient(e.x, e.y, 0, e.x, e.y, e.range);
g.addColorStop(0, 'rgba(255,220,90,0.55)');
g.addColorStop(0.5, 'rgba(255,130,20,0.3)');
g.addColorStop(1, 'rgba(255,60,0,0)');
ctx.beginPath();
ctx.moveTo(e.x, e.y);
ctx.arc(e.x, e.y, e.range, e.ang-e.spread, e.ang+e.spread);
ctx.closePath();
ctx.fillStyle = g;
ctx.globalAlpha = alpha;
ctx.fill();
ctx.globalAlpha = 1;
} else if (e.k === 'proj'){
ctx.beginPath(); ctx.arc(e.x, e.y, e.r*1.8, 0, TAU);
ctx.fillStyle = e.c; ctx.globalAlpha = 0.22; ctx.fill(); ctx.globalAlpha = 1;
ctx.beginPath(); ctx.arc(e.x, e.y, e.r, 0, TAU);
ctx.fillStyle = e.c; ctx.fill();
ctx.beginPath(); ctx.arc(e.x-e.r*0.25, e.y-e.r*0.25, e.r*0.42, 0, TAU);
ctx.fillStyle = '#fff'; ctx.globalAlpha = 0.75; ctx.fill(); ctx.globalAlpha = 1;
} else if (e.k === 'cloud' || e.k === 'trail'){
const a2 = Math.min(1, alpha*2.4) * 0.34;
const rad = e.curR !== undefined ? e.curR : e.r;
if (rad > 1){
ctx.beginPath(); ctx.arc(e.x, e.y, rad, 0, TAU);
ctx.fillStyle = e.c; ctx.globalAlpha = a2; ctx.fill();
ctx.globalAlpha = a2*2;
ctx.strokeStyle = e.c; ctx.lineWidth = 3; ctx.stroke();
ctx.globalAlpha = 1;
}
} else if (e.k === 'pull'){
ctx.beginPath(); ctx.arc(e.x, e.y, e.radius*(1-alpha*0.4), 0, TAU);
ctx.strokeStyle = e.c; ctx.globalAlpha = alpha*0.55;
ctx.lineWidth = 4; ctx.stroke();
if (e.spin){
ctx.beginPath();
ctx.arc(e.x, e.y, e.radius*(1-alpha*0.4), time*3, time*3+2);
ctx.strokeStyle = e.c; ctx.lineWidth = 6; ctx.stroke();
}
ctx.globalAlpha = 1;
} else if (e.k === 'beam'){
ctx.beginPath(); ctx.moveTo(e.x1, e.y1); ctx.lineTo(e.x2, e.y2);
ctx.strokeStyle = e.c; ctx.globalAlpha = alpha; ctx.lineWidth = 10; ctx.stroke();
ctx.strokeStyle = '#fff'; ctx.lineWidth = 4; ctx.stroke();
ctx.globalAlpha = 1;
} else if (e.k === 'bolt'){
ctx.save();
ctx.translate(e.x, e.y);
ctx.strokeStyle = e.c; ctx.lineWidth = 8; ctx.globalAlpha = alpha;
ctx.beginPath();
let cx=0, cy=-400;
ctx.moveTo(cx, cy);
for (let i=0;i<8;i++){ cx+=rand(-18,18); cy+=50; ctx.lineTo(cx, cy); }
ctx.stroke();
ctx.lineWidth = 3; ctx.strokeStyle = '#fff'; ctx.stroke();
ctx.restore();
ctx.globalAlpha = 1;
}
}
}
function drawParticles(){
for (const p of particles){
const a = Math.max(0, p.life/p.max);
ctx.globalAlpha = a;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r*(0.4+a*0.6), 0, TAU);
ctx.fillStyle = p.c; ctx.fill();
}
ctx.globalAlpha = 1;
}
/* ====== HUD ====== */
const hpfill=document.getElementById('hpfill');
const shfill=document.getElementById('shfill');
const stfill=document.getElementById('stfill');
const hptext=document.getElementById('hptext');
const shtext=document.getElementById('shtext');
const sttext=document.getElementById('sttext');
const scoreval=document.getElementById('scoreval');
const lblist=document.getElementById('lblist');
const lbTitle=document.getElementById('lbTitle');
const atkCdEl=document.getElementById('atkCd');
const atkLblEl=document.getElementById('atkLbl');
const btnAttack=document.getElementById('btnAttack');
const shieldLbl=document.getElementById('shieldLbl');
const sprintLbl=document.getElementById('sprintLbl');
function updateHUD(){
if (!player || !player.alive) return;
const hpPct = clamp(player.hp/player.hpMax, 0, 1);
hpfill.style.width = (hpPct*100)+'%';
hptext.textContent = Math.max(0, Math.round(player.hp))+' / '+Math.round(player.hpMax);
const shPct = clamp(player.shield/player.shMax, 0, 1);
shfill.style.width = (shPct*100)+'%';
shtext.textContent = Math.round(player.shield)+' / '+Math.round(player.shMax);
const stPct = clamp(player.stamina/player.stMax, 0, 1);
stfill.style.width = (stPct*100)+'%';
sttext.textContent = Math.round(player.stamina)+' / '+Math.round(player.stMax);
scoreval.textContent = Math.floor(player.score);
if (player.skin.noAttack){
btnAttack.classList.add('disabled');
atkLblEl.textContent = '—';
} else {
btnAttack.classList.remove('disabled');
if (player.acd > 0){
atkCdEl.classList.remove('hidden');
atkCdEl.textContent = player.acd.toFixed(1);
} else {
atkCdEl.classList.add('hidden');
}
atkLblEl.textContent = (player.skin.abn[LANG]||player.skin.abn.ru).split(' ')[0].toUpperCase();
}
const sorted = snakes.slice().sort((a,b)=>b.score-a.score);
let html = '';
const top = sorted.slice(0, 5);
for (let i=0;i<top.length;i++){
const s = top[i];
const me = (s===player) ? ' me' : '';
html += '<div class="lbrow'+me+'"><span>'+(i+1)+'. '+escapeHtml(s.name)+
'</span><span class="sc">'+Math.floor(s.score)+'</span></div>';
}
const myIdx = sorted.indexOf(player);
if (player && myIdx >= 5){
html += '<div class="lbrow me"><span>'+(myIdx+1)+'. '+escapeHtml(player.name)+
'</span><span class="sc">'+Math.floor(player.score)+'</span></div>';
}
lblist.innerHTML = html;
}
function escapeHtml(t){
return String(t).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
/* ====== INPUT ====== */
const joyzone=document.getElementById('joyzone');
const joybase=document.getElementById('joybase');
const joyknob=document.getElementById('joyknob');
let joyPointer=null;
const joyOrigin={x:0,y:0};
const joyVec={x:0,y:0};
const JOY_R=50;
function placeJoyDefault(){
if (joyPointer !== null) return;
const w = joyzone.clientWidth || cw*0.52;
const h = joyzone.clientHeight || ch*0.62;
const x = Math.min(120, w*0.34);
const y = h - Math.min(130, h*0.32);
joybase.style.left = x+'px';
joybase.style.top = y+'px';
joyOrigin.x = x; joyOrigin.y = y;
}
joyzone.addEventListener('pointerdown', e => {
if (state !== 'playing') return;
e.preventDefault();
if (joyPointer !== null) return;
joyPointer = e.pointerId;
try{ joyzone.setPointerCapture(e.pointerId); }catch(err){}
const r = joyzone.getBoundingClientRect();
joyOrigin.x = e.clientX-r.left;
joyOrigin.y = e.clientY-r.top;
joybase.style.left = joyOrigin.x+'px';
joybase.style.top = joyOrigin.y+'px';
joybase.style.opacity = '1';
joyknob.style.transform = 'translate(-50%,-50%)';
joyVec.x=0; joyVec.y=0;
}, {passive:false});
joyzone.addEventListener('pointermove', e => {
if (e.pointerId !== joyPointer) return;
e.preventDefault();
const r = joyzone.getBoundingClientRect();
let dx = (e.clientX-r.left)-joyOrigin.x;
let dy = (e.clientY-r.top)-joyOrigin.y;
const d = Math.hypot(dx,dy);
if (d > JOY_R){ dx = dx/d*JOY_R; dy = dy/d*JOY_R; }
joyknob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
joyVec.x = dx/JOY_R;
joyVec.y = dy/JOY_R;
}, {passive:false});
function joyEnd(e){
if (e.pointerId !== joyPointer) return;
joyPointer = null;
joyVec.x=0; joyVec.y=0;
joybase.style.opacity = '0.4';
joyknob.style.transform = 'translate(-50%,-50%)';
placeJoyDefault();
}
joyzone.addEventListener('pointerup', joyEnd);
joyzone.addEventListener('pointercancel', joyEnd);
const btnShield=document.getElementById('btnShield');
const btnSprint=document.getElementById('btnSprint');
let shieldHeld=false;
let sprintHeld=false;
btnShield.addEventListener('pointerdown', e => {
e.preventDefault(); shieldHeld=true;
if (player && player.alive) player.shieldOn = true;
btnShield.style.transform = 'scale(0.93)';
}, {passive:false});
function releaseShield(e){
if (e) e.preventDefault();
shieldHeld = false;
if (player) player.shieldOn = false;
btnShield.style.transform = '';
}
btnShield.addEventListener('pointerup', releaseShield);
btnShield.addEventListener('pointercancel', releaseShield);
btnShield.addEventListener('pointerleave', releaseShield);
btnSprint.addEventListener('pointerdown', e => {
e.preventDefault(); sprintHeld=true;
if (player && player.alive) player.sprinting = true;
btnSprint.style.transform = 'scale(0.9)';
}, {passive:false});
function releaseSprint(e){
if (e) e.preventDefault();
sprintHeld = false;
if (player) player.sprinting = false;
btnSprint.style.transform = '';
}
btnSprint.addEventListener('pointerup', releaseSprint);
btnSprint.addEventListener('pointercancel', releaseSprint);
btnSprint.addEventListener('pointerleave', releaseSprint);
btnAttack.addEventListener('pointerdown', e => {
e.preventDefault();
if (player && player.alive && !player.skin.noAttack) useAbility(player);
btnAttack.style.transform = 'scale(0.9)';
setTimeout(()=>btnAttack.style.transform='', 90);
}, {passive:false});
const pointers = new Map();
let pinchStartDist=0, pinchStartZoom=1;
canvas.addEventListener('pointerdown', e => {
if (state !== 'playing') return;
pointers.set(e.pointerId, {x:e.clientX, y:e.clientY});
if (pointers.size===2){
const p = [...pointers.values()];
pinchStartDist = Math.hypot(p[0].x-p[1].x, p[0].y-p[1].y);
pinchStartZoom = cam.zoom;
}
});
canvas.addEventListener('pointermove', e => {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, {x:e.clientX, y:e.clientY});
if (pointers.size===2 && pinchStartDist>0){
const p = [...pointers.values()];
const d = Math.hypot(p[0].x-p[1].x, p[0].y-p[1].y);
cam.zoom = clamp(pinchStartZoom*(d/pinchStartDist), 0.42, 3.0);
}
});
function canvasUp(e){
pointers.delete(e.pointerId);
if (pointers.size<2) pinchStartDist = 0;
}
canvas.addEventListener('pointerup', canvasUp);
canvas.addEventListener('pointercancel', canvasUp);
canvas.addEventListener('pointerout', canvasUp);
canvas.addEventListener('wheel', e => {
if (state !== 'playing') return;
e.preventDefault();
cam.zoom = clamp(cam.zoom*(e.deltaY>0?0.9:1.11), 0.42, 3.0);
}, {passive:false});
const keys = {};
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (e.code === 'Space'){ e.preventDefault(); if (player && player.alive && !player.skin.noAttack) useAbility(player); }
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight'){ if (player) player.shieldOn = true; }
if (e.code === 'KeyE'){ if (player && player.alive) player.sprinting = true; }
if (e.code === 'Escape' && state === 'playing'){ toMenu(); }
});
window.addEventListener('keyup', e => {
keys[e.code] = false;
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight'){ if (player) player.shieldOn = false; }
if (e.code === 'KeyE'){ if (player) player.sprinting = false; }
});
function updatePlayerInput(){
if (!player || !player.alive) return;
let dx = joyVec.x, dy = joyVec.y;
if (keys['KeyA']||keys['ArrowLeft']) dx -= 1;
if (keys['KeyD']||keys['ArrowRight']) dx += 1;
if (keys['KeyW']||keys['ArrowUp']) dy -= 1;
if (keys['KeyS']||keys['ArrowDown']) dy += 1;
const mag = Math.hypot(dx,dy);
if (mag > 0.14){
let ang = Math.atan2(dy,dx);
if (player.confuseT > 0) ang += Math.PI;
player.targetAngle = ang;
}
player.shieldOn = shieldHeld || (player.shieldOn && (keys['ShiftLeft']||keys['ShiftRight']));
if (!player.skin.noAttack){
if (keys['KeyE']) player.sprinting = true;
if (!sprintHeld && !keys['KeyE']) player.sprinting = false;
} else {
if (!sprintHeld && !keys['KeyE']) player.sprinting = false;
}
}
/* ====== MAIN LOOP ====== */
let lastT = 0;
function update(dt){
time += dt;
updateFoodRespawn(dt);
buildBodyGrid();
updatePlayerInput();
const snapshot = snakes.slice();
for (let i=0;i<snapshot.length;i++){
const s = snapshot[i];
if (!s.alive) continue;
if (!s.isPlayer) aiUpdate(s, dt);
if (!s.alive) continue;
physics(s, dt);
}
buildBodyGrid();
handleCollisions();
updateEffects(dt);
updateParticles(dt);
updateTextPops(dt);
updateCamera(dt);
for (let i=respawnQueue.length-1;i>=0;i--){
respawnQueue[i] -= dt;
if (respawnQueue[i] <= 0){
respawnQueue.splice(i,1);
if (snakes.length < MAX_SNAKES) spawnBot();
}
}
if (snakes.length < MAX_SNAKES-2 && Math.random() < dt*0.8) spawnBot();
hudTimer -= dt;
if (hudTimer<=0){ hudTimer = 0.28; updateHUD(); }
}
function loop(t){
requestAnimationFrame(loop);
const dt = Math.min(0.05, (t-lastT)/1000 || 0);
lastT = t;
if (state === 'playing') update(dt);
else time += dt;
render();
}
/* ====== SCREENS ====== */
const menuEl=document.getElementById('menu');
const hudEl=document.getElementById('hud');
const skinsScreen=document.getElementById('skinsScreen');
const deathScreen=document.getElementById('deathScreen');
const langScreen=document.getElementById('langScreen');
const authorScreen=document.getElementById('authorScreen');
function applyI18N(){
document.querySelectorAll('[data-i18n]').forEach(el => {
const k = el.getAttribute('data-i18n');
const v = t(k);
if (v) el.textContent = v;
});
document.getElementById('nickInput').placeholder = t('nickPlaceholder');
document.getElementById('skinsTitle').textContent = t('skinsTitle') + ' (' + SKINS.length + ')';
document.getElementById('lbTitle').textContent = t('top');
document.getElementById('deathTitle').textContent = t('death');
document.getElementById('shieldLbl').textContent = t('shield');
document.getElementById('sprintLbl').textContent = t('sprint');
document.getElementById('scorebox').childNodes[0].nodeValue = t('scoreHud') + ': ';
document.getElementById('menu').querySelector('#subtitle').textContent = t('subtitle');
document.getElementById('authorTitle').textContent = t('authorTitle');
document.getElementById('authorText').textContent = t('authorText');
document.getElementById('authorLink').textContent = t('authorLink');
updateSkinBadge();
updateCoinDisplay();
if (player && player.alive) updateHUD();
}
function startGame(){
initAudio();
const nick = document.getElementById('nickInput').value.trim();
try{
localStorage.setItem('snakeNick', nick);
localStorage.setItem('snakeSkin', String(selectedSkin));
localStorage.setItem('snakeLang', LANG);
localStorage.setItem('snakeMap', currentMapKey);
}catch(e){}
menuEl.classList.add('hidden');
skinsScreen.classList.add('hidden');
deathScreen.classList.add('hidden');
langScreen.classList.add('hidden');
authorScreen.classList.add('hidden');
hudEl.classList.remove('hidden');
state = 'playing';
cam.zoom = 1; cam.zoomCur = 1;
currentMap = MAPS[currentMapKey];
generateFood();
generateDecos();
setupMatch();
hudTimer = 0;
updateHUD();
placeJoyDefault();
updateCoinDisplay();
}
function toMenu(){
state = 'menu';
hudEl.classList.add('hidden');
deathScreen.classList.add('hidden');
skinsScreen.classList.add('hidden');
langScreen.classList.add('hidden');
authorScreen.classList.add('hidden');
menuEl.classList.remove('hidden');
player = null;
updateSkinBadge();
updateCoinDisplay();
doSaveProgress();
}
function showDeath(){
if (state !== 'playing') return;
state = 'dead';
deathScreen.classList.remove('hidden');
document.getElementById('deathInfo').innerHTML =
t('score')+': <b>'+playerDeathInfo.score+'</b><br>'+t('killer')+': '+escapeHtml(playerDeathInfo.killer);
hudEl.classList.add('hidden');
}
function updateSkinBadge(){
const badge = document.getElementById('skinBadge');
if (badge){
const n = SKINS[selectedSkin].n[LANG] || SKINS[selectedSkin].n.ru;
badge.textContent = t('skins') + ': ' + n;
}
}
function renderSkins(){
const grid = document.getElementById('skinsGrid');
let html = '';
for (let i=0;i<SKINS.length;i++){
const s = SKINS[i];
const owned = isSkinOwned(i);
const sel = (i === selectedSkin && owned) ? ' sel' : '';
const locked = !owned ? ' locked' : '';
const legendary = (s.price >= 1500) ? ' legendary' : '';
const abn = s.abn[LANG] || s.abn.ru;
const desc = s.desc[LANG] || s.desc.ru;
const name = s.n[LANG] || s.n.ru;
let priceHtml = '';
if (owned){
priceHtml = '<div class="skinPrice owned">✓ '+t('owned')+'</div>';
} else if (s.price >= 1500){
priceHtml = '<div class="skinPrice" style="color:#f7d94a">★ '+s.price+' 🪙</div>';
} else {
priceHtml = '<div class="skinPrice">🔒 '+s.price+' 🪙</div>';
}
html += '<div class="skinCard'+sel+locked+legendary+'" data-idx="'+i+'">' +
'<div class="skinPrev" style="background:linear-gradient(135deg,'+s.b2+','+s.b+')">' +
'<div class="skinHead" style="background:'+s.h+';border:2px solid '+s.p+'"></div>' +
'</div>' +
'<div class="skinName">'+name+'</div>' +
priceHtml +
'<div class="skinAb">'+abn+'</div>' +
'<div class="skinDesc">'+desc+'</div>' +
'<div class="skinStats">❤'+s.hp+' · 🛡'+s.sh+' · ⚡'+s.sp.toFixed(2)+'<br>⚔'+s.atk.toFixed(2)+' · 🏃'+s.maxS+'</div>' +
'</div>';
}
grid.innerHTML = html;
grid.querySelectorAll('.skinCard').forEach(card => {
card.addEventListener('click', () => {
const idx = parseInt(card.getAttribute('data-idx'),10);
const s = SKINS[idx];
if (isSkinOwned(idx)){
selectedSkin = idx;
saveProgress(); // мгновенно сохраняем выбранный скин
grid.querySelectorAll('.skinCard').forEach(c => c.classList.remove('sel'));
card.classList.add('sel');
updateSkinBadge();
} else {
if (coins >= s.price){
coins -= s.price;
ownedSkins.add(idx);
selectedSkin = idx;
saveProgress(); // мгновенно сохраняем покупку
updateCoinDisplay();
renderSkins();
updateSkinBadge();
} else {
card.classList.add('cantafford');
setTimeout(()=>card.classList.remove('cantafford'), 500);
}
}
});
});
}
function setLang(lang){
LANG = lang;
try{ localStorage.setItem('snakeLang', lang); }catch(e){}
applyI18N();
if (!skinsScreen.classList.contains('hidden')) renderSkins();
}
/* ====== BINDINGS ====== */
document.getElementById('langRu').addEventListener('click', () => {
setLang('ru');
document.getElementById('langScreen').classList.add('hidden');
document.getElementById('menu').classList.remove('hidden');
state = 'menu';
initAudio();
});
document.getElementById('langEn').addEventListener('click', () => {
setLang('en');
document.getElementById('langScreen').classList.add('hidden');
document.getElementById('menu').classList.remove('hidden');
state = 'menu';
initAudio();
});
document.getElementById('langBtn').addEventListener('click', () => {
menuEl.classList.add('hidden');
langScreen.classList.remove('hidden');
});
document.getElementById('playBtn').addEventListener('click', startGame);
document.getElementById('skinsBtn').addEventListener('click', () => {
menuEl.classList.add('hidden');
skinsScreen.classList.remove('hidden');
renderSkins();
updateCoinDisplay();
});
document.getElementById('backBtn').addEventListener('click', () => {
skinsScreen.classList.add('hidden');
menuEl.classList.remove('hidden');
updateSkinBadge();
});
document.getElementById('authorBtn').addEventListener('click', () => {
menuEl.classList.add('hidden');
authorScreen.classList.remove('hidden');
updateCoinDisplay();
});
document.getElementById('authorBackBtn').addEventListener('click', () => {
authorScreen.classList.add('hidden');
menuEl.classList.remove('hidden');
});
document.getElementById('respawnBtn').addEventListener('click', () => {
deathScreen.classList.add('hidden');
hudEl.classList.remove('hidden');
state = 'playing';
spawnPlayer();
updateHUD();
});
document.getElementById('toMenuBtn').addEventListener('click', toMenu);
document.getElementById('menuBtnHud').addEventListener('click', toMenu);
document.querySelectorAll('.chip[data-map]').forEach(chip => {
chip.addEventListener('click', () => {
const m = chip.getAttribute('data-map');
if (!MAPS[m]) return;
currentMapKey = m;
currentMap = MAPS[m];
try{ localStorage.setItem('snakeMap', m); }catch(e){}
document.querySelectorAll('.chip[data-map]').forEach(c => c.classList.remove('sel'));
chip.classList.add('sel');
});
});
const ni = document.getElementById('nickInput');
if (ni) {
ni.addEventListener('input', () => {
try{ localStorage.setItem('snakeNick', ni.value.trim()); }catch(e){}
});
}
/* ====== BOOT ====== */
document.addEventListener('contextmenu', e => e.preventDefault());
document.addEventListener('gesturestart', e => e.preventDefault());
document.addEventListener('dblclick', e => e.preventDefault());
resize();
document.querySelectorAll('.chip[data-map]').forEach(c => {
c.classList.toggle('sel', c.getAttribute('data-map') === currentMapKey);
});
generateFood();
generateDecos();
buildBodyGrid();
if (localStorage.getItem('snakeLang')){
document.getElementById('menu').classList.remove('hidden');
state = 'menu';
applyI18N();
} else {
document.getElementById('langScreen').classList.remove('hidden');
state = 'lang';
}
updateCoinDisplay();
requestAnimationFrame(loop);
document.body.addEventListener('touchmove', e => {
if (e.target === document.body) e.preventDefault();
}, {passive:false});
window.addEventListener('beforeunload', doSaveProgress);
window.addEventListener('visibilitychange', () => {
if (document.hidden) doSaveProgress();
});
})();
</script>
</body>
</html>Game Source: SNAKE CLASH.IO
Creator: CosmicRanger90
Libraries: none
Complexity: complex (3805 lines, 150.8 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: snake-clash-io-cosmicranger90" to link back to the original. Then publish at arcadelab.ai/publish.