Загрузка данных
const part1 = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Maze Tanks</title>
<style>
:root {
--bg: #dcd3bc;
--panel: #e6dcc7;
--panel-2: #d7ccb7;
--line: rgba(0,0,0,.12);
--text: #23262b;
--muted: #6f6257;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Arial, sans-serif;
color: var(--text);
background: linear-gradient(180deg, #e5e7eb 0%, #cfd4dc 100%);
}
.page {
max-width: 1460px;
margin: 0 auto;
padding: 20px;
background: transparent;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 255, 255, .42);
border: 1px solid rgba(71, 85, 105, .24);
box-shadow: 0 10px 26px rgba(30, 41, 59, .14);
}
h1 {
margin: 0 0 6px;
font-size: 34px;
color: #203552;
text-shadow: 0 1px 0 rgba(255,255,255,.28);
}
.subtitle {
margin: 0;
color: #4b5563;
}
.status {
margin-top: 10px;
min-height: 42px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255, 255, 255, .42);
border: 1px solid rgba(71, 85, 105, .26);
color: #1f2937;
box-shadow: inset 0 1px 0 rgba(255,255,255,.3);
}
.buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
button {
border: 1px solid rgba(0,0,0,.16);
border-radius: 14px;
padding: 12px 18px;
font-size: 15px;
font-weight: 700;
color: #1b2530;
background: #e5e7eb;
cursor: pointer;
box-shadow: 0 8px 18px rgba(30, 41, 59, .14);
transition: transform .15s ease, box-shadow .15s ease, filter .15s ease;
}
button:hover {
transform: translateY(-1px);
filter: saturate(1.06);
box-shadow: 0 12px 22px rgba(30, 41, 59, .2);
}
button:active {
transform: translateY(0);
box-shadow: 0 5px 10px rgba(30, 41, 59, .18);
}
#restartBtn {
background: #f2cf7b;
border-color: rgba(130, 95, 31, .35);
}
#roundBtn {
background: #67c075;
border-color: rgba(30, 103, 48, .4);
}
.layout {
display: grid;
grid-template-columns: minmax(720px, 1fr) 340px;
gap: 16px;
}
.game-wrap,
.panel {
background: rgba(244, 245, 247, .95);
border: 1px solid var(--line);
border-radius: 22px;
box-shadow: 0 18px 34px rgba(30, 41, 59, .14);
}
.game-wrap { padding: 14px; }
canvas {
display: block;
width: 100%;
height: auto;
border-radius: 16px;
background: #d7dde5;
}
.panel { padding: 18px; }
.panel {
background: rgba(244, 245, 247, .95);
}
.panel h2 {
margin: 0 0 10px;
font-size: 18px;
color: #334155;
}
.panel section + section {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid var(--line);
}
.panel ul {
margin: 0;
padding-left: 18px;
color: var(--muted);
}
.panel li + li { margin-top: 8px; }
.name-edit {
margin-top: 12px;
display: grid;
gap: 8px;
}
.name-edit input {
width: 100%;
border: 1px solid rgba(0,0,0,.16);
border-radius: 10px;
padding: 8px 10px;
font-size: 14px;
background: rgba(255,255,255,.62);
color: #1f2430;
}
.name-edit button {
padding: 10px 12px;
border-radius: 10px;
font-size: 14px;
}
.card {
margin-top: 10px;
padding: 12px;
border-radius: 14px;
background: rgba(255,255,255,.46);
color: #4b5563;
border-left: 4px solid transparent;
}
.card strong {
display: block;
margin-bottom: 5px;
color: var(--text);
}
.p1 { border-left-color: #61dd90; background: rgba(129, 227, 166, .22); }
.p2 { border-left-color: #d4524d; background: rgba(242, 137, 132, .22); }
.p3 { border-left-color: #315fbe; background: rgba(131, 168, 245, .22); }
@media (max-width: 1160px) {
.layout { grid-template-columns: 1fr; }
}
@media (max-width: 760px) {
.page { padding: 12px; }
.topbar { flex-direction: column; }
.buttons { justify-content: flex-start; }
h1 { font-size: 28px; }
}
</style>
</head>
<body>
<div class="page">
<div class="topbar">
<div>
<h1>Maze Tanks</h1>
<p class="subtitle">Хаос-режим: порталы, торнадо и случайный размер лабиринта каждый раунд.</p>
<div class="status" id="status">Загрузка...</div>
</div>
<div class="buttons">
<button id="roundBtn" type="button">Новый раунд</button>
</div>
</div>
<div class="layout">
<div class="game-wrap">
<canvas id="game" width="1100" height="760"></canvas>
</div>
<aside class="panel">
<section>
<h2>Управление</h2>
<div class="card p1">
<strong>Игрок 1</strong>
W/S - вперед/назад<br>
A/D - поворот<br>
Q - выстрел
</div>
<div class="card p2">
<strong>Игрок 2</strong>
I/K - вперед/назад<br>
J/L - поворот<br>
Space - выстрел
</div>
<div class="card p3">
<strong>Игрок 3</strong>
Up/Down - вперед/назад<br>
Left/Right - поворот<br>
Enter - выстрел
</div>
<div class="name-edit">
<input id="name1" type="text" maxlength="14" placeholder="Имя Игрока 1" />
<input id="name2" type="text" maxlength="14" placeholder="Имя Игрока 2" />
<input id="name3" type="text" maxlength="14" placeholder="Имя Игрока 3" />
<button id="applyNamesBtn" type="button">Применить имена</button>
</div>
</section>
</aside>
</div>
</div>
<script>
(function () {
if (window.__mazeTanksApp && typeof window.__mazeTanksApp.destroy === 'function') {
try { window.__mazeTanksApp.destroy(); } catch (e) {}
}
`;
const part2 = `
function resetNode(id) {
var node = document.getElementById(id);
if (!node) return null;
var clone = node.cloneNode(true);
if (node.parentNode) node.parentNode.replaceChild(clone, node);
return clone;
}
var statusEl = document.getElementById('status');
var canvas = resetNode('game');
var roundBtn = resetNode('roundBtn');
var applyNamesBtn = resetNode('applyNamesBtn');
var name1El = document.getElementById('name1');
var name2El = document.getElementById('name2');
var name3El = document.getElementById('name3');
if (!canvas) return;
var ctx = canvas.getContext('2d');
if (!ctx) return;
function setStatus(text) {
if (statusEl) statusEl.textContent = text;
}
window.onerror = function (message) {
var msg = String(message || '');
if (msg && msg.indexOf('SecurityError') === -1 && statusEl) statusEl.textContent = 'Ошибка JS: ' + msg;
};
try {
var VIEW_W = canvas.width;
var VIEW_H = canvas.height;
var CELL = 96;
var COLS = 10;
var ROWS = 6;
var WALL = 6;
var WORLD_W = COLS * CELL;
var WORLD_H = ROWS * CELL;
var WORLD_SCALE = CELL / 96;
var OFFSET_X = Math.round((VIEW_W - WORLD_W) / 2);
var OFFSET_Y = 132;
var arenaPresets = [
{ cols: 8, rows: 5, cell: 112, label: 'Компакт' },
{ cols: 10, rows: 6, cell: 96, label: 'Стандарт' },
{ cols: 12, rows: 7, cell: 78, label: 'Широкий' }
];
var players = [
{ id: 1, name: 'Игрок 1', color: '#4fbf73', shell: '#7fe09d', keys: { f: 'KeyW', b: 'KeyS', l: 'KeyA', r: 'KeyD', s: 'KeyQ' } },
{ id: 2, name: 'Игрок 2', color: '#d4524d', shell: '#ef8b86', keys: { f: 'KeyI', b: 'KeyK', l: 'KeyJ', r: 'KeyL', s: 'Space' } },
{ id: 3, name: 'Игрок 3', color: '#315fbe', shell: '#6f97ea', keys: { f: 'ArrowUp', b: 'ArrowDown', l: 'ArrowLeft', r: 'ArrowRight', s: 'Enter' } }
];
if (name1El) name1El.value = players[0].name;
if (name2El) name2El.value = players[1].name;
if (name3El) name3El.value = players[2].name;
var state = {
started: true,
cells: [],
walls: [],
obstacles: [],
solids: [],
tanks: [],
bullets: [],
groundDecals: [],
explosions: [],
rocketStrikes: [],
pickups: [],
pickupTimer: 0,
scores: { 1: 0, 2: 0, 3: 0 },
round: 1,
countdown: 0,
message: 'Раунд 1',
lastWinner: 0,
flash: 0,
time: 0,
portals: [],
arenaLabel: 'Стандарт',
toastText: '',
toastTimer: 0,
laserBeams: [],
tankTracks: [],
tankSmoke: [],
cameraShake: 0,
eventTimer: 12,
eventName: '',
eventTimeLeft: 0,
lastEvent: '',
eventPortalShiftTimer: 0,
chatterTimer: 20
};
var keysDown = Object.create(null);
var animId = 0;
var destroyed = false;
var audioCtx = null;
var audioUnlocked = false;
var MAX_BULLETS_PER_TANK = 5;
var nextBulletId = 1;
var runId = (window.__mazeTanksRunId || 0) + 1;
window.__mazeTanksRunId = runId;
var usedKeys = {
Space: true, Enter: true, KeyQ: true,
ArrowUp: true, ArrowDown: true, ArrowLeft: true, ArrowRight: true,
KeyW: true, KeyS: true, KeyA: true, KeyD: true,
KeyI: true, KeyK: true, KeyJ: true, KeyL: true
};
var groundTextures = {
asphalt: null,
stones: null
};
`;
const part3 = `
function loadGroundTexture(key, url) {
try {
var img = new Image();
img.src = url;
groundTextures[key] = img;
} catch (e) {}
}
loadGroundTexture('asphalt', '/images/grass-texture.jpg');
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
function SR(v) {
return v * WORLD_SCALE;
}
function hypot2(dx, dy) {
return Math.sqrt(dx * dx + dy * dy);
}
function rand(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function shuffle(arr) {
var out = arr.slice();
for (var i = out.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = out[i];
out[i] = out[j];
out[j] = t;
}
return out;
}
function roundRect(x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function ensureAudio() {
var Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) return null;
if (!audioCtx) audioCtx = new Ctx();
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
audioUnlocked = true;
return audioCtx;
}
var webSfx = {
shoot: 'https://actions.google.com/sounds/v1/weapons/gun_shooting_cocking_foam.ogg',
hit: 'https://actions.google.com/sounds/v1/weapons/explosion_hiss_bop_bang.ogg',
roundEnd: 'https://actions.google.com/sounds/v1/weapons/cocking_a_50cal_gun.ogg',
crate: 'https://actions.google.com/sounds/v1/weapons/gun_reload.ogg',
bulletFade: 'https://actions.google.com/sounds/v1/weapons/gun_drop_on_metal_grate.ogg',
goofyPortal: 'https://www.soundjay.com/mechanical/sounds/spring-boing-1.mp3',
goofyHit: 'https://www.soundjay.com/human/sounds/fart-01.mp3',
goofyPickup: 'https://www.soundjay.com/button/sounds/button-16.mp3',
bounce: 'https://actions.google.com/sounds/v1/impacts/metal_thin_impact.ogg',
laser: 'https://www.soundjay.com/mechanical/sounds/laser-gun-81720.mp3',
move: 'https://www.soundjay.com/transportation/sounds/car-drive-by-1.mp3',
click: 'https://www.soundjay.com/button/sounds/button-20.mp3'
};
var activeSfx = [];
var lastBounceAt = 0;
var lastMoveSoundAt = 0;
var selectedMaleVoice = null;
var speechLockUntil = 0;
var chatterPhrases = [
'Аккуратно на поворотах.',
'Кто последний, тот чинит гусеницы.',
'Не стой под рикошетом.',
'Мина любит спешащих.',
'Лазер ошибок не прощает.',
'Порталы не так дружелюбны как выглядят.',
'Держитесь подальше от стен.',
'Кто стоит на месте, тот мишень.',
'Осторожно, торнадо не выбирает друзей.',
'Рикошет может вернуться к хозяину.',
'Ящик не всегда подарок.',
'Дробовик хорош вблизи.',
'Мега пуля летит медленно, но бьёт больно.',
'Бур ломает стены, но не спину.',
'Ракета найдёт тебя.',
'Танки не умеют плавать.'
];
function resolveMaleVoice() {
if (!('speechSynthesis' in window)) return null;
var voices = window.speechSynthesis.getVoices ? window.speechSynthesis.getVoices() : [];
if (!voices || !voices.length) return null;
var maleHints = ['male', 'man', 'maxim', 'alexander', 'sergey', 'ivan', 'pavel', 'dmit', 'yuri'];
var ruVoices = voices.filter(function (v) { return (v.lang || '').toLowerCase().indexOf('ru') !== -1; });
for (var i = 0; i < ruVoices.length; i++) {
var name = (ruVoices[i].name || '').toLowerCase();
for (var j = 0; j < maleHints.length; j++) {
if (name.indexOf(maleHints[j]) !== -1) return ruVoices[i];
}
}
for (var k = 0; k < voices.length; k++) {
var name2 = (voices[k].name || '').toLowerCase();
for (var h = 0; h < maleHints.length; h++) {
if (name2.indexOf(maleHints[h]) !== -1) return voices[k];
}
}
return null;
}
function playWebSound(url, volume, fromSec, maxDurationSec) {
if (!audioUnlocked) return;
try {
var audio = new Audio(url);
audio.preload = 'auto';
audio.volume = typeof volume === 'number' ? volume : 0.35;
audio.muted = false;
audio.loop = false;
if (typeof fromSec === 'number') {
audio.currentTime = fromSec;
}
activeSfx.push(audio);
function cleanupAudio() {
var idx = activeSfx.indexOf(audio);
if (idx !== -1) activeSfx.splice(idx, 1);
}
var cutTimer = 0;
var cutMs = typeof maxDurationSec === 'number' && maxDurationSec > 0 ? Math.floor(maxDurationSec * 1000) : 0;
if (cutMs > 0) {
audio.addEventListener('playing', function () {
cutTimer = window.setTimeout(function () {
try {
audio.pause();
audio.currentTime = 0;
} catch (e) {}
cleanupAudio();
}, cutMs);
}, { once: true });
}
audio.addEventListener('ended', function () {
if (cutTimer) window.clearTimeout(cutTimer);
cleanupAudio();
}, { once: true });
window.setTimeout(cleanupAudio, 4000);
audio.play().catch(function () {
cleanupAudio();
});
} catch (e) {}
}
function playTone(freq, dur, type, vol, endFreq) {
var ac = ensureAudio();
if (!ac) return;
var now = ac.currentTime;
var osc = ac.createOscillator();
var gain = ac.createGain();
osc.type = type || 'sine';
osc.frequency.setValueAtTime(freq, now);
if (typeof endFreq === 'number') {
osc.frequency.exponentialRampToValueAtTime(Math.max(18, endFreq), now + dur);
}
gain.gain.setValueAtTime(0.0001, now);
gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, vol || 0.08), now + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, now + dur);
osc.connect(gain);
gain.connect(ac.destination);
osc.start(now);
osc.stop(now + dur + 0.02);
}
function playShootSound() {
playTone(360, 0.045, 'square', 0.095, 220);
setTimeout(function () { playTone(220, 0.08, 'triangle', 0.075, 130); }, 12);
setTimeout(function () { playTone(520, 0.045, 'triangle', 0.05, 360); }, 92);
}
function playShotgunSound() {
playShootSound();
}
function playRocketSound() {
playShootSound();
}
function playRocketBurstSound() {
playTone(130, 0.16, 'sawtooth', 0.08, 65);
setTimeout(function () { playTone(240, 0.08, 'square', 0.05, 120); }, 24);
}
function playRocketLockBeep() {
playTone(760, 0.045, 'square', 0.045, 700);
}
function playMegaLaunchSound() {
playShootSound();
}
function playMegaExplodeSound() {
playTone(95, 0.22, 'sawtooth', 0.09, 48);
setTimeout(function () { playTone(160, 0.14, 'square', 0.05, 80); }, 45);
}
function playTornadoCastSound() {
playShootSound();
}
function playFireClickSound() {
playWebSound(webSfx.click, 0.2, 0.01, 0.12);
}
function playTornadoHitSound() {
playTone(180, 0.09, 'sawtooth', 0.04, 110);
}
function playLaserSound() {
playTone(840, 0.08, 'sine', 0.04, 690);
}
function playMachinegunSound() {
playTone(420, 0.04, 'square', 0.026, 300);
}
function playCrateSpawnSound() {
playWebSound(webSfx.crate, 0.55, 0.02, 0.35);
}
function playPickupSound() {
playWebSound(webSfx.goofyPickup, 0.35, 0.01, 0.25);
}
function playHitSound() {
playWebSound(webSfx.goofyHit, 0.2, 0, 0.22);
playTone(210, 0.1, 'square', 0.11, 120);
setTimeout(function () { playTone(120, 0.18, 'sawtooth', 0.09, 55); }, 24);
}
function playPortalSound() {
playWebSound(webSfx.goofyPortal, 0.35, 0.05, 0.25);
playTone(680, 0.06, 'triangle', 0.05, 530);
}
function playBulletFadeSound(kind) {
playTone(540, 0.055, 'triangle', 0.065, 390);
}
function playWinSound() {
playTone(480, 0.11, 'triangle', 0.07, 620);
setTimeout(function () { playTone(680, 0.14, 'triangle', 0.075, 860); }, 90);
}
function playBounceSound() {
if (state.time - lastBounceAt < 0.045) return;
lastBounceAt = state.time;
playWebSound(webSfx.bounce, 0.3, 0.01, 0.18);
playTone(640, 0.03, 'triangle', 0.03, 520);
}
function playDrillSound() {
playWebSound(webSfx.shoot, 0.24, 0.03, 0.18);
playTone(180, 0.1, 'sawtooth', 0.06, 100);
}
function playMinePlaceSound() {
playWebSound(webSfx.crate, 0.32, 0.02, 0.2);
}
function playLaserFireSound() {
playWebSound(webSfx.laser, 0.26, 0.02, 0.22);
playLaserSound();
}
function playMoveSound() {
if (state.time - lastMoveSoundAt < 0.22) return;
lastMoveSoundAt = state.time;
playWebSound(webSfx.move, 0.16, 0.05, 0.16);
}
function speakText(text, lockSec) {
try {
if (!('speechSynthesis' in window)) return;
window.speechSynthesis.cancel();
var u = new SpeechSynthesisUtterance(text);
u.lang = 'ru-RU';
u.rate = 1;
u.pitch = 0.75;
var voices = window.speechSynthesis.getVoices ? window.speechSynthesis.getVoices() : [];
if (voices && voices.length) {
var maleHints = ['male', 'man', 'maxim', 'dmit', 'ivan', 'pavel', 'sergey', 'alex', 'yuri'];
var ruVoices = voices.filter(function (v) { return (v.lang || '').toLowerCase().indexOf('ru') !== -1; });
var pick = null;
var pool = ruVoices.length ? ruVoices : voices;
for (var i = 0; i < pool.length; i++) {
var n = (pool[i].name || '').toLowerCase();
for (var j = 0; j < maleHints.length; j++) {
if (n.indexOf(maleHints[j]) !== -1) { pick = pool[i]; break; }
}
if (pick) break;
}
if (pick) u.voice = pick;
else if (ruVoices.length) u.voice = ruVoices[0];
}
window.speechSynthesis.speak(u);
if (typeof lockSec === 'number' && lockSec > 0) {
speechLockUntil = Math.max(speechLockUntil, state.time + lockSec);
}
} catch (e) {}
}
function maybeSpeakRandomLine() {
if (state.time < speechLockUntil) return;
if ('speechSynthesis' in window && window.speechSynthesis.speaking) return;
var line = chatterPhrases[rand(0, chatterPhrases.length - 1)];
speakText(line, 0.6);
}
`;
const part4 = `
function makeGrid() {
var cells = [];
for (var y = 0; y < ROWS; y++) {
var row = [];
for (var x = 0; x < COLS; x++) {
row.push({
x: x,
y: y,
v: true,
walls: { top: true, right: true, bottom: true, left: true }
});
}
cells.push(row);
}
return cells;
}
function inBounds(x, y) {
return x >= 0 && y >= 0 && x < COLS && y < ROWS;
}
function carveMaze(cells) {
var stack = [];
var start = cells[0][0];
start.v = false;
stack.push(start);
while (stack.length) {
var current = stack[stack.length - 1];
var dirs = shuffle([
{ dx: 0, dy: -1, side: 'top', other: 'bottom' },
{ dx: 1, dy: 0, side: 'right', other: 'left' },
{ dx: 0, dy: 1, side: 'bottom', other: 'top' },
{ dx: -1, dy: 0, side: 'left', other: 'right' }
]);
var moved = false;
for (var i = 0; i < dirs.length; i++) {
var d = dirs[i];
var nx = current.x + d.dx;
var ny = current.y + d.dy;
if (!inBounds(nx, ny)) continue;
var next = cells[ny][nx];
if (!next.v) continue;
current.walls[d.side] = false;
next.walls[d.other] = false;
next.v = false;
stack.push(next);
moved = true;
break;
}
if (!moved) stack.pop();
}
var extra = 6;
while (extra > 0) {
var x = rand(0, COLS - 1);
var y = rand(0, ROWS - 1);
var choices = [];
if (x < COLS - 1 && cells[y][x].walls.right) choices.push({ dx: 1, dy: 0, side: 'right', other: 'left' });
if (y < ROWS - 1 && cells[y][x].walls.bottom) choices.push({ dx: 0, dy: 1, side: 'bottom', other: 'top' });
if (!choices.length) continue;
var pick = choices[rand(0, choices.length - 1)];
var n = cells[y + pick.dy][x + pick.dx];
cells[y][x].walls[pick.side] = false;
n.walls[pick.other] = false;
extra -= 1;
}
}
function buildWalls(cells) {
var hEdges = [];
var vEdges = [];
var rects = [];
for (var hy = 0; hy <= ROWS; hy++) {
hEdges[hy] = [];
for (var hx = 0; hx < COLS; hx++) hEdges[hy][hx] = false;
}
for (var vy = 0; vy < ROWS; vy++) {
vEdges[vy] = [];
for (var vx = 0; vx <= COLS; vx++) vEdges[vy][vx] = false;
}
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++) {
var cell = cells[y][x];
if (cell.walls.top) hEdges[y][x] = true;
if (cell.walls.bottom) hEdges[y + 1][x] = true;
if (cell.walls.left) vEdges[y][x] = true;
if (cell.walls.right) vEdges[y][x + 1] = true;
}
}
for (var y2 = 0; y2 <= ROWS; y2++) {
var startX = -1;
for (var x2 = 0; x2 <= COLS; x2++) {
var activeH = x2 < COLS && hEdges[y2][x2];
if (activeH && startX === -1) startX = x2;
if ((!activeH || x2 === COLS) && startX !== -1) {
rects.push({
x: startX * CELL - WALL / 2,
y: y2 * CELL - WALL / 2,
w: (x2 - startX) * CELL + WALL,
h: WALL,
axis: 'h',
destroyedUntil: 0
});
startX = -1;
}
}
}
for (var x3 = 0; x3 <= COLS; x3++) {
var startY = -1;
for (var y3 = 0; y3 <= ROWS; y3++) {
var activeV = y3 < ROWS && vEdges[y3][x3];
if (activeV && startY === -1) startY = y3;
if ((!activeV || y3 === ROWS) && startY !== -1) {
rects.push({
x: x3 * CELL - WALL / 2,
y: startY * CELL,
w: WALL,
h: (y3 - startY) * CELL,
axis: 'v',
destroyedUntil: 0
});
startY = -1;
}
}
}
return rects;
}
function buildObstacles(spawnCells) {
if (!spawnCells || !spawnCells.length) return [];
return [];
}
function rebuildSolids() {
state.solids = state.walls.concat(state.obstacles);
}
function getOpenCells() {
var list = [];
for (var y = 0; y < ROWS; y++) {
for (var x = 0; x < COLS; x++) {
list.push({ x: x, y: y });
}
}
return shuffle(list);
}
function chooseSpawnCells(count) {
var all = getOpenCells();
var minDist = 3;
while (minDist >= 2) {
var result = [];
for (var i = 0; i < all.length; i++) {
var c = all[i];
var ok = true;
for (var j = 0; j < result.length; j++) {
var dx = result[j].x - c.x;
var dy = result[j].y - c.y;
if (hypot2(dx, dy) < minDist) ok = false;
}
if (ok) result.push(c);
if (result.length === count) return result;
}
minDist -= 1;
}
return all.slice(0, count);
}
function cellCenter(cell) {
return {
x: cell.x * CELL + CELL / 2,
y: cell.y * CELL + CELL / 2
};
}
`;
const part5 = `
function makeTank(def, point) {
return {
id: def.id,
name: def.name,
color: def.color,
shell: def.shell,
x: point.x,
y: point.y,
angle: Math.random() * Math.PI * 2,
radius: SR(16),
speed: SR(122 + rand(0, 22)),
turn: 4.1,
cooldown: 0.15,
weapon: 'normal',
weaponAmmo: 0,
weaponTimer: 0,
megaBulletId: 0,
rocketBulletId: 0,
rocketAiming: false,
rocketAimX: point.x,
rocketAimY: point.y,
drillActive: false,
drillTimer: 0,
fireHeld: false,
slowUntil: 0,
alive: true,
keys: def.keys
};
}
function normAngle(a) {
while (a > Math.PI) a -= Math.PI * 2;
while (a < -Math.PI) a += Math.PI * 2;
return a;
}
function getOwnedActiveBulletCount(ownerId) {
var count = 0;
for (var i = 0; i < state.bullets.length; i++) {
if (!state.bullets[i].dead && state.bullets[i].owner === ownerId) count += 1;
}
return count;
}
function findNearestEnemy(ownerId, x, y) {
var nearest = null;
var best = Infinity;
for (var i = 0; i < state.tanks.length; i++) {
var t = state.tanks[i];
if (!t.alive || t.id === ownerId) continue;
var d = hypot2(t.x - x, t.y - y);
if (d < best) {
best = d;
nearest = t;
}
}
return nearest;
}
function getTankById(id) {
for (var i = 0; i < state.tanks.length; i++) {
if (state.tanks[i].id === id) return state.tanks[i];
}
return null;
}
function mixHex(colorA, colorB, t) {
var a = String(colorA || '').replace('#', '');
var b = String(colorB || '').replace('#', '');
if (a.length !== 6 || b.length !== 6) return colorA;
var ar = parseInt(a.slice(0, 2), 16);
var ag = parseInt(a.slice(2, 4), 16);
var ab = parseInt(a.slice(4, 6), 16);
var br = parseInt(b.slice(0, 2), 16);
var bg = parseInt(b.slice(2, 4), 16);
var bb = parseInt(b.slice(4, 6), 16);
var r = Math.round(ar + (br - ar) * t);
var g = Math.round(ag + (bg - ag) * t);
var bl = Math.round(ab + (bb - ab) * t);
var hex = function (n) {
var s = Math.max(0, Math.min(255, n)).toString(16);
return s.length === 1 ? '0' + s : s;
};
return '#' + hex(r) + hex(g) + hex(bl);
}
function getWeaponPalette(ownerId) {
var tank = getTankById(ownerId);
var primary = tank ? tank.color : '#9aa0aa';
var secondary = tank ? tank.shell : '#c7d0de';
return {
primary: primary,
secondary: secondary,
stroke: mixHex(primary, '#111111', 0.45),
accent: mixHex(secondary, '#ffffff', 0.35),
flame: mixHex(secondary, '#ffd66b', 0.38)
};
}
function cellFromWorld(x, y) {
return {
x: clamp(Math.floor(x / CELL), 0, COLS - 1),
y: clamp(Math.floor(y / CELL), 0, ROWS - 1)
};
}
function buildPathBetweenCells(start, goal) {
var key = function (c) { return c.x + ':' + c.y; };
var startKey = key(start);
var goalKey = key(goal);
if (startKey === goalKey) return [start];
var queue = [start];
var head = 0;
var visited = Object.create(null);
var prev = Object.create(null);
visited[startKey] = true;
while (head < queue.length) {
var cur = queue[head++];
var cell = state.cells[cur.y] && state.cells[cur.y][cur.x];
if (!cell) continue;
var neighbors = [];
if (!cell.walls.top && cur.y > 0) neighbors.push({ x: cur.x, y: cur.y - 1 });
if (!cell.walls.right && cur.x < COLS - 1) neighbors.push({ x: cur.x + 1, y: cur.y });
if (!cell.walls.bottom && cur.y < ROWS - 1) neighbors.push({ x: cur.x, y: cur.y + 1 });
if (!cell.walls.left && cur.x > 0) neighbors.push({ x: cur.x - 1, y: cur.y });
for (var i = 0; i < neighbors.length; i++) {
var n = neighbors[i];
var nk = key(n);
if (visited[nk]) continue;
visited[nk] = true;
prev[nk] = cur;
if (nk === goalKey) {
var path = [n];
var walk = cur;
while (walk) {
path.push(walk);
if (key(walk) === startKey) break;
walk = prev[key(walk)] || null;
}
path.reverse();
return path;
}
queue.push(n);
}
}
return null;
}
function buildRocketRoute(fromX, fromY, toX, toY) {
var start = cellFromWorld(fromX, fromY);
var goal = cellFromWorld(toX, toY);
return buildPathBetweenCells(start, goal);
}
function hasLineOfSight(x1, y1, x2, y2, probeRadius) {
var dist = hypot2(x2 - x1, y2 - y1);
var steps = Math.max(1, Math.ceil(dist / 10));
for (var i = 1; i <= steps; i++) {
var t = i / steps;
var px = x1 + (x2 - x1) * t;
var py = y1 + (y2 - y1) * t;
if (circleHitsWall(px, py, probeRadius)) return false;
}
return true;
}
function traceLaser(originX, originY, angle, maxRange, maxBounces, ownerId, applyDamage) {
var segments = [];
var step = SR(8);
var probe = SR(3);
var x = originX;
var y = originY;
var dx = Math.cos(angle);
var dy = Math.sin(angle);
var remaining = maxRange;
var bounces = 0;
var loops = 0;
while (remaining > 0 && loops < 18) {
loops += 1;
var startX = x;
var startY = y;
var traveled = 0;
var collided = false;
while (traveled < remaining) {
var nx = x + dx * step;
var ny = y + dy * step;
var blocked = nx < 0 || nx > WORLD_W || ny < 0 || ny > WORLD_H || circleHitsWall(nx, ny, probe);
if (blocked) {
segments.push({ x1: startX, y1: startY, x2: x, y2: y });
if (bounces >= maxBounces) {
return { segments: segments, hitTank: null };
}
var blockX = nx < 0 || nx > WORLD_W || circleHitsWall(nx, y, probe);
var blockY = ny < 0 || ny > WORLD_H || circleHitsWall(x, ny, probe);
if (blockX) dx *= -1;
if (blockY) dy *= -1;
if (!blockX && !blockY) {
dx *= -1;
dy *= -1;
}
x = clamp(x + dx * SR(4), SR(2), WORLD_W - SR(2));
y = clamp(y + dy * SR(4), SR(2), WORLD_H - SR(2));
bounces += 1;
remaining -= traveled;
collided = true;
break;
}
for (var ti = 0; ti < state.tanks.length; ti++) {
var tank = state.tanks[ti];
if (!tank.alive || tank.id === ownerId) continue;
if (hypot2(tank.x - nx, tank.y - ny) <= tank.radius + probe) {
segments.push({ x1: startX, y1: startY, x2: nx, y2: ny });
if (applyDamage) tank.alive = false;
return { segments: segments, hitTank: tank };
}
}
x = nx;
y = ny;
traveled += step;
}
if (!collided) {
segments.push({ x1: startX, y1: startY, x2: x, y2: y });
break;
}
}
return { segments: segments, hitTank: null };
}
function steerBulletTo(bullet, tx, ty, dt) {
var currentAngle = Math.atan2(bullet.vy, bullet.vx);
var targetAngle = Math.atan2(ty - bullet.y, tx - bullet.x);
var diff = normAngle(targetAngle - currentAngle);
var maxTurn = bullet.turnRate * dt;
var nextAngle = currentAngle + clamp(diff, -maxTurn, maxTurn);
var speed = bullet.speed || SR(132);
bullet.vx = Math.cos(nextAngle) * speed;
bullet.vy = Math.sin(nextAngle) * speed;
}
function updateRocketGuidance(bullet, dt) {
if (bullet.homingDelay > 0) {
bullet.homingDelay -= dt;
return;
}
bullet.homingTime = (bullet.homingTime || 0) + dt;
if (bullet.homingTime > 2.2) return;
var target = findNearestEnemy(bullet.owner, bullet.x, bullet.y);
if (!target) return;
var targetDist = hypot2(target.x - bullet.x, target.y - bullet.y);
bullet.navTimer -= dt;
if (bullet.navTimer <= 0 || typeof bullet.lockX !== 'number') {
var targetIntent = getTankMoveIntent(target);
var lead = Math.min(0.07, targetDist / 1700);
bullet.lockX = target.x + Math.cos(target.angle) * target.speed * targetIntent * lead;
bullet.lockY = target.y + Math.sin(target.angle) * target.speed * targetIntent * lead;
bullet.navTimer = 0.2 + Math.random() * 0.12;
}
var desiredSpeed = targetDist > 260 ? 185 : 145;
bullet.speed += (desiredSpeed - bullet.speed) * Math.min(1, dt * 1.8);
bullet.turnRate = targetDist < 120 ? 3.2 : 4.2;
steerBulletTo(bullet, bullet.lockX, bullet.lockY, dt);
}
function getTankMoveIntent(tank) {
var k = tank.keys;
return (keysDown[k.f] ? 1 : 0) + (keysDown[k.b] ? -0.68 : 0);
}
function buildTornadoRoamPath(startX, startY) {
var margin = 40;
var rowStep = Math.max(68, Math.floor(CELL * 0.82));
var colStep = Math.max(92, Math.floor(CELL * 1.05));
var points = [];
var row = 0;
for (var y = margin; y <= WORLD_H - margin; y += rowStep) {
if (row % 2 === 0) {
for (var x = margin; x <= WORLD_W - margin; x += colStep) {
points.push({ x: x, y: y });
}
} else {
for (var xr = WORLD_W - margin; xr >= margin; xr -= colStep) {
points.push({ x: xr, y: y });
}
}
row += 1;
}
if (!points.length) points.push({ x: WORLD_W / 2, y: WORLD_H / 2 });
var nearest = 0;
var best = Infinity;
for (var i = 0; i < points.length; i++) {
var d = hypot2(points[i].x - startX, points[i].y - startY);
if (d < best) {
best = d;
nearest = i;
}
}
return { points: points, index: nearest, stuck: 0, lastDist: Infinity };
}
function updateTornadoGuidance(bullet, dt) {
if (!bullet.roam) {
bullet.roam = buildTornadoRoamPath(bullet.x, bullet.y);
}
var roam = bullet.roam;
var speed = bullet.speed || 360;
var point = roam.points[roam.index];
var dist = hypot2(point.x - bullet.x, point.y - bullet.y);
if (dist < 34) {
roam.index = (roam.index + 1) % roam.points.length;
point = roam.points[roam.index];
dist = hypot2(point.x - bullet.x, point.y - bullet.y);
roam.stuck = 0;
bullet.navPath = null;
bullet.navIndex = 1;
}
if (dist >= roam.lastDist - 0.8) {
roam.stuck += dt;
} else {
roam.stuck = Math.max(0, roam.stuck - dt * 0.6);
}
roam.lastDist = dist;
if (roam.stuck > 1.6) {
roam.index = (roam.index + 3) % roam.points.length;
point = roam.points[roam.index];
roam.stuck = 0;
bullet.navPath = null;
bullet.navIndex = 1;
}
bullet.navTimer -= dt;
if (!bullet.navPath || bullet.navTimer <= 0) {
bullet.navPath = buildRocketRoute(bullet.x, bullet.y, point.x, point.y);
bullet.navIndex = 1;
bullet.navTimer = bullet.navInterval || 0.18;
}
var aimX = point.x;
var aimY = point.y;
if (bullet.navPath && bullet.navPath.length > 1) {
if (bullet.navIndex >= bullet.navPath.length) bullet.navIndex = bullet.navPath.length - 1;
var wpCell = bullet.navPath[bullet.navIndex];
var wp = cellCenter(wpCell);
while (bullet.navIndex < bullet.navPath.length - 1 && hypot2(wp.x - bullet.x, wp.y - bullet.y) < 12) {
bullet.navIndex += 1;
wpCell = bullet.navPath[bullet.navIndex];
wp = cellCenter(wpCell);
}
aimX = wp.x;
aimY = wp.y;
}
bullet.wanderTimer = (bullet.wanderTimer || 0) - dt;
if (bullet.wanderTimer <= 0) {
bullet.wanderTimer = 0.18 + Math.random() * 0.2;
bullet.wanderAngle = (Math.random() - 0.5) * 0.5;
}
var desiredAngle = Math.atan2(aimY - bullet.y, aimX - bullet.x) + (bullet.wanderAngle || 0);
var probeX = bullet.x + Math.cos(desiredAngle) * (bullet.radius + 20);
var probeY = bullet.y + Math.sin(desiredAngle) * (bullet.radius + 20);
if (circleHitsWall(probeX, probeY, bullet.radius * 0.7)) {
var leftA = desiredAngle - 0.75;
var rightA = desiredAngle + 0.75;
var leftBlocked = circleHitsWall(bullet.x + Math.cos(leftA) * (bullet.radius + 18), bullet.y + Math.sin(leftA) * (bullet.radius + 18), bullet.radius * 0.65);
var rightBlocked = circleHitsWall(bullet.x + Math.cos(rightA) * (bullet.radius + 18), bullet.y + Math.sin(rightA) * (bullet.radius + 18), bullet.radius * 0.65);
if (leftBlocked && !rightBlocked) desiredAngle = rightA;
else if (!leftBlocked && rightBlocked) desiredAngle = leftA;
else desiredAngle += (Math.random() < 0.5 ? -1 : 1) * 0.75;
}
var currentAngle = Math.atan2(bullet.vy, bullet.vx);
var turnRate = bullet.turnRate || 4.8;
var nextAngle = currentAngle + clamp(normAngle(desiredAngle - currentAngle), -turnRate * dt, turnRate * dt);
bullet.vx = Math.cos(nextAngle) * speed;
bullet.vy = Math.sin(nextAngle) * speed;
}
function spawnBullet(ownerTank, cfg) {
state.bullets.push({
id: nextBulletId++,
owner: ownerTank.id,
kind: cfg.kind || 'normal',
x: cfg.x,
y: cfg.y,
vx: cfg.vx,
vy: cfg.vy,
radius: cfg.radius,
color: cfg.color,
strokeColor: '#000000',
accentColor: cfg.accentColor || cfg.color,
flameColor: cfg.flameColor || cfg.color,
safe: cfg.safe,
bounces: 0,
maxBounces: typeof cfg.maxBounces === 'number' ? cfg.maxBounces : 120,
noBounce: !!cfg.noBounce,
life: cfg.life,
homingDelay: cfg.homingDelay || 0,
turnRate: cfg.turnRate || 0,
speed: cfg.speed || 0,
fuse: typeof cfg.fuse === 'number' ? cfg.fuse : 0,
stuck: !!cfg.stuck,
hitLock: cfg.hitLock || 0,
wanderTimer: cfg.wanderTimer || 0,
navTargetId: 0,
navPath: null,
navIndex: 1,
navTimer: 0,
navInterval: cfg.navInterval || 0.22,
rocketMode: '',
avoidSide: 0,
axisPref: cfg.axisPref || 'x',
lastTornadoDirKey: '',
wanderAngle: cfg.wanderAngle || 0,
stuckTimer: 0,
lastTargetDist: 0,
portalCooldown: 0,
trail: [],
dead: false
});
}
function getWeaponName(type) {
if (type === 'shotgun') return 'Дробовик';
if (type === 'rocket') return 'Ракета';
if (type === 'drill') return 'Бур';
if (type === 'mega') return 'Мега пуля';
if (type === 'tornado') return 'Торнадо';
if (type === 'laser') return 'Лазер';
if (type === 'mine') return 'Мина';
return 'Обычный';
}
function drawPickupIcon(type, size) {
var s = size;
if (type === 'mine') {
ctx.beginPath();
ctx.arc(0, 0, s * 0.3, 0, Math.PI * 2);
ctx.fillStyle = '#3a3a3a';
ctx.fill();
ctx.beginPath();
ctx.arc(0, 0, s * 0.12, 0, Math.PI * 2);
ctx.fillStyle = '#ff5555';
ctx.fill();
for (var mi = 0; mi < 4; mi++) {
var ma = (Math.PI * 2 * mi) / 4 + 0.4;
ctx.beginPath();
ctx.moveTo(Math.cos(ma) * s * 0.3, Math.sin(ma) * s * 0.3);
ctx.lineTo(Math.cos(ma) * s * 0.48, Math.sin(ma) * s * 0.48);
ctx.strokeStyle = '#eee';
ctx.lineWidth = 2;
ctx.stroke();
}
return;
}
if (type === 'tornado') {
ctx.strokeStyle = '#9ed8f0';
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.arc(0, -s * 0.12, s * 0.12, 0.3, Math.PI * 1.7);
ctx.stroke();
ctx.beginPath();
ctx.arc(0, s * 0.04, s * 0.2, 0.3, Math.PI * 1.7);
ctx.stroke();
ctx.beginPath();
ctx.arc(0, s * 0.2, s * 0.28, 0.3, Math.PI * 1.7);
ctx.stroke();
return;
}
if (type === 'shotgun') {
var angles = [-0.34, -0.17, 0, 0.17, 0.34];
for (var si = 0; si < angles.length; si++) {
var bx = Math.cos(angles[si]) * s * 0.34;
var by = Math.sin(angles[si]) * s * 0.34;
ctx.beginPath();
ctx.arc(bx, by, s * 0.07, 0, Math.PI * 2);
ctx.fillStyle = '#eee';
ctx.fill();
}
return;
}
if (type === 'mega') {
ctx.beginPath();
ctx.arc(0, 0, s * 0.22, 0, Math.PI * 2);
ctx.fillStyle = '#ffd84a';
ctx.fill();
ctx.strokeStyle = '#aa8822';
ctx.lineWidth = 2;
ctx.stroke();
return;
}
if (type === 'rocket') {
ctx.save();
roundRect(-s * 0.22, -s * 0.08, s * 0.44, s * 0.16, s * 0.05);
ctx.fillStyle = '#c8d6e0';
ctx.fill();
ctx.beginPath();
ctx.moveTo(s * 0.26, 0);
ctx.lineTo(s * 0.16, -s * 0.13);
ctx.lineTo(s * 0.16, s * 0.13);
ctx.closePath();
ctx.fillStyle = '#ff9944';
ctx.fill();
ctx.beginPath();
ctx.moveTo(-s * 0.22, -s * 0.05);
ctx.lineTo(-s * 0.32, -s * 0.12);
ctx.lineTo(-s * 0.22, -s * 0.02);
ctx.closePath();
ctx.fillStyle = '#8899aa';
ctx.fill();
ctx.beginPath();
ctx.moveTo(-s * 0.22, s * 0.05);
ctx.lineTo(-s * 0.32, s * 0.12);
ctx.lineTo(-s * 0.22, s * 0.02);
ctx.closePath();
ctx.fillStyle = '#8899aa';
ctx.fill();
ctx.restore();
return;
}
if (type === 'laser') {
ctx.strokeStyle = '#77ddff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-s * 0.4, 0);
ctx.lineTo(s * 0.05, -s * 0.25);
ctx.lineTo(s * 0.35, s * 0.12);
ctx.stroke();
ctx.beginPath();
ctx.arc(s * 0.35, s * 0.12, s * 0.06, 0, Math.PI * 2);
ctx.fillStyle = '#aaeeff';
ctx.fill();
return;
}
if (type === 'drill') {
ctx.save();
ctx.rotate(0.5);
roundRect(-s * 0.12, -s * 0.24, s * 0.24, s * 0.48, s * 0.06);
ctx.fillStyle = '#b0b0b0';
ctx.fill();
ctx.beginPath();
ctx.moveTo(0, -s * 0.28);
ctx.lineTo(-s * 0.08, -s * 0.2);
ctx.lineTo(s * 0.08, -s * 0.2);
ctx.closePath();
ctx.fillStyle = '#ddd';
ctx.fill();
ctx.restore();
return;
}
ctx.beginPath();
ctx.arc(0, 0, s * 0.16, 0, Math.PI * 2);
ctx.fillStyle = '#e0e4ea';
ctx.fill();
}
function applyPickup(tank, pickupType) {
tank.weapon = pickupType;
tank.weaponAmmo = 0;
tank.weaponTimer = 5;
tank.megaBulletId = 0;
tank.rocketAiming = false;
tank.drillActive = false;
tank.drillTimer = 0;
if (pickupType === 'shotgun') tank.weaponAmmo = 3;
if (pickupType === 'rocket') tank.weaponAmmo = 1;
if (pickupType === 'drill') tank.weaponAmmo = 1;
if (pickupType === 'mega') tank.weaponAmmo = 1;
if (pickupType === 'tornado') tank.weaponAmmo = 1;
if (pickupType === 'laser') tank.weaponAmmo = 1;
if (pickupType === 'mine') tank.weaponAmmo = 2;
playPickupSound();
var bonusText = tank.name + ' взял бонус: ' + getWeaponName(pickupType);
setStatus(bonusText);
state.toastText = bonusText;
state.toastTimer = 2.4;
speakText(getWeaponName(pickupType), 2.2);
}
function triggerEvent(now) {
var events = ['speedRush', 'crateRain', 'flood', 'rapidFire', 'ricochet', 'gravity', 'vortex', 'slip'];
var pool = events.filter(function (ev) { return ev !== state.lastEvent; });
if (!pool.length) pool = events.slice();
var picked = pool[rand(0, pool.length - 1)];
state.lastEvent = picked;
if (picked === 'speedRush') {
state.eventName = 'speedRush';
state.eventTimeLeft = 7;
setStatus('Событие: турбо-рывок');
if (now) state.toastText = 'Событие: турбо-рывок';
if (now) speakText('Турбо рывок');
} else if (picked === 'crateRain') {
state.eventName = '';
state.eventTimeLeft = 0;
state.pickupTimer = 0;
spawnPickupIfNeeded();
setStatus('Событие: дождь ящиков');
if (now) state.toastText = 'Событие: дождь ящиков';
if (now) speakText('Дождь ящиков');
} else if (picked === 'flood') {
state.eventName = 'flood';
state.eventTimeLeft = 8;
setStatus('Событие: наводнение');
if (now) state.toastText = 'Событие: наводнение';
if (now) speakText('Наводнение');
} else if (picked === 'rapidFire') {
state.eventName = 'rapidFire';
state.eventTimeLeft = 7;
setStatus('Событие: ускоренная перезарядка');
if (now) state.toastText = 'Событие: ускоренная перезарядка';
if (now) speakText('Ускоренная перезарядка');
} else if (picked === 'ricochet') {
state.eventName = 'ricochet';
state.eventTimeLeft = 9;
setStatus('Событие: бешеный рикошет');
if (now) state.toastText = 'Событие: бешеный рикошет';
if (now) speakText('Бешеный рикошет');
} else if (picked === 'gravity') {
state.eventName = 'gravity';
state.eventTimeLeft = 8;
setStatus('Событие: грави волна');
if (now) state.toastText = 'Событие: грави волна';
if (now) speakText('Грави волна');
} else if (picked === 'vortex') {
state.eventName = 'vortex';
state.eventTimeLeft = 9;
setStatus('Событие: вихрь центра');
if (now) state.toastText = 'Событие: вихрь центра';
if (now) speakText('Вихрь центра');
} else {
state.eventName = 'slip';
state.eventTimeLeft = 8;
setStatus('Событие: скользкий грунт');
if (now) state.toastText = 'Событие: скользкий грунт';
if (now) speakText('Скользкий грунт');
}
if (now) state.toastTimer = 1.7;
state.eventTimer = rand(24, 55);
}
function spawnPickupIfNeeded() {
if (state.countdown > 0) return;
if (state.pickupTimer > 0) return;
if (state.pickups.length >= 10) {
state.pickupTimer = rand(6, 10);
return;
}
var candidates = getOpenCells();
var spawned = 0;
var targetSpawnCount = 1;
for (var i = 0; i < candidates.length; i++) {
var c = candidates[i];
var center = cellCenter(c);
var x = center.x;
var y = center.y;
var ok = !circleHitsWall(x, y, SR(14));
if (!ok) continue;
for (var t = 0; t < state.tanks.length; t++) {
var tank = state.tanks[t];
if (!tank.alive) continue;
if (hypot2(tank.x - x, tank.y - y) < SR(130)) {
ok = false;
break;
}
}
for (var px = 0; px < state.pickups.length; px++) {
if (hypot2(state.pickups[px].x - x, state.pickups[px].y - y) < SR(96)) ok = false;
}
if (!ok) continue;
var pool = ['drill', 'shotgun', 'rocket', 'tornado', 'laser', 'mine', 'mega'];
state.pickups.push({
x: x,
y: y,
radius: SR(13),
type: pool[rand(0, pool.length - 1)]
});
spawned += 1;
if (spawned >= targetSpawnCount || state.pickups.length >= 10) break;
}
if (spawned > 0) playCrateSpawnSound();
state.pickupTimer = rand(6, 11);
}
function circleIntersectsRect(x, y, r, rect) {
var cx = clamp(x, rect.x, rect.x + rect.w);
var cy = clamp(y, rect.y, rect.y + rect.h);
var dx = x - cx;
var dy = y - cy;
return dx * dx + dy * dy < r * r;
}
function isWallIntact(wall) {
return !wall || !wall.destroyedUntil || state.time >= wall.destroyedUntil;
}
function breakWallsNear(x, y, radius, restoreDelay) {
var broken = 0;
for (var i = 0; i < state.walls.length; i++) {
var wall = state.walls[i];
if (!isWallIntact(wall)) continue;
if (!circleIntersectsRect(x, y, radius, wall)) continue;
wall.destroyedUntil = state.time + restoreDelay;
broken += 1;
}
return broken;
}
function isHitOnShieldFront(tank, hitX, hitY) {
var fx = Math.cos(tank.angle);
var fy = Math.sin(tank.angle);
var vx = hitX - tank.x;
var vy = hitY - tank.y;
var len = Math.max(0.0001, hypot2(vx, vy));
return (vx / len) * fx + (vy / len) * fy > 0.35;
}
function resolveDrillContact(drillTank) {
if (!drillTank.alive) return;
if (state.eventName === 'vortex' && state.eventTimeLeft > 0) return;
for (var i = 0; i < state.tanks.length; i++) {
var target = state.tanks[i];
if (!target.alive || target.id === drillTank.id) continue;
var d = hypot2(target.x - drillTank.x, target.y - drillTank.y);
if (d > drillTank.radius + target.radius + 2) continue;
if (isHitOnShieldFront(drillTank, target.x, target.y)) {
target.alive = false;
state.flash = Math.max(state.flash, 0.14);
playHitSound();
} else {
drillTank.alive = false;
state.flash = Math.max(state.flash, 0.14);
playHitSound();
return;
}
}
}
function circleHitsWall(x, y, r) {
for (var i = 0; i < state.solids.length; i++) {
var solid = state.solids[i];
if (!isWallIntact(solid)) continue;
if (circleIntersectsRect(x, y, r, solid)) return true;
}
return false;
}
function hitsWallOnAxis(x, y, r, axis) {
for (var i = 0; i < state.solids.length; i++) {
var wall = state.solids[i];
if (!isWallIntact(wall)) continue;
if (wall.axis !== axis && wall.axis !== 'both') continue;
if (circleIntersectsRect(x, y, r, wall)) return true;
}
return false;
}
function insideWorld(x, y, r) {
return x - r >= 0 && x + r <= WORLD_W && y - r >= 0 && y + r <= WORLD_H;
}
function moveBodyWithCollisions(body, dx, dy, radius) {
var steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / 2));
var sx = dx / steps;
var sy = dy / steps;
for (var i = 0; i < steps; i++) {
var nx = body.x + sx;
if (insideWorld(nx, body.y, radius) && !circleHitsWall(nx, body.y, radius)) {
body.x = nx;
}
var ny = body.y + sy;
if (insideWorld(body.x, ny, radius) && !circleHitsWall(body.x, ny, radius)) {
body.y = ny;
}
}
body.x = clamp(body.x, radius, WORLD_W - radius);
body.y = clamp(body.y, radius, WORLD_H - radius);
}
function findFreeSpawnOnRay(originX, originY, angle, radius, minDist, maxDist) {
var step = 6;
for (var dist = minDist; dist <= maxDist; dist += step) {
var sx = originX + Math.cos(angle) * dist;
var sy = originY + Math.sin(angle) * dist;
if (!insideWorld(sx, sy, radius)) continue;
if (!circleHitsWall(sx, sy, radius)) {
return { x: sx, y: sy };
}
}
return null;
}
function randomOpenPoint(margin) {
for (var i = 0; i < 50; i++) {
var x = rand(margin, WORLD_W - margin);
var y = rand(margin, WORLD_H - margin);
if (!circleHitsWall(x, y, margin * 0.7)) return { x: x, y: y };
}
return { x: WORLD_W / 2, y: WORLD_H / 2 };
}
function spawnPortals() {
state.portals = [];
if (Math.random() < 0.35) return;
var portalRadius = SR(22);
var minPortalDist = SR(340);
var a = randomOpenPoint(SR(28));
var b = randomOpenPoint(SR(28));
var tries = 0;
while (tries < 30 && hypot2(a.x - b.x, a.y - b.y) < minPortalDist) {
b = randomOpenPoint(SR(28));
tries += 1;
}
if (hypot2(a.x - b.x, a.y - b.y) < minPortalDist) {
b = { x: clamp(a.x + SR(360), SR(50), WORLD_W - SR(50)), y: clamp(a.y + SR(260), SR(50), WORLD_H - SR(50)) };
}
state.portals.push({ id: 1, x: a.x, y: a.y, radius: portalRadius, pair: 2 });
state.portals.push({ id: 2, x: b.x, y: b.y, radius: portalRadius, pair: 1 });
}
function getPortalById(id) {
for (var i = 0; i < state.portals.length; i++) {
if (state.portals[i].id === id) return state.portals[i];
}
return null;
}
function teleportEntity(entity, radius) {
if (!state.portals || state.portals.length < 2) return;
var insidePortalId = 0;
for (var ci = 0; ci < state.portals.length; ci++) {
var cp = state.portals[ci];
if (hypot2(entity.x - cp.x, entity.y - cp.y) <= cp.radius + radius) {
insidePortalId = cp.id;
break;
}
}
if (entity.portalLockId) {
if (insidePortalId === entity.portalLockId) return;
if (!insidePortalId) entity.portalLockId = 0;
}
if (!insidePortalId) return;
for (var i = 0; i < state.portals.length; i++) {
var p = state.portals[i];
if (hypot2(entity.x - p.x, entity.y - p.y) > p.radius + radius) continue;
var out = getPortalById(p.pair);
if (!out) return;
var savedVx = entity.vx;
var savedVy = entity.vy;
var savedSpeed = entity.speed;
entity.x = clamp(out.x, radius + 2, WORLD_W - radius - 2);
entity.y = clamp(out.y, radius + 2, WORLD_H - radius - 2);
if (typeof savedVx === 'number') entity.vx = savedVx;
if (typeof savedVy === 'number') entity.vy = savedVy;
if (typeof savedSpeed === 'number') entity.speed = savedSpeed;
entity.portalLockId = out.id;
playPortalSound();
return;
}
}
function findSafeSpotNear(cx, cy, radius, bodyRadius) {
for (var i = 0; i < 24; i++) {
var a = Math.random() * Math.PI * 2;
var d = Math.random() * radius;
var x = clamp(cx + Math.cos(a) * d, bodyRadius + 3, WORLD_W - bodyRadius - 3);
var y = clamp(cy + Math.sin(a) * d, bodyRadius + 3, WORLD_H - bodyRadius - 3);
if (!circleHitsWall(x, y, bodyRadius)) return { x: x, y: y };
}
return { x: clamp(cx, bodyRadius + 3, WORLD_W - bodyRadius - 3), y: clamp(cy, bodyRadius + 3, WORLD_H - bodyRadius - 3) };
}
function startRocketAim(tank) {
tank.rocketAimX = clamp(tank.x + Math.cos(tank.angle) * 180, 24, WORLD_W - 24);
tank.rocketAimY = clamp(tank.y + Math.sin(tank.angle) * 180, 24, WORLD_H - 24);
tank.rocketAiming = true;
setStatus(tank.name + ' наводит ракету. Нажми выстрел еще раз для подрыва.');
}
function confirmRocketAim(tank) {
state.rocketStrikes.push({
owner: tank.id,
x: tank.rocketAimX,
y: tank.rocketAimY,
timer: 2.5,
total: 2.5,
nextBeep: 0,
beepGap: 0.42
});
tank.rocketAiming = false;
tank.weaponAmmo -= 1;
if (tank.weaponAmmo <= 0) tank.weaponTimer = 5;
tank.weapon = 'normal';
tank.weaponAmmo = 0;
setStatus(tank.name + ' зафиксировал цель ракеты.');
}
function updateRocketStrikeTimers(dt) {
for (var i = 0; i < state.rocketStrikes.length; i++) {
var strike = state.rocketStrikes[i];
strike.timer -= dt;
strike.nextBeep -= dt;
if (strike.nextBeep <= 0 && strike.timer > 0) {
playRocketLockBeep();
var t = clamp(strike.timer / strike.total, 0, 1);
strike.beepGap = 0.12 + t * 0.4;
strike.nextBeep = strike.beepGap;
}
if (strike.timer <= 0) {
state.explosions.push({
x: strike.x,
y: strike.y,
owner: strike.owner,
maxRadius: 142,
radius: 40,
life: 0.38,
duration: 0.38,
hit: Object.create(null)
});
state.flash = Math.max(state.flash, 0.18);
playRocketBurstSound();
strike.dead = true;
}
}
state.rocketStrikes = state.rocketStrikes.filter(function (s) { return !s.dead; });
}
function shoot(tank) {
var palette = getWeaponPalette(tank.id);
if (tank.weapon === 'drill') {
if (tank.drillActive) return false;
tank.drillActive = true;
tank.drillTimer = 4.8;
tank.weapon = 'normal';
tank.weaponAmmo = 0;
tank.weaponTimer = 0;
setStatus(tank.name + ' активировал бур. Лоб защищен, фланги уязвимы.');
playDrillSound();
tank.cooldown = 0.35;
return true;
}
if (tank.weapon === 'rocket') {
if (tank.rocketAiming) {
confirmRocketAim(tank);
playRocketSound();
tank.cooldown = 0.35;
return true;
}
startRocketAim(tank);
playRocketSound();
tank.cooldown = 0.18;
return true;
}
if (tank.weapon === 'mega' && tank.megaBulletId) {
var mega = null;
for (var mb = 0; mb < state.bullets.length; mb++) {
if (!state.bullets[mb].dead && state.bullets[mb].id === tank.megaBulletId) {
mega = state.bullets[mb];
break;
}
}
if (mega) {
mega.dead = true;
state.explosions.push({
x: mega.x,
y: mega.y,
owner: tank.id,
maxRadius: Math.max(WORLD_W, WORLD_H) * 0.5,
radius: SR(40),
life: 0.42,
duration: 0.42,
hit: Object.create(null)
});
state.flash = Math.max(state.flash, 0.28);
tank.megaBulletId = 0;
tank.weapon = 'normal';
tank.weaponAmmo = 0;
playMegaExplodeSound();
tank.cooldown = 0.24;
return true;
}
tank.megaBulletId = 0;
tank.weapon = 'normal';
tank.weaponAmmo = 0;
}
var activeBullets = getOwnedActiveBulletCount(tank.id);
var slots = MAX_BULLETS_PER_TANK - activeBullets;
if (slots <= 0) return false;
var effectiveWeapon = tank.weapon;
if (effectiveWeapon !== 'normal' && tank.weaponAmmo <= 0 && !(effectiveWeapon === 'mega' && tank.megaBulletId)) {
effectiveWeapon = 'normal';
}
var muzzle = tank.radius + SR(8);
var bx = tank.x + Math.cos(tank.angle) * muzzle;
var by = tank.y + Math.sin(tank.angle) * muzzle;
if (effectiveWeapon !== 'tornado' && circleHitsWall(bx, by, SR(4))) return false;
playFireClickSound();
if (effectiveWeapon === 'shotgun') {
var pellets = Math.min(5, slots);
for (var p = 0; p < pellets; p++) {
var spread = ((p / Math.max(1, pellets - 1)) - 0.5) * 0.5 + (Math.random() - 0.5) * 0.08;
var a = tank.angle + spread;
spawnBullet(tank, {
kind: 'shotgun',
x: bx,
y: by,
vx: Math.cos(a) * SR(175),
vy: Math.sin(a) * SR(175),
radius: SR(2.5),
color: palette.secondary,
strokeColor: palette.stroke,
safe: 0.08,
maxBounces: 0,
noBounce: true,
life: 0.95
});
}
playShotgunSound();
tank.weaponAmmo -= 1;
if (tank.weaponAmmo <= 0) tank.weaponTimer = 5;
tank.cooldown = 0.72;
} else if (effectiveWeapon === 'mega') {
spawnBullet(tank, {
kind: 'mega',
x: bx,
y: by,
vx: Math.cos(tank.angle) * SR(120),
vy: Math.sin(tank.angle) * SR(120),
radius: SR(8),
color: mixHex(palette.primary, '#f4dc67', 0.42),
strokeColor: mixHex(palette.primary, '#aa8822', 0.5),
safe: 0.14,
maxBounces: 4,
life: 12
});
tank.megaBulletId = nextBulletId - 1;
playMegaLaunchSound();
tank.weaponAmmo -= 1;
if (tank.weaponAmmo <= 0) tank.weaponTimer = 5;
tank.cooldown = 0.55;
} else if (effectiveWeapon === 'tornado') {
var tornadoRadius = SR(36);
var tornadoSpawn = findFreeSpawnOnRay(tank.x, tank.y, tank.angle, tornadoRadius, 34, 140);
if (!tornadoSpawn) return false;
spawnBullet(tank, {
kind: 'tornado',
x: tornadoSpawn.x,
y: tornadoSpawn.y,
vx: Math.cos(tank.angle) * SR(430),
vy: Math.sin(tank.angle) * SR(430),
radius: tornadoRadius,
color: mixHex(palette.primary, '#8fd3ff', 0.4),
strokeColor: palette.stroke,
safe: 0.2,
maxBounces: 999,
noBounce: false,
life: 9,
speed: SR(430),
turnRate: 5.4,
axisPref: Math.abs(Math.cos(tank.angle)) >= Math.abs(Math.sin(tank.angle)) ? 'x' : 'y',
navInterval: 0.16,
wanderTimer: 0
});
playTornadoCastSound();
tank.weaponAmmo -= 1;
if (tank.weaponAmmo <= 0) tank.weaponTimer = 5;
tank.cooldown = 0.7;
} else if (effectiveWeapon === 'laser') {
var laserSpeed = SR(520);
spawnBullet(tank, {
kind: 'laser-bolt',
x: bx,
y: by,
vx: Math.cos(tank.angle) * laserSpeed,
vy: Math.sin(tank.angle) * laserSpeed,
radius: SR(3),
color: '#111111',
strokeColor: '#333333',
safe: 0.06,
maxBounces: 7,
noBounce: false,
life: 2.4,
speed: laserSpeed
});
playLaserFireSound();
tank.weaponAmmo -= 1;
if (tank.weaponAmmo <= 0) tank.weaponTimer = 5;
tank.cooldown = 0.5;
} else if (effectiveWeapon === 'mine') {
spawnBullet(tank, {
kind: 'mine',
x: tank.x - Math.cos(tank.angle) * SR(14),
y: tank.y - Math.sin(tank.angle) * SR(14),
vx: 0,
vy: 0,
radius: SR(10),
color: mixHex(palette.primary, '#2f2a2a', 0.45),
strokeColor: palette.stroke,
safe: 0.2,
noBounce: true,
life: 16,
maxBounces: 0
});
playMinePlaceSound();
tank.weaponAmmo -= 1;
if (tank.weaponAmmo <= 0) tank.weaponTimer = 5;
tank.cooldown = 0.45;
} else {
spawnBullet(tank, {
kind: 'normal',
x: bx,
y: by,
vx: Math.cos(tank.angle) * SR(170),
vy: Math.sin(tank.angle) * SR(170),
radius: SR(3),
color: palette.secondary,
strokeColor: palette.stroke,
safe: 0.16,
life: 14
});
playShootSound();
tank.cooldown = 0.52;
}
if (tank.weapon !== 'normal' && tank.weaponAmmo <= 0) tank.weaponAmmo = 0;
return true;
}
function startRound() {
var preset = arenaPresets[rand(0, arenaPresets.length - 1)];
COLS = preset.cols;
ROWS = preset.rows;
CELL = preset.cell;
WORLD_W = COLS * CELL;
WORLD_H = ROWS * CELL;
WORLD_SCALE = CELL / 96;
OFFSET_X = Math.round((VIEW_W - WORLD_W) / 2);
state.arenaLabel = preset.label + ' ' + COLS + 'x' + ROWS;
state.cells = makeGrid();
carveMaze(state.cells);
state.walls = buildWalls(state.cells);
state.groundDecals = [];
for (var gd = 0; gd < 220; gd++) {
state.groundDecals.push({
kind: 'asphalt',
x: Math.random() * WORLD_W,
y: Math.random() * WORLD_H,
r: 5 + Math.random() * 16,
a: 0.03 + Math.random() * 0.09
});
}
for (var gs = 0; gs < 56; gs++) {
state.groundDecals.push({
kind: 'stone',
x: 18 + Math.random() * (WORLD_W - 36),
y: 18 + Math.random() * (WORLD_H - 36),
w: 8 + Math.random() * 24,
h: 6 + Math.random() * 14,
rot: (Math.random() - 0.5) * 1.1,
a: 0.1 + Math.random() * 0.18
});
}
var spawnCells = chooseSpawnCells(players.length);
var spawns = spawnCells.map(cellCenter);
state.obstacles = buildObstacles(spawnCells);
rebuildSolids();
state.tanks = players.map(function (p, i) { return makeTank(p, spawns[i]); });
state.bullets = [];
state.explosions = [];
state.rocketStrikes = [];
state.laserBeams = [];
state.tankTracks = [];
state.tankSmoke = [];
state.pickups = [];
state.pickupTimer = rand(2, 5);
spawnPortals();
state.eventTimer = rand(18, 42);
state.eventName = '';
state.eventTimeLeft = 0;
state.eventPortalShiftTimer = 0;
state.chatterTimer = rand(16, 32);
state.countdown = 0;
state.flash = 0;
state.time = 0;
state.message = 'Раунд ' + state.round + ' - ' + state.arenaLabel;
}
function startMatch() {
keysDown = Object.create(null);
lastTime = 0;
state.scores = { 1: 0, 2: 0, 3: 0 };
state.round = 1;
state.lastWinner = 0;
startRound();
setStatus('Игра запущена. Лабиринт и танки готовы к бою.');
}
function endRound(alive) {
state.countdown = 2.1;
if (alive.length === 1) {
var winner = alive[0];
state.scores[winner.id] += 1;
state.lastWinner = winner.id;
state.message = winner.name + ' победил';
playWinSound();
speakText(winner.name + ' победил в раунде');
} else {
state.lastWinner = 0;
state.message = 'Ничья';
speakText('Ничья в раунде');
}
}
function updateTank(tank, dt) {
if (!tank.alive) return;
var k = tank.keys;
var prevX = tank.x;
var prevY = tank.y;
if (tank.tornadoCarry && tank.tornadoCarry.timeLeft > 0) {
tank.tornadoCarry.timeLeft -= dt;
var source = null;
for (var tb = 0; tb < state.bullets.length; tb++) {
if (!state.bullets[tb].dead && state.bullets[tb].id === tank.tornadoCarry.bulletId) {
source = state.bullets[tb];
break;
}
}
if (source) {
tank.tornadoCarry.spin += dt * 9;
tank.x = source.x + Math.cos(tank.tornadoCarry.spin) * 26;
tank.y = source.y + Math.sin(tank.tornadoCarry.spin) * 26;
tank.angle += dt * 9;
}
if (!source || tank.tornadoCarry.timeLeft <= 0) {
var lastX = source ? source.x : tank.x;
var lastY = source ? source.y : tank.y;
var eject = findSafeSpotNear(lastX, lastY, 90, tank.radius);
tank.x = eject.x;
tank.y = eject.y;
tank.tornadoCarry = null;
playPortalSound();
}
return;
}
if (tank.drillActive) {
tank.drillTimer -= dt;
if (tank.drillTimer <= 0) {
tank.drillActive = false;
tank.drillTimer = 0;
}
}
if (tank.weapon === 'rocket' && tank.rocketAiming) {
var aimSpeed = 260;
var aimDX = (keysDown[k.r] ? 1 : 0) - (keysDown[k.l] ? 1 : 0);
var aimDY = (keysDown[k.b] ? 1 : 0) - (keysDown[k.f] ? 1 : 0);
tank.rocketAimX = clamp(tank.rocketAimX + aimDX * aimSpeed * dt, 14, WORLD_W - 14);
tank.rocketAimY = clamp(tank.rocketAimY + aimDY * aimSpeed * dt, 14, WORLD_H - 14);
tank.angle = Math.atan2(tank.rocketAimY - tank.y, tank.rocketAimX - tank.x);
} else {
if (keysDown[k.l]) tank.angle -= tank.turn * dt;
if (keysDown[k.r]) tank.angle += tank.turn * dt;
if (tank.drillActive) {
var drillFrontX = tank.x + Math.cos(tank.angle) * (tank.radius + SR(11));
var drillFrontY = tank.y + Math.sin(tank.angle) * (tank.radius + SR(11));
breakWallsNear(drillFrontX, drillFrontY, SR(13), 9.5);
var drillMove = (tank.speed * 0.72) * dt;
moveBodyWithCollisions(tank, Math.cos(tank.angle) * drillMove, Math.sin(tank.angle) * drillMove, tank.radius);
resolveDrillContact(tank);
} else {
var intent = (keysDown[k.f] ? 1 : 0) + (keysDown[k.b] ? -0.68 : 0);
if (intent !== 0) {
var speedMul = state.time < tank.slowUntil ? 0.55 : 1;
if (state.eventName === 'speedRush' && state.eventTimeLeft > 0) speedMul *= 1.25;
if (state.eventName === 'flood' && state.eventTimeLeft > 0) speedMul *= 0.68;
var move = tank.speed * speedMul * intent * dt;
moveBodyWithCollisions(tank, Math.cos(tank.angle) * move, Math.sin(tank.angle) * move, tank.radius);
}
if (state.eventName === 'gravity' && state.eventTimeLeft > 0) {
var driftX = Math.sin(state.time * 2.3 + tank.id * 1.7) * SR(26) * dt;
var driftY = Math.cos(state.time * 2.1 + tank.id * 1.3) * SR(26) * dt;
moveBodyWithCollisions(tank, driftX, driftY, tank.radius);
}
if (state.eventName === 'vortex' && state.eventTimeLeft > 0) {
var toCx = WORLD_W / 2 - tank.x;
var toCy = WORLD_H / 2 - tank.y;
var len = Math.max(SR(10), hypot2(toCx, toCy));
moveBodyWithCollisions(tank, (toCx / len) * SR(36) * dt, (toCy / len) * SR(36) * dt, tank.radius);
}
if (state.eventName === 'slip' && state.eventTimeLeft > 0) {
moveBodyWithCollisions(tank, Math.cos(tank.angle) * SR(22) * dt, Math.sin(tank.angle) * SR(22) * dt, tank.radius);
}
}
}
var movedDist = hypot2(tank.x - prevX, tank.y - prevY);
if (movedDist > SR(0.9)) {
playMoveSound();
var backX = tank.x - Math.cos(tank.angle) * tank.radius * 0.78;
var backY = tank.y - Math.sin(tank.angle) * tank.radius * 0.78;
var sideX = Math.cos(tank.angle + Math.PI / 2) * tank.radius * 0.54;
var sideY = Math.sin(tank.angle + Math.PI / 2) * tank.radius * 0.54;
state.tankTracks.push({ x: backX + sideX, y: backY + sideY, a: tank.angle, size: tank.radius * 0.68, life: 0.45, color: 'rgba(42,46,54,0.2)' });
state.tankTracks.push({ x: backX - sideX, y: backY - sideY, a: tank.angle, size: tank.radius * 0.68, life: 0.45, color: 'rgba(42,46,54,0.2)' });
if (Math.random() < 0.7) {
var smokeA = Math.random() * Math.PI * 2;
var smokeR = tank.radius * (0.25 + Math.random() * 0.65);
state.tankSmoke.push({
x: tank.x + Math.cos(smokeA) * smokeR,
y: tank.y + Math.sin(smokeA) * smokeR,
vx: (Math.random() - 0.5) * SR(14),
vy: (Math.random() - 0.5) * SR(14),
r: SR(4 + Math.random() * 4),
life: 0.7,
});
}
}
teleportEntity(tank, tank.radius);
if (tank.weapon !== 'normal' && tank.weaponAmmo <= 0 && !(tank.weapon === 'mega' && tank.megaBulletId)) {
tank.weaponTimer -= dt;
if (tank.weaponTimer <= 0) {
tank.weapon = 'normal';
tank.weaponAmmo = 0;
tank.weaponTimer = 0;
tank.megaBulletId = 0;
}
}
var fireRateMul = state.eventName === 'rapidFire' && state.eventTimeLeft > 0 ? 1.65 : 1;
tank.cooldown -= dt * fireRateMul;
var firePressed = !!keysDown[k.s];
if (tank.weapon === 'rocket' && tank.rocketAiming) {
if (firePressed && !tank.fireHeld && tank.cooldown <= 0) {
shoot(tank);
}
tank.fireHeld = firePressed;
return;
}
if (tank.weapon === 'mega') {
if (firePressed && !tank.fireHeld && tank.cooldown <= 0) {
shoot(tank);
}
} else if (firePressed && tank.cooldown <= 0) {
shoot(tank);
}
tank.fireHeld = firePressed;
}
function reflectBullet(bullet, axis) {
if (bullet.noBounce) {
bullet.dead = true;
playBulletFadeSound(bullet.kind);
return;
}
if (axis === 'v') bullet.vx *= -1;
else if (axis === 'h') bullet.vy *= -1;
else {
bullet.vx *= -1;
bullet.vy *= -1;
}
if (state.eventName === 'ricochet' && state.eventTimeLeft > 0) {
var bounceSpeed = Math.max(SR(120), hypot2(bullet.vx, bullet.vy) * 1.06);
var bounceAngle = Math.atan2(bullet.vy, bullet.vx) + (Math.random() - 0.5) * 0.22;
bullet.vx = Math.cos(bounceAngle) * bounceSpeed;
bullet.vy = Math.sin(bounceAngle) * bounceSpeed;
}
playBounceSound();
bullet.bounces += 1;
if (bullet.bounces > bullet.maxBounces) {
bullet.dead = true;
playBulletFadeSound(bullet.kind);
}
}
function detonateRocket(bullet) {
if (!bullet || bullet.dead || bullet.kind !== 'rocket') return;
bullet.dead = true;
state.explosions.push({
x: bullet.x,
y: bullet.y,
owner: bullet.owner,
maxRadius: 118,
radius: 32,
life: 0.34,
duration: 0.34,
hit: Object.create(null)
});
state.flash = Math.max(state.flash, 0.16);
playRocketBurstSound();
}
function updateExplosions(dt) {
for (var e = 0; e < state.explosions.length; e++) {
var explosion = state.explosions[e];
explosion.life -= dt;
var progress = 1 - clamp(explosion.life / explosion.duration, 0, 1);
explosion.radius = explosion.maxRadius * (0.28 + progress * 0.72);
for (var t = 0; t < state.tanks.length; t++) {
var tank = state.tanks[t];
if (!tank.alive) continue;
if (tank.id === explosion.owner) continue;
if (explosion.hit[tank.id]) continue;
if (state.eventName === 'vortex' && state.eventTimeLeft > 0) continue;
var d = hypot2(tank.x - explosion.x, tank.y - explosion.y);
if (d > explosion.radius + tank.radius) continue;
if (!explosion.ignoreLOS && !hasLineOfSight(explosion.x, explosion.y, tank.x, tank.y, 6)) continue;
explosion.hit[tank.id] = true;
tank.alive = false;
state.flash = Math.max(state.flash, 0.2);
playHitSound();
}
}
state.explosions = state.explosions.filter(function (exp) {
return exp.life > 0;
});
}
function updateBullet(bullet, dt) {
if (bullet.hitLock > 0) bullet.hitLock = Math.max(0, bullet.hitLock - dt);
bullet.safe -= dt;
bullet.life -= dt;
if (bullet.kind === 'mine') {
if (state.eventName === 'vortex' && state.eventTimeLeft > 0) return;
for (var mi = 0; mi < state.tanks.length; mi++) {
var mineVictim = state.tanks[mi];
if (!mineVictim.alive || mineVictim.id === bullet.owner) continue;
if (hypot2(mineVictim.x - bullet.x, mineVictim.y - bullet.y) <= SR(78)) {
bullet.dead = true;
state.explosions.push({
x: bullet.x,
y: bullet.y,
owner: bullet.owner,
maxRadius: SR(140),
radius: SR(36),
life: 0.26,
duration: 0.26,
hit: Object.create(null),
ignoreLOS: true
});
playHitSound();
return;
}
}
return;
}
if (state.eventName === 'gravity' && state.eventTimeLeft > 0) {
bullet.vx += Math.sin(state.time * 3.5 + bullet.id) * SR(10) * dt;
bullet.vy += Math.cos(state.time * 3.2 + bullet.id * 0.7) * SR(10) * dt;
}
if (state.eventName === 'flood' && state.eventTimeLeft > 0) {
bullet.vx *= 1 - dt * 0.16;
bullet.vy *= 1 - dt * 0.16;
}
if (state.eventName === 'ricochet' && state.eventTimeLeft > 0 && !bullet.noBounce) {
bullet.maxBounces = Math.max(bullet.maxBounces || 0, 260);
}
if (bullet.kind === 'rocket') {
if (bullet.stuck) {
bullet.fuse -= dt;
if (bullet.fuse <= 0) {
detonateRocket(bullet);
return;
}
} else {
bullet.speed = Math.min(SR(230), (bullet.speed || SR(175)) + dt * SR(28));
var rocketAngle = Math.atan2(bullet.vy, bullet.vx);
bullet.vx = Math.cos(rocketAngle) * bullet.speed;
bullet.vy = Math.sin(rocketAngle) * bullet.speed;
}
}
if (bullet.life <= 0) {
if (bullet.kind === 'rocket') {
detonateRocket(bullet);
} else {
bullet.dead = true;
playBulletFadeSound(bullet.kind);
}
return;
}
if (bullet.kind === 'rocket' && bullet.trail && bullet.trail.length) {
for (var ti = 0; ti < bullet.trail.length; ti++) {
bullet.trail[ti].life -= dt;
}
bullet.trail = bullet.trail.filter(function (p) { return p.life > 0; });
}
if (bullet.kind === 'tornado') {
updateTornadoGuidance(bullet, dt);
}
var steps = Math.max(1, Math.ceil((Math.max(Math.abs(bullet.vx), Math.abs(bullet.vy)) * dt) / 3));
var sdt = dt / steps;
for (var i = 0; i < steps; i++) {
var nextX = bullet.x + bullet.vx * sdt;
var nextY = bullet.y + bullet.vy * sdt;
if (bullet.kind === 'tornado') {
if (nextX < bullet.radius) nextX = WORLD_W - bullet.radius;
else if (nextX > WORLD_W - bullet.radius) nextX = bullet.radius;
if (nextY < bullet.radius) nextY = WORLD_H - bullet.radius;
else if (nextY > WORLD_H - bullet.radius) nextY = bullet.radius;
bullet.x = nextX;
bullet.y = nextY;
for (var tw = 0; tw < state.tanks.length; tw++) {
var victim = state.tanks[tw];
if (!victim.alive) continue;
if (victim.id === bullet.owner) continue;
if (hypot2(victim.x - bullet.x, victim.y - bullet.y) <= victim.radius + bullet.radius) {
if (bullet.hitLock <= 0 && !victim.tornadoCarry) {
victim.tornadoCarry = {
bulletId: bullet.id,
timeLeft: 2.2,
spin: Math.random() * Math.PI * 2
};
bullet.hitLock = 0.35;
playTornadoHitSound();
}
}
}
continue;
}
var ignoresWalls = false;
var blockedNext = !insideWorld(nextX, nextY, bullet.radius) || (!ignoresWalls && circleHitsWall(nextX, nextY, bullet.radius));
if (!blockedNext) {
bullet.x = nextX;
bullet.y = nextY;
} else {
if (bullet.kind === 'rocket') {
bullet.stuck = true;
bullet.vx = 0;
bullet.vy = 0;
bullet.x = clamp(bullet.x, bullet.radius, WORLD_W - bullet.radius);
bullet.y = clamp(bullet.y, bullet.radius, WORLD_H - bullet.radius);
break;
}
var blockedX = !insideWorld(nextX, bullet.y, bullet.radius) || (!ignoresWalls && circleHitsWall(nextX, bullet.y, bullet.radius));
var blockedY = !insideWorld(bullet.x, nextY, bullet.radius) || (!ignoresWalls && circleHitsWall(bullet.x, nextY, bullet.radius));
if (blockedX && !blockedY) {
reflectBullet(bullet, 'v');
bullet.x += (bullet.vx >= 0 ? 1 : -1) * 2;
bullet.y = nextY;
} else if (!blockedX && blockedY) {
reflectBullet(bullet, 'h');
bullet.x = nextX;
bullet.y += (bullet.vy >= 0 ? 1 : -1) * 2;
} else {
reflectBullet(bullet, 'both');
bullet.x += (bullet.vx >= 0 ? 1 : -1) * 2;
bullet.y += (bullet.vy >= 0 ? 1 : -1) * 2;
}
var pushTries = 0;
while (pushTries < 4 && (!insideWorld(bullet.x, bullet.y, bullet.radius) || circleHitsWall(bullet.x, bullet.y, bullet.radius))) {
bullet.x += (bullet.vx >= 0 ? 1 : -1) * 1.5;
bullet.y += (bullet.vy >= 0 ? 1 : -1) * 1.5;
pushTries += 1;
}
}
bullet.x = clamp(bullet.x, bullet.radius, WORLD_W - bullet.radius);
bullet.y = clamp(bullet.y, bullet.radius, WORLD_H - bullet.radius);
teleportEntity(bullet, bullet.radius);
if (bullet.dead) return;
for (var t = 0; t < state.tanks.length; t++) {
var tank = state.tanks[t];
if (!tank.alive) continue;
if (bullet.kind === 'tornado' && tank.id === bullet.owner) continue;
if (bullet.kind === 'mine' && tank.id === bullet.owner) continue;
if (tank.id === bullet.owner && bullet.safe > 0) continue;
if (hypot2(tank.x - bullet.x, tank.y - bullet.y) <= tank.radius + bullet.radius) {
if (state.eventName === 'vortex' && state.eventTimeLeft > 0) continue;
if (bullet.kind === 'tornado') {
if (bullet.hitLock <= 0 && !tank.tornadoCarry) {
tank.tornadoCarry = {
bulletId: bullet.id,
timeLeft: 2.2,
spin: Math.random() * Math.PI * 2
};
bullet.hitLock = 0.35;
playTornadoHitSound();
}
continue;
}
if (tank.drillActive && isHitOnShieldFront(tank, bullet.x, bullet.y)) {
bullet.dead = true;
playBulletFadeSound(bullet.kind);
continue;
}
if (bullet.kind === 'rocket') {
detonateRocket(bullet);
return;
}
tank.alive = false;
bullet.dead = true;
playBulletFadeSound(bullet.kind);
state.flash = 0.14;
playHitSound();
return;
}
}
}
if (bullet.kind === 'rocket' && !bullet.dead && !bullet.stuck) {
bullet.trail.push({ x: bullet.x, y: bullet.y, life: 0.22, size: bullet.radius });
if (bullet.trail.length > 10) bullet.trail.shift();
}
if (bullet.kind === 'laser-bolt' && !bullet.dead) {
var prevLX = bullet.x - bullet.vx * dt;
var prevLY = bullet.y - bullet.vy * dt;
state.laserBeams.push({
x1: prevLX,
y1: prevLY,
x2: bullet.x,
y2: bullet.y,
life: 0.35,
dur: 0.35,
color: '#111'
});
}
}
function update(dt) {
state.time += dt;
if (state.flash > 0) state.flash = Math.max(0, state.flash - dt);
state.eventTimer -= dt;
if (state.eventTimeLeft > 0) {
state.eventTimeLeft -= dt;
if (state.eventTimeLeft <= 0) {
state.eventName = '';
state.eventTimeLeft = 0;
state.eventPortalShiftTimer = 0;
}
}
if (state.eventTimer <= 0) triggerEvent(true);
if (state.eventName === 'crateRain' && state.eventTimeLeft > 0) {
state.pickupTimer -= dt * 2.4;
state.cameraShake = Math.max(state.cameraShake, 0.22);
}
state.pickupTimer -= dt;
if (state.pickupTimer <= 0) spawnPickupIfNeeded();
if (state.toastTimer > 0) state.toastTimer = Math.max(0, state.toastTimer - dt);
state.chatterTimer -= dt;
if (state.chatterTimer <= 0) {
maybeSpeakRandomLine();
state.chatterTimer = rand(18, 40);
}
if (state.cameraShake > 0) state.cameraShake = Math.max(0, state.cameraShake - dt * 1.8);
if (state.countdown > 0) {
state.countdown -= dt;
if (state.countdown <= 0) {
state.round += 1;
startRound();
}
return;
}
for (var i = 0; i < state.tanks.length; i++) updateTank(state.tanks[i], dt);
for (var j = 0; j < state.bullets.length; j++) {
if (!state.bullets[j].dead) updateBullet(state.bullets[j], dt);
}
state.bullets = state.bullets.filter(function (b) { return !b.dead; });
for (var lb = 0; lb < state.laserBeams.length; lb++) {
state.laserBeams[lb].life -= dt;
}
state.laserBeams = state.laserBeams.filter(function (beam) { return beam.life > 0; });
for (var tr = 0; tr < state.tankTracks.length; tr++) state.tankTracks[tr].life -= dt;
for (var sm = 0; sm < state.tankSmoke.length; sm++) {
var s = state.tankSmoke[sm];
s.life -= dt;
s.x += s.vx * dt;
s.y += s.vy * dt;
s.r += SR(5) * dt;
}
state.tankTracks = state.tankTracks.filter(function (t) { return t.life > 0; });
state.tankSmoke = state.tankSmoke.filter(function (s) { return s.life > 0; });
updateRocketStrikeTimers(dt);
updateExplosions(dt);
for (var mt = 0; mt < state.tanks.length; mt++) {
var owner = state.tanks[mt];
if (!owner.megaBulletId) continue;
var megaAlive = false;
for (var bi = 0; bi < state.bullets.length; bi++) {
if (!state.bullets[bi].dead && state.bullets[bi].id === owner.megaBulletId) {
megaAlive = true;
break;
}
}
if (!megaAlive) {
owner.megaBulletId = 0;
if (owner.weapon === 'mega' && owner.weaponAmmo <= 0) {
owner.weapon = 'normal';
}
}
}
for (var rt = 0; rt < state.tanks.length; rt++) {
var rocketOwner = state.tanks[rt];
if (!rocketOwner.rocketBulletId) continue;
var rocketAlive = false;
for (var rbi = 0; rbi < state.bullets.length; rbi++) {
if (!state.bullets[rbi].dead && state.bullets[rbi].id === rocketOwner.rocketBulletId) {
rocketAlive = true;
break;
}
}
if (!rocketAlive) {
rocketOwner.rocketBulletId = 0;
if (rocketOwner.weapon === 'rocket' && rocketOwner.weaponAmmo <= 0) {
rocketOwner.weapon = 'normal';
}
}
}
if (state.eventName !== 'vortex' || state.eventTimeLeft <= 0) {
for (var p = state.pickups.length - 1; p >= 0; p--) {
var pickup = state.pickups[p];
var collectors = [];
for (var pt = 0; pt < state.tanks.length; pt++) {
var tank = state.tanks[pt];
if (!tank.alive) continue;
if (tank.weapon !== 'normal' && tank.weaponAmmo > 0) continue;
if (tank.weapon === 'mega' && tank.megaBulletId) continue;
if (hypot2(tank.x - pickup.x, tank.y - pickup.y) <= tank.radius + pickup.radius) {
collectors.push(tank);
}
}
if (!collectors.length) continue;
var winner = collectors[rand(0, collectors.length - 1)];
if (collectors.length > 1) {
var clashText = 'Спорный ящик: ' + getWeaponName(pickup.type);
state.toastText = clashText;
state.toastTimer = 1.6;
speakText('Спорный бонус');
}
applyPickup(winner, pickup.type);
state.pickups.splice(p, 1);
state.pickupTimer = rand(2, 5);
}
}
var alive = state.tanks.filter(function (t) { return t.alive; });
if (alive.length <= 1) endRound(alive);
}
function drawBackground() {
ctx.fillStyle = '#e7edf5';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
if (state.eventName === 'flood' && state.eventTimeLeft > 0) {
var wave = Math.sin(state.time * 2.2) * 0.5 + 0.5;
ctx.fillStyle = 'rgba(70, 140, 190, ' + (0.07 + wave * 0.06).toFixed(3) + ')';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
} else if (state.eventName === 'speedRush' && state.eventTimeLeft > 0) {
var rush = Math.sin(state.time * 8) * 0.5 + 0.5;
ctx.fillStyle = 'rgba(150, 215, 255, ' + (0.04 + rush * 0.05).toFixed(3) + ')';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
} else if (state.eventName === 'rapidFire' && state.eventTimeLeft > 0) {
var pulse = Math.sin(state.time * 12) * 0.5 + 0.5;
ctx.fillStyle = 'rgba(186, 164, 255, ' + (0.03 + pulse * 0.06).toFixed(3) + ')';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
} else if (state.eventName === 'ricochet' && state.eventTimeLeft > 0) {
var rc = Math.sin(state.time * 16) * 0.5 + 0.5;
ctx.fillStyle = 'rgba(210, 255, 180, ' + (0.03 + rc * 0.05).toFixed(3) + ')';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
} else if (state.eventName === 'gravity' && state.eventTimeLeft > 0) {
var gv = Math.sin(state.time * 5) * 0.5 + 0.5;
ctx.fillStyle = 'rgba(150, 170, 255, ' + (0.03 + gv * 0.05).toFixed(3) + ')';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
} else if (state.eventName === 'vortex' && state.eventTimeLeft > 0) {
var vx = Math.sin(state.time * 7) * 0.5 + 0.5;
ctx.fillStyle = 'rgba(200, 150, 255, ' + (0.03 + vx * 0.06).toFixed(3) + ')';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
} else if (state.eventName === 'slip' && state.eventTimeLeft > 0) {
var sl = Math.sin(state.time * 9) * 0.5 + 0.5;
ctx.fillStyle = 'rgba(185, 245, 255, ' + (0.03 + sl * 0.05).toFixed(3) + ')';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
}
}
function drawScores() {
roundRect(18, 10, VIEW_W - 36, 98, 14);
ctx.fillStyle = 'rgba(255,255,255,.22)';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,.12)';
ctx.lineWidth = 1;
ctx.stroke();
var cards = [
{ x: 28, y: 18, id: 1, color: players[0].color, label: players[0].name },
{ x: 390, y: 18, id: 2, color: players[1].color, label: players[1].name },
{ x: 752, y: 18, id: 3, color: players[2].color, label: players[2].name }
];
ctx.textBaseline = 'middle';
for (var i = 0; i < cards.length; i++) {
var c = cards[i];
roundRect(c.x, c.y, 320, 42, 13);
ctx.fillStyle = 'rgba(255,255,255,.36)';
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = state.lastWinner === c.id ? c.color : 'rgba(0,0,0,.12)';
ctx.stroke();
ctx.beginPath();
ctx.arc(c.x + 24, c.y + 21, 8, 0, Math.PI * 2);
ctx.fillStyle = c.color;
ctx.fill();
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'left';
ctx.fillStyle = '#20242a';
ctx.fillText(c.label, c.x + 42, c.y + 21);
ctx.font = 'bold 30px Arial';
ctx.textAlign = 'right';
ctx.fillText(String(state.scores[c.id]), c.x + 292, c.y + 21);
}
}
function drawMessage() {
roundRect(VIEW_W / 2 - 380, 64, 760, 40, 12);
ctx.fillStyle = 'rgba(255,255,255,.42)';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,.12)';
ctx.stroke();
ctx.fillStyle = '#1f2328';
ctx.font = 'bold 22px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
var suffix = state.countdown > 0 ? ' - новый раунд через ' + Math.ceil(state.countdown) : '';
var eventSuffix = state.eventName && state.eventTimeLeft > 0 ? ' | ' + Math.ceil(state.eventTimeLeft) + 'с' : '';
ctx.fillText(state.message + suffix + eventSuffix, VIEW_W / 2, 84);
if (state.toastTimer > 0 && state.toastText) {
var a = clamp(state.toastTimer / 2.4, 0, 1);
roundRect(VIEW_W / 2 - 330, 108, 660, 46, 12);
ctx.fillStyle = 'rgba(25,30,35,' + (0.5 + a * 0.35).toFixed(3) + ')';
ctx.fill();
ctx.strokeStyle = 'rgba(255,230,120,' + (0.4 + a * 0.5).toFixed(3) + ')';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#fff6cc';
ctx.font = 'bold 24px Arial';
ctx.fillText(state.toastText, VIEW_W / 2, 131);
}
}
function drawArena() {
ctx.save();
var shakeX = state.cameraShake > 0 ? (Math.random() - 0.5) * SR(10) * state.cameraShake : 0;
var shakeY = state.cameraShake > 0 ? (Math.random() - 0.5) * SR(10) * state.cameraShake : 0;
ctx.translate(OFFSET_X + shakeX, OFFSET_Y + shakeY);
roundRect(-10, -10, WORLD_W + 20, WORLD_H + 20, 22);
ctx.fillStyle = '#d7dee8';
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,.16)';
ctx.lineWidth = 2;
ctx.stroke();
ctx.fillStyle = '#d6e8f7';
ctx.fillRect(0, 0, WORLD_W, WORLD_H);
for (var trk = 0; trk < state.tankTracks.length; trk++) {
var track = state.tankTracks[trk];
var ta = clamp(track.life / 0.45, 0, 1);
ctx.save();
ctx.translate(track.x, track.y);
ctx.rotate(track.a);
ctx.fillStyle = 'rgba(42,46,54,' + (0.05 + ta * 0.18).toFixed(3) + ')';
roundRect(-track.size * 0.42, -track.size * 0.15, track.size * 0.84, track.size * 0.3, track.size * 0.07);
ctx.fill();
ctx.restore();
}
for (var sk = 0; sk < state.tankSmoke.length; sk++) {
var sm = state.tankSmoke[sk];
var sa = clamp(sm.life / 0.7, 0, 1);
ctx.beginPath();
ctx.arc(sm.x, sm.y, sm.r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(70,70,70,' + (sa * 0.16).toFixed(3) + ')';
ctx.fill();
}
for (var i = 0; i < state.walls.length; i++) {
var wall = state.walls[i];
if (!isWallIntact(wall)) {
var restoreT = clamp((wall.destroyedUntil - state.time) / 9.5, 0, 1);
ctx.fillStyle = 'rgba(112,108,103,' + (0.16 + (1 - restoreT) * 0.14).toFixed(3) + ')';
ctx.fillRect(wall.x, wall.y, wall.w, wall.h);
continue;
}
ctx.fillStyle = '#605b56';
ctx.fillRect(wall.x, wall.y, wall.w, wall.h);
}
for (var o = 0; o < state.obstacles.length; o++) {
var obstacle = state.obstacles[o];
ctx.fillStyle = '#8e877f';
roundRect(obstacle.x, obstacle.y, obstacle.w, obstacle.h, 6);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,.2)';
ctx.lineWidth = 1;
ctx.stroke();
}
for (var lb = 0; lb < state.laserBeams.length; lb++) {
var beam = state.laserBeams[lb];
var ba = clamp(beam.life / beam.dur, 0, 1);
ctx.strokeStyle = 'rgba(0,0,0,' + (0.25 + ba * 0.45).toFixed(3) + ')';
ctx.lineWidth = SR(4);
ctx.beginPath();
ctx.moveTo(beam.x1, beam.y1);
ctx.lineTo(beam.x2, beam.y2);
ctx.stroke();
ctx.strokeStyle = 'rgba(60,60,60,' + (0.3 + ba * 0.5).toFixed(3) + ')';
ctx.lineWidth = SR(2);
ctx.beginPath();
ctx.moveTo(beam.x1, beam.y1);
ctx.lineTo(beam.x2, beam.y2);
ctx.stroke();
}
for (var pp = 0; pp < state.portals.length; pp++) {
var portal = state.portals[pp];
var wob = Math.sin(state.time * 6 + pp) * 0.5 + 0.5;
ctx.beginPath();
ctx.arc(portal.x, portal.y, portal.radius + wob * 3, 0, Math.PI * 2);
ctx.strokeStyle = pp === 0 ? 'rgba(120, 255, 240, 0.85)' : 'rgba(255, 164, 255, 0.85)';
ctx.lineWidth = 3;
ctx.stroke();
ctx.beginPath();
ctx.arc(portal.x, portal.y, portal.radius * 0.55, 0, Math.PI * 2);
ctx.fillStyle = pp === 0 ? 'rgba(92, 210, 255, 0.3)' : 'rgba(234, 121, 255, 0.3)';
ctx.fill();
}
for (var at = 0; at < state.tanks.length; at++) {
var aimTank = state.tanks[at];
if (!aimTank.alive || aimTank.weapon !== 'laser') continue;
var aimLen = SR(380);
var aimX1 = aimTank.x + Math.cos(aimTank.angle) * (aimTank.radius + SR(4));
var aimY1 = aimTank.y + Math.sin(aimTank.angle) * (aimTank.radius + SR(4));
var aimX2 = aimTank.x + Math.cos(aimTank.angle) * aimLen;
var aimY2 = aimTank.y + Math.sin(aimTank.angle) * aimLen;
ctx.strokeStyle = 'rgba(0,0,0,0.22)';
ctx.lineWidth = SR(1.6);
ctx.setLineDash([SR(6), SR(4)]);
ctx.beginPath();
ctx.moveTo(aimX1, aimY1);
ctx.lineTo(aimX2, aimY2);
ctx.stroke();
ctx.setLineDash([]);
}
for (var b = 0; b < state.bullets.length; b++) {
var bullet = state.bullets[b];
if (bullet.kind === 'tornado') {
var spin = state.time * 10 + b * 0.2;
ctx.save();
ctx.translate(bullet.x, bullet.y);
ctx.rotate(spin);
ctx.globalAlpha = 0.9;
for (var sw = 0; sw < 3; sw++) {
ctx.beginPath();
ctx.strokeStyle = bullet.strokeColor;
ctx.lineWidth = 1.4;
ctx.arc(0, 0, bullet.radius - sw * 4, 0.4, Math.PI * 1.85);
ctx.stroke();
}
ctx.beginPath();
ctx.fillStyle = bullet.color;
ctx.arc(0, 0, 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
} else if (bullet.kind === 'rocket') {
if (bullet.trail && bullet.trail.length) {
for (var tr = 0; tr < bullet.trail.length; tr++) {
var tp = bullet.trail[tr];
var alpha = Math.max(0, tp.life / 0.22);
ctx.beginPath();
ctx.fillStyle = 'rgba(255, 210, 120,' + (alpha * 0.22).toFixed(3) + ')';
ctx.arc(tp.x, tp.y, tp.size * (0.4 + alpha * 0.3), 0, Math.PI * 2);
ctx.fill();
}
}
var rocketAng = Math.atan2(bullet.vy, bullet.vx);
var rocketBlink = bullet.stuck ? (Math.sin(state.time * 16) * 0.5 + 0.5) : 0;
ctx.save();
ctx.translate(bullet.x, bullet.y);
ctx.rotate(rocketAng);
roundRect(-8, -2.7, 12, 5.4, 2);
ctx.fillStyle = mixHex(bullet.color, '#d9d9d9', 0.35);
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = '#101214';
ctx.stroke();
ctx.beginPath();
ctx.moveTo(7, 0);
ctx.lineTo(3.2, -3.5);
ctx.lineTo(3.2, 3.5);
ctx.closePath();
ctx.fillStyle = '#cfd8df';
ctx.fill();
ctx.strokeStyle = '#101214';
ctx.stroke();
ctx.beginPath();
ctx.moveTo(-7, -2);
ctx.lineTo(-10, -4.5);
ctx.lineTo(-6.2, -2.4);
ctx.closePath();
ctx.fillStyle = '#4a525a';
ctx.fill();
ctx.beginPath();
ctx.moveTo(-7, 2);
ctx.lineTo(-10, 4.5);
ctx.lineTo(-6.2, 2.4);
ctx.closePath();
ctx.fillStyle = '#4a525a';
ctx.fill();
if (bullet.stuck) {
ctx.beginPath();
ctx.arc(0, 0, 10.5, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 90, 60,' + (0.35 + rocketBlink * 0.6).toFixed(3) + ')';
ctx.lineWidth = 2;
ctx.stroke();
} else {
ctx.beginPath();
ctx.moveTo(-8, 0);
ctx.lineTo(-12 - Math.random() * 2.4, 2.1);
ctx.lineTo(-10.1, 0);
ctx.lineTo(-12 - Math.random() * 2.4, -2.1);
ctx.closePath();
ctx.fillStyle = '#ffbc54';
ctx.fill();
ctx.beginPath();
ctx.moveTo(-8.2, 0);
ctx.lineTo(-10.8 - Math.random() * 1.4, 1.2);
ctx.lineTo(-9.4, 0);
ctx.lineTo(-10.8 - Math.random() * 1.4, -1.2);
ctx.closePath();
ctx.fillStyle = '#fff1b4';
ctx.fill();
}
ctx.restore();
} else if (bullet.kind === 'mega') {
var megaPulse = Math.sin(state.time * 14) * 0.5 + 0.5;
ctx.beginPath();
ctx.arc(bullet.x, bullet.y, bullet.radius + SR(3) * megaPulse, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,220,100,' + (0.15 + megaPulse * 0.12).toFixed(3) + ')';
ctx.fill();
ctx.beginPath();
ctx.arc(bullet.x, bullet.y, bullet.radius, 0, Math.PI * 2);
ctx.fillStyle = bullet.color;
ctx.fill();
ctx.strokeStyle = bullet.strokeColor;
ctx.lineWidth = 2;
ctx.stroke();
} else if (bullet.kind === 'mega-shard') {
var ang = Math.atan2(bullet.vy, bullet.vx);
ctx.save();
ctx.translate(bullet.x, bullet.y);
ctx.rotate(ang);
ctx.fillStyle = bullet.color;
ctx.beginPath();
ctx.moveTo(3.2, 0);
ctx.lineTo(-2.4, 1.8);
ctx.lineTo(-2.4, -1.8);
ctx.closePath();
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = bullet.strokeColor;
ctx.stroke();
ctx.restore();
} else if (bullet.kind === 'laser-bolt') {
ctx.beginPath();
ctx.arc(bullet.x, bullet.y, bullet.radius, 0, Math.PI * 2);
ctx.fillStyle = '#111';
ctx.fill();
ctx.strokeStyle = '#444';
ctx.lineWidth = 1.4;
ctx.stroke();
} else if (bullet.kind === 'mine') {
ctx.beginPath();
ctx.arc(bullet.x, bullet.y, bullet.radius, 0, Math.PI * 2);
ctx.fillStyle = bullet.color;
ctx.fill();
ctx.strokeStyle = bullet.strokeColor;
ctx.lineWidth = 2;
ctx.stroke();
var minePulse = Math.sin(state.time * 12) * 0.5 + 0.5;
ctx.beginPath();
ctx.arc(bullet.x, bullet.y, 2.4 + minePulse * 1.8, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,70,70,0.9)';
ctx.fill();
} else {
ctx.fillStyle = bullet.color;
ctx.beginPath();
ctx.arc(bullet.x, bullet.y, bullet.radius, 0, Math.PI * 2);
ctx.fill();
ctx.lineWidth = 1.3;
ctx.strokeStyle = bullet.strokeColor;
ctx.stroke();
}
}
for (var rs = 0; rs < state.rocketStrikes.length; rs++) {
var strike = state.rocketStrikes[rs];
var pulse = Math.sin(state.time * 18) * 0.5 + 0.5;
var strikeAlpha = clamp(strike.timer / strike.total, 0, 1);
ctx.beginPath();
ctx.arc(strike.x, strike.y, 18 + pulse * 4, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 96, 64,' + (0.35 + (1 - strikeAlpha) * 0.5).toFixed(3) + ')';
ctx.lineWidth = 2.2;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(strike.x - 10, strike.y);
ctx.lineTo(strike.x + 10, strike.y);
ctx.moveTo(strike.x, strike.y - 10);
ctx.lineTo(strike.x, strike.y + 10);
ctx.strokeStyle = 'rgba(255, 236, 170,' + (0.35 + pulse * 0.5).toFixed(3) + ')';
ctx.lineWidth = 1.4;
ctx.stroke();
}
for (var ta = 0; ta < state.tanks.length; ta++) {
var aimingTank = state.tanks[ta];
if (!aimingTank.alive || !aimingTank.rocketAiming) continue;
var aimPulse = Math.sin(state.time * 10 + ta) * 0.5 + 0.5;
ctx.beginPath();
ctx.arc(aimingTank.rocketAimX, aimingTank.rocketAimY, 14 + aimPulse * 3, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255, 84, 54, 0.75)';
ctx.lineWidth = 2;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(aimingTank.rocketAimX - 12, aimingTank.rocketAimY);
ctx.lineTo(aimingTank.rocketAimX + 12, aimingTank.rocketAimY);
ctx.moveTo(aimingTank.rocketAimX, aimingTank.rocketAimY - 12);
ctx.lineTo(aimingTank.rocketAimX, aimingTank.rocketAimY + 12);
ctx.strokeStyle = 'rgba(255, 250, 210, 0.85)';
ctx.lineWidth = 1.6;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(aimingTank.x, aimingTank.y);
ctx.lineTo(aimingTank.rocketAimX, aimingTank.rocketAimY);
ctx.strokeStyle = 'rgba(255, 110, 80, 0.25)';
ctx.lineWidth = 1.2;
ctx.stroke();
}
for (var ex = 0; ex < state.explosions.length; ex++) {
var explosion = state.explosions[ex];
var a = clamp(explosion.life / explosion.duration, 0, 1);
ctx.beginPath();
ctx.fillStyle = 'rgba(255, 176, 92,' + (0.18 * a + 0.08).toFixed(3) + ')';
ctx.arc(explosion.x, explosion.y, explosion.radius, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.lineWidth = 2.4;
ctx.strokeStyle = 'rgba(255, 238, 170,' + (0.62 * a).toFixed(3) + ')';
ctx.arc(explosion.x, explosion.y, explosion.radius * 0.82, 0, Math.PI * 2);
ctx.stroke();
}
for (var pk = 0; pk < state.pickups.length; pk++) {
var pack = state.pickups[pk];
ctx.save();
ctx.translate(pack.x, pack.y);
var s = pack.radius;
roundRect(-s, -s, s * 2, s * 2, s * 0.35);
ctx.fillStyle = '#9b6e43';
ctx.fill();
ctx.lineWidth = 1.4;
ctx.strokeStyle = '#5f3f23';
ctx.stroke();
ctx.fillStyle = '#f7e0b5';
drawPickupIcon(pack.type, s);
ctx.restore();
}
for (var t = 0; t < state.tanks.length; t++) {
var tank = state.tanks[t];
if (!tank.alive) continue;
drawTank(tank);
}
ctx.restore();
}
function drawTank(tank) {
ctx.save();
ctx.translate(tank.x, tank.y);
ctx.rotate(tank.angle);
var tankScale = tank.radius / 20;
ctx.scale(tankScale, tankScale);
ctx.shadowColor = 'rgba(0,0,0,0.3)';
ctx.shadowBlur = 8;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 3;
var baseDark = mixHex(tank.color, '#111111', 0.35);
var baseMid = mixHex(tank.color, '#111111', 0.18);
var baseLight = mixHex(tank.color, '#ffffff', 0.22);
var railGrad = ctx.createLinearGradient(-20, 0, 20, 0);
railGrad.addColorStop(0, '#2b2b2b');
railGrad.addColorStop(0.5, '#5a5a5a');
railGrad.addColorStop(1, '#1f1f1f');
ctx.fillStyle = railGrad;
roundRect(-20, -15, 40, 8, 2);
ctx.fill();
roundRect(-20, 7, 40, 8, 2);
ctx.fill();
var hullGrad = ctx.createLinearGradient(-18, -14, 18, 14);
hullGrad.addColorStop(0, baseLight);
hullGrad.addColorStop(0.6, baseMid);
hullGrad.addColorStop(1, baseDark);
ctx.fillStyle = hullGrad;
roundRect(-18, -14, 36, 28, 4);
ctx.fill();
var topGrad = ctx.createLinearGradient(-14, -10, 14, 10);
topGrad.addColorStop(0, mixHex(tank.color, '#ffffff', 0.28));
topGrad.addColorStop(1, mixHex(tank.color, '#111111', 0.12));
ctx.fillStyle = topGrad;
roundRect(-14, -10, 28, 20, 3);
ctx.fill();
var barrelGrad = ctx.createLinearGradient(0, -3, 26, 3);
barrelGrad.addColorStop(0, '#202020');
barrelGrad.addColorStop(0.55, '#585858');
barrelGrad.addColorStop(1, '#1a1a1a');
ctx.fillStyle = barrelGrad;
roundRect(0, -3, 26, 6, 2);
ctx.fill();
var turretGrad = ctx.createRadialGradient(-4, -4, 2, 0, 0, 12);
turretGrad.addColorStop(0, mixHex(tank.color, '#ffffff', 0.32));
turretGrad.addColorStop(0.7, mixHex(tank.color, '#111111', 0.12));
turretGrad.addColorStop(1, mixHex(tank.color, '#111111', 0.35));
ctx.fillStyle = turretGrad;
ctx.beginPath();
ctx.arc(0, 0, 12, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#232323';
ctx.beginPath();
ctx.arc(0, 0, 6.5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#5a5a5a';
ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.arc(0, 0, 12, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
var boltPos = [[-9, -6], [9, -6], [-9, 6], [9, 6]];
ctx.fillStyle = '#1d1d1d';
for (var b = 0; b < boltPos.length; b++) {
ctx.beginPath();
ctx.arc(boltPos[b][0], boltPos[b][1], 1.2, 0, Math.PI * 2);
ctx.fill();
}
if (tank.drillActive) {
var glow = Math.sin(state.time * 14 + tank.id) * 0.5 + 0.5;
ctx.beginPath();
ctx.arc(20, 0, 12, -1.05, 1.05);
ctx.strokeStyle = 'rgba(170, 225, 255,' + (0.45 + glow * 0.35).toFixed(3) + ')';
ctx.lineWidth = 4;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(26, 0);
ctx.lineTo(33 + glow * 3, 0);
ctx.strokeStyle = '#dff8ff';
ctx.lineWidth = 3;
ctx.stroke();
}
ctx.restore();
}
function drawFooter() {
ctx.fillStyle = 'rgba(30,30,30,.68)';
ctx.font = '15px Arial';
ctx.textAlign = 'left';
ctx.fillText('Локальный режим: 3 игрока на одной клавиатуре', 58, VIEW_H - 28);
}
function drawFlash() {
if (state.flash <= 0) return;
ctx.save();
ctx.globalAlpha = state.flash * 1.7;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, VIEW_W, VIEW_H);
ctx.restore();
}
function render() {
drawBackground();
drawScores();
drawMessage();
drawArena();
drawFooter();
drawFlash();
}
var lastTime = 0;
function loop(ts) {
if (destroyed || window.__mazeTanksRunId !== runId) return;
if (!lastTime) lastTime = ts;
var dt = Math.min(0.033, (ts - lastTime) / 1000);
lastTime = ts;
update(dt);
render();
animId = requestAnimationFrame(loop);
}
function onKeyDown(event) {
var target = event.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
if (!audioUnlocked) ensureAudio();
if (usedKeys[event.code]) event.preventDefault();
keysDown[event.code] = true;
}
function onKeyUp(event) {
var target = event.target;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
if (usedKeys[event.code]) event.preventDefault();
keysDown[event.code] = false;
}
function onBlur() {
keysDown = Object.create(null);
}
function applyPlayerNames() {
var n1 = name1El && name1El.value ? name1El.value.trim() : '';
var n2 = name2El && name2El.value ? name2El.value.trim() : '';
var n3 = name3El && name3El.value ? name3El.value.trim() : '';
players[0].name = n1 || 'Игрок 1';
players[1].name = n2 || 'Игрок 2';
players[2].name = n3 || 'Игрок 3';
for (var i = 0; i < state.tanks.length; i++) {
var t = state.tanks[i];
for (var p = 0; p < players.length; p++) {
if (players[p].id === t.id) t.name = players[p].name;
}
}
setStatus('Имена обновлены');
}
function onNewRound() {
ensureAudio();
applyPlayerNames();
keysDown = Object.create(null);
lastTime = 0;
startRound();
}
window.onkeydown = onKeyDown;
window.onkeyup = onKeyUp;
window.onblur = onBlur;
if (roundBtn) roundBtn.onclick = onNewRound;
if (applyNamesBtn) applyNamesBtn.onclick = applyPlayerNames;
window.__mazeTanksApp = {
destroy: function () {
destroyed = true;
window.__mazeTanksRunId = runId + 1;
if (animId && typeof cancelAnimationFrame === 'function') cancelAnimationFrame(animId);
if (window.onkeydown === onKeyDown) window.onkeydown = null;
if (window.onkeyup === onKeyUp) window.onkeyup = null;
if (window.onblur === onBlur) window.onblur = null;
if (roundBtn) roundBtn.onclick = null;
if (applyNamesBtn) applyNamesBtn.onclick = null;
if (ctx && canvas) {
try {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.height);
} catch (e) {}
}
}
};
startMatch();
animId = requestAnimationFrame(loop);
} catch (e) {
setStatus('Ошибка запуска игры: ' + (e && e.message ? e.message : e));
}
})();
</script>
</body>
</html>`;
const mazeTanksHtml = part1 + part2 + part3 + part4 + part5;
export default function App() {
return (
<main className="min-h-screen bg-stone-200 p-4 md:p-6">
<iframe
title="Maze Tanks test"
srcDoc={mazeTanksHtml}
allow="autoplay"
className="mx-auto block h-[92vh] w-full max-w-[1500px] rounded-xl border border-black/10 bg-white shadow-lg"
/>
</main>
);
}