Загрузка данных
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Aim Trainer 3D</title>
<style>
*{scrollbar-width:none;-ms-overflow-style:none}
*::-webkit-scrollbar{display:none;width:0;height:0}
:root{
--c1:#00e5ff;--c2:#00e5ff;
--c1-rgb:0,229,255;--c2-rgb:0,229,255;
--text:#e8eef7;
--menu-bg: radial-gradient(ellipse at 50% 40%,#140a26 0%,#0b0714 55%,#05030a 100%);
--ui-bg-1-rgb: 30,20,52;
--ui-bg-2-rgb: 14,10,26;
--ui-border: #2a2140;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#07060f;
font-family:'Segoe UI',system-ui,-apple-system,sans-serif;color:var(--text);user-select:none}
canvas{display:block}
.hidden{display:none !important}
#ui{position:fixed;inset:0;z-index:20;pointer-events:none}
.screen{position:absolute;inset:0;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:18px;pointer-events:auto;
background:var(--menu-bg);animation:screenIn .42s cubic-bezier(.2,.85,.3,1);padding:20px;overflow:hidden}
@keyframes screenIn{
0%{opacity:0;transform:scale(1.02);filter:blur(6px)}
60%{filter:blur(0)}
100%{opacity:1;transform:scale(1);filter:blur(0)}
}
.screen::before,.screen::after{content:'';position:absolute;border-radius:50%;
filter:blur(100px);opacity:.45;pointer-events:none;z-index:-1}
.screen::before{width:460px;height:460px;background:var(--c1);top:-140px;left:-120px;
animation:floatA 9s ease-in-out infinite}
.screen::after{width:400px;height:400px;background:var(--c2);bottom:-160px;right:-100px;
animation:floatB 11s ease-in-out infinite}
@keyframes floatA{50%{transform:translate(30px,20px)}}
@keyframes floatB{50%{transform:translate(-25px,-15px)}}
.logo{font-size:clamp(38px,7vw,72px);font-weight:900;letter-spacing:8px;
background:linear-gradient(120deg,var(--c1),var(--c2));
-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;
filter:drop-shadow(0 0 30px rgba(var(--c2-rgb),.4))}
.sub{color:var(--text);opacity:.55;letter-spacing:5px;font-size:12px;
text-transform:uppercase;margin-top:-14px;font-weight:600}
.mainBtns{display:flex;flex-direction:column;gap:14px;width:280px;margin-top:12px}
.mbtn{position:relative;padding:16px 28px;font-size:15px;font-weight:700;
letter-spacing:3px;text-transform:uppercase;color:var(--text);cursor:pointer;
border-radius:14px;border:1px solid var(--ui-border);
background:linear-gradient(160deg,rgba(var(--ui-bg-1-rgb),.85),rgba(var(--ui-bg-2-rgb),.85));
backdrop-filter:blur(10px);transition:.22s;overflow:hidden;font-family:inherit;text-align:center}
.mbtn:hover{border-color:var(--c2);transform:translateY(-2px);
box-shadow:0 10px 40px rgba(var(--c2-rgb),.35),0 0 0 1px rgba(var(--c2-rgb),.4) inset;color:#fff}
.mbtn.play{border-color:rgba(var(--c1-rgb),.4);
background:linear-gradient(160deg,rgba(var(--c1-rgb),.15),rgba(var(--ui-bg-2-rgb),.85))}
.mbtn.play:hover{border-color:var(--c1)}
.mbtn.exit:hover{border-color:#ff2d6f;
box-shadow:0 10px 40px #ff2d6f55,0 0 0 1px #ff2d6f66 inset}
.title{font-size:clamp(24px,3.4vw,36px);font-weight:800;letter-spacing:5px;
background:linear-gradient(120deg,var(--c1),var(--c2));
-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;
text-transform:uppercase}
.modes{display:grid;grid-template-columns:repeat(3,minmax(190px,215px));gap:11px}
@media(max-width:760px){.modes{grid-template-columns:repeat(2,minmax(190px,215px))}}
@media(max-width:520px){.modes{grid-template-columns:minmax(190px,215px)}}
.card{position:relative;padding:16px 18px;border-radius:14px;cursor:pointer;
border:1px solid var(--ui-border);
background:linear-gradient(160deg,rgba(var(--ui-bg-1-rgb),.9),rgba(var(--ui-bg-2-rgb),.9));
backdrop-filter:blur(10px);transition:.22s;overflow:hidden;
animation:cardIn .45s cubic-bezier(.2,.85,.3,1) backwards}
.card:nth-child(1){animation-delay:.04s}
.card:nth-child(2){animation-delay:.09s}
.card:nth-child(3){animation-delay:.14s}
.card:nth-child(4){animation-delay:.19s}
.card:nth-child(5){animation-delay:.24s}
@keyframes cardIn{from{opacity:0;transform:translateY(14px) scale(.96)}to{opacity:1;transform:translateY(0) scale(1)}}
.card:hover{transform:translateY(-4px);border-color:rgba(var(--c2-rgb),.55);
box-shadow:0 12px 40px rgba(var(--c2-rgb),.28)}
.card h3{font-size:15px;letter-spacing:1px;margin-bottom:5px;
background:linear-gradient(120deg,var(--c1),var(--c2));
-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.card p{font-size:11px;color:var(--text);opacity:.55;line-height:1.45}
.card .tag{position:absolute;top:11px;right:11px;font-size:8.5px;letter-spacing:1.5px;
padding:2px 6px;border-radius:5px;background:rgba(var(--c2-rgb),.15);
color:var(--c2);border:1px solid rgba(var(--c2-rgb),.3)}
.tabs{display:flex;gap:6px;padding:5px;border-radius:14px;
background:rgba(var(--ui-bg-1-rgb),.7);border:1px solid var(--ui-border);
backdrop-filter:blur(10px);flex-wrap:wrap;justify-content:center;
animation:tabBarIn .45s cubic-bezier(.2,.85,.3,1) backwards}
@keyframes tabBarIn{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
.tab{padding:10px 18px;border:none;border-radius:10px;cursor:pointer;font-family:inherit;
font-size:11px;font-weight:700;letter-spacing:2px;text-transform:uppercase;
color:#8895b5;background:transparent;transition:.2s;position:relative;overflow:hidden}
.tab::after{content:'';position:absolute;left:50%;top:50%;width:0;height:0;
background:rgba(var(--c2-rgb),.35);border-radius:50%;transform:translate(-50%,-50%);
transition:width .4s ease, height .4s ease, opacity .5s ease;opacity:0;pointer-events:none}
.tab.ripple::after{width:180px;height:180px;opacity:1;transition:0s}
.tab:hover{color:var(--text)}
.tab.active{color:#04020a;background:linear-gradient(120deg,var(--c1),var(--c2));
box-shadow:0 6px 20px rgba(var(--c2-rgb),.35)}
.settingsBox{display:flex;flex-direction:column;gap:14px;padding:22px 26px;
border-radius:16px;border:1px solid var(--ui-border);
background:linear-gradient(160deg,rgba(var(--ui-bg-1-rgb),.9),rgba(var(--ui-bg-2-rgb),.9));
backdrop-filter:blur(10px);width:520px;max-width:95vw;min-height:340px;
animation:boxIn .4s cubic-bezier(.2,.85,.3,1) backwards}
@keyframes boxIn{from{opacity:0;transform:translateY(16px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
.tabContent{display:flex;flex-direction:column;gap:14px;
animation:tabIn .42s cubic-bezier(.18,.9,.32,1.15)}
@keyframes tabIn{
0%{opacity:0;transform:translateY(14px) scale(.97);filter:blur(5px)}
55%{filter:blur(0)}
100%{opacity:1;transform:translateY(0) scale(1);filter:blur(0)}
}
.tabContent .row{animation:rowIn .4s cubic-bezier(.2,.85,.3,1) backwards}
.tabContent .row:nth-child(1){animation-delay:.03s}
.tabContent .row:nth-child(2){animation-delay:.07s}
.tabContent .row:nth-child(3){animation-delay:.11s}
.tabContent .row:nth-child(4){animation-delay:.15s}
.tabContent .row:nth-child(5){animation-delay:.19s}
.tabContent .row:nth-child(6){animation-delay:.23s}
@keyframes rowIn{from{opacity:0;transform:translateX(-10px)}to{opacity:1;transform:translateX(0)}}
.sliderGrid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:480px){.sliderGrid{grid-template-columns:1fr}}
.row{display:flex;flex-direction:column;gap:7px}
.row-h{flex-direction:row !important;align-items:center;justify-content:space-between;gap:16px}
.row .lbl{display:flex;justify-content:space-between;align-items:center;
font-size:10.5px;letter-spacing:2px;color:#8895b5;text-transform:uppercase;font-weight:600}
.row .lbl b{color:var(--c1);font-weight:700;letter-spacing:0;font-size:12px}
.gated{transition:opacity .25s, filter .25s}
.gated.off{opacity:.35;pointer-events:none;filter:grayscale(.6)}
input[type=range]{-webkit-appearance:none;width:100%;height:4px;border-radius:4px;
background:linear-gradient(90deg,var(--c1),var(--c2));outline:none;cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;
border-radius:50%;background:#fff;cursor:pointer;
box-shadow:0 0 10px var(--c2),0 0 0 3px rgba(var(--c2-rgb),.25)}
input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;
background:#fff;cursor:pointer;box-shadow:0 0 10px var(--c2)}
.colorRow{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.colorCell{display:flex;flex-direction:column;gap:5px}
.colorCell span{font-size:9.5px;letter-spacing:2px;color:#8895b5;text-transform:uppercase;font-weight:600}
input[type=color]{width:100%;height:44px;padding:0;border:none;border-radius:9px;
cursor:pointer;background:transparent}
input[type=color]::-webkit-color-swatch-wrapper{padding:0}
input[type=color]::-webkit-color-swatch{border:1px solid var(--ui-border);border-radius:9px}
input[type=color]::-moz-color-swatch{border:1px solid var(--ui-border);border-radius:9px}
.presetGrid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
@media(max-width:480px){.presetGrid{grid-template-columns:repeat(2,1fr)}}
.preset,.shapeBtn,.effectBtn{padding:11px 4px;border-radius:10px;border:1px solid var(--ui-border);
cursor:pointer;font-family:inherit;font-weight:700;font-size:10px;letter-spacing:1px;
text-transform:uppercase;color:#8895b5;
background:linear-gradient(160deg,rgba(var(--ui-bg-1-rgb),1),rgba(var(--ui-bg-2-rgb),1));transition:.2s}
.preset{font-size:10.5px}
.preset:hover,.shapeBtn:hover,.effectBtn:hover{color:var(--text);border-color:rgba(var(--c2-rgb),.5);
transform:translateY(-2px)}
.preset.active,.shapeBtn.active,.effectBtn.active{color:#04020a;
background:linear-gradient(120deg,var(--c1),var(--c2));border-color:transparent;
box-shadow:0 8px 24px rgba(var(--c2-rgb),.4)}
.shapeGrid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px}
@media(max-width:560px){.shapeGrid{grid-template-columns:repeat(3,1fr)}}
.effectGrid{display:grid;grid-template-columns:repeat(5,1fr);gap:7px}
@media(max-width:560px){.effectGrid{grid-template-columns:repeat(3,1fr)}}
.toggle{position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0}
.toggle input{opacity:0;width:0;height:0;position:absolute}
.toggle .sl{position:absolute;inset:0;background:var(--ui-border);border-radius:24px;
transition:.2s;cursor:pointer}
.toggle .sl::before{content:'';position:absolute;width:18px;height:18px;left:3px;top:3px;
background:#fff;border-radius:50%;transition:.2s}
.toggle input:checked + .sl{background:linear-gradient(120deg,var(--c1),var(--c2))}
.toggle input:checked + .sl::before{transform:translateX(20px)}
.backBtn{padding:10px 24px;border-radius:10px;cursor:pointer;font-family:inherit;
font-size:12px;font-weight:600;letter-spacing:3px;text-transform:uppercase;
background:transparent;color:#8895b5;border:1px solid var(--ui-border);transition:.2s}
.backBtn:hover{color:var(--text);border-color:var(--c2);background:rgba(var(--c2-rgb),.1)}
.hint{font-size:10px;letter-spacing:1.5px;color:var(--text);opacity:.45;
text-align:center;padding:0 6px;line-height:1.5}
.divider{height:1px;background:linear-gradient(90deg,transparent,var(--ui-border),transparent);margin:4px 0}
.overlay{position:fixed;inset:0;z-index:25;display:flex;align-items:center;justify-content:center;
background:#05030ac0;backdrop-filter:blur(8px);pointer-events:auto;
animation:overlayIn .3s ease;padding:20px;overflow:hidden}
@keyframes overlayIn{from{opacity:0;backdrop-filter:blur(0)}to{opacity:1;backdrop-filter:blur(8px)}}
.overlay .panel{animation:panelIn .4s cubic-bezier(.2,.85,.3,1.05)}
@keyframes panelIn{from{opacity:0;transform:translateY(18px) scale(.96)}to{opacity:1;transform:translateY(0) scale(1)}}
.panel{padding:28px 34px;border-radius:20px;border:1px solid rgba(var(--c2-rgb),.35);
background:linear-gradient(160deg,rgba(var(--ui-bg-1-rgb),.95),rgba(var(--ui-bg-2-rgb),.95));
box-shadow:0 30px 80px rgba(var(--c2-rgb),.2);display:flex;flex-direction:column;
align-items:center;gap:16px;min-width:360px;max-width:92vw}
.panel h3{font-size:19px;letter-spacing:4px;text-transform:uppercase;
background:linear-gradient(120deg,var(--c1),var(--c2));
-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
.panelRow{display:flex;flex-direction:column;gap:8px;width:100%}
.panelLabel{font-size:10px;letter-spacing:3px;color:#8895b5;text-transform:uppercase;
font-weight:700;text-align:left}
.sizeOpts{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;width:100%}
.sizeBtn{height:72px;padding:6px 4px;border-radius:12px;border:1px solid var(--ui-border);
cursor:pointer;font-family:inherit;font-weight:800;font-size:9.5px;letter-spacing:1px;
color:#8895b5;background:linear-gradient(160deg,rgba(var(--ui-bg-1-rgb),1),rgba(var(--ui-bg-2-rgb),1));
transition:.2s;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px}
.sizeBtn:hover{color:var(--text);border-color:rgba(var(--c2-rgb),.5);transform:translateY(-2px)}
.sizeBtn.active{color:#04020a;background:linear-gradient(120deg,var(--c1),var(--c2));
border-color:transparent;box-shadow:0 8px 24px rgba(var(--c2-rgb),.4)}
.sizeBtn i{display:block;border-radius:50%;background:currentColor;
width:var(--ds,12px);height:var(--ds,12px)}
.sizeBtn[data-size="xs"] i{--ds:5px}
.sizeBtn[data-size="s"] i{--ds:8px}
.sizeBtn[data-size="m"] i{--ds:12px}
.sizeBtn[data-size="l"] i{--ds:17px}
.sizeBtn[data-size="xl"] i{--ds:22px}
.timeOpts{display:grid;grid-template-columns:repeat(4,1fr);gap:9px;width:100%}
.tbtn{padding:16px 8px;border-radius:12px;border:1px solid var(--ui-border);cursor:pointer;
font-family:inherit;font-weight:800;font-size:15px;color:var(--text);
background:linear-gradient(160deg,rgba(var(--ui-bg-1-rgb),1),rgba(var(--ui-bg-2-rgb),1));
transition:.2s;display:flex;flex-direction:column;align-items:center;gap:3px}
.tbtn span{font-size:8.5px;letter-spacing:2px;color:#8895b5;font-weight:600}
.tbtn:hover{border-color:var(--c1);transform:translateY(-3px);color:var(--c1)}
.tbtn:hover span{color:var(--c1)}
#hud{position:fixed;inset:0;z-index:10;pointer-events:none}
.topbar{display:flex;align-items:flex-start;justify-content:space-between;padding:24px 34px}
.stat{display:flex;flex-direction:column;gap:3px}
.stat span{font-size:10px;letter-spacing:4px;color:#5d7a97;font-weight:700}
.stat b{font-size:28px;font-weight:800;color:var(--text);
text-shadow:0 0 20px rgba(var(--c1-rgb),.3)}
.stat.right{align-items:flex-end}
.timer{font-size:42px;font-weight:900;color:var(--c1);
text-shadow:0 0 30px rgba(var(--c1-rgb),.5);letter-spacing:2px;
font-variant-numeric:tabular-nums}
.timer.warn{color:#ff2d6f;text-shadow:0 0 30px #ff2d6f88;animation:pulse .8s infinite}
@keyframes pulse{50%{opacity:.55}}
.crosshair{position:absolute;left:50%;top:50%;width:0;height:0;
--gap:5px; --len:10px; --thick:2px; --dotSize:2px; --chColor:#00e5ff}
.crosshair .line{position:absolute;background:var(--chColor);
box-shadow:0 0 8px var(--chColor);border-radius:1px}
.crosshair .l1{left:calc(var(--thick) * -0.5); bottom:var(--gap);width:var(--thick); height:var(--len)}
.crosshair .l2{left:calc(var(--thick) * -0.5); top:var(--gap);width:var(--thick); height:var(--len)}
.crosshair .l3{top:calc(var(--thick) * -0.5); right:var(--gap);height:var(--thick); width:var(--len)}
.crosshair .l4{top:calc(var(--thick) * -0.5); left:var(--gap);height:var(--thick); width:var(--len)}
.crosshair .dot{position:absolute;left:calc(var(--dotSize) * -0.5);top:calc(var(--dotSize) * -0.5);
width:var(--dotSize); height:var(--dotSize); background:var(--chColor);
box-shadow:0 0 6px var(--chColor);border-radius:50%}
.crosshair.noDot .dot{display:none}
.crosshair.hit .line, .crosshair.hit .dot{background:#ff2d6f;box-shadow:0 0 12px #ff2d6f}
.crosshair.onTarget .line, .crosshair.onTarget .dot{
background:var(--c2);box-shadow:0 0 14px var(--c2)}
.modeLabel{position:absolute;bottom:26px;left:50%;transform:translateX(-50%);
font-size:11px;letter-spacing:6px;color:#4d6a86;text-transform:uppercase;font-weight:700}
.trackBar{position:absolute;bottom:56px;left:50%;transform:translateX(-50%);
width:220px;height:4px;background:#1a2233;border-radius:4px;overflow:hidden}
.trackBar i{display:block;height:100%;background:linear-gradient(90deg,var(--c1),var(--c2));
width:0%;transition:width .1s}
.resGrid{display:grid;grid-template-columns:repeat(2,145px);gap:11px}
.resGrid>div{padding:14px;border-radius:14px;text-align:center;
background:linear-gradient(160deg,rgba(var(--ui-bg-1-rgb),.9),rgba(var(--ui-bg-2-rgb),.9));
border:1px solid var(--ui-border);
animation:rowIn .45s cubic-bezier(.2,.85,.3,1) backwards}
.resGrid>div:nth-child(1){animation-delay:.05s}
.resGrid>div:nth-child(2){animation-delay:.1s}
.resGrid>div:nth-child(3){animation-delay:.15s}
.resGrid>div:nth-child(4){animation-delay:.2s}
.resGrid span{display:block;font-size:9px;letter-spacing:3px;color:#6b8199;
margin-bottom:7px;text-transform:uppercase;font-weight:700}
.resGrid b{font-size:22px;color:var(--text);font-weight:800}
.btn{background:linear-gradient(120deg,var(--c1),var(--c2));color:#04020a;border:none;
border-radius:12px;padding:13px 36px;font-size:13px;font-weight:800;letter-spacing:3px;
cursor:pointer;transition:.2s;text-transform:uppercase;font-family:inherit;
box-shadow:0 10px 30px rgba(var(--c2-rgb),.3);width:100%}
.btn:hover{transform:translateY(-2px);box-shadow:0 15px 40px rgba(var(--c2-rgb),.5)}
.btn.ghost{background:transparent;color:var(--text);border:1px solid var(--ui-border);
box-shadow:none;opacity:.75}
.btn.ghost:hover{opacity:1;border-color:var(--c2);background:rgba(var(--c2-rgb),.1)}
</style>
</head>
<body>
<div id="ui">
<div id="screen-main" class="screen">
<h1 class="logo">AIM TRAINER</h1>
<p class="sub">3D · Тренажёр аима</p>
<div class="mainBtns">
<button class="mbtn play" data-go="play">Играть</button>
<button class="mbtn" data-go="settings">Настройки</button>
<button class="mbtn exit" id="exitBtnMain">Выход</button>
</div>
</div>
<div id="screen-play" class="screen hidden">
<h2 class="title">Выбери режим</h2>
<div class="modes" id="modesGrid"></div>
<button class="backBtn" data-back>← Назад</button>
</div>
<div id="screen-settings" class="screen hidden">
<h2 class="title">Настройки</h2>
<div class="tabs">
<button class="tab active" data-tab="graphics">Графика</button>
<button class="tab" data-tab="crosshair">Прицел</button>
<button class="tab" data-tab="camera">Камера</button>
<button class="tab" data-tab="animations">Анимации</button>
<button class="tab" data-tab="custom">Кастомизация</button>
</div>
<div class="settingsBox">
<div class="tabContent" data-tab="graphics">
<div class="row">
<div class="lbl"><span>Качество графики</span></div>
<div class="presetGrid">
<button class="preset" data-preset="low">Низкая</button>
<button class="preset" data-preset="medium">Средняя</button>
<button class="preset" data-preset="high">Высокая</button>
<button class="preset" data-preset="ultra">Ультра</button>
</div>
</div>
<div class="row hidden" id="particlesRow">
<div class="lbl"><span>Частицы (ультра)</span><b id="particlesVal">700</b></div>
<input type="range" id="particlesSlider" min="0" max="1500" step="50" value="700">
</div>
<div class="row hidden" id="bloomRow">
<div class="lbl"><span>Сила Bloom (ультра)</span><b id="bloomVal">0.75</b></div>
<input type="range" id="bloomSlider" min="0" max="3" step="0.05" value="0.75">
</div>
<div class="row hidden" id="brightnessRow">
<div class="lbl"><span>Яркость (ультра)</span><b id="brightnessVal">1.00</b></div>
<input type="range" id="brightnessSlider" min="0.3" max="3" step="0.05" value="1">
</div>
<div class="row hidden" id="effBloomRow">
<div class="lbl"><span>Сила блума эффектов (ультра)</span><b id="effBloomVal">1.00×</b></div>
<input type="range" id="effBloomSlider" min="0" max="3" step="0.05" value="1">
</div>
<div class="row">
<div class="lbl"><span>Форма целей</span></div>
<div class="shapeGrid">
<button class="shapeBtn active" data-shape="sphere">Сфера</button>
<button class="shapeBtn" data-shape="cube">Куб</button>
<button class="shapeBtn" data-shape="heart">Сердце</button>
<button class="shapeBtn" data-shape="star">Звезда</button>
<button class="shapeBtn" data-shape="crystal">Кристалл</button>
</div>
</div>
<div class="row">
<div class="lbl"><span>Эффект попадания · 11 вариантов</span></div>
<div class="effectGrid">
<button class="effectBtn" data-effect="none">Ничего</button>
<button class="effectBtn" data-effect="circle">Кружок</button>
<button class="effectBtn" data-effect="stars">Звёзды</button>
<button class="effectBtn" data-effect="explosion">Взрыв</button>
<button class="effectBtn" data-effect="shards">Осколки</button>
<button class="effectBtn" data-effect="lightning">Молния</button>
<button class="effectBtn" data-effect="plasma">Плазма</button>
<button class="effectBtn" data-effect="sand">Песок</button>
<button class="effectBtn" data-effect="sparks">Искры</button>
<button class="effectBtn" data-effect="crystals">Кристаллы</button>
<button class="effectBtn" data-effect="portals">Порталы</button>
</div>
</div>
<div class="row">
<div class="lbl"><span>Размер эффектов</span><b id="effSizeVal">1.00×</b></div>
<input type="range" id="effSizeSlider" min="0.3" max="2.5" step="0.05" value="1">
</div>
<div class="row">
<div class="lbl"><span>Скорость эффекта попадания</span><b id="effSpeedVal">1.00×</b></div>
<input type="range" id="effSpeed" min="0.25" max="3" step="0.05" value="1">
</div>
<p class="hint" id="graphicsHint">
Выбери пресет — графика применится сразу. Ультра включает HDR-Bloom, отражения и частицы.
</p>
</div>
<div class="tabContent hidden" data-tab="crosshair">
<div class="sliderGrid">
<div class="row"><div class="lbl"><span>Толщина</span><b id="chThickVal">2</b></div>
<input type="range" id="chThick" min="1" max="8" step="1" value="2"></div>
<div class="row"><div class="lbl"><span>Длина линий</span><b id="chLenVal">10</b></div>
<input type="range" id="chLen" min="0" max="30" step="1" value="10"></div>
<div class="row"><div class="lbl"><span>Промежуток</span><b id="chGapVal">5</b></div>
<input type="range" id="chGap" min="0" max="20" step="1" value="5"></div>
<div class="row"><div class="lbl"><span>Размер точки</span><b id="chDotVal">2</b></div>
<input type="range" id="chDot" min="0" max="10" step="1" value="2"></div>
</div>
<div class="row">
<div class="lbl"><span>Цвет прицела</span></div>
<div class="colorRow">
<div class="colorCell"><span>Цвет</span><input type="color" id="chColor" value="#00e5ff"></div>
<div class="colorCell"><span>Синхр. с темой</span>
<label class="toggle" style="margin-top:8px">
<input type="checkbox" id="chSyncTheme" checked><span class="sl"></span>
</label></div>
</div>
</div>
<div class="row">
<div class="lbl"><span>Прозрачность</span><b id="chOpacityVal">100%</b></div>
<input type="range" id="chOpacity" min="20" max="100" step="1" value="100">
</div>
<div class="row row-h"><div class="lbl"><span>Показывать точку</span></div>
<label class="toggle"><input type="checkbox" id="chDotToggle" checked><span class="sl"></span></label></div>
<div class="row row-h"><div class="lbl"><span>Прицел не меняет цвет</span></div>
<label class="toggle"><input type="checkbox" id="chFixed"><span class="sl"></span></label></div>
</div>
<div class="tabContent hidden" data-tab="camera">
<div class="row"><div class="lbl"><span>Сенса</span><b id="sensVal">1.00</b></div>
<input type="range" id="sens" min="0.1" max="5" step="0.01" value="1"></div>
<div class="row"><div class="lbl"><span>Угол обзора (FOV)</span><b id="fovVal">90</b></div>
<input type="range" id="fov" min="60" max="120" step="1" value="90"></div>
<p class="hint">Сенса влияет на скорость движения камеры. FOV — угол обзора в градусах.</p>
</div>
<div class="tabContent hidden" data-tab="animations">
<div class="row"><div class="lbl"><span>Скорость анимации пистолета</span><b id="animPistolVal">1.00×</b></div>
<input type="range" id="animPistol" min="0.25" max="3" step="0.05" value="1"></div>
<p class="hint">Влияет на скорость отдачи и возврата пистолета после выстрела.</p>
</div>
<div class="tabContent hidden" data-tab="custom">
<div class="row"><div class="lbl"><span>Основной цвет (акцент)</span></div>
<input type="color" id="color1" value="#00e5ff"></div>
<div class="row row-h"><div class="lbl"><span>Градиент (2 цвета)</span></div>
<label class="toggle"><input type="checkbox" id="gradToggle"><span class="sl"></span></label></div>
<div class="row hidden" id="color2Row">
<div class="lbl"><span>Второй цвет</span></div>
<input type="color" id="color2" value="#b14cff"></div>
<div class="divider"></div>
<div class="row"><div class="lbl"><span>Цвет интерфейса (панели, кнопки)</span></div>
<input type="color" id="uiColor" value="#1a1230"></div>
<div class="divider"></div>
<div class="row row-h"><div class="lbl"><span>Свой фон меню</span></div>
<label class="toggle"><input type="checkbox" id="menuBgToggle"><span class="sl"></span></label></div>
<div class="row gated off" id="menuBgGated">
<div class="lbl"><span>Цвет фона меню</span></div>
<input type="color" id="menuBgColor" value="#0b0714"></div>
<div class="divider"></div>
<div class="row"><div class="lbl"><span>Цвет текста</span></div>
<input type="color" id="textColor" value="#e8eef7"></div>
<p class="hint">По умолчанию всё выключено — включай только то, что тебе нужно.</p>
</div>
</div>
<button class="backBtn" id="settingsBackBtn">← Назад</button>
</div>
<div id="screen-exit" class="screen hidden">
<h1 class="logo">ДО ВСТРЕЧИ</h1>
<p class="sub">Спасибо за игру</p>
<button class="btn" id="closeTabBtn">Закрыть вкладку</button>
<button class="backBtn" data-back>← Вернуться</button>
</div>
<div id="overlay-time" class="overlay hidden">
<div class="panel">
<h3 id="timeTitle">Gridshot</h3>
<div class="panelRow">
<div class="panelLabel">Размер мячей</div>
<div class="sizeOpts">
<button class="sizeBtn" data-size="xs"><i></i><span>XS</span></button>
<button class="sizeBtn" data-size="s"><i></i><span>S</span></button>
<button class="sizeBtn active" data-size="m"><i></i><span>M</span></button>
<button class="sizeBtn" data-size="l"><i></i><span>L</span></button>
<button class="sizeBtn" data-size="xl"><i></i><span>XL</span></button>
</div>
</div>
<div class="panelRow">
<div class="panelLabel">Время матча</div>
<div class="timeOpts">
<button class="tbtn" data-time="30">30<span>СЕК</span></button>
<button class="tbtn" data-time="60">60<span>СЕК</span></button>
<button class="tbtn" data-time="120">120<span>СЕК</span></button>
<button class="tbtn" data-time="0">∞<span>БЕЗ ЛИМ.</span></button>
</div>
</div>
<button class="backBtn" id="cancelTime">Отмена</button>
</div>
</div>
</div>
<div id="hud" class="hidden">
<div class="topbar">
<div class="stat"><span>ОЧКИ</span><b id="hScore">0</b></div>
<div class="timer" id="hTime">60</div>
<div class="stat right"><span id="hAccLbl">ТОЧНОСТЬ</span><b id="hAcc">100%</b></div>
</div>
<div class="crosshair" id="crosshair">
<div class="line l1"></div><div class="line l2"></div>
<div class="line l3"></div><div class="line l4"></div>
<div class="dot"></div>
</div>
<div class="trackBar hidden" id="trackBar"><i></i></div>
<div class="modeLabel" id="hMode"></div>
</div>
<div id="pause" class="overlay hidden">
<div class="panel">
<h3>Пауза</h3>
<button class="btn" id="resumeBtn">Продолжить</button>
<button class="btn ghost" id="pauseSettingsBtn">Настройки</button>
<button class="backBtn" id="pauseExitBtn">В меню</button>
</div>
</div>
<div id="results" class="overlay hidden">
<div class="panel">
<h3>Результат</h3>
<div class="resGrid">
<div><span>Очки</span><b id="rScore">0</b></div>
<div><span id="rAccLbl">Точность</span><b id="rAcc">0%</b></div>
<div><span>Попадания</span><b id="rHits">0</b></div>
<div><span>Выстрелы</span><b id="rShots">0</b></div>
</div>
<button class="btn" id="againBtn">Ещё раз</button>
<button class="backBtn" id="menuBtn">В меню</button>
</div>
</div>
<script type="importmap">
{ "imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
import * as THREE from 'three';
/* ============================ РЕЖИМЫ ============================ */
const MODES = {
gridshot: { name:'Gridshot', desc:'Три цели одновременно. Скорость и точность.', tag:'ПОПУЛЯРНЫЙ',
count:3, radius:0.42, duration:60, type:'click', spread:1.0 },
flick: { name:'Flick', desc:'Одна цель за раз. Тренировка резких разворотов.',
count:1, radius:0.38, duration:60, type:'click', spread:1.0 },
tracking: { name:'Tracking', desc:'Удерживай прицел на движущейся цели (зажми ЛКМ).', tag:'НОВОЕ',
count:1, radius:0.55, duration:30, type:'track' },
spider: { name:'Spider Shot', desc:'Цель исчезает через 1.4 сек. Не успел — переехала.', tag:'НОВОЕ',
count:1, radius:0.40, duration:60, type:'click', spread:1.0, spawnTimeout:1.4 },
motion: { name:'Motion', desc:'Движущиеся цели. Клик по скорости.', tag:'НОВОЕ',
count:3, radius:0.40, duration:60, type:'click', spread:1.0, moving:true }
};
const SHAPES = ['sphere','cube','heart','star','crystal'];
const BALL_SIZES = [
{ id:'xs', mult:0.55 }, { id:'s', mult:0.75 }, { id:'m', mult:1.00 },
{ id:'l', mult:1.35 }, { id:'xl', mult:1.80 }
];
const VALID_EFFECTS = ['none','circle','stars','explosion','shards','lightning','plasma','sand','sparks','crystals','portals'];
const VALID_TABS = ['graphics','crosshair','camera','animations','custom'];
/* ============================ НАСТРОЙКИ ============================ */
const THEME = { c1:'#00e5ff', c2:'#b14cff', gradient:false,
uiColor:'#1a1230', menuBgEnabled:false, menuBgColor:'#0b0714', textColor:'#e8eef7' };
const DEFAULT_MENU_GRADIENT =
'radial-gradient(ellipse at 50% 40%,#140a26 0%,#0b0714 55%,#05030a 100%)';
const CROSSHAIR = { thickness:2, length:10, gap:5, dot:true, dotSize:2,
fixedColor:false, color:'#00e5ff', opacity:1, syncTheme:true };
const HIT_EFFECT = { mode:'stars', speed:1.0, hdr:1.0 };
const ANIM = { pistolSpeed:1.0 };
const BLOOM = { strength:0.75, radius:0.5, threshold:0.75 };
const EFFECT_HDR = 3.5;
const EFFECT_HDR_MIN = 0.65;
function hdrMult(baseMult = 1){
const userHdr = Math.max(0, HIT_EFFECT.hdr ?? 1);
const hdr = EFFECT_HDR_MIN + (EFFECT_HDR - EFFECT_HDR_MIN) * userHdr;
return hdr * baseMult;
}
const GRAPHICS_PRESETS = {
low: { pr:1, shadows:false, shadowSize:512, fog:false, bloom:false, detail:'low' },
medium: { pr:1.25, shadows:true, shadowSize:512, fog:true, bloom:false, detail:'medium' },
high: { pr:1.5, shadows:true, shadowSize:1024, fog:true, bloom:false, detail:'high' },
ultra: { pr:2, shadows:true, shadowSize:2048, fog:true, bloom:true, detail:'ultra' }
};
const GRAPHICS = { level:'high', shape:'sphere', particles:700, effectSize:1.0, exposure:1.0 };
const state = {
phase:'menu', mode:null,
score:0, shots:0, hits:0, timeLeft:0, elapsed:0, trackTime:0,
infinite:false, sens:1.0, mouseDown:false,
recoil:0, recoilRot:0, swayX:0, swayY:0,
useComposer:false,
settingsReturnTo:null, activeSettingsTab:'graphics',
sizeId:'m', sizeMultiplier:1.0,
lastDuration:60
};
let effectScale = 1.0;
let crosshairHitTimer = null;
function getBallRadius(){
const m = MODES[state.mode];
if (!m) return 0.42;
return m.radius * (state.sizeMultiplier || 1);
}
function hexToRgb(hex){
const n = parseInt(hex.replace('#',''), 16);
if (isNaN(n)) return [255,255,255];
return [(n>>16)&255, (n>>8)&255, n&255];
}
function hexToRgbStr(hex){
const [r,g,b] = hexToRgb(hex); return `${r},${g},${b}`;
}
function clamp255(x){ return Math.max(0, Math.min(255, Math.round(x))); }
function getC2(){ return THEME.gradient ? THEME.c2 : THEME.c1; }
/* ============================ THREE.JS ============================ */
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05030a);
scene.fog = new THREE.FogExp2(0x05030a, 0.02);
const camera = new THREE.PerspectiveCamera(90, innerWidth/innerHeight, 0.01, 300);
camera.rotation.order = 'YXZ';
camera.position.set(0, 1.7, 0);
scene.add(camera);
const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:'high-performance' });
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.body.appendChild(renderer.domElement);
addEventListener('resize', () => {
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
if (composer) composer.setSize(innerWidth, innerHeight);
});
let composer = null, bloomPass = null, outputPass = null, fxReady = false;
async function initPostFX(){
try {
const { EffectComposer } = await import('three/addons/postprocessing/EffectComposer.js');
const { RenderPass } = await import('three/addons/postprocessing/RenderPass.js');
const { UnrealBloomPass } = await import('three/addons/postprocessing/UnrealBloomPass.js');
const { OutputPass } = await import('three/addons/postprocessing/OutputPass.js');
const rt = new THREE.WebGLRenderTarget(
innerWidth, innerHeight,
{ type: THREE.HalfFloatType }
);
composer = new EffectComposer(renderer, rt);
composer.addPass(new RenderPass(scene, camera));
bloomPass = new UnrealBloomPass(
new THREE.Vector2(innerWidth, innerHeight),
BLOOM.strength, BLOOM.radius, BLOOM.threshold
);
bloomPass.enabled = false;
composer.addPass(bloomPass);
outputPass = new OutputPass();
composer.addPass(outputPass);
composer.setSize(innerWidth, innerHeight);
fxReady = true;
updateComposerState();
} catch(e){ console.warn('Постобработка недоступна:', e); }
}
function updateComposerState(){
if (!fxReady || !bloomPass) return;
const wantBloom = GRAPHICS_PRESETS[GRAPHICS.level].bloom;
bloomPass.enabled = wantBloom;
bloomPass.strength = BLOOM.strength;
bloomPass.threshold = BLOOM.threshold;
bloomPass.radius = BLOOM.radius;
state.useComposer = wantBloom;
}
scene.add(new THREE.HemisphereLight(0x9ec8ff, 0x0a0518, 0.85));
const dir = new THREE.DirectionalLight(0xffffff, 2.0);
dir.position.set(6, 14, 6);
dir.castShadow = true;
dir.shadow.mapSize.set(1024, 1024);
Object.assign(dir.shadow.camera, { left:-18, right:18, top:18, bottom:-6, near:1, far:60 });
dir.shadow.camera.updateProjectionMatrix();
scene.add(dir);
const lCyan = new THREE.PointLight(0x00e5ff, 140, 45, 2); lCyan.position.set(-10, 7, -9);
const lPurple = new THREE.PointLight(0xb14cff, 160, 45, 2); lPurple.position.set(0, 8, -12);
const lPink = new THREE.PointLight(0xff2d92, 120, 45, 2); lPink.position.set(10, 7, -9);
scene.add(lCyan, lPurple, lPink);
const gunLight = new THREE.PointLight(0xffffff, 4, 4, 2);
gunLight.position.set(0.3, 0.2, 0.4);
camera.add(gunLight);
function gridTexLight(){
const c = document.createElement('canvas'); c.width = c.height = 512;
const x = c.getContext('2d');
x.fillStyle = '#dddddd'; x.fillRect(0,0,512,512);
x.strokeStyle = '#3a3a3a'; x.lineWidth = 3;
for (let i=0;i<=512;i+=64){
x.beginPath(); x.moveTo(i,0); x.lineTo(i,512); x.stroke();
x.beginPath(); x.moveTo(0,i); x.lineTo(512,i); x.stroke();
}
const t = new THREE.CanvasTexture(c);
t.wrapS = t.wrapT = THREE.RepeatWrapping;
t.repeat.set(5,3);
return t;
}
const floor = new THREE.Mesh(new THREE.PlaneGeometry(60,60),
new THREE.MeshStandardMaterial({ color:0x0a0718, roughness:0.65, metalness:0.35 }));
floor.rotation.x = -Math.PI/2; floor.receiveShadow = true; scene.add(floor);
const wallMat = new THREE.MeshStandardMaterial({
map: gridTexLight(), color:0x0c0a18, emissive:0x000000,
emissiveIntensity:0, roughness:0.9, metalness:0.05 });
const wall = new THREE.Mesh(new THREE.PlaneGeometry(60,26), wallMat);
wall.position.set(0, 12, -17); wall.receiveShadow = true; scene.add(wall);
const sideMat = new THREE.MeshStandardMaterial({ color:0x0a0718, roughness:0.95 });
const wallL = new THREE.Mesh(new THREE.PlaneGeometry(40,26), sideMat);
wallL.position.set(-16, 12, -8); wallL.rotation.y = Math.PI/2; scene.add(wallL);
const wallR = wallL.clone(); wallR.position.x = 16; wallR.rotation.y = -Math.PI/2; scene.add(wallR);
const ceil = new THREE.Mesh(new THREE.PlaneGeometry(60,60),
new THREE.MeshStandardMaterial({ color:0x05030a, roughness:1 }));
ceil.rotation.x = Math.PI/2; ceil.position.y = 14; scene.add(ceil);
const strips = [];
for (let i = 0; i < 3; i++){
const strip = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.02, 30),
new THREE.MeshBasicMaterial({ color: 0x00e5ff }));
strip.position.set((i - 1) * 11, 0.02, -6);
if (i === 1){
strip.geometry = new THREE.BoxGeometry(0.05, 0.02, 20);
strip.position.set(0, 0.02, -12);
}
strips.push(strip);
scene.add(strip);
}
const SHARED_GEOS = new Set();
const detailGroup = new THREE.Group();
scene.add(detailGroup);
let particles = null;
function disposeGroup(g){
while (g.children.length){
const c = g.children[0];
g.remove(c);
if (c.geometry && !SHARED_GEOS.has(c.geometry)) c.geometry.dispose();
if (c.material){
if (Array.isArray(c.material)) c.material.forEach(m => m.dispose());
else c.material.dispose();
}
}
}
function getParticlesCount(level){
if (level === 'high') return 220;
if (level === 'ultra') return Math.max(0, Math.min(1500, GRAPHICS.particles));
return 0;
}
function buildDetail(level){
disposeGroup(detailGroup);
particles = null;
if (level === 'low') return;
const count = level === 'medium' ? 4 : level === 'high' ? 6 : 8;
for (let i = 0; i < count; i++){
const c = (i % 2 === 0) ? themeC1 : themeC2;
const s = new THREE.Mesh(new THREE.SphereGeometry(0.15, 16, 16),
new THREE.MeshBasicMaterial({ color: c }));
s.position.set((i - (count-1)/2) * 3.2, 12.2, -10);
detailGroup.add(s);
if (level !== 'medium'){
const l = new THREE.PointLight(c, 25, 14, 2);
l.position.copy(s.position);
detailGroup.add(l);
}
}
const pCount = getParticlesCount(level);
if (pCount > 0){
const pos = new Float32Array(pCount * 3);
const cols = new Float32Array(pCount * 3);
for (let i = 0; i < pCount; i++){
pos[i*3] = (Math.random() - 0.5) * 30;
pos[i*3+1] = Math.random() * 12 + 1;
pos[i*3+2] = (Math.random() - 0.5) * 25 - 8;
const c = Math.random() < 0.5 ? themeC1 : themeC2;
cols[i*3] = c.r; cols[i*3+1] = c.g; cols[i*3+2] = c.b;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.BufferAttribute(cols, 3));
const mat = new THREE.PointsMaterial({
size: level === 'ultra' ? 0.07 : 0.055,
vertexColors: true, transparent: true, opacity: 0.5,
blending: THREE.AdditiveBlending, depthWrite: false, sizeAttenuation: true });
particles = new THREE.Points(geo, mat);
detailGroup.add(particles);
}
if (level === 'ultra'){
for (let i = 0; i < 7; i++){
const c = (i % 2 === 0) ? themeC1 : themeC2;
const stripL = new THREE.Mesh(new THREE.BoxGeometry(0.06, 4.5, 0.06),
new THREE.MeshBasicMaterial({ color: c }));
stripL.position.set(-15.85, 2.4 + i*1.4, -3 - i*1.7);
detailGroup.add(stripL);
const stripR = stripL.clone(); stripR.position.x = 15.85;
detailGroup.add(stripR);
}
}
}
function createPistol(){
const g = new THREE.Group();
const steel = new THREE.MeshStandardMaterial({ color:0x2b313b, metalness:0.92, roughness:0.32 });
const dark = new THREE.MeshStandardMaterial({ color:0x12151a, metalness:0.65, roughness:0.6 });
const gripM = new THREE.MeshStandardMaterial({ color:0x1a1e24, metalness:0.15, roughness:0.95 });
const glow = new THREE.MeshStandardMaterial({ color:0x00e5ff, emissive:0x00b8d4,
emissiveIntensity:1.6, metalness:0.4, roughness:0.25 });
const slide = new THREE.Mesh(new THREE.BoxGeometry(0.115, 0.115, 0.54), steel);
slide.position.set(0, 0, -0.07); slide.castShadow = true; g.add(slide);
for (let i = 0; i < 6; i++) {
const s = new THREE.Mesh(new THREE.BoxGeometry(0.122, 0.085, 0.011), dark);
s.position.set(0, 0, 0.14 - i*0.026); g.add(s);
}
const barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.029, 0.029, 0.10, 16), dark);
barrel.rotation.x = Math.PI/2; barrel.position.set(0, 0.005, -0.395); g.add(barrel);
const muzzle = new THREE.Mesh(new THREE.TorusGeometry(0.033, 0.008, 8, 20), steel);
muzzle.position.set(0, 0.005, -0.445); g.add(muzzle);
const frame = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.075, 0.36), steel);
frame.position.set(0, -0.093, -0.05); g.add(frame);
const grip = new THREE.Mesh(new THREE.BoxGeometry(0.095, 0.30, 0.135), gripM);
grip.position.set(0, -0.245, 0.095); grip.rotation.x = -0.18; g.add(grip);
for (let i = 0; i < 5; i++) {
const l = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.010, 0.14), steel);
l.position.set(0, -0.135 - i*0.05, 0.088 + i*0.009);
l.rotation.x = -0.18; g.add(l);
}
const guard = new THREE.Mesh(new THREE.TorusGeometry(0.055, 0.010, 8, 24), steel);
guard.rotation.y = Math.PI/2; guard.position.set(0, -0.115, -0.015); g.add(guard);
const trig = new THREE.Mesh(new THREE.BoxGeometry(0.018, 0.058, 0.018), dark);
trig.position.set(0, -0.118, -0.025); trig.rotation.x = 0.22; g.add(trig);
const rear = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.022, 0.022), dark);
rear.position.set(0, 0.068, 0.155); g.add(rear);
const rl = new THREE.Mesh(new THREE.BoxGeometry(0.018, 0.032, 0.026), dark);
rl.position.set(-0.041, 0.074, 0.155); g.add(rl);
const rr = rl.clone(); rr.position.x = 0.041; g.add(rr);
const front = new THREE.Mesh(new THREE.BoxGeometry(0.016, 0.030, 0.020), dark);
front.position.set(0, 0.072, -0.325); g.add(front);
const accent = new THREE.Mesh(new THREE.BoxGeometry(0.118, 0.007, 0.30), glow);
accent.position.set(0, 0.046, -0.09); g.add(accent);
const flashMat = new THREE.MeshBasicMaterial({
color:0xffd27a, transparent:true, opacity:0,
depthWrite:false, blending:THREE.AdditiveBlending });
const flash = new THREE.Mesh(new THREE.SphereGeometry(0.085, 12, 12), flashMat);
flash.position.set(0, 0.005, -0.47); g.add(flash);
g.userData = { flash, flashMat, accent };
return g;
}
const gun = createPistol();
const GUN_HOME = new THREE.Vector3(0.28, -0.26, -0.72);
gun.position.copy(GUN_HOME);
gun.rotation.set(0.02, -0.06, 0);
camera.add(gun);
/* ============================ ТЕМА ============================ */
const themeC1 = new THREE.Color(THEME.c1);
const themeC2 = new THREE.Color(THEME.c1);
function makeDimThemeColor(baseColor, satMul = 0.7, light = 0.055){
const hsl = { h:0, s:0, l:0 };
baseColor.getHSL(hsl);
const c = new THREE.Color();
c.setHSL(hsl.h, Math.min(0.65, hsl.s * satMul), light);
return c;
}
function applyTheme(){
themeC1.set(THEME.c1); themeC2.set(getC2());
const c1 = THEME.c1, c2 = getC2();
const root = document.documentElement;
root.style.setProperty('--c1', c1);
root.style.setProperty('--c2', c2);
root.style.setProperty('--c1-rgb', hexToRgbStr(c1));
root.style.setProperty('--c2-rgb', hexToRgbStr(c2));
root.style.setProperty('--text', THEME.textColor);
applyUiColor(); applyMenuBg();
const wallColor = makeDimThemeColor(themeC1, 0.7, 0.055);
wallMat.color.copy(wallColor);
wallMat.emissive.copy(wallColor);
wallMat.emissiveIntensity = 0.35;
wallMat.needsUpdate = true;
sideMat.color.setHex(0x0a0718); sideMat.needsUpdate = true;
lCyan.color.copy(themeC1); lPurple.color.copy(themeC2); lPink.color.copy(themeC2);
strips.forEach((s, i) => s.material.color.copy(i % 2 === 0 ? themeC1 : themeC2));
gun.userData.accent.material.color.copy(themeC1);
gun.userData.accent.material.emissive.copy(themeC1);
buildDetail(GRAPHICS_PRESETS[GRAPHICS.level].detail);
if (CROSSHAIR.syncTheme){
CROSSHAIR.color = c1;
const chc = $('chColor'); if (chc) chc.value = c1;
}
applyCrosshair();
const m = state.mode ? MODES[state.mode] : null;
if (m && m.type === 'click'){
for (const t of targets){
if (t.mesh && t.mesh.material){
t.mesh.material.color.copy(themeC1);
t.mesh.material.emissive.copy(themeC1).multiplyScalar(0.45);
}
}
}
}
function applyUiColor(){
const [r, g, b] = hexToRgb(THEME.uiColor);
const bg1 = [clamp255(r*1.2), clamp255(g*1.2), clamp255(b*1.2)];
const bg2 = [clamp255(r*0.5), clamp255(g*0.5), clamp255(b*0.5)];
const br = [clamp255(r*1.6), clamp255(g*1.6), clamp255(b*1.6)];
const root = document.documentElement;
root.style.setProperty('--ui-bg-1-rgb', `${bg1[0]},${bg1[1]},${bg1[2]}`);
root.style.setProperty('--ui-bg-2-rgb', `${bg2[0]},${bg2[1]},${bg2[2]}`);
root.style.setProperty('--ui-border', `rgb(${br[0]},${br[1]},${br[2]})`);
}
function applyMenuBg(){
if (THEME.menuBgEnabled){
const [r,g,b] = hexToRgb(THEME.menuBgColor);
const lr = Math.min(255, Math.round(r * 1.8));
const lg = Math.min(255, Math.round(g * 1.8));
const lb = Math.min(255, Math.round(b * 1.8));
const dr = Math.round(r * 0.5), dg = Math.round(g * 0.5), db = Math.round(b * 0.5);
const gradient = `radial-gradient(ellipse at 50% 40%,rgb(${lr},${lg},${lb}) 0%,rgb(${r},${g},${b}) 55%,rgb(${dr},${dg},${db}) 100%)`;
document.documentElement.style.setProperty('--menu-bg', gradient);
} else {
document.documentElement.style.setProperty('--menu-bg', DEFAULT_MENU_GRADIENT);
}
}
/* ============================ ГРАФИКА ============================ */
function updateParticlesRowVisibility(){
const isUltra = GRAPHICS.level === 'ultra';
['particlesRow','bloomRow','brightnessRow','effBloomRow'].forEach(id => {
const el = $(id);
if (el) el.classList.toggle('hidden', !isUltra);
});
}
function applyGraphics(level){
if (!GRAPHICS_PRESETS[level]) level = 'high';
GRAPHICS.level = level;
const p = GRAPHICS_PRESETS[level];
renderer.setPixelRatio(Math.min(devicePixelRatio, p.pr));
renderer.toneMappingExposure = GRAPHICS.exposure;
renderer.shadowMap.enabled = p.shadows;
dir.castShadow = p.shadows;
if (dir.shadow.map){ dir.shadow.map.dispose(); dir.shadow.map = null; }
dir.shadow.mapSize.set(p.shadowSize, p.shadowSize);
if (p.fog){ if (!scene.fog) scene.fog = new THREE.FogExp2(0x05030a, 0.02); }
else scene.fog = null;
if (p.detail === 'ultra'){ floor.material.metalness = 0.6; floor.material.roughness = 0.22; }
else if (p.detail === 'high'){ floor.material.metalness = 0.4; floor.material.roughness = 0.5; }
else { floor.material.metalness = 0.15; floor.material.roughness = 0.85; }
floor.material.needsUpdate = true;
buildDetail(p.detail);
updateComposerState();
updateParticlesRowVisibility();
document.querySelectorAll('.preset[data-preset]').forEach(b => {
b.classList.toggle('active', b.dataset.preset === level);
});
const hints = {
low: 'Максимальный FPS. Без теней, тумана и деталей.',
medium: 'Баланс производительности и качества. Мягкие тени, потолочный свет.',
high: 'Красивая графика: детальные тени, частицы, больше света.',
ultra: 'Максимум качества: HDR-Bloom, отражения, частицы.'
};
const hint = $('graphicsHint'); if (hint) hint.textContent = hints[level];
saveSettings();
}
/* ============================ ПРИЦЕЛ ============================ */
function applyCrosshair(){
if (!crosshairEl) return;
crosshairEl.style.setProperty('--gap', CROSSHAIR.gap + 'px');
crosshairEl.style.setProperty('--len', CROSSHAIR.length + 'px');
crosshairEl.style.setProperty('--thick', CROSSHAIR.thickness + 'px');
crosshairEl.style.setProperty('--dotSize', CROSSHAIR.dotSize + 'px');
const col = CROSSHAIR.color || THEME.c1;
crosshairEl.style.setProperty('--chColor', col);
const op = CROSSHAIR.opacity;
crosshairEl.querySelectorAll('.line, .dot').forEach(el => { el.style.opacity = op; });
crosshairEl.classList.toggle('noDot', !CROSSHAIR.dot);
}
/* ============================ СОХРАНЕНИЕ ============================ */
function saveSettings(){
try {
localStorage.setItem('aimTrainer', JSON.stringify({
c1: THEME.c1, c2: THEME.c2, gradient: THEME.gradient,
uiColor: THEME.uiColor, menuBgEnabled: THEME.menuBgEnabled,
menuBgColor: THEME.menuBgColor, textColor: THEME.textColor,
sizeId: state.sizeId, sens: state.sens, fov: camera.fov,
lastDuration: state.lastDuration,
graphics: { level:GRAPHICS.level, shape:GRAPHICS.shape,
particles:GRAPHICS.particles, effectSize:GRAPHICS.effectSize,
bloomStrength:BLOOM.strength, exposure:GRAPHICS.exposure },
hitEffect: HIT_EFFECT.mode, hitEffectSpeed: HIT_EFFECT.speed, hitEffectHdr: HIT_EFFECT.hdr,
crosshair: CROSSHAIR, anim: ANIM, activeTab: state.activeSettingsTab
}));
} catch(e){ console.warn('Не удалось сохранить:', e); }
}
function loadSettings(){
try {
const raw = localStorage.getItem('aimTrainer');
if (!raw) return;
const s = JSON.parse(raw);
if (typeof s.c1 === 'string') THEME.c1 = s.c1;
if (typeof s.c2 === 'string') THEME.c2 = s.c2;
if (typeof s.gradient === 'boolean') THEME.gradient = s.gradient;
if (typeof s.uiColor === 'string') THEME.uiColor = s.uiColor;
if (typeof s.menuBgEnabled === 'boolean') THEME.menuBgEnabled = s.menuBgEnabled;
if (typeof s.menuBgColor === 'string') THEME.menuBgColor = s.menuBgColor;
if (typeof s.textColor === 'string') THEME.textColor = s.textColor;
if (typeof s.sizeId === 'string' && BALL_SIZES.some(b => b.id === s.sizeId)) state.sizeId = s.sizeId;
if (typeof s.activeTab === 'string' && VALID_TABS.includes(s.activeTab)){
state.activeSettingsTab = s.activeTab;
}
if (typeof s.sens === 'number' && s.sens >= 0.1 && s.sens <= 5) state.sens = s.sens;
if (typeof s.lastDuration === 'number' && s.lastDuration >= 0) state.lastDuration = s.lastDuration;
if (typeof s.fov === 'number' && s.fov >= 60 && s.fov <= 120){
camera.fov = s.fov; camera.updateProjectionMatrix();
}
if (typeof s.graphics === 'string' && GRAPHICS_PRESETS[s.graphics]){
GRAPHICS.level = s.graphics;
} else if (s.graphics && typeof s.graphics === 'object'){
if (GRAPHICS_PRESETS[s.graphics.level]) GRAPHICS.level = s.graphics.level;
if (SHAPES.includes(s.graphics.shape)) GRAPHICS.shape = s.graphics.shape;
if (typeof s.graphics.particles === 'number'){
GRAPHICS.particles = Math.max(0, Math.min(1500, s.graphics.particles));
}
if (typeof s.graphics.effectSize === 'number'){
GRAPHICS.effectSize = Math.max(0.3, Math.min(2.5, s.graphics.effectSize));
}
if (typeof s.graphics.bloomStrength === 'number'){
BLOOM.strength = Math.max(0, Math.min(3, s.graphics.bloomStrength));
}
if (typeof s.graphics.exposure === 'number'){
GRAPHICS.exposure = Math.max(0.3, Math.min(3, s.graphics.exposure));
}
}
if (s.hitEffect && VALID_EFFECTS.includes(s.hitEffect)){
HIT_EFFECT.mode = s.hitEffect;
} else {
HIT_EFFECT.mode = 'stars';
}
if (typeof s.hitEffectSpeed === 'number' && s.hitEffectSpeed >= 0.25 && s.hitEffectSpeed <= 3){
HIT_EFFECT.speed = s.hitEffectSpeed;
}
if (typeof s.hitEffectHdr === 'number' && s.hitEffectHdr >= 0 && s.hitEffectHdr <= 3){
HIT_EFFECT.hdr = s.hitEffectHdr;
}
if (s.crosshair && typeof s.crosshair === 'object') Object.assign(CROSSHAIR, s.crosshair);
if (s.anim && typeof s.anim === 'object') Object.assign(ANIM, s.anim);
} catch(e){ console.warn('Не удалось загрузить:', e); }
}
/* ============================ ЦЕЛИ ============================ */
let targets = [];
const geoCache = {};
function makeHeartShape(size){
const shape = new THREE.Shape();
const s = size;
shape.moveTo(0, -s);
shape.bezierCurveTo(s * 0.9, -s * 0.3, s * 0.9, s * 0.55, 0, s * 0.15);
shape.bezierCurveTo(-s * 0.9, s * 0.55, -s * 0.9, -s * 0.3, 0, -s);
return shape;
}
function makeTargetStarShape(size){
const shape = new THREE.Shape();
const outer = size * 1.15, inner = size * 0.48, points = 5, step = Math.PI / points;
for (let i = 0; i < points * 2; i++){
const r = (i % 2 === 0) ? outer : inner;
const a = i * step - Math.PI / 2;
const x = Math.cos(a) * r, y = Math.sin(a) * r;
if (i === 0) shape.moveTo(x, y); else shape.lineTo(x, y);
}
shape.closePath();
return shape;
}
function getGeo(radius, shape){
const key = `${shape}_${radius.toFixed(3)}`;
if (geoCache[key]) return geoCache[key];
let g;
const s = radius;
switch (shape){
case 'cube': g = new THREE.BoxGeometry(s * 1.55, s * 1.55, s * 1.55); break;
case 'heart': {
const shp = makeHeartShape(s * 1.15);
g = new THREE.ExtrudeGeometry(shp, { depth:s * 0.7, bevelEnabled:true,
bevelSize:s * 0.06, bevelThickness:s * 0.06, bevelSegments:2 });
g.center(); break;
}
case 'star': {
const shp = makeTargetStarShape(s);
g = new THREE.ExtrudeGeometry(shp, { depth:s * 0.7, bevelEnabled:true,
bevelSize:s * 0.05, bevelThickness:s * 0.05, bevelSegments:1 });
g.center(); break;
}
case 'crystal': g = new THREE.OctahedronGeometry(s * 1.1, 0); break;
default: g = new THREE.SphereGeometry(s, 24, 24);
}
SHARED_GEOS.add(g);
geoCache[key] = g;
return g;
}
function findFreeSpawnPosition(R, m){
const z = m.depth ?? -13;
const spread = m.spread ?? 1.0;
const minY = 1.6;
const maxY = 1.6 + 5.6 * spread;
const minDist = R * 2 + R * 0.8;
const ATTEMPTS = 50;
let bestPos = null;
let bestDist = -1;
for (let i = 0; i < ATTEMPTS; i++){
const x = (Math.random() - 0.5) * 13 * spread;
const y = minY + Math.random() * (maxY - minY);
let minD = Infinity;
for (const t of targets){
const dx = t.mesh.position.x - x;
const dy = t.mesh.position.y - y;
const dz = t.mesh.position.z - z;
const d = Math.sqrt(dx*dx + dy*dy + dz*dz);
if (d < minD) minD = d;
}
if (minD >= minDist){
return { x, y, z };
}
if (minD > bestDist){
bestDist = minD;
bestPos = { x, y, z };
}
}
return bestPos || { x: 0, y: (minY + maxY) * 0.5, z };
}
function spawnTarget(){
const m = MODES[state.mode];
if (!m) return;
const R = getBallRadius();
const mat = new THREE.MeshStandardMaterial({
color: themeC1.clone(),
emissive: themeC1.clone().multiplyScalar(0.45),
emissiveIntensity: 0.85,
roughness: 0.25,
metalness: 0.15,
transparent: true,
opacity: 1
});
const mesh = new THREE.Mesh(getGeo(R, GRAPHICS.shape), mat);
mesh.castShadow = true;
const z = m.depth ?? -13;
const spread = m.spread ?? 1.0;
const t = { mesh, phase: Math.random()*10, life: m.spawnTimeout ?? Infinity, dying: false };
if (m.type === 'track'){
const speed = 4.5, ang = Math.random() * Math.PI * 2;
t.vel = new THREE.Vector3(Math.cos(ang)*speed, Math.sin(ang)*speed, 0);
t.bounds = { x:6.5, y0:1.9, y1:7.5 };
mesh.position.set((Math.random()-0.5)*8, 2.5 + Math.random()*4, -11);
} else if (m.moving){
const pos = findFreeSpawnPosition(R, m);
t.vel = new THREE.Vector3((Math.random() < 0.5 ? 1 : -1) * (3 + Math.random()*3), 0, 0);
t.bounds = { x: 6.5 };
mesh.position.set(pos.x, pos.y, pos.z);
} else {
const pos = findFreeSpawnPosition(R, m);
mesh.position.set(pos.x, pos.y, pos.z);
}
targets.push(t);
scene.add(mesh);
}
function clearTargets(){
for (const t of targets){
scene.remove(t.mesh);
if (t.mesh.material) t.mesh.material.dispose();
}
targets = [];
}
function fillTargets(){
const m = MODES[state.mode];
if (!m) return;
const alive = targets.reduce((n, t) => n + (t.dying ? 0 : 1), 0);
let toSpawn = m.count - alive;
while (toSpawn-- > 0) spawnTarget();
}
function removeTarget(t){
const idx = targets.indexOf(t);
if (idx === -1) return;
scene.remove(t.mesh);
if (t.mesh.material) t.mesh.material.dispose();
targets.splice(idx, 1);
}
function applyShapeToTargets(){
const m = state.mode ? MODES[state.mode] : null;
if (!m) return;
const newGeo = getGeo(getBallRadius(), GRAPHICS.shape);
for (const t of targets) t.mesh.geometry = newGeo;
}
/* ============================ ЭФФЕКТЫ ============================ */
const effectGroup = new THREE.Group();
scene.add(effectGroup);
const effects = [];
function makeMat(color, opacity = 1, boost = null){
const c = color.clone ? color.clone() : new THREE.Color(color);
const mult = (boost !== null) ? boost : hdrMult();
c.multiplyScalar(mult);
return new THREE.MeshBasicMaterial({
color: c, transparent: true, opacity,
side: THREE.DoubleSide, depthTest: false, depthWrite: false,
blending: THREE.AdditiveBlending });
}
function randDir(){
return new THREE.Vector3(Math.random()-0.5, Math.random()-0.5, Math.random()-0.5).normalize();
}
function pushEffect(mesh, life, update, extra = {}){
mesh.renderOrder = 1000;
effectGroup.add(mesh);
effects.push({ mesh, age: 0, life, update, ...extra });
}
function easeOutBack(t){
const c1 = 1.70158, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
function dampVel(vel, base, dt){
vel.multiplyScalar(Math.pow(base, dt * 60));
}
function makeStarShape(outerR, innerR, points = 5){
const shape = new THREE.Shape();
const step = Math.PI / points;
for (let i = 0; i < points * 2; i++){
const r = (i % 2 === 0) ? outerR : innerR;
const a = i * step - Math.PI / 2;
const x = Math.cos(a) * r, y = Math.sin(a) * r;
if (i === 0) shape.moveTo(x, y); else shape.lineTo(x, y);
}
shape.closePath();
return shape;
}
let _starGeoFlat = null, _starGeoBig = null;
function getStarGeo(size){
if (!_starGeoFlat){ _starGeoFlat = new THREE.ShapeGeometry(makeStarShape(0.08, 0.032, 5)); SHARED_GEOS.add(_starGeoFlat); }
if (!_starGeoBig){ _starGeoBig = new THREE.ShapeGeometry(makeStarShape(0.55, 0.24, 5)); SHARED_GEOS.add(_starGeoBig); }
return size === 'big' ? _starGeoBig : _starGeoFlat;
}
function effNone(pos){}
/* ---------- CIRCLE (только плавное уменьшение и исчезновение шара — без доп. визуала) ---------- */
function effCircle(pos){
/* Никаких дополнительных частиц, колец или кружков.
Вся анимация происходит с самим шаром (см. "умирающие" цели в animate()). */
}
/* ---------- STARS ---------- */
function effStars(pos){
const c1 = themeC1.clone(), c2 = themeC2.clone();
const S = effectScale;
const pop = new THREE.Mesh(getStarGeo('big'), makeMat(c1));
pop.position.copy(pos);
pop.lookAt(camera.position);
pop.scale.setScalar(0.05);
pushEffect(pop, 0.42, (e, dt, t) => {
const s = (0.05 + 2.55 * easeOutBack(t)) * S;
e.mesh.scale.setScalar(s);
e.mesh.rotation.z += dt * 6;
e.mesh.material.opacity = 1 - t * t;
});
for (let i = 0; i < 10; i++){
const s = new THREE.Mesh(getStarGeo('flat'), makeMat(i % 2 ? c2 : c1));
s.position.copy(pos);
s.lookAt(camera.position);
const v = randDir().multiplyScalar((4 + Math.random() * 5) * S);
v.y += 1.2 * S;
const startScale = (0.55 + Math.random() * 0.9) * S;
s.scale.setScalar(startScale);
const rSpeed = (Math.random() - 0.5) * 14;
pushEffect(s, 0.75 + Math.random() * 0.35, (e, dt, t) => {
e.mesh.position.addScaledVector(e.vel, dt);
dampVel(e.vel, 0.92, dt);
e.vel.y -= dt * 5 * S;
e.mesh.rotation.z += dt * e.rSpeed;
const sc = startScale * (1 - t) * (1 - t);
e.mesh.scale.setScalar(Math.max(0.001, sc));
e.mesh.material.opacity = 1 - t * t;
}, { vel: v, rSpeed });
}
}
/* ---------- EXPLOSION ---------- */
function effExplosion(pos){
const c1 = themeC1.clone(), c2 = themeC2.clone();
const S = effectScale;
const bright1 = c1.clone().lerp(new THREE.Color(0xffffff), 0.55);
const bright2 = c2.clone().lerp(new THREE.Color(0xffffff), 0.55);
for (let i = 0; i < 6; i++){
const g = new THREE.SphereGeometry((0.1 + Math.random() * 0.08) * S, 10, 10);
const col = i < 2 ? bright1 : (i < 4 ? bright2 : c1);
const f = new THREE.Mesh(g, makeMat(col, 1, hdrMult(1.3)));
const off = randDir().multiplyScalar(Math.random() * 0.35 * S);
f.position.copy(pos).add(off);
const sx = 1 + Math.random() * 0.7, sy = 1 + Math.random() * 0.7, sz = 1 + Math.random() * 0.7;
f.scale.set(sx, sy, sz);
const targetScale = 2.5 + Math.random() * 3.5;
pushEffect(f, 0.32 + Math.random() * 0.18, (e, dt, t) => {
const s = 1 + t * targetScale;
e.mesh.scale.set(s * e.sx, s * e.sy, s * e.sz);
e.mesh.material.opacity = (1 - t) * (1 - t);
}, { sx, sy, sz });
}
for (let i = 0; i < 8; i++){
const g = new THREE.ConeGeometry(0.08 * S, 0.55 * S, 5);
const col = i % 2 ? c2 : c1;
const s = new THREE.Mesh(g, makeMat(col));
s.position.copy(pos);
const d = randDir();
s.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), d);
const v = d.clone().multiplyScalar((4 + Math.random() * 4) * S);
pushEffect(s, 0.4 + Math.random() * 0.2, (e, dt, t) => {
e.mesh.position.addScaledVector(e.vel, dt);
dampVel(e.vel, 0.9, dt);
e.mesh.scale.z = 1 + (1 - t) * 2.2;
e.mesh.scale.x = 1 - t * 0.6;
e.mesh.scale.y = 1 - t * 0.6;
e.mesh.material.opacity = 1 - t * t;
}, { vel: v });
}
for (let i = 0; i < 18; i++){
const useCube = Math.random() < 0.5;
const g = useCube
? new THREE.BoxGeometry((0.06 + Math.random() * 0.04) * S, 0.06 * S, 0.06 * S)
: new THREE.TetrahedronGeometry((0.07 + Math.random() * 0.04) * S);
const s = new THREE.Mesh(g, makeMat(i % 2 ? c2 : c1));
s.position.copy(pos);
s.rotation.set(Math.random() * 6, Math.random() * 6, Math.random() * 6);
const v = randDir().multiplyScalar((3 + Math.random() * 7) * S);
const rSpd = new THREE.Vector3(
(Math.random() - 0.5) * 18, (Math.random() - 0.5) * 18, (Math.random() - 0.5) * 18);
pushEffect(s, 0.7 + Math.random() * 0.4, (e, dt, t) => {
e.mesh.position.addScaledVector(e.vel, dt);
dampVel(e.vel, 0.93, dt);
e.vel.y -= dt * 12 * S;
e.mesh.rotation.x += dt * e.rSpd.x;
e.mesh.rotation.y += dt * e.rSpd.y;
e.mesh.rotation.z += dt * e.rSpd.z;
e.mesh.material.opacity = 1 - t * t;
}, { vel: v, rSpd });
}
for (let i = 0; i < 26; i++){
const col = i % 3 === 0 ? bright1 : (i % 3 === 1 ? c2 : c1);
const s = new THREE.Mesh(
new THREE.BoxGeometry(0.02 * S, 0.02 * S, 0.35 * S),
makeMat(col, 1, hdrMult(1.4)));
s.position.copy(pos);
const d = randDir();
s.lookAt(pos.clone().add(d));
const v = d.multiplyScalar((9 + Math.random() * 9) * S);
const stretch = 1 + Math.random() * 1.5;
pushEffect(s, 0.35 + Math.random() * 0.3, (e, dt, t) => {
e.mesh.position.addScaledVector(e.vel, dt);
dampVel(e.vel, 0.88, dt);
e.vel.y -= dt * 12 * S;
e.mesh.scale.z = stretch * (1 + (1 - t) * 2);
e.mesh.material.opacity = 1 - t * t;
}, { vel: v });
}
}
/* ---------- SHARDS ---------- */
function effShards(pos){
const c1 = themeC1.clone(), c2 = themeC2.clone();
const S = effectScale;
const shardGeo = new THREE.ConeGeometry(0.08 * S, 0.22 * S, 3);
for (let i = 0; i < 14; i++){
const s = new THREE.Mesh(shardGeo, makeMat(i % 2 ? c2 : c1));
s.position.copy(pos);
s.rotation.set(Math.random()*6, Math.random()*6, Math.random()*6);
const v = randDir().multiplyScalar((3 + Math.random() * 5) * S);
const rSpeed = new THREE.Vector3((Math.random()-0.5)*12, (Math.random()-0.5)*12, (Math.random()-0.5)*12);
pushEffect(s, 0.7 + Math.random() * 0.4, (e, dt, t) => {
e.mesh.position.addScaledVector(e.vel, dt);
dampVel(e.vel, 0.94, dt);
e.vel.y -= dt * 8 * S;
e.mesh.rotation.x += dt * e.rSpeed.x;
e.mesh.rotation.y += dt * e.rSpeed.y;
e.mesh.rotation.z += dt * e.rSpeed.z;
e.mesh.material.opacity = 1 - t * t;
}, { vel: v, rSpeed });
}
}
/* ---------- LIGHTNING ---------- */
function makeLightningPath(start, end, segments, jitter){
const pts = [start.clone()];
for (let i = 1; i < segments; i++){
const t = i / segments;
const p = new THREE.Vector3().lerpVectors(start, end, t);
p.x += (Math.random() - 0.5) * jitter * 2;
p.z += (Math.random() - 0.5) * jitter * 2;
pts.push(p);
}
pts.push(end.clone());
return pts;
}
function createBolt(path, colA, colB, thickness, life){
const segs = path.length - 1;
for (let i = 0; i < segs; i++){
const a = path[i], b = path[i + 1];
const len = a.distanceTo(b);
if (len < 0.001) continue;
const t = segs > 1 ? i / segs : 0;
const col = colA.clone().lerp(colB, t);
const geo = new THREE.CylinderGeometry(thickness, thickness, len, 6);
const mat = makeMat(col);
const seg = new THREE.Mesh(geo, mat);
seg.position.copy(a).add(b).multiplyScalar(0.5);
seg.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
b.clone().sub(a).normalize());
pushEffect(seg, life, (e, dt, t2) => {
const flick = 0.35 + 0.65 * Math.abs(Math.sin(t2 * 35 + e.mesh.position.x * 8));
e.mesh.material.opacity = (1 - t2) * flick;
});
}
}
function effLightning(pos){
const c1 = themeC1.clone();
const c2 = themeC2.clone();
const WHITE = new THREE.Color(0xffffff);
const S = effectScale;
const jitterS = Math.sqrt(S);
const minThick = 0.012;
const start = new THREE.Vector3(
pos.x + (Math.random() - 0.5) * 1.5 * jitterS,
13.8,
pos.z + (Math.random() - 0.5) * 1.5 * jitterS);
const end = pos.clone();
const mainPath = makeLightningPath(start, end, 12, 0.55 * jitterS);
createBolt(mainPath, c1, c2, Math.max(minThick, 0.05 * S), 0.55);
createBolt(mainPath, WHITE, WHITE, Math.max(minThick * 0.55, 0.025 * S), 0.5);
for (let b = 0; b < 3; b++){
const idx = 4 + Math.floor(Math.random() * 5);
const branchStart = mainPath[idx].clone();
const branchEnd = branchStart.clone().add(new THREE.Vector3(
(Math.random() - 0.5) * 3 * jitterS,
(-1.2 - Math.random() * 1.8) * jitterS,
(Math.random() - 0.5) * 3 * jitterS));
const branchPath = makeLightningPath(branchStart, branchEnd, 6, 0.3 * jitterS);
const branchA = b % 2 ? c2 : c1;
const branchB = b % 2 ? c1 : c2;
createBolt(branchPath, branchA, branchB, Math.max(minThick * 0.7, 0.03 * S), 0.45);
}
const ringInner = Math.max(0.06, 0.35 * S);
const ringOuter = Math.max(0.1, 0.55 * S);
const ring = new THREE.Mesh(
new THREE.RingGeometry(ringInner, ringOuter, 32),
makeMat(c1));
ring.position.set(pos.x, 0.06, pos.z);
ring.rotation.x = -Math.PI / 2;
pushEffect(ring, 0.55, (e, dt, t) => {
const maxScale = 5;
e.mesh.scale.setScalar(1 + t * maxScale);
e.mesh.material.opacity = (1 - t) * (1 - t);
});
for (let i = 0; i < 10; i++){
const col = i % 2 ? c2 : c1;
const s = new THREE.Mesh(
new THREE.BoxGeometry(
Math.max(0.008, 0.025 * S),
Math.max(0.008, 0.025 * S),
Math.max(0.09, 0.28 * S)),
makeMat(col));
s.position.copy(pos);
const d = new THREE.Vector3(
(Math.random() - 0.5) * 2,
Math.random() * 1.2 + 0.1,
(Math.random() - 0.5) * 2).normalize();
s.lookAt(pos.clone().add(d));
const v = d.multiplyScalar((5 + Math.random() * 5) * S);
pushEffect(s, 0.4 + Math.random() * 0.3, (e, dt, t) => {
e.mesh.position.addScaledVector(e.vel, dt);
dampVel(e.vel, 0.88, dt);
e.vel.y -= dt * 9 * S;
e.mesh.scale.z = 1 + (1 - t) * 2;
e.mesh.material.opacity = 1 - t * t;
}, { vel: v });
}
}
/* ---------- PLASMA (с градиентом при включённой теме) ---------- */
function effPlasma(pos){
const c1 = themeC1.clone(), c2 = themeC2.clone();
const S = effectScale;
const minR = 0.03;
const COUNT = 10;
const useGrad = THEME.gradient;
for (let i = 0; i < COUNT; i++){
const tN = i / (COUNT - 1);
const col = useGrad ? c1.clone().lerp(c2, tN) : c1.clone();
const s = new THREE.Mesh(
new THREE.SphereGeometry(Math.max(minR, 0.09 * S), 12, 12),
makeMat(col, 1, hdrMult(1.2)));
s.position.copy(pos);
const ang = (i / COUNT) * Math.PI * 2;
const v = new THREE.Vector3(Math.cos(ang), Math.sin(ang), 0)
.multiplyScalar((3 + Math.random() * 3) * S);
pushEffect(s, 0.55 + Math.random() * 0.3, (e, dt, t2) => {
e.mesh.position.addScaledVector(e.vel, dt);
dampVel(e.vel, 0.92, dt);
const pulse = 1 + Math.sin(t2 * 20) * 0.3;
e.mesh.scale.setScalar((1 - t2) * pulse);
e.mesh.material.opacity = 1 - t2 * t2;
}, { vel: v });
}
const coreCol = useGrad ? c1.clone().lerp(c2, 0.5) : c1.clone();
const core = new THREE.Mesh(
new THREE.SphereGeometry(Math.max(0.04, 0.13 * S), 16, 16),
makeMat(coreCol, 1, hdrMult(1.5)));
core.position.copy(pos);
pushEffect(core, 0.4, (e, dt, t) => {
e.mesh.scale.setScalar(1 + t * 3);
e.mesh.material.opacity = (1 - t) * (1 - t);
});
}
/* ---------- SAND ---------- */
let _sandGeos = null;
function getSandGeos(){
if (_sandGeos) return _sandGeos;
const geos = [
new THREE.BoxGeometry(1, 1, 1.35),
new THREE.TetrahedronGeometry(0.95),
new THREE.OctahedronGeometry(0.85)
];
geos.forEach(g => SHARED_GEOS.add(g));
_sandGeos = geos;
return geos;
}
function effSand(pos){
const S = effectScale;
const hdrUser = Math.max(0, HIT_EFFECT.hdr ?? 1);
const hdrBoost = hdrUser;
const base1 = themeC1.clone();
const base2 = THEME.gradient ? themeC2.clone() : themeC1.clone();
const WHITE = new THREE.Color(0xffffff);
const BLACK = new THREE.Color(0x000000);
const palette = [
base1.clone(),
base2.clone(),
base1.clone().lerp(WHITE, 0.35),
base2.clone().lerp(WHITE, 0.35),
base1.clone().lerp(BLACK, 0.4),
base2.clone().lerp(BLACK, 0.4)
];
const FLOOR_Y = 0.02;
const COUNT = 45;
const geos = getSandGeos();
const puffCol = THEME.gradient
? themeC1.clone().lerp(themeC2, 0.5)
: themeC1.clone();
const puffBrightness = 0.5 + 0.9 * hdrBoost;
const puff = new THREE.Mesh(
new THREE.SphereGeometry(0.14 * S, 14, 14),
new THREE.MeshBasicMaterial({
color: puffCol.clone().multiplyScalar(puffBrightness),
transparent: true, opacity: 0.55,
depthWrite: false,
blending: THREE.AdditiveBlending
}));
puff.position.copy(pos);
pushEffect(puff, 0.28, (e, dt, t) => {
e.mesh.scale.setScalar(1 + t * 2.4);
e.mesh.material.opacity = (1 - t) * 0.55;
});
for (let i = 0; i < COUNT; i++){
const size = (0.02 + Math.random() * 0.03) * S;
const geo = geos[i % geos.length];
const col = palette[i % palette.length].clone();
col.offsetHSL((Math.random()-0.5)*0.03, (Math.random()-0.5)*0.08, (Math.random()-0.5)*0.12);
const mat = new THREE.MeshStandardMaterial({
color: col,
emissive: col.clone().multiplyScalar(0.6 * hdrBoost),
emissiveIntensity: 0.9 * hdrBoost,
metalness: 0.15,
roughness: 0.85,
flatShading: true,
transparent: true,
opacity: 1
});
const m = new THREE.Mesh(geo, mat);
m.position.copy(pos);
m.scale.setScalar(size);
m.rotation.set(Math.random()*6, Math.random()*6, Math.random()*6);
const angle = Math.random() * Math.PI * 2;
const spread = 0.5 + Math.random() * 2.0;
const v = new THREE.Vector3(
Math.cos(angle) * spread * S,
(0.1 + Math.random() * 1.6) * S,
Math.sin(angle) * spread * S
);
const rSpd = new THREE.Vector3(
(Math.random()-0.5)*24,
(Math.random()-0.5)*24,
(Math.random()-0.5)*24
);
const life = 2.2 + Math.random() * 1.2;
pushEffect(m, life, (e, dt, t) => {
if (e.grounded){
const el = e.age - e.groundTime;
const gt = Math.min(1, el / 0.55);
e.mesh.material.opacity = e.groundOpacity * (1 - gt);
return;
}
e.mesh.position.addScaledVector(e.vel, dt);
e.vel.y -= dt * 14;
e.vel.x *= Math.pow(0.92, dt * 60);
e.vel.z *= Math.pow(0.92, dt * 60);
e.mesh.rotation.x += dt * e.rSpd.x;
e.mesh.rotation.y += dt * e.rSpd.y;
e.mesh.rotation.z += dt * e.rSpd.z;
if (e.mesh.position.y <= FLOOR_Y){
e.mesh.position.y = FLOOR_Y;
e.mesh.rotation.x = (Math.random() - 0.5) * 0.6;
e.mesh.rotation.z = (Math.random() - 0.5) * 0.6;
e.grounded = true;
e.groundTime = e.age;
e.groundOpacity = e.mesh.material.opacity;
}
e.mesh.material.opacity = Math.max(0, 1 - t * 0.35);
}, { vel: v, rSpd, grounded: false, groundTime: 0, groundOpacity: 1 });
}
}
/* ---------- SPARKS ---------- */
function effSparks(pos){
const c1 = themeC1.clone(), c2 = themeC2.clone();
const S = effectScale;
for (let i = 0; i < 22; i++){
const s = new THREE.Mesh(
new THREE.BoxGeometry(Math.max(0.008, 0.03 * S), Math.max(0.008, 0.03 * S), Math.max(0.1, 0.5 * S)),
makeMat(i % 2 ? c2 : c1, 1, hdrMult(1.3)));
s.position.copy(pos);
const d = randDir();
s.lookAt(pos.clone().add(d));
const v = d.clone().multiplyScalar((6 + Math.random() * 8) * S);
const stretch = 1 + Math.random() * 1.5;
pushEffect(s, 0.4 + Math.random() * 0.35, (e, dt, t) => {
e.mesh.position.addScaledVector(e.vel, dt);
dampVel(e.vel, 0.9, dt);
e.vel.y -= dt * 10 * S;
e.mesh.scale.z = stretch * (1 + (1 - t) * 1.6);
e.mesh.material.opacity = 1 - t * t;
}, { vel: v });
}
}
/* ---------- CRYSTALS ---------- */
let _crystalGeos = null;
function getCrystalGeos(){
if (_crystalGeos) return _crystalGeos;
const geos = [
new THREE.OctahedronGeometry(1, 0),
new THREE.DodecahedronGeometry(1, 0),
new THREE.IcosahedronGeometry(1, 0),
new THREE.TetrahedronGeometry(1, 0)
];
geos.forEach(g => SHARED_GEOS.add(g));
_crystalGeos = geos;
return geos;
}
function effCrystals(pos){
const c1 = themeC1.clone();
const c2 = themeC2.clone();
const WHITE = new THREE.Color(0xffffff);
const S = effectScale;
const geos = getCrystalGeos();
const COUNT = 12;
const hdrBoost = Math.max(0, HIT_EFFECT.hdr ?? 1);
for (let i = 0; i < COUNT; i++){
const kind = i % geos.length;
const baseR = Math.max(0.05, (0.09 + Math.random() * 0.05) * S);
const col = i % 5 === 0 ? WHITE : (i % 2 ? c2 : c1);
const darkBase = col.clone().multiplyScalar(0.35);
const mat = new THREE.MeshStandardMaterial({
color: darkBase,
emissive: col.clone().multiplyScalar(hdrBoost),
emissiveIntensity: 0.8 * hdrBoost,
metalness: 0.7,
roughness: 0.15,
flatShading: true
});
const m = new THREE.Mesh(geos[kind], mat);
m.position.copy(pos);
const sx0 = baseR * (0.75 + Math.random() * 0.5);
const sy0 = baseR * (1.15 + Math.random() * 0.95);
const sz0 = baseR * (0.75 + Math.random() * 0.5);
m.scale.set(sx0, sy0, sz0);
m.rotation.set(
Math.random() * Math.PI * 2,
Math.random() * Math.PI * 2,
Math.random() * Math.PI * 2
);
const dir = randDir();
const v = dir.clone().multiplyScalar((2.4 + Math.random() * 4) * S);
const rSpd = new THREE.Vector3(
(Math.random() - 0.5) * 14,
(Math.random() - 0.5) * 14,
(Math.random() - 0.5) * 14
);
const life = 0.85 + Math.random() * 0.5;
pushEffect(m, life, (e, dt, t) => {
e.mesh.position.addScaledVector(e.vel, dt);
dampVel(e.vel, 0.94, dt);
e.vel.y -= dt * 4.5 * S;
e.mesh.rotation.x += dt * e.rSpd.x;
e.mesh.rotation.y += dt * e.rSpd.y;
e.mesh.rotation.z += dt * e.rSpd.z;
const grow = 1 + Math.sin(t * Math.PI) * 0.18;
const fade = Math.max(0.0001, 1 - t * t * 0.85);
e.mesh.scale.set(e.sx0 * grow * fade, e.sy0 * grow * fade, e.sz0 * grow * fade);
}, { vel: v, rSpd, sx0, sy0, sz0 });
}
const flash = new THREE.Mesh(
new THREE.SphereGeometry(Math.max(0.08, 0.14 * S), 12, 12),
makeMat(WHITE, 0.9, hdrMult(1.5)));
flash.position.copy(pos);
pushEffect(flash, 0.28, (e, dt, t) => {
e.mesh.scale.setScalar(1 + t * 4.5);
e.mesh.material.opacity = (1 - t) * (1 - t) * 0.9;
});
}
/* ---------- PORTALS ---------- */
function effPortals(pos){
const R = Math.max(0.15, 0.42 * effectScale);
const c1 = themeC1.clone(), c2 = themeC2.clone();
const WHITE = new THREE.Color(0xffffff);
const envelope = (t) => {
if (t < 0.15) return t / 0.15;
if (t > 0.7) return (1 - t) / 0.3;
return 1;
};
const hole = new THREE.Mesh(new THREE.CircleGeometry(R * 1.05, 48),
new THREE.MeshBasicMaterial({ color:0x000000, transparent:true, opacity:0.95,
side:THREE.DoubleSide, depthTest:false, depthWrite:false }));
hole.position.copy(pos);
hole.lookAt(camera.position);
pushEffect(hole, 0.95, (e, dt, t) => {
const env = envelope(t);
e.mesh.scale.setScalar(env);
e.mesh.rotation.z += dt * 0.5;
e.mesh.material.opacity = 0.95 * env;
});
const inner = new THREE.Mesh(new THREE.CircleGeometry(R * 0.75, 40),
new THREE.MeshBasicMaterial({ color: c1.clone().multiplyScalar(0.18 * Math.max(0.5, HIT_EFFECT.hdr ?? 1)),
transparent:true, opacity:0.75, side:THREE.DoubleSide,
depthTest:false, depthWrite:false, blending:THREE.AdditiveBlending }));
inner.position.copy(pos);
inner.lookAt(camera.position);
pushEffect(inner, 0.95, (e, dt, t) => {
const env = envelope(t);
const pulse = 0.85 + Math.sin(t * 28) * 0.12;
e.mesh.scale.setScalar(env * pulse);
e.mesh.rotation.z -= dt * 4;
e.mesh.material.opacity = (1 - t) * env;
});
const rim = new THREE.Mesh(new THREE.RingGeometry(R * 0.95, R * 1.12, 60),
makeMat(c1, 1, hdrMult(1.2)));
rim.position.copy(pos);
rim.lookAt(camera.position);
pushEffect(rim, 0.95, (e, dt, t) => {
const env = envelope(t);
const pulse = 0.8 + Math.sin(t * 32) * 0.2;
e.mesh.scale.setScalar(env);
e.mesh.rotation.z += dt * 5;
e.mesh.material.opacity = env * pulse;
});
const rim2 = new THREE.Mesh(new THREE.RingGeometry(R * 1.22, R * 1.35, 60),
makeMat(c2, 1, hdrMult(1.1)));
rim2.position.copy(pos);
rim2.lookAt(camera.position);
pushEffect(rim2, 0.95, (e, dt, t) => {
const env = envelope(t);
e.mesh.scale.setScalar(env);
e.mesh.rotation.z -= dt * 3;
e.mesh.material.opacity = env * (1 - t) * 0.8;
});
for (let i = 0; i < 8; i++){
const arc = new THREE.Mesh(
new THREE.TorusGeometry(R * 1.0, R * 0.055, 6, 20, Math.PI * 0.55),
makeMat(i % 2 ? c2 : c1));
arc.position.copy(pos);
arc.lookAt(camera.position);
const baseRot = (i / 8) * Math.PI * 2;
arc.rotation.z = baseRot;
pushEffect(arc, 0.95, (e, dt, t) => {
const env = envelope(t);
const r = 1 - t * 0.65;
e.mesh.scale.setScalar(env * r);
e.mesh.rotation.z = baseRot + t * 10;
e.mesh.material.opacity = env * (1 - t);
});
}
for (let i = 0; i < 2; i++){
const torus = new THREE.Mesh(
new THREE.TorusGeometry(R * 1.15, R * 0.045, 8, 40),
makeMat(i === 0 ? c1 : WHITE, 1, hdrMult(1.3)));
torus.position.copy(pos);
const axis = new THREE.Vector3(
Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize();
pushEffect(torus, 0.95, (e, dt, t) => {
const env = envelope(t);
e.mesh.scale.setScalar(env);
e.mesh.rotateOnAxis(axis, dt * 8);
e.mesh.material.opacity = env * (1 - t) * 0.8;
});
}
const STAR_COUNT = 20;
for (let i = 0; i < STAR_COUNT; i++){
const ang0 = (i / STAR_COUNT) * Math.PI * 2 + Math.random() * 0.4;
const dist0 = R * (1.8 + Math.random() * 1.6);
const z0 = (Math.random() - 0.5) * R * 1.4;
const spark = new THREE.Mesh(
new THREE.BoxGeometry(R * 0.055, R * 0.055, R * 0.35),
makeMat(i % 3 === 0 ? WHITE : (i % 3 === 1 ? c1 : c2), 1, hdrMult(1.3)));
spark.position.copy(pos);
pushEffect(spark, 0.9, (e, dt, t) => {
const ang = ang0 + t * 7;
const dist = dist0 * (1 - t * 0.95);
const z = z0 * (1 - t);
e.mesh.position.set(
pos.x + Math.cos(ang) * dist,
pos.y + Math.sin(ang) * dist,
pos.z + z);
e.mesh.lookAt(pos);
e.mesh.material.opacity = Math.max(0, 1 - t * 1.15);
});
}
const flash = new THREE.Mesh(
new THREE.SphereGeometry(Math.max(0.12, R * 0.55), 16, 16),
makeMat(WHITE, 1, hdrMult(1.4)));
flash.position.copy(pos);
pushEffect(flash, 0.9, (e, dt, t) => {
if (t < 0.72){ e.mesh.material.opacity = 0; return; }
const rt = (t - 0.72) / 0.28;
e.mesh.scale.setScalar(rt * 2.5);
e.mesh.material.opacity = (1 - rt) * 0.9;
});
}
function spawnHitEffect(pos){
effectScale = GRAPHICS.effectSize || 1;
const m = HIT_EFFECT.mode;
if (m === 'none') return effNone(pos);
if (m === 'circle') return effCircle(pos);
if (m === 'stars') return effStars(pos);
if (m === 'explosion') return effExplosion(pos);
if (m === 'shards') return effShards(pos);
if (m === 'lightning') return effLightning(pos);
if (m === 'plasma') return effPlasma(pos);
if (m === 'sand') return effSand(pos);
if (m === 'sparks') return effSparks(pos);
if (m === 'crystals') return effCrystals(pos);
if (m === 'portals') return effPortals(pos);
}
function updateEffects(dt){
const sdt = dt * HIT_EFFECT.speed;
for (let i = effects.length - 1; i >= 0; i--){
const e = effects[i];
e.age += sdt;
const t = e.age / e.life;
if (t >= 1){
effectGroup.remove(e.mesh);
if (e.mesh.material) e.mesh.material.dispose();
if (e.mesh.geometry && !SHARED_GEOS.has(e.mesh.geometry)) e.mesh.geometry.dispose();
effects.splice(i, 1);
continue;
}
e.update(e, sdt, t);
}
}
/* ============================ ЗВУК ============================ */
let actx = null;
function ensureAudio(){
if (!actx) actx = new (window.AudioContext || window.webkitAudioContext)();
if (actx.state === 'suspended') actx.resume().catch(()=>{});
return actx;
}
function beep(freq, dur, type='sine', vol=0.06){
try {
const ctx = ensureAudio();
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, ctx.currentTime);
o.frequency.exponentialRampToValueAtTime(freq*0.4, ctx.currentTime+dur);
g.gain.setValueAtTime(vol, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime+dur);
o.connect(g); g.connect(ctx.destination);
o.start(); o.stop(ctx.currentTime+dur);
} catch(e){}
}
function tabSound(){
try {
const ctx = ensureAudio();
const t0 = ctx.currentTime;
const o1 = ctx.createOscillator(), g1 = ctx.createGain();
o1.type = 'sine';
o1.frequency.setValueAtTime(560, t0);
o1.frequency.exponentialRampToValueAtTime(900, t0 + 0.11);
g1.gain.setValueAtTime(0, t0);
g1.gain.linearRampToValueAtTime(0.045, t0 + 0.012);
g1.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18);
o1.connect(g1); g1.connect(ctx.destination);
o1.start(t0); o1.stop(t0 + 0.2);
const o2 = ctx.createOscillator(), g2 = ctx.createGain();
o2.type = 'triangle';
o2.frequency.setValueAtTime(1120, t0 + 0.02);
o2.frequency.exponentialRampToValueAtTime(1500, t0 + 0.09);
g2.gain.setValueAtTime(0, t0);
g2.gain.linearRampToValueAtTime(0.02, t0 + 0.03);
g2.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.15);
o2.connect(g2); g2.connect(ctx.destination);
o2.start(t0 + 0.02); o2.stop(t0 + 0.17);
} catch(e){}
}
function clickSound(kind = 'default'){
try {
const ctx = ensureAudio();
const t0 = ctx.currentTime;
let f0 = 480, f1 = 780, vol = 0.05, dur = 0.14;
if (kind === 'exit'){
f0 = 620; f1 = 300; vol = 0.055; dur = 0.22;
} else if (kind === 'play'){
f0 = 420; f1 = 980; vol = 0.06; dur = 0.2;
} else if (kind === 'back'){
f0 = 620; f1 = 380; vol = 0.04; dur = 0.14;
} else if (kind === 'small'){
f0 = 700; f1 = 1000; vol = 0.03; dur = 0.09;
}
const o1 = ctx.createOscillator(), g1 = ctx.createGain();
o1.type = 'sine';
o1.frequency.setValueAtTime(f0, t0);
o1.frequency.exponentialRampToValueAtTime(f1, t0 + dur);
g1.gain.setValueAtTime(0, t0);
g1.gain.linearRampToValueAtTime(vol, t0 + 0.008);
g1.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o1.connect(g1); g1.connect(ctx.destination);
o1.start(t0); o1.stop(t0 + dur + 0.02);
const o2 = ctx.createOscillator(), g2 = ctx.createGain();
o2.type = 'triangle';
o2.frequency.setValueAtTime(f1 * 1.4, t0 + 0.012);
o2.frequency.exponentialRampToValueAtTime(f1 * 1.9, t0 + 0.06);
g2.gain.setValueAtTime(0, t0);
g2.gain.linearRampToValueAtTime(vol * 0.5, t0 + 0.022);
g2.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.1);
o2.connect(g2); g2.connect(ctx.destination);
o2.start(t0 + 0.012); o2.stop(t0 + 0.12);
} catch(e){}
}
/* ============================ UI ============================ */
const $ = id => document.getElementById(id);
const screens = {
main: $('screen-main'), play: $('screen-play'),
settings: $('screen-settings'), exit: $('screen-exit')
};
const hudEl = $('hud'), pauseEl = $('pause'), resEl = $('results');
const overlay = $('overlay-time'), crosshairEl = $('crosshair'), trackBar = $('trackBar');
function showScreen(name){
for (const k in screens) screens[k].classList.toggle('hidden', k !== name);
}
document.querySelectorAll('[data-go]').forEach(btn => {
btn.onclick = () => {
const target = btn.dataset.go;
clickSound(target === 'settings' ? 'default' : 'play');
showScreen(target);
};
});
document.querySelectorAll('[data-back]').forEach(btn => {
btn.onclick = () => {
clickSound('back');
showScreen('main');
};
});
$('settingsBackBtn').onclick = () => {
clickSound('back');
if (state.settingsReturnTo === 'pause' && state.phase === 'paused'){
screens.settings.classList.add('hidden');
pauseEl.classList.remove('hidden');
state.settingsReturnTo = null;
} else {
state.settingsReturnTo = null;
showScreen('main');
}
};
function tryExitGame(){
clickSound('exit');
try { window.close(); } catch(e){}
setTimeout(() => { if (!window.closed) showScreen('exit'); }, 200);
}
$('exitBtnMain').onclick = tryExitGame;
$('closeTabBtn').onclick = tryExitGame;
function activateTab(tabName){
if (!VALID_TABS.includes(tabName)) tabName = 'graphics';
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabName));
const contents = document.querySelectorAll('.tabContent');
contents.forEach(c => {
const match = c.dataset.tab === tabName;
if (match){
c.classList.add('hidden');
void c.offsetWidth;
c.classList.remove('hidden');
} else {
c.classList.add('hidden');
}
});
state.activeSettingsTab = tabName;
}
document.querySelectorAll('.tab').forEach(tab => {
tab.onclick = () => {
const name = tab.dataset.tab;
if (name === state.activeSettingsTab) return;
tab.classList.add('ripple');
setTimeout(() => tab.classList.remove('ripple'), 20);
tabSound();
activateTab(name);
saveSettings();
};
});
const modesGrid = $('modesGrid');
for (const key in MODES){
const m = MODES[key];
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `${m.tag ? `<span class="tag">${m.tag}</span>` : ''}
<h3>${m.name}</h3><p>${m.desc}</p>`;
card.onclick = () => { clickSound('small'); openTimePicker(key); };
modesGrid.appendChild(card);
}
let pendingMode = null;
let pendingSize = 'm';
function updateSizeButtons(){
document.querySelectorAll('.sizeBtn').forEach(b => b.classList.toggle('active', b.dataset.size === pendingSize));
}
function openTimePicker(modeKey){
pendingMode = modeKey;
pendingSize = state.sizeId || 'm';
$('timeTitle').textContent = MODES[modeKey].name;
updateSizeButtons();
overlay.classList.remove('hidden');
}
document.querySelectorAll('.sizeBtn').forEach(b => {
b.onclick = () => { clickSound('small'); pendingSize = b.dataset.size; updateSizeButtons(); };
});
document.querySelectorAll('.tbtn[data-time]').forEach(btn => {
btn.onclick = () => {
clickSound('play');
const t = parseInt(btn.dataset.time);
overlay.classList.add('hidden');
if (pendingMode) startGame(pendingMode, t, pendingSize);
};
});
$('cancelTime').onclick = () => { clickSound('back'); overlay.classList.add('hidden'); };
document.querySelectorAll('.preset[data-preset]').forEach(b => {
b.onclick = () => { clickSound('small'); applyGraphics(b.dataset.preset); };
});
function applyEffectMode(mode){
if (!VALID_EFFECTS.includes(mode)) mode = 'stars';
HIT_EFFECT.mode = mode;
document.querySelectorAll('.effectBtn').forEach(b => b.classList.toggle('active', b.dataset.effect === mode));
saveSettings();
}
document.querySelectorAll('.effectBtn').forEach(b => {
b.onclick = () => { clickSound('small'); applyEffectMode(b.dataset.effect); };
});
function applyShape(shape){
if (!SHAPES.includes(shape)) return;
GRAPHICS.shape = shape;
document.querySelectorAll('.shapeBtn').forEach(b => b.classList.toggle('active', b.dataset.shape === shape));
applyShapeToTargets();
saveSettings();
}
document.querySelectorAll('.shapeBtn').forEach(b => {
b.onclick = () => { clickSound('small'); applyShape(b.dataset.shape); };
});
function updateParticlesLabel(v){ $('particlesVal').textContent = v === 0 ? 'Выкл' : v; }
$('particlesSlider').oninput = e => {
GRAPHICS.particles = +e.target.value;
updateParticlesLabel(GRAPHICS.particles);
if (GRAPHICS.level === 'ultra') buildDetail('ultra');
saveSettings();
};
$('bloomSlider').oninput = e => {
BLOOM.strength = +e.target.value;
$('bloomVal').textContent = BLOOM.strength.toFixed(2);
if (bloomPass) bloomPass.strength = BLOOM.strength;
saveSettings();
};
$('brightnessSlider').oninput = e => {
GRAPHICS.exposure = +e.target.value;
$('brightnessVal').textContent = GRAPHICS.exposure.toFixed(2);
renderer.toneMappingExposure = GRAPHICS.exposure;
saveSettings();
};
$('effBloomSlider').oninput = e => {
HIT_EFFECT.hdr = +e.target.value;
const v = HIT_EFFECT.hdr;
$('effBloomVal').textContent = v === 0 ? 'ВЫКЛ' : v.toFixed(2) + '×';
saveSettings();
};
$('effSizeSlider').oninput = e => {
GRAPHICS.effectSize = +e.target.value;
$('effSizeVal').textContent = GRAPHICS.effectSize.toFixed(2) + '×';
saveSettings();
};
$('chThick').oninput = e => { CROSSHAIR.thickness = +e.target.value; $('chThickVal').textContent = e.target.value; applyCrosshair(); saveSettings(); };
$('chLen').oninput = e => { CROSSHAIR.length = +e.target.value; $('chLenVal').textContent = e.target.value; applyCrosshair(); saveSettings(); };
$('chGap').oninput = e => { CROSSHAIR.gap = +e.target.value; $('chGapVal').textContent = e.target.value; applyCrosshair(); saveSettings(); };
$('chDot').oninput = e => { CROSSHAIR.dotSize = +e.target.value; $('chDotVal').textContent = e.target.value; applyCrosshair(); saveSettings(); };
$('chDotToggle').onchange = e => { CROSSHAIR.dot = e.target.checked; applyCrosshair(); saveSettings(); };
$('chFixed').onchange = e => {
CROSSHAIR.fixedColor = e.target.checked;
if (CROSSHAIR.fixedColor) crosshairEl.classList.remove('hit','onTarget');
saveSettings();
};
$('chColor').oninput = e => {
CROSSHAIR.color = e.target.value;
CROSSHAIR.syncTheme = false;
$('chSyncTheme').checked = false;
applyCrosshair();
saveSettings();
};
$('chOpacity').oninput = e => {
CROSSHAIR.opacity = +e.target.value / 100;
$('chOpacityVal').textContent = e.target.value + '%';
applyCrosshair();
saveSettings();
};
$('chSyncTheme').onchange = e => {
CROSSHAIR.syncTheme = e.target.checked;
if (CROSSHAIR.syncTheme){
CROSSHAIR.color = THEME.c1;
$('chColor').value = THEME.c1;
}
applyCrosshair();
saveSettings();
};
$('sens').oninput = e => { state.sens = +e.target.value; $('sensVal').textContent = state.sens.toFixed(2); saveSettings(); };
$('fov').oninput = e => {
camera.fov = +e.target.value;
camera.updateProjectionMatrix();
$('fovVal').textContent = e.target.value;
saveSettings();
};
$('effSpeed').oninput = e => {
HIT_EFFECT.speed = +e.target.value;
$('effSpeedVal').textContent = HIT_EFFECT.speed.toFixed(2) + '×';
saveSettings();
};
$('animPistol').oninput = e => {
ANIM.pistolSpeed = +e.target.value;
$('animPistolVal').textContent = ANIM.pistolSpeed.toFixed(2) + '×';
saveSettings();
};
$('color1').oninput = e => { THEME.c1 = e.target.value; applyTheme(); saveSettings(); };
$('color2').oninput = e => { THEME.c2 = e.target.value; applyTheme(); saveSettings(); };
$('gradToggle').onchange = e => {
THEME.gradient = e.target.checked;
$('color2Row').classList.toggle('hidden', !THEME.gradient);
applyTheme();
saveSettings();
};
$('uiColor').oninput = e => { THEME.uiColor = e.target.value; applyUiColor(); saveSettings(); };
$('menuBgToggle').onchange = e => {
THEME.menuBgEnabled = e.target.checked;
$('menuBgGated').classList.toggle('off', !THEME.menuBgEnabled);
applyMenuBg();
saveSettings();
};
$('menuBgColor').oninput = e => {
THEME.menuBgColor = e.target.value;
if (THEME.menuBgEnabled) applyMenuBg();
saveSettings();
};
$('textColor').oninput = e => {
THEME.textColor = e.target.value;
document.documentElement.style.setProperty('--text', THEME.textColor);
saveSettings();
};
/* ============================ УПРАВЛЕНИЕ ============================ */
document.addEventListener('mousemove', e => {
if (state.phase !== 'playing') return;
if (document.pointerLockElement !== renderer.domElement) return;
const s = 0.0022 * state.sens;
camera.rotation.y -= e.movementX * s;
camera.rotation.x -= e.movementY * s;
camera.rotation.x = Math.max(-Math.PI/2 + 0.02, Math.min(Math.PI/2 - 0.02, camera.rotation.x));
state.swayX += (-e.movementX * 0.00035 - state.swayX) * 0.12;
state.swayY += ( e.movementY * 0.00035 - state.swayY) * 0.12;
});
document.addEventListener('mousedown', e => {
if (e.button !== 0) return;
if (state.phase !== 'playing') return;
if (document.pointerLockElement !== renderer.domElement) return;
state.mouseDown = true;
if (state.mode && MODES[state.mode].type === 'click') shoot();
});
document.addEventListener('mouseup', () => { state.mouseDown = false; });
document.addEventListener('pointerlockchange', () => {
if (document.pointerLockElement !== renderer.domElement && state.phase === 'playing'){
state.phase = 'paused';
pauseEl.classList.remove('hidden');
hudEl.classList.add('hidden');
state.mouseDown = false;
crosshairEl.classList.remove('hit','onTarget');
}
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && state.phase === 'playing') document.exitPointerLock();
});
document.addEventListener('visibilitychange', () => {
if (document.hidden && state.phase === 'playing') document.exitPointerLock();
});
/* ============================ СТРЕЛЬБА ============================ */
function shoot(){
state.shots++;
raycaster.setFromCamera(CENTER, camera);
const activeTargets = targets.filter(t => !t.dying);
const hits = raycaster.intersectObjects(activeTargets.map(t => t.mesh), false);
if (hits.length){
const obj = hits[0].object;
const t = targets.find(x => x.mesh === obj);
if (t){
state.hits++;
state.score += 100;
spawnHitEffect(hits[0].point);
if (HIT_EFFECT.mode === 'circle'){
t.dying = true;
t.deathAge = 0;
t.deathDuration = 0.4;
fillTargets();
} else {
removeTarget(t);
fillTargets();
}
fireFeedback(true);
return;
}
}
fireFeedback(false);
}
function fireFeedback(hit){
state.recoil = 1;
state.recoilRot = 1;
gun.userData.flashMat.opacity = 1;
beep(hit ? 1000 : 160, 0.08, hit ? 'sine' : 'square', hit ? 0.08 : 0.04);
if (hit && !CROSSHAIR.fixedColor){
crosshairEl.classList.add('hit');
if (crosshairHitTimer) clearTimeout(crosshairHitTimer);
crosshairHitTimer = setTimeout(() => {
crosshairEl.classList.remove('hit');
crosshairHitTimer = null;
}, 70);
}
}
/* ============================ ИГРА ============================ */
function startGame(modeKey, duration, sizeId){
const m = MODES[modeKey];
if (!m) return;
state.mode = modeKey;
state.score = 0; state.shots = 0; state.hits = 0;
state.infinite = duration === 0;
state.timeLeft = state.infinite ? 0 : duration;
state.elapsed = 0; state.trackTime = 0;
state.phase = 'playing';
state.settingsReturnTo = null;
state.mouseDown = false;
state.lastDuration = duration;
if (sizeId && BALL_SIZES.some(b => b.id === sizeId)) state.sizeId = sizeId;
const sz = BALL_SIZES.find(b => b.id === state.sizeId) || BALL_SIZES[2];
state.sizeMultiplier = sz.mult;
camera.rotation.set(0, 0, 0);
camera.position.set(0, 1.7, 0);
gun.position.copy(GUN_HOME);
gun.rotation.set(0.02, -0.06, 0);
clearTargets();
fillTargets();
document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden'));
resEl.classList.add('hidden');
pauseEl.classList.add('hidden');
hudEl.classList.remove('hidden');
$('hMode').textContent = m.name;
$('hAccLbl').textContent = m.type === 'track' ? 'УДЕРЖАНИЕ' : 'ТОЧНОСТЬ';
trackBar.classList.toggle('hidden', m.type !== 'track');
$('hTime').classList.remove('warn');
const pl = renderer.domElement.requestPointerLock();
if (pl && pl.catch) pl.catch(() => {});
updateHUD();
saveSettings();
}
function endGame(){
state.phase = 'results';
document.exitPointerLock();
hudEl.classList.add('hidden');
state.mouseDown = false;
crosshairEl.classList.remove('hit','onTarget');
const m = MODES[state.mode];
if (!m) return;
const acc = state.shots > 0 ? Math.round(state.hits / state.shots * 100) : 0;
const trackAcc = state.elapsed > 0 ? Math.round(state.trackTime / state.elapsed * 100) : 0;
$('rScore').textContent = Math.floor(state.score);
$('rAcc').textContent = (m.type === 'track' ? trackAcc : acc) + '%';
$('rAccLbl').textContent = m.type === 'track' ? 'Удержание' : 'Точность';
$('rHits').textContent = m.type === 'track' ? '—' : state.hits;
$('rShots').textContent = m.type === 'track' ? '—' : state.shots;
resEl.classList.remove('hidden');
}
function updateHUD(){
if (!state.mode) return;
$('hScore').textContent = Math.floor(state.score);
if (state.infinite){
const t = Math.floor(state.elapsed);
$('hTime').textContent = `${String(Math.floor(t/60)).padStart(2,'0')}:${String(t%60).padStart(2,'0')}`;
} else {
$('hTime').textContent = Math.ceil(Math.max(0, state.timeLeft));
}
if (state.timeLeft <= 5 && !state.infinite) $('hTime').classList.add('warn');
const m = MODES[state.mode];
if (!m) return;
if (m.type === 'track'){
const acc = state.elapsed > 0 ? Math.round(state.trackTime / state.elapsed * 100) : 100;
$('hAcc').textContent = acc + '%';
trackBar.querySelector('i').style.width = acc + '%';
} else {
const acc = state.shots > 0 ? Math.round(state.hits / state.shots * 100) : 100;
$('hAcc').textContent = acc + '%';
}
}
function toMenu(){
state.phase = 'menu';
state.settingsReturnTo = null;
state.mouseDown = false;
document.exitPointerLock();
pauseEl.classList.add('hidden');
resEl.classList.add('hidden');
hudEl.classList.add('hidden');
crosshairEl.classList.remove('hit','onTarget');
clearTargets();
showScreen('main');
}
$('resumeBtn').onclick = () => {
clickSound('play');
pauseEl.classList.add('hidden');
hudEl.classList.remove('hidden');
state.phase = 'playing';
state.mouseDown = false;
const pl = renderer.domElement.requestPointerLock();
if (pl && pl.catch) pl.catch(() => {});
};
$('pauseSettingsBtn').onclick = () => {
clickSound('default');
state.settingsReturnTo = 'pause';
pauseEl.classList.add('hidden');
showScreen('settings');
};
$('pauseExitBtn').onclick = () => { clickSound('back'); toMenu(); };
$('againBtn').onclick = () => {
clickSound('play');
startGame(state.mode, state.lastDuration, state.sizeId);
};
$('menuBtn').onclick = () => { clickSound('back'); toMenu(); };
/* ============================ ЦИКЛ ============================ */
const clock = new THREE.Clock();
const raycaster = new THREE.Raycaster();
const CENTER = new THREE.Vector2(0, 0);
const tmpV = new THREE.Vector3();
const camDir = new THREE.Vector3();
function animate(){
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
if (state.phase === 'playing' && state.mode){
state.elapsed += dt;
if (!state.infinite){
state.timeLeft -= dt;
if (state.timeLeft <= 0){ state.timeLeft = 0; endGame(); }
}
updateHUD();
const m = MODES[state.mode];
if (m.spawnTimeout){
let needRespawn = false;
for (let i = targets.length - 1; i >= 0; i--){
if (targets[i].dying) continue;
targets[i].life -= dt;
if (targets[i].life <= 0){
removeTarget(targets[i]);
needRespawn = true;
}
}
if (needRespawn) fillTargets();
}
if (m.moving){
for (const t of targets){
if (t.dying) continue;
t.mesh.position.x += t.vel.x * dt;
if (t.mesh.position.x < -t.bounds.x || t.mesh.position.x > t.bounds.x){
t.vel.x *= -1;
t.mesh.position.x = THREE.MathUtils.clamp(t.mesh.position.x, -t.bounds.x, t.bounds.x);
}
t.mesh.rotation.y += dt * 1.2;
}
}
if (m.type === 'track'){
for (const t of targets){
if (t.dying) continue;
t.mesh.position.x += t.vel.x * dt;
t.mesh.position.y += t.vel.y * dt;
if (t.mesh.position.x < -t.bounds.x || t.mesh.position.x > t.bounds.x){
t.vel.x *= -1;
t.mesh.position.x = THREE.MathUtils.clamp(t.mesh.position.x, -t.bounds.x, t.bounds.x);
}
if (t.mesh.position.y < t.bounds.y0 || t.mesh.position.y > t.bounds.y1){
t.vel.y *= -1;
t.mesh.position.y = THREE.MathUtils.clamp(t.mesh.position.y, t.bounds.y0, t.bounds.y1);
}
if (Math.random() < 0.02){
t.vel.x += (Math.random()-0.5) * 7;
t.vel.y += (Math.random()-0.5) * 7;
if (t.vel.length() > 8) t.vel.setLength(8);
if (t.vel.length() < 2.5) t.vel.setLength(2.5);
}
t.phase += dt * 3;
t.mesh.scale.setScalar(1 + Math.sin(t.phase) * 0.05);
}
if (targets.length){
const t = targets[0];
camera.getWorldDirection(camDir);
tmpV.subVectors(t.mesh.position, camera.position);
const dist = tmpV.length();
const angle = camDir.angleTo(tmpV.normalize());
const ballR = getBallRadius();
const hitAngle = Math.atan(ballR / dist) * 1.25;
const onTarget = angle < hitAngle;
if (onTarget && state.mouseDown){
state.score += dt * 150;
state.trackTime += dt;
t.mesh.material.color.copy(themeC2);
t.mesh.material.emissive.copy(themeC2);
t.mesh.material.emissiveIntensity = 2.0;
if (!CROSSHAIR.fixedColor) crosshairEl.classList.add('onTarget');
if (Math.random() < 0.25) gun.userData.flashMat.opacity = 0.5;
} else if (onTarget){
t.mesh.material.color.copy(themeC2);
t.mesh.material.emissive.copy(themeC2).multiplyScalar(0.7);
t.mesh.material.emissiveIntensity = 1.3;
if (!CROSSHAIR.fixedColor) crosshairEl.classList.add('onTarget');
} else {
t.mesh.material.color.copy(themeC1);
t.mesh.material.emissive.copy(themeC1).multiplyScalar(0.45);
t.mesh.material.emissiveIntensity = 0.85;
crosshairEl.classList.remove('onTarget');
}
}
}
else if (!m.moving){
const p = performance.now() * 0.004;
for (const t of targets){
if (t.dying) continue;
t.mesh.material.emissiveIntensity = 0.7 + Math.sin(p + t.mesh.position.x) * 0.3;
t.mesh.rotation.y += dt * 0.6;
}
} else {
for (const t of targets){
if (!t.dying) t.mesh.material.emissiveIntensity = 1.0;
}
}
/* Анимация "умирающих" шаров (эффект кружок — плавное уменьшение и исчезновение) */
for (let i = targets.length - 1; i >= 0; i--){
const t = targets[i];
if (t.dying){
t.deathAge += dt;
const k = Math.min(1, t.deathAge / t.deathDuration);
const s = Math.max(0.001, 1 - k);
t.mesh.scale.setScalar(s);
if (t.mesh.material) t.mesh.material.opacity = Math.max(0, 1 - k);
if (k >= 1) removeTarget(t);
}
}
}
const animSpeed = ANIM.pistolSpeed;
state.recoil = Math.max(0, state.recoil - dt * 9 * animSpeed);
state.recoilRot = Math.max(0, state.recoilRot - dt * 11 * animSpeed);
const kick = state.recoil * state.recoil;
gun.position.set(
GUN_HOME.x + state.swayX,
GUN_HOME.y + state.swayY - kick * 0.012,
GUN_HOME.z + kick * 0.07);
gun.rotation.x = 0.02 + kick * 0.35 + state.recoilRot * 0.02;
gun.rotation.y = -0.06 + state.swayX * 0.4;
gun.rotation.z = state.swayX * 0.3;
const swayDecay = Math.pow(0.90, dt * 60);
state.swayX *= swayDecay;
state.swayY *= swayDecay;
const fm = gun.userData.flashMat;
fm.opacity = Math.max(0, fm.opacity - dt * 22 * animSpeed);
gun.userData.flash.scale.setScalar(0.7 + fm.opacity * 1.6);
updateEffects(dt);
if (state.phase === 'playing'){
const t = performance.now() * 0.0012;
camera.position.y = 1.7 + Math.sin(t) * 0.006;
}
if (state.useComposer && composer){
composer.render();
} else {
renderer.setRenderTarget(null);
renderer.render(scene, camera);
}
}
/* ============================ СТАРТ ============================ */
loadSettings();
$('color1').value = THEME.c1;
$('color2').value = THEME.c2;
$('gradToggle').checked = THEME.gradient;
$('color2Row').classList.toggle('hidden', !THEME.gradient);
$('uiColor').value = THEME.uiColor;
$('menuBgToggle').checked = THEME.menuBgEnabled;
$('menuBgColor').value = THEME.menuBgColor;
$('menuBgGated').classList.toggle('off', !THEME.menuBgEnabled);
$('textColor').value = THEME.textColor;
$('sens').value = state.sens;
$('sensVal').textContent = state.sens.toFixed(2);
$('fov').value = camera.fov;
$('fovVal').textContent = camera.fov;
$('particlesSlider').value = GRAPHICS.particles;
updateParticlesLabel(GRAPHICS.particles);
$('bloomSlider').value = BLOOM.strength;
$('bloomVal').textContent = BLOOM.strength.toFixed(2);
$('brightnessSlider').value = GRAPHICS.exposure;
$('brightnessVal').textContent = GRAPHICS.exposure.toFixed(2);
$('effBloomSlider').value = HIT_EFFECT.hdr;
$('effBloomVal').textContent = HIT_EFFECT.hdr === 0 ? 'ВЫКЛ' : HIT_EFFECT.hdr.toFixed(2) + '×';
$('effSizeSlider').value = GRAPHICS.effectSize;
$('effSizeVal').textContent = GRAPHICS.effectSize.toFixed(2) + '×';
document.querySelectorAll('.shapeBtn').forEach(b => {
b.classList.toggle('active', b.dataset.shape === GRAPHICS.shape);
});
$('chThick').value = CROSSHAIR.thickness; $('chThickVal').textContent = CROSSHAIR.thickness;
$('chLen').value = CROSSHAIR.length; $('chLenVal').textContent = CROSSHAIR.length;
$('chGap').value = CROSSHAIR.gap; $('chGapVal').textContent = CROSSHAIR.gap;
$('chDot').value = CROSSHAIR.dotSize; $('chDotVal').textContent = CROSSHAIR.dotSize;
$('chDotToggle').checked = CROSSHAIR.dot;
$('chFixed').checked = CROSSHAIR.fixedColor;
$('chColor').value = CROSSHAIR.color;
$('chOpacity').value = Math.round(CROSSHAIR.opacity * 100);
$('chOpacityVal').textContent = Math.round(CROSSHAIR.opacity * 100) + '%';
$('chSyncTheme').checked = CROSSHAIR.syncTheme;
$('animPistol').value = ANIM.pistolSpeed;
$('animPistolVal').textContent = ANIM.pistolSpeed.toFixed(2) + '×';
$('effSpeed').value = HIT_EFFECT.speed;
$('effSpeedVal').textContent = HIT_EFFECT.speed.toFixed(2) + '×';
applyTheme();
applyCrosshair();
applyGraphics(GRAPHICS.level);
applyEffectMode(HIT_EFFECT.mode);
activateTab(state.activeSettingsTab);
initPostFX();
showScreen('main');
animate();
</script>
</body>
</html>