// GrechkaCraft — auto-bundled from ES modules
(function() {
"use strict";
// === core/constants.js ===
// ==================== КОНСТАНТЫ ИГРЫ ====================
const TILE = 40;
// Мир
const SEA_GY = 14; // уровень воды (gy) - уменьшил для меньших водоёмов
const BEDROCK_GY = 140; // глубина бедрока (gy), чем больше - тем глубже
const GEN_MARGIN_X = 40; // запас генерации по X (в тайлах) — увеличен для плавной прогрузки
// Физика
const GRAV = 2200;
const GRAV_WATER = 550;
const MOVE = 320;
const JUMP = 760;
// День/ночь
const DAY_LEN = 360; // замедлил смену дня/ночи в 3 раза
// Сохранение
const SAVE_KEY = 'minegrechka_save';
// Сеть
const MOVE_SEND_INTERVAL = 0.05; // 50ms = 20 раз в секунду
// Погода
const MAX_RAINDROPS = 200;
// Чат
const MAX_CHAT_MESSAGES = 50;
// Голосовой чат
const VOICE_SERVER = 'wss://voicegrech.mkn8n.ru';
// === core/state.js ===
// Все мутабельные переменные игры в одном объекте состояния
const state = {
// Камера
camX: 0,
camY: 0,
// День/ночь
worldTime: 0,
isNightTime: false,
// Мультиплеер
isMultiplayer: false,
mySocketId: null,
socket: null,
// Инвентарь/UI
selected: 0,
showFullInventory: false,
craftOpen: false,
inventoryOpen: false,
chatOpen: false,
modeIdx: 0,
// Мир
worldSeed: Math.floor(Math.random() * 1000000),
// Погода
isRaining: false,
rainIntensity: 0,
weatherTimer: 0,
weatherChangeInterval: 60 + Math.random() * 120,
// Мобы/спавн
spawnT: 0,
// Цикл
last: 0,
prevJump: false,
// Сеть — throttle отправки позиции
lastMoveSendTime: 0,
lastSentX: 0,
lastSentY: 0,
// Игрок
player: {
x: 6 * TILE,
y: 0 * TILE,
w: 34,
h: 34,
vx: 0,
vy: 0,
grounded: false,
inWater: false,
headInWater: false,
hp: 100,
hunger: 100,
o2: 100,
invuln: 0,
fallStartY: 0,
lastStepTime: 0,
sleeping: false,
inBoat: false,
armor: 0,
equippedArmor: null
},
// Точка спавна
spawnPoint: { x: 6 * TILE, y: 0 * TILE },
// Инвентарь
inv: {
dirt: 6, stone: 0, sand: 0, gravel: 0, clay: 0,
wood: 0, planks: 0, ladder: 0, leaves: 0, coal: 0,
copper_ore: 0, iron_ore: 0, gold_ore: 0, diamond_ore: 0,
brick: 0, glass: 0,
tnt: 1, campfire: 0, torch: 0,
meat: 0, cooked: 0, arrow: 0,
wood_pickaxe: 0, stone_pickaxe: 0, iron_pickaxe: 0,
wood_sword: 0, stone_sword: 0, iron_sword: 0,
iron_armor: 0,
bow: 0, furnace: 0,
bed: 0, boat: 0,
iron_ingot: 0, gold_ingot: 0, copper_ingot: 0
},
// Лодка
boat: {
x: 0, y: 0,
w: 34, h: 34,
vx: 0, vy: 0,
active: false,
inWater: false
},
// Ввод
inp: { up: false, down: false, left: false, right: false, jump: false, mine: false, build: false, bow: false },
// Мышь
mouse: { x: 0, y: 0 },
// Другие игроки (MP)
otherPlayers: new Map(),
// Серверные мобы (MP)
serverMobs: new Map(),
// Мобы
mobs: [],
// Снаряды
projectiles: [],
// Отслеживание изменений мира
placedBlocks: [],
removedBlocks: [],
// Серверные изменения
serverOverrides: new Map(),
// Чат
chatMessages: [],
// Погода — капли
raindrops: [],
// Облака
clouds: Array.from({ length: 10 }, () => ({
x: Math.random() * 2000,
y: -200 - Math.random() * 260,
w: 80 + Math.random() * 120,
s: 12 + Math.random() * 20
})),
// Частицы
parts: [],
// Активный TNT
activeTNT: new Set(),
// Прочность инструментов
toolDurability: new Map(),
// Последние выбранные предметы
recentItems: [],
// Активные печи
activeFurnaces: new Map(),
// Сгенерированные колонны
generated: new Set(),
// Изображение героя
heroImg: null
};
// === core/canvas.js ===
// ==================== ХОЛСТ И РАЗМЕРЫ ====================
const gameEl = document.getElementById('game');
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
// offscreen light map (не вставляем в DOM)
const lightC = document.createElement('canvas');
const lightCtx = lightC.getContext('2d');
const dpr = Math.max(1, window.devicePixelRatio || 1);
let W = 0, H = 0;
function resize() {
W = gameEl.clientWidth;
H = gameEl.clientHeight;
canvas.width = W * dpr;
canvas.height = H * dpr;
lightC.width = W * dpr;
lightC.height = H * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function setW(v) { W = v; }
function setH(v) { H = v; }
// === data/blocks.js ===
const BLOCKS = {
air: { n:'Воздух', solid:false },
grass: { n:'Трава', c:'#7cfc00', solid:true },
dirt: { n:'Грязь', c:'#8b4513', solid:true },
stone: { n:'Камень', c:'#7f8c8d', solid:true },
sand: { n:'Песок', c:'#f4d06f', solid:true },
gravel: { n:'Гравий', c:'#95a5a6', solid:true },
clay: { n:'Глина', c:'#74b9ff', solid:true },
wood: { n:'Дерево', c:'#d35400', solid:true },
planks: { n:'Доски', c:'#e67e22', solid:true },
ladder: { n:'Лестница',c:'#d35400', solid:false, climbable:true },
leaves: { n:'Листва', c:'#2ecc71', solid:true },
glass: { n:'Стекло', c:'rgba(200,240,255,0.25)', solid:true, alpha:0.55 },
water: { n:'Вода', c:'rgba(52,152,219,0.55)', solid:false, fluid:true },
coal: { n:'Уголь', c:'#2c3e50', solid:true },
copper_ore:{ n:'Медь', c:'#e17055', solid:true },
iron_ore: { n:'Железо', c:'#dcdde1', solid:true },
iron_armor:{ n:'Железная броня', c:'#95a5a6', solid:false, armor:0.5 },
gold_ore: { n:'Золото', c:'#f1c40f', solid:true },
diamond_ore:{n:'Алмаз', c:'#00a8ff', solid:true },
brick: { n:'Кирпич', c:'#c0392b', solid:true },
tnt: { n:'TNT', c:'#e74c3c', solid:true, explosive:true },
campfire: { n:'Костёр', c:'#e67e22', solid:true, lightRadius:190 },
torch: { n:'Факел', c:'#f9ca24', solid:true, lightRadius:140 },
bedrock: { n:'Бедрок', c:'#2d3436', solid:true, unbreakable:true },
flower: { n:'Цветок', c:'#ff4757', solid:false, decor:true },
bed: { n:'Кровать', c:'#e91e63', solid:true, bed:true },
boat: { n:'Лодка', c:'#8B4513', solid:false },
furnace: { n:'Печь', c:'#696969', solid:true, smelting:true }
};
// === world/world-storage.js ===
// Мир-хранилище
const grid = new Map(); // key "gx,gy" => {gx,gy,t, ...}
const blocks = []; // для рендера/перебора видимых
function k(gx, gy) { return gx + ',' + gy; }
function getBlock(gx, gy) { return grid.get(k(gx, gy)); }
function hasBlock(gx, gy) { return grid.has(k(gx, gy)); }
function isSolid(gx, gy) {
const b = getBlock(gx, gy);
if (!b || b.dead) return false;
const def = BLOCKS[b.t];
return !!def.solid && !def.fluid && !def.decor;
}
function setBlock(gx, gy, t, isPlayerPlaced = false) {
const key = k(gx, gy);
if (grid.has(key)) return false;
const b = { gx, gy, t, dead: false, active: false, fuse: 0 };
grid.set(key, b);
blocks.push(b);
// Отслеживаем блоки, установленные игроком
if (isPlayerPlaced) {
state.placedBlocks.push({ gx, gy, t });
}
return true;
}
function removeBlock(gx, gy) {
const key = k(gx, gy);
const b = grid.get(key);
if (!b) return null;
if (BLOCKS[b.t].unbreakable) return null;
grid.delete(key);
b.dead = true;
// Отслеживаем удалённые блоки
const wasPlayerPlaced = state.placedBlocks.some(pb => pb.gx === gx && pb.gy === gy);
if (wasPlayerPlaced) {
// Удаляем из placedBlocks
state.placedBlocks = state.placedBlocks.filter(pb => !(pb.gx === gx && pb.gy === gy));
} else {
// Это природный блок - добавляем в removedBlocks
state.removedBlocks.push({ gx, gy });
}
return b;
}
// === world/generation.js ===
// Генерация (по X, на всю глубину до bedrock)
const generated = state.generated; // Set of gx already generated
function surfaceGyAt(gx) {
// базовая поверхность выше уровня воды с вариациями + "горы"
// Используем seed для детерминированной генерации
// Увеличили амплитуду и добавили больше частот для разнообразия
const n1 = Math.sin(gx * 0.025 + state.worldSeed * 0.001) * 8; // крупные горы
const n2 = Math.sin(gx * 0.012 + state.worldSeed * 0.002) * 12; // средние горы
const n3 = Math.sin(gx * 0.006 + state.worldSeed * 0.003) * 6; // мелкие холмы
const n4 = Math.sin(gx * 0.045 + state.worldSeed * 0.004) * 4; // детали
const n5 = Math.cos(gx * 0.018 + state.worldSeed * 0.005) * 5; // дополнительные вариации
const h = Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5); // чем меньше gy - тем выше
return h;
}
function genColumn(gx) {
if (generated.has(gx)) return;
generated.add(gx);
const sgy = surfaceGyAt(gx);
// вода (если поверхность ниже уровня моря => sgy > SEA_GY)
if (sgy > SEA_GY) {
for (let gy = SEA_GY; gy < sgy; gy++) {
setBlock(gx, gy, 'water');
}
// пляж
setBlock(gx, sgy, 'sand');
} else {
// верхний блок: снег на высоких точках
if (sgy < SEA_GY - 10) setBlock(gx, sgy, 'stone');
else setBlock(gx, sgy, 'grass');
}
// подповерхностные слои
for (let gy = sgy + 1; gy <= BEDROCK_GY; gy++) {
if (gy === BEDROCK_GY) {
setBlock(gx, gy, 'bedrock');
continue;
}
let t = 'stone';
// ближе к поверхности
if (gy <= sgy + 3) t = 'dirt';
// биомы/материалы
if (sgy > SEA_GY && gy === sgy + 1 && seededRandom(gx, gy) < 0.25) t = 'clay';
if (gy > sgy + 6 && seededRandom(gx, gy) < 0.07) t = 'gravel';
// руды: чем глубже, тем интереснее
const depth = gy - sgy;
const r = seededRandom(gx, gy);
if (t === 'stone') {
if (r < 0.06) t = 'coal';
else if (r < 0.10) t = 'copper_ore';
else if (r < 0.13) t = 'iron_ore';
else if (depth > 40 && r < 0.145) t = 'gold_ore';
else if (depth > 70 && r < 0.152) t = 'diamond_ore';
}
setBlock(gx, gy, t);
}
// Деревья и цветы (только на траве, и не в воде)
const top = getBlock(gx, sgy);
if (top && top.t === 'grass') {
if (seededRandom(gx, sgy - 1) < 0.10) {
setBlock(gx, sgy - 1, 'flower');
}
if (seededRandom(gx, sgy - 2) < 0.12) {
// простое дерево
setBlock(gx, sgy - 1, 'wood');
setBlock(gx, sgy - 2, 'wood');
setBlock(gx, sgy - 3, 'leaves');
setBlock(gx - 1, sgy - 3, 'leaves');
setBlock(gx + 1, sgy - 3, 'leaves');
}
}
// Применяем серверные оверрайды для этой колонны
const colPrefix = gx + ',';
for (const [key, ov] of state.serverOverrides) {
if (!key.startsWith(colPrefix)) continue;
if (ov.op === 'remove') {
const b = grid.get(key);
if (b) { grid.delete(key); b.dead = true; }
} else if (ov.op === 'set') {
if (!grid.has(key)) {
const gy = parseInt(key.split(',')[1]);
const nb = { gx, gy, t: ov.t, dead: false, active: false, fuse: 0 };
grid.set(key, nb);
blocks.push(nb);
}
}
}
}
// Перегенерация видимых чанков (используется при загрузке сохранения)
function regenerateVisibleChunks() {
const gx0 = Math.floor(state.camX / TILE);
for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
// Принудительно перегенерируем колонну
generated.delete(gx);
genColumn(gx);
}
}
function ensureGenAroundCamera() {
const gx0 = Math.floor(state.camX / TILE);
for (let gx = gx0 - GEN_MARGIN_X; gx <= gx0 + GEN_MARGIN_X; gx++) {
genColumn(gx);
}
}
function seededRandom(gx, gy) {
const n = Math.sin(gx * 12.9898 + gy * 78.233 + state.worldSeed * 0.1) * 43758.5453;
return n - Math.floor(n);
}
// === render/textures.js ===
// Текстуры блоков (простые)
const tex = {};
function makeTex(type) {
const t = BLOCKS[type];
const c = document.createElement('canvas');
c.width = 32; c.height = 32;
const g = c.getContext('2d');
if (type === 'tnt') {
g.fillStyle = '#c0392b'; g.fillRect(0, 0, 32, 32);
g.fillStyle = '#fff'; g.fillRect(0, 12, 32, 8);
g.fillStyle = '#000'; g.font = 'bold 10px sans-serif'; g.fillText('TNT', 6, 20);
return c;
}
if (type === 'campfire') {
g.fillStyle = '#5d4037'; g.fillRect(4, 26, 24, 6);
g.fillStyle = '#3e2723'; g.fillRect(7, 23, 18, 4);
return c;
}
if (type === 'torch') {
g.fillStyle = '#6d4c41'; g.fillRect(14, 10, 4, 18);
g.fillStyle = '#f39c12'; g.fillRect(12, 6, 8, 8);
return c;
}
if (type === 'glass') {
g.fillStyle = 'rgba(200,240,255,0.25)'; g.fillRect(0, 0, 32, 32);
g.strokeStyle = 'rgba(255,255,255,0.65)'; g.strokeRect(2, 2, 28, 28);
g.beginPath(); g.moveTo(5, 27); g.lineTo(27, 5); g.stroke();
return c;
}
if (type === 'water') {
g.fillStyle = t.c; g.fillRect(0, 0, 32, 32);
g.fillStyle = 'rgba(255,255,255,0.08)';
g.fillRect(0, 6, 32, 2);
return c;
}
if (type === 'bed') {
// Основание кровати
g.fillStyle = '#e91e63';
g.fillRect(0, 0, 32, 32);
// Подушка
g.fillStyle = '#f8bbd0';
g.fillRect(2, 2, 14, 14);
// Одеяло
g.fillStyle = '#c2185b';
g.fillRect(16, 4, 14, 24);
// Детали одеяла
g.fillStyle = '#e91e63';
g.fillRect(18, 6, 10, 20);
return c;
}
if (type === 'flower') {
g.fillStyle = '#2ecc71'; g.fillRect(14, 14, 4, 18);
g.fillStyle = t.c; g.beginPath(); g.arc(16, 12, 6, 0, 6.28); g.fill();
return c;
}
if (type === 'boat') {
// Корпус лодки
g.fillStyle = '#8B4513';
g.fillRect(2, 12, 28, 8);
// Борта
g.fillStyle = '#A0522D';
g.fillRect(0, 10, 32, 12);
// Внутренность
g.fillStyle = '#DEB887';
g.fillRect(4, 14, 24, 4);
// Дно
g.fillStyle = '#654321';
g.fillRect(2, 20, 28, 4);
return c;
}
if (type === 'ladder') {
// Боковые стойки лестницы
g.fillStyle = '#8B4513';
g.fillRect(4, 0, 4, 32);
g.fillRect(24, 0, 4, 32);
// Ступени
g.fillStyle = '#A0522D';
g.fillRect(4, 4, 24, 3);
g.fillRect(4, 12, 24, 3);
g.fillRect(4, 20, 24, 3);
g.fillRect(4, 28, 24, 3);
return c;
}
g.fillStyle = t.c || '#000';
g.fillRect(0, 0, 32, 32);
g.fillStyle = 'rgba(0,0,0,0.10)';
for (let i = 0; i < 6; i++) g.fillRect((Math.random() * 28) | 0, (Math.random() * 28) | 0, 4, 4);
if (type.endsWith('_ore') || type === 'coal') {
g.fillStyle = 'rgba(0,0,0,0.35)';
for (let i = 0; i < 4; i++) g.fillRect((Math.random() * 24) | 0, (Math.random() * 24) | 0, 6, 6);
}
return c;
}
function initTextures() {
Object.keys(BLOCKS).forEach(k => tex[k] = makeTex(k));
}
// === audio/sound-engine.js ===
// Звуковой движок
const sounds = {};
function loadSound(id, src) {
const audio = new Audio();
audio.src = src;
audio.volume = 0.3;
sounds[id] = audio;
}
// Загрузка звуков
loadSound('splash', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//splash.mp3');
loadSound('sand1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand1.mp3');
loadSound('snow1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//snow1.mp3');
loadSound('stone1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1.mp3');
loadSound('wood1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1.mp3');
loadSound('cloth1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//cloth1.mp3');
loadSound('fire', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//fire.mp3');
loadSound('hit1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hit1.mp3');
loadSound('attack', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//attack.mp3');
loadSound('hurt_chicken', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//hurt1_chicken.mp3');
loadSound('stone_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//stone1%20(1).mp3');
loadSound('wood_build', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//wood1%20(1).mp3');
loadSound('click', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//click.mp3');
loadSound('explode1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//explode1.mp3');
loadSound('glass1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//glass1.mp3');
loadSound('eat1', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//eat1.mp3');
loadSound('step', 'https://supamg.mkn8n.ru/storage/v1/object/public/sounds//sand2.mp3');
function playSound(id) {
if (sounds[id]) {
sounds[id].currentTime = 0;
sounds[id].play().catch(e => console.error('Sound error:', e));
}
}
// === worms/sim.js ===
// Worms-Grechka: абстрактная симуляция боя. Ноль импортов, ноль DOM.
// Один и тот же файл едет в клиент (хотсит) и в node-сервер (авторитет).
// Сетка: адаптер { isSolid(gx,gy), isFluid(gx,gy), unbreakable(gx,gy),
// remove(gx,gy)->bool, waterYPx, xMinPx, xMaxPx, killYPx }.
const STILE = 40;
const SGRAV = 2200; // как в Гречке
const SMOVE = 320;
const SJUMP = 760;
const WORM_W = 34, WORM_H = 34;
const WEAPONS = {
bazooka: { name: 'Базука', kind: 'aimed', power: true, windK: 1.0, dmg: 45, blast: 2.3, ammo: Infinity },
homing: { name: 'Самонавод', kind: 'target', power: false, windK: 0, dmg: 50, blast: 2.3, ammo: 2 },
sheep: { name: 'Овца', kind: 'sheep', power: false, windK: 0, dmg: 55, blast: 2.6, ammo: 1 },
grenade: { name: 'Граната', kind: 'thrown', power: true, windK: 0.25, dmg: 40, blast: 2.1, fuse: [1, 2, 3, 4, 5], ammo: Infinity },
cluster: { name: 'Кластер', kind: 'thrown', power: true, windK: 0.5, dmg: 26, blast: 1.5, fuse: [2, 3, 4], ammo: 3 },
shotgun: { name: 'Дробовик', kind: 'hitscan', shots: 2, windK: 0, dmg: 6, pellets: 5, ammo: 3 },
uzi: { name: 'Узи', kind: 'spray', windK: 0, dmg: 4, pellets: 12, ammo: 3 },
dynamite: { name: 'Динамит', kind: 'drop', windK: 0, dmg: 70, blast: 3.6, fuse: 5, ammo: 2 },
mine: { name: 'Мина', kind: 'drop', windK: 0, dmg: 45, blast: 2.2, ammo: 3 },
airstrike:{ name: 'Авиаудар', kind: 'target', windK: 0, dmg: 35, blast: 1.7, ammo: 2 },
teleport: { name: 'Телепорт', kind: 'teleport', windK: 0, ammo: 2 },
shovel: { name: 'Лопата', kind: 'dig', windK: 0, ammo: Infinity },
skip: { name: 'Пропуск', kind: 'skip', windK: 0, ammo: Infinity },
};
const TURN_TIME = 30;
const RETREAT_TIME = 3;
const PROJ_GRAV = 950; // снаряды более «плавучие», чем червяки
const WIND_ACCEL = 55; // px/s^2 на единицу ветра
const MAX_WIND = 5;
function mulberry(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
class Sim {
constructor(grid, seed) {
this.g = grid;
this.rand = mulberry(seed >>> 0);
this.worms = [];
this.proj = [];
this.mines = [];
this.dyn = [];
this.strikes = [];
this.events = [];
this.wind = 0;
this._wid = 1; this._pid = 1;
}
ev(e) { this.events.push(e); }
drainEvents() { const e = this.events; this.events = []; return e; }
addWorm(team, name, x, y) {
const w = {
id: this._wid++, team, name, x, y, w: WORM_W, h: WORM_H,
vx: 0, vy: 0, dir: team === 0 ? 1 : -1,
hp: 100, alive: true, grounded: false, moveDir: 0, fallStartY: y,
inWater: false, o2: 100, o2acc: 0,
};
this.worms.push(w);
return w;
}
wormById(id) { return this.worms.find((w) => w.id === id); }
aliveTeams() { const s = new Set(); for (const w of this.worms) if (w.alive) s.add(w.team); return [...s]; }
// --- тайловая AABB-физика в стиле Гречки ---
solidBox(x, y, w, h) {
const g = this.g;
const gx0 = Math.floor(x / STILE), gx1 = Math.floor((x + w - 1) / STILE);
const gy0 = Math.floor(y / STILE), gy1 = Math.floor((y + h - 1) / STILE);
for (let gy = gy0; gy <= gy1; gy++)
for (let gx = gx0; gx <= gx1; gx++)
if (g.isSolid(gx, gy)) return true;
return false;
}
fluidBox(x, y, w, h) {
const g = this.g;
const gx0 = Math.floor(x / STILE), gx1 = Math.floor((x + w - 1) / STILE);
const gy0 = Math.floor(y / STILE), gy1 = Math.floor((y + h - 1) / STILE);
for (let gy = gy0; gy <= gy1; gy++)
for (let gx = gx0; gx <= gx1; gx++)
if (g.isFluid(gx, gy)) return true;
return false;
}
setMove(id, dir) { const w = this.wormById(id); if (w && w.alive) { w.moveDir = dir; if (dir) w.dir = dir; } }
jump(id) {
const w = this.wormById(id);
if (w && w.alive && (w.grounded || w.inWater)) {
w.vy = w.inWater ? -SJUMP * 0.55 : -SJUMP; // в воде — гребок вверх
w.grounded = false;
}
}
hurt(w, dmg, cause) {
if (!w.alive) return;
w.hp -= dmg;
this.ev({ t: 'damage', worm: w.id, dmg: Math.round(dmg), hp: Math.max(0, Math.round(w.hp)) });
if (w.hp <= 0) this.kill(w, cause);
}
kill(w, cause) {
if (!w.alive) return;
w.alive = false; w.hp = 0; w.moveDir = 0;
this.ev({ t: 'die', worm: w.id, team: w.team, cause: cause || 'killed', x: Math.round(w.x), y: Math.round(w.y) });
}
explodePx(x, y, dmg, blastTiles, cause) {
const g = this.g;
const R = blastTiles * STILE;
// разрушение: круг по тайлам (жидкости и неразрушаемое не трогаем)
const removed = [];
const gx0 = Math.floor((x - R) / STILE), gx1 = Math.floor((x + R) / STILE);
const gy0 = Math.floor((y - R) / STILE), gy1 = Math.floor((y + R) / STILE);
for (let gy = gy0; gy <= gy1; gy++)
for (let gx = gx0; gx <= gx1; gx++) {
const cx = gx * STILE + STILE / 2, cy = gy * STILE + STILE / 2;
if (Math.hypot(cx - x, cy - y) > R) continue;
// взрыв на суше не роет ниже уровня моря — в воду ничего не падает
if (y < g.waterYPx && gy * STILE > g.waterYPx) continue;
if (g.isFluid(gx, gy) || g.unbreakable(gx, gy)) continue;
if (g.remove(gx, gy)) removed.push([gx, gy]);
}
this.ev({ t: 'carve', x: Math.round(x), y: Math.round(y), r: Math.round(R), tiles: removed });
this.ev({ t: 'explode', x: Math.round(x), y: Math.round(y), r: Math.round(R), dmg });
// урон + отброс
for (const w of this.worms) {
if (!w.alive) continue;
const dx = (w.x + w.w / 2) - x, dy = (w.y + w.h / 2) - y;
const dist = Math.hypot(dx, dy);
if (dist < R + 60) {
const dd = Math.max(1, Math.round(dmg * (1 - dist / (R + 60))));
this.hurt(w, dd, cause || 'boom');
// отброс с ограничением: серия взрывов не катапультирует через полкарты в воду
w.vx = Math.max(-450, Math.min(450, w.vx + (dx / (dist || 1)) * 620));
w.vy = Math.max(-450, w.vy - 330);
w.grounded = false;
}
}
// цепные реакции
for (const m of this.mines)
if (m.fuse <= 0 && Math.hypot(m.x - x, m.y - y) < R) { m.fuse = 0.25; m.armed = 1; }
for (const d of this.dyn)
if (Math.hypot(d.x - x, d.y - y) < R) d.fuse = Math.min(d.fuse, 0.4);
for (const p of this.proj)
if ((p.kind === 'grenade' || p.kind === 'cluster') && Math.hypot(p.x - x, p.y - y) < R)
p.fuse = Math.min(p.fuse, 0.05);
}
fire(wormId, weaponId, p) {
const w = this.wormById(wormId);
const def = WEAPONS[weaponId];
if (!w || !w.alive || !def) return false;
p = p || {};
const ang = p.angle || 0;
const pow = Math.max(0, Math.min(1, p.power == null ? 0.5 : p.power));
const cx = w.x + w.w / 2, cy = w.y + w.h / 2;
const mx = cx + Math.cos(ang) * 26, my = cy - 6 + Math.sin(ang) * 26;
const P = (kind, x, y, vx, vy, extra) => Object.assign({ id: this._pid++, kind, x, y, vx, vy, age: 0 }, extra || {});
switch (def.kind) {
case 'aimed': {
const sp = 500 + pow * 1000;
this.proj.push(P(weaponId === 'homing' ? 'homing' : 'missile', mx, my, Math.cos(ang) * sp, Math.sin(ang) * sp,
{ dmg: def.dmg, blast: def.blast, windK: def.windK, owner: w.id, team: w.team, life: weaponId === 'homing' ? 5 : 0 }));
break;
}
case 'sheep': {
// овцу выпускаем у ног — бежит сама, прыгает через уступы, разворачивается у стен
this.proj.push(P('sheep', cx + w.dir * 24, cy + 10, w.dir * 60, -60,
{ dmg: def.dmg, blast: def.blast, fuse: 4, owner: w.id, team: w.team, dir: w.dir }));
break;
}
case 'thrown': {
const sp = 300 + pow * 800;
const fuse = p.fuse || (def.fuse ? def.fuse[2] : 3);
this.proj.push(P(weaponId === 'cluster' ? 'cluster' : 'grenade', cx, cy - 20,
w.dir * 60 + Math.cos(ang) * sp * 0.7, Math.sin(ang) * sp - 140,
{ dmg: def.dmg, blast: def.blast, windK: def.windK, fuse, owner: w.id, team: w.team }));
break;
}
case 'hitscan': {
for (let i = 0; i < def.pellets; i++) {
const a = ang + (this.rand() - 0.5) * 0.14;
this.proj.push(P('pellet', mx, my, Math.cos(a) * 1500, Math.sin(a) * 1500,
{ dmg: def.dmg, life: 1.6, owner: w.id, team: w.team }));
}
break;
}
case 'spray': {
for (let i = 0; i < def.pellets; i++) {
const a = ang + (this.rand() - 0.5) * 0.26;
const sp = 1250 + this.rand() * 250;
this.proj.push(P('pellet', mx, my, Math.cos(a) * sp, Math.sin(a) * sp,
{ dmg: def.dmg, life: 1.4, owner: w.id, team: w.team }));
}
break;
}
case 'drop': {
if (weaponId === 'dynamite') this.dyn.push({ x: cx + w.dir * 30, y: cy, fuse: def.fuse });
else this.mines.push({ x: cx + w.dir * 30, y: cy, team: w.team, armed: 0, fuse: 0 });
break;
}
case 'target': {
const tx = Math.max(this.g.xMinPx + 30, Math.min(this.g.xMaxPx - 30, p.x || (cx + w.dir * 200)));
if (weaponId === 'homing') {
// самонавод: ракета вылетает и летит в отмеченную крестиком точку.
// стартуем из первой свободной клетки вдоль прицела — иначе нос в стене = взрыв в упор
const tgtY = p.y == null ? cy : p.y;
const a0 = Math.atan2(tgtY - my, tx - mx);
let hx = mx, hy = my;
for (let d = 8; d <= 80; d += 8) {
const qx = mx + Math.cos(a0) * d, qy = my + Math.sin(a0) * d;
if (!this.pointSolid(qx, qy)) { hx = qx; hy = qy; break; }
}
this.proj.push(P('homing', hx, hy, Math.cos(a0) * 950, Math.sin(a0) * 950,
{ dmg: def.dmg, blast: def.blast, windK: 0, owner: w.id, team: w.team, life: 6, tx, ty: tgtY }));
} else {
this.strikes.push({ x: tx, t: 0.8, left: 5, next: 0, dir: this.rand() < 0.5 ? -1 : 1, px: 0, py: 0, started: false, team: w.team });
}
this.ev({ t: 'strike_incoming', x: Math.round(tx) });
break;
}
case 'teleport': {
const tx = Math.max(this.g.xMinPx + 20, Math.min(this.g.xMaxPx - 20, p.x || cx));
// ищем землю под точкой
const gy = this.g.groundBelowPx(tx, p.y == null ? 0 : p.y);
if (gy !== null) {
w.x = tx - w.w / 2; w.y = gy - w.h - 1; w.vx = 0; w.vy = 0;
this.ev({ t: 'teleport', worm: w.id, x: Math.round(w.x), y: Math.round(w.y) });
}
break;
}
case 'dig': {
// лопата: рейкаст вдоль прицела — копаем первую твёрдую землю (вперёд/назад/вниз)
let hx = null, hy = null;
const dx = Math.cos(ang), dy = Math.sin(ang);
for (let d = 8; d <= 95 && hx == null; d += 8) {
const gx = Math.floor((cx + dx * d) / STILE), gy = Math.floor((cy + dy * d) / STILE);
if (this.g.unbreakable(gx, gy) || this.g.isFluid(gx, gy)) continue;
if (this.g.isSolid ? this.g.isSolid(gx, gy) : true) { hx = gx; hy = gy; }
}
// не зацепили — опускаемся от точки прицела вниз до земли
let px = cx + dx * 52, py = cy + dy * 52;
if (hx == null) {
for (let dd = 0; dd <= 120; dd += 10) {
const gx = Math.floor(px / STILE), gy = Math.floor((py + dd) / STILE);
if (this.g.unbreakable(gx, gy) || this.g.isFluid(gx, gy)) continue;
if (this.g.isSolid ? this.g.isSolid(gx, gy) : true) { px = gx * STILE + STILE / 2; py = gy * STILE + STILE / 2; hx = gx; hy = gy; break; }
}
if (hx == null) return false; // рядом нет земли — пустой взмах
} else { px = hx * STILE + STILE / 2; py = hy * STILE + STILE / 2; }
const R = STILE * 1.6; // тоннели просторные — внутри можно ходить и прыгать
let dug = 0;
const gx0 = Math.floor((px - R) / STILE), gx1 = Math.floor((px + R) / STILE);
const gy0 = Math.floor((py - R) / STILE), gy1 = Math.floor((py + R) / STILE);
for (let gy = gy0; gy <= gy1; gy++)
for (let gx = gx0; gx <= gx1; gx++) {
const bx = gx * STILE + STILE / 2, by = gy * STILE + STILE / 2;
if (Math.hypot(bx - px, by - py) > R) continue;
if (this.g.isFluid(gx, gy) || this.g.unbreakable(gx, gy)) continue;
if (this.g.remove(gx, gy)) dug++;
}
if (dug > 0) this.ev({ t: 'dig', x: Math.round(px), y: Math.round(py) });
return dug > 0;
}
case 'skip': break;
}
w.moveDir = 0;
this.ev({ t: 'fired', worm: w.id, weapon: weaponId });
return true;
}
step(dt) {
const g = this.g;
// --- червяки ---
for (const w of this.worms) {
if (!w.alive) continue;
const cx = w.x + w.w / 2, cy = w.y + w.h / 2;
// вода: можно плыть (как в Гречке), смерть только от нехватки кислорода
const wasWater = w.inWater;
w.inWater = this.fluidBox(w.x + 4, w.y + 4, w.w - 8, w.h - 8);
if (w.inWater && !wasWater) this.ev({ t: 'splash', x: Math.round(cx) });
const headWet = this.fluidBox(w.x + 8, w.y, w.w - 16, 14);
if (headWet) {
w.o2 = Math.max(0, w.o2 - 14 * dt);
if (w.o2 <= 0) {
w.o2acc += 12 * dt;
if (w.o2acc >= 5) { w.o2acc -= 5; this.hurt(w, 5, 'drown'); }
}
} else w.o2 = Math.min(100, w.o2 + 50 * dt);
if (w.y > g.killYPx) { this.kill(w, 'out'); continue; }
// горизонталь (в воде медленнее)
const spd = w.inWater ? SMOVE * 0.5 : SMOVE;
w.vx = w.moveDir * spd;
if (w.vx !== 0) {
let nx = w.x + w.vx * dt;
nx = Math.max(g.xMinPx, Math.min(g.xMaxPx - w.w, nx));
if (!this.solidBox(nx, w.y + 2, w.w, w.h - 2)) w.x = nx;
else {
// подъём по склону до 1 тайла
let stepped = false;
for (let k = 8; k <= STILE; k += 8) {
if (!this.solidBox(nx, w.y - k, w.w, w.h)) { w.x = nx; w.y -= k; stepped = true; break; }
}
if (!stepped) w.vx = 0;
}
}
// вертикаль (в воде — плавучесть как в Гречке)
w.vy += (w.inWater ? 550 : SGRAV) * dt;
if (w.inWater && w.vy > 260) w.vy = 260;
if (w.vy > 1600) w.vy = 1600;
let ny = w.y + w.vy * dt;
if (w.vy >= 0) {
// опора под ногами
if (this.solidBox(w.x + 2, ny + w.h - 6, w.w - 4, 8)) {
const gy = Math.floor((ny + w.h) / STILE);
w.y = gy * STILE - w.h;
const fallTiles = (w.y - w.fallStartY) / STILE;
if (fallTiles > 6 && !this.fluidBox(w.x, w.y, w.w, w.h)) { console.log('[DBG] fall worm=' + w.id + ' tiles=' + fallTiles.toFixed(1)); this.hurt(w, (fallTiles - 6) * 10, 'fall'); }
w.vy = 0; w.grounded = true; w.fallStartY = w.y;
} else { w.y = ny; w.grounded = false; }
} else {
if (this.solidBox(w.x + 2, ny, w.w - 4, 8)) { w.vy = 0; }
else { w.y = ny; w.grounded = false; }
}
if (!w.grounded && this.solidBox(w.x + 2, w.y + w.h, w.w - 4, 3)) {
w.grounded = true; w.vy = 0; w.fallStartY = w.y;
}
}
// --- мины ---
for (const m of this.mines) {
if (m.armed < 1 && m.fuse <= 0) m.armed = Math.min(1, m.armed + dt / 2);
else if (m.fuse > 0) {
m.fuse -= dt;
if (m.fuse <= 0) { this.explodePx(m.x, m.y, WEAPONS.mine.dmg, WEAPONS.mine.blast, 'mine'); m.dead = true; }
} else {
for (const w of this.worms) {
if (w.alive && w.team !== m.team &&
Math.hypot(w.x + w.w / 2 - m.x, w.y + w.h / 2 - m.y) < 46) {
m.fuse = 0.8; this.ev({ t: 'mine_beep', x: Math.round(m.x), y: Math.round(m.y) }); break;
}
}
}
}
this.mines = this.mines.filter((m) => !m.dead);
// --- динамит ---
for (const d of this.dyn) {
d.fuse -= dt;
if (d.fuse <= 0) { this.explodePx(d.x, d.y, WEAPONS.dynamite.dmg, WEAPONS.dynamite.blast, 'dynamite'); d.dead = true; }
}
this.dyn = this.dyn.filter((d) => !d.dead);
// --- авиаудары: пиксельный самолёт пролетает и сбрасывает бомбы ---
for (const s of this.strikes) {
if (!s.started) {
s.t -= dt;
if (s.t <= 0) {
s.started = true;
s.px = s.dir > 0 ? this.g.xMinPx - 300 : this.g.xMaxPx + 300;
s.py = -170; // выше самых высоких деревьев на пиках (пик 0 + дерево 80 + запас)
}
continue;
}
s.px += s.dir * 560 * dt;
const inZone = Math.abs(s.px - s.x) < 260;
const passed = s.dir > 0 ? s.px > s.x + 200 : s.px < s.x - 200;
if (s.left > 0 && (inZone || passed)) {
s.next -= dt;
if (s.next <= 0) {
s.next = 0.16; s.left--;
this.proj.push({ id: this._pid++, kind: 'striker', x: s.px, y: s.py + 10, vx: s.dir * 120, vy: 200, age: 0, dmg: WEAPONS.airstrike.dmg, blast: WEAPONS.airstrike.blast, windK: 0, team: s.team });
}
}
if (s.left <= 0 && Math.abs(s.px - s.x) > 600) s.dead = true;
}
this.strikes = this.strikes.filter((s) => !s.dead);
// --- снаряды ---
for (const p of this.proj) {
p.age += dt;
if (p.kind === 'pellet') {
p.life -= dt;
// субстепы ≤10px: иначе быстрая пуля пролетает червяка насквозь
const dist = Math.hypot(p.vx * dt, p.vy * dt);
const n = Math.max(1, Math.ceil(dist / 10));
for (let k = 0; k < n && !p.dead; k++) {
if (p.life <= 0 || this.advance(p, dt / n)) { p.dead = true; break; }
for (const w of this.worms) {
if (!w.alive || (w.id === p.owner && p.age < 0.05)) continue;
if (p.x > w.x && p.x < w.x + w.w && p.y > w.y && p.y < w.y + w.h) {
this.hurt(w, p.dmg, 'shot');
w.vx = 0; w.vy = -120; w.grounded = false;
p.dead = true; break;
}
}
}
if (p.dead) continue;
} else if (p.kind === 'striker') {
p.vy += PROJ_GRAV * 0.5 * dt;
if (this.advance(p, dt)) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'air'); p.dead = true; }
} else if (p.kind === 'missile') {
p.vx += this.wind * WIND_ACCEL * (p.windK || 0) * dt;
p.vy += PROJ_GRAV * dt;
if (this.advance(p, dt)) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'bazooka'); p.dead = true; continue; }
// вода — только настоящая (клетка), а не «всё ниже уровня моря»: сухие катакомбы под морем — не вода
if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) {
// упала в воду — взрыв на поверхности, а не тихое бульк
this.ev({ t: 'splash', x: Math.round(p.x) });
this.explodePx(p.x, p.y, p.dmg, p.blast, 'bazooka');
p.dead = true; continue;
}
for (const w of this.worms) {
if (!w.alive || (w.id === p.owner && p.age < 0.1)) continue;
if (Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y) < 26) {
this.explodePx(p.x, p.y, p.dmg, p.blast, 'bazooka'); p.dead = true; break;
}
}
} else if (p.kind === 'homing') {
// самонаводящаяся ракета: летит в отмеченную крестиком точку (или доворачивает к врагу рядом с ней)
p.life -= dt;
let tgt = null, bd = 1e9;
for (const w of this.worms) {
if (!w.alive || w.team === p.team) continue;
const d = Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y);
if (d < bd) { bd = d; tgt = w; }
}
// враг далеко от точки — летим строго в точку; рядом с точкой — доворачиваем к нему
const tdx = (p.tx == null ? p.x : p.tx), tdy = (p.ty == null ? p.y : p.ty);
let gx = tdx, gy = tdy;
if (tgt && Math.hypot(tgt.x - tdx, tgt.y - tdy) < 120) { gx = tgt.x + tgt.w / 2; gy = tgt.y + tgt.h / 2; }
const SPD = 950;
{
const cur = Math.atan2(p.vy, p.vx);
const want = Math.atan2(gy - p.y, gx - p.x);
let d = want - cur;
while (d > Math.PI) d -= Math.PI * 2;
while (d < -Math.PI) d += Math.PI * 2;
const turn = 3.4 * dt, na = cur + Math.max(-turn, Math.min(turn, d));
p.vx = Math.cos(na) * SPD; p.vy = Math.sin(na) * SPD;
}
if (Math.hypot(gx - p.x, gy - p.y) < 30) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; continue; }
if (this.advance(p, dt)) {
if (p.age < 0.12) continue; // только вылетела — нос ещё выходит из-за укрытия, не взрываем
this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; continue;
}
if (p.life <= 0) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; continue; }
if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) {
this.ev({ t: 'splash', x: Math.round(p.x) });
this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing');
p.dead = true; continue;
}
for (const w of this.worms) {
if (!w.alive || (w.id === p.owner && p.age < 0.1)) continue;
if (Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y) < 26) {
this.explodePx(p.x, p.y, p.dmg, p.blast, 'homing'); p.dead = true; break;
}
}
} else if (p.kind === 'sheep') {
// супер-овца: бежит по земле, вскарабкивается на уступы, у стен разворачивается, у врага — бум
p.fuse -= dt;
p.vy = Math.min(900, p.vy + SGRAV * dt);
const dir = p.dir || 1, nx = p.x + dir * 150 * dt;
if (!this.pointSolid(nx, p.y)) p.x = nx;
else if (!this.pointSolid(nx, p.y - 34)) { p.x = nx; p.y -= 34; p.vy = Math.min(0, p.vy); }
else p.dir = -dir;
p.y += p.vy * dt;
if (this.pointSolid(p.x, p.y + 10)) {
let n = 0;
while (n++ < 20 && this.pointSolid(p.x, p.y + 8)) p.y -= 2;
p.vy = 0;
}
if (p.y > this.g.killYPx) { p.dead = true; continue; }
if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) {
this.ev({ t: 'splash', x: Math.round(p.x) });
this.explodePx(p.x, p.y, p.dmg, p.blast, 'sheep');
p.dead = true; continue;
}
let hit = p.fuse <= 0;
if (!hit) for (const w of this.worms) {
if (!w.alive || w.team === p.team) continue; // своих (включая хозяина) не задевает — только враги
if (Math.hypot(w.x + w.w / 2 - p.x, w.y + w.h / 2 - p.y) < 30) { hit = true; break; }
}
if (hit) { this.explodePx(p.x, p.y, p.dmg, p.blast, 'sheep'); p.dead = true; }
} else { // grenade / cluster
p.vx += this.wind * WIND_ACCEL * (p.windK || 0) * dt;
p.vy += PROJ_GRAV * dt;
this.bounce(p, dt);
p.fuse -= dt;
// вода — только настоящая клетка: в сухих катакомбах под морем граната летит дальше
if (this.g.isFluid(Math.floor(p.x / STILE), Math.floor(p.y / STILE))) {
// граната/кластер в воде — взрыв на месте + осколки, а не тихое бульк
this.ev({ t: 'splash', x: Math.round(p.x) });
const cy = p.y;
if (p.kind === 'cluster') {
this.explodePx(p.x, cy, 10, 1.1, 'cluster');
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2 + this.rand() * 0.6;
this.proj.push({ id: this._pid++, kind: 'grenade', x: p.x, y: cy - 4, vx: Math.cos(a) * (160 + this.rand() * 180), vy: Math.sin(a) * 240 - 180, age: 0, dmg: p.dmg, blast: WEAPONS.cluster.blast, windK: 0.3, fuse: 1.0, owner: p.owner });
}
} else this.explodePx(p.x, cy, p.dmg, p.blast, 'grenade');
p.dead = true; continue;
}
if (p.fuse <= 0) {
if (p.kind === 'cluster') {
this.explodePx(p.x, p.y, 10, 1.1, 'cluster');
for (let i = 0; i < 5; i++) {
const a = (i / 5) * Math.PI * 2 + this.rand() * 0.6;
this.proj.push({ id: this._pid++, kind: 'grenade', x: p.x, y: p.y - 4, vx: Math.cos(a) * (160 + this.rand() * 180), vy: Math.sin(a) * 240 - 180, age: 0, dmg: p.dmg, blast: WEAPONS.cluster.blast, windK: 0.3, fuse: 1.0, owner: p.owner });
}
} else this.explodePx(p.x, p.y, p.dmg, p.blast, 'grenade');
p.dead = true;
}
}
}
this.proj = this.proj.filter((p) => !p.dead);
}
pointSolid(x, y) {
return this.g.isSolid(Math.floor(x / STILE), Math.floor(y / STILE));
}
advance(p, dt) {
// Возвращает true при ударе о землю (dead НЕ ставит — решает вызывающий:
// пуля гаснет, базука взрывается).
const dist = Math.hypot(p.vx * dt, p.vy * dt);
const n = Math.max(1, Math.ceil(dist / 6));
for (let i = 0; i < n; i++) {
p.x += (p.vx * dt) / n; p.y += (p.vy * dt) / n;
if (this.pointSolid(p.x, p.y)) { p.hitX = p.x; p.hitY = p.y; return true; }
if (p.x < this.g.xMinPx + 2 || p.x > this.g.xMaxPx - 2) { p.vx = -p.vx; p.x = Math.max(this.g.xMinPx + 2, Math.min(this.g.xMaxPx - 2, p.x)); }
}
return false;
}
bounce(p, dt) {
const dist = Math.hypot(p.vx * dt, p.vy * dt);
const n = Math.max(1, Math.ceil(dist / 5));
let hitX = false, hitY = false;
for (let i = 0; i < n; i++) {
const sx = (p.vx * dt) / n, sy = (p.vy * dt) / n;
if (!this.pointSolid(p.x + sx, p.y)) p.x += sx; else hitX = true;
if (!this.pointSolid(p.x, p.y + sy)) p.y += sy; else hitY = true;
}
// отклик — один раз за шаг, а не за каждый подшаг: иначе касание склона
// на скорости гасит скорость в ноль и граната падает сильно ближе прицела
if (hitX) p.vx = -p.vx * 0.45;
if (hitY) { p.vy = -p.vy * 0.42; p.vx *= 0.82; if (Math.abs(p.vy) < 70) p.vy = 0; }
if (p.vy === 0 && Math.abs(p.vx) < 30) p.vx = 0;
if (p.y < 0) { p.y = 0; p.vy = Math.abs(p.vy) * 0.5; }
}
snapshot() {
return {
worms: this.worms.map((w) => ({ id: w.id, team: w.team, name: w.name, x: Math.round(w.x * 10) / 10, y: Math.round(w.y * 10) / 10, dir: w.dir, hp: Math.round(w.hp), alive: w.alive, swim: w.inWater, o2: Math.round(w.o2) })),
proj: this.proj.map((p) => ({ id: p.id, k: p.kind, x: Math.round(p.x), y: Math.round(p.y), vx: Math.round(p.vx || 0), vy: Math.round(p.vy || 0), t: p.team, o: p.owner })),
mines: this.mines.map((m) => ({ x: Math.round(m.x), y: Math.round(m.y), armed: m.armed >= 1, beep: m.fuse > 0 })),
dyn: this.dyn.map((d) => ({ x: Math.round(d.x), y: Math.round(d.y) })),
strikes: this.strikes.length,
planes: this.strikes.filter((s) => s.started && !s.dead).map((s) => ({ x: Math.round(s.px), y: Math.round(s.py), d: s.dir, t: s.team })),
wind: this.wind,
};
}
}
// Перебор углов/сил через превью: куда упадёт снаряд ближе к цели.
// Чистая функция — используется и ИИ в клиенте, и тестами.
function solveShot(grid, mx, my, tx, ty, thrown, wind, dir) {
let best = { ang: -1.1, pow: 0.6, err: 1e12 };
const angs = thrown ? [-2.4, -2.0, -1.6, -1.2, -0.8] : [-2.5, -2.1, -1.7, -1.3, -0.9, -0.5];
const pows = [0.3, 0.5, 0.7, 0.9, 1.0];
for (const a of angs) {
for (const p of pows) {
const sx = mx + Math.cos(a) * 26, sy = my + Math.sin(a) * 26;
const pts = previewShot(grid, sx, sy, a, p,
{ thrown, wind, windK: thrown ? 0.25 : 1.0, fuse: 4, dir: dir || 1 });
if (!pts.length) continue;
const land = pts[pts.length - 1];
const err = Math.hypot(land[0] - tx, land[1] - ty) + (thrown ? 0 : Math.abs(a + 1.2) * 30);
if (err < best.err) best = { ang: a, pow: p, err };
}
}
return best;
}
// Превью траектории для прицела (только aimed/thrown, без взрывов по пути).
function previewStraight(grid, sx, sy, angle, maxLen) {
// прямой прицел дроби/УЗИ: луч без гравитации до земли или лимита
const pts = [];
const dx = Math.cos(angle), dy = Math.sin(angle);
for (let d = 24; d <= (maxLen || 520); d += 24) {
const x = sx + dx * d, y = sy + dy * d;
pts.push([Math.round(x), Math.round(y)]);
if (grid.isSolid(Math.floor(x / STILE), Math.floor(y / STILE))) break;
}
return pts;
}
function previewShot(grid, sx, sy, angle, power, opts) {
opts = opts || {};
const pts = [];
let x = sx, y = sy;
let vx, vy;
if (opts.thrown) {
const sp = 300 + power * 800;
vx = (opts.dir || 1) * 60 + Math.cos(angle) * sp * 0.7;
vy = Math.sin(angle) * sp - 140;
} else {
vx = Math.cos(angle) * (500 + power * 1000);
vy = Math.sin(angle) * (500 + power * 1000);
}
const dt = 1 / 60;
const windAcc = (opts.wind || 0) * WIND_ACCEL * (opts.windK || 0);
const maxT = opts.fuse || 4;
const solid = (x, y) => grid.isSolid(Math.floor(x / STILE), Math.floor(y / STILE));
const fluid = (x, y) => grid.isFluid(Math.floor(x / STILE), Math.floor(y / STILE));
for (let t = 0; t < maxT; t += dt) {
vx += windAcc * dt; vy += PROJ_GRAV * dt;
// подшаги как в bounce/advance сима: иначе на скорости превью проваливается
// в склон и рисует точку падения дальше, чем долетит граната
const dist = Math.hypot(vx * dt, vy * dt);
const n = Math.max(1, Math.ceil(dist / 5));
let hit = false;
for (let i = 0; i < n; i++) {
x += (vx * dt) / n; y += (vy * dt) / n;
if (solid(x, y) || fluid(x, y)) { hit = true; break; }
}
const last = pts[pts.length - 1];
if (!last || Math.abs(x - last[0]) + Math.abs(y - last[1]) > 16) pts.push([Math.round(x), Math.round(y)]);
if (hit || pts.length > 90) break;
}
return pts;
}
// API для node (require) и для бандлера (общая область IIFE — без export).
const __api = { STILE, SGRAV, SMOVE, SJUMP, WORM_W, WORM_H, WEAPONS, TURN_TIME, RETREAT_TIME, MAX_WIND, Sim, previewShot, solveShot };
if (typeof module !== 'undefined' && module.exports) module.exports = __api;
// === worms/worms-main.js ===
// Worms-Grechka: хотсит-клиент (2 игрока на одном экране).
// Мир, земля, вода, спрайт — настоящая Гречка. Бой — пошаговый, как Worms.
const WEAPON_ORDER = ['bazooka', 'homing', 'grenade', 'cluster', 'sheep', 'shotgun', 'uzi', 'dynamite', 'mine', 'shovel', 'airstrike', 'teleport', 'skip'];
const TEAM_COLORS = ['#e74c3c', '#3498db'];
const TEAM_NAMES = ['Красные', 'Синие'];
const WORMS_PER_TEAM = 5;
const ARENA_TILES = 100;
const GAP_HALF = 3; // полуширина пропасти с водой (в тайлах)
const HILL_RUN = 18; // длина склона горы в тайлах (узкие пики — над бойцами открытое небо)
const HILL_TOP = 14; // вершина горы: SEA_GY - 14
// ---------- состояние боя ----------
const F = {
sim: null, grid: null,
seed: (Math.random() * 1000000) | 0,
mode: 'ai', // 'ai' | 'hotseat'
ax0: 0, arenaW: ARENA_TILES,
xMinPx: 0, xMaxPx: 0,
order: [], turnPos: 0, lastTeam: 1,
activeWorm: null, teamTurn: 0,
phase: 'move', timeLeft: TURN_TIME, retreatT: 0, shotsLeft: 0,
weapon: 'bazooka', fuseIdx: 2,
ammo: [{}, {}],
aim: -0.7, power: 0, charging: false, chargeT: 0,
camX: 0, camY: 0, zoom: 1,
zoomManual: false, panT: 0, // panT обязан быть числом: undefined глушит ветку камеры (undefined <= 0 === false)
boomX: 0, boomY: 0, boomT: 0, // память о взрыве: камера держится 2 с
night: false, torches: [],
particles: [], floaters: [],
over: false, winner: -1,
keys: {},
stickId: null, stickOX: 0, stickOY: 0,
pinchD: 0,
};
function newAmmo() {
const a = {};
for (const id of WEAPON_ORDER) a[id] = WEAPONS[id].ammo;
return a;
}
// Целевая поверхность арены: две горы с пропастью посередине.
function arenaSurfaceGy(gx) {
const gapC = F.ax0 + F.arenaW / 2;
const d = Math.abs(gx - gapC);
if (d < GAP_HALF) return SEA_GY + 3; // мелкое дно пропасти — под водой
const t = Math.min(1, (d - GAP_HALF) / HILL_RUN);
const peak = SEA_GY - HILL_TOP, floor = SEA_GY + 3;
return Math.round(floor + (peak - floor) * Math.sin(t * Math.PI / 2));
}
function sculptArena(flats) {
// th-карта + выравнивание площадок под спавн
const thMap = {};
for (let gx = F.ax0; gx <= F.ax0 + F.arenaW; gx++) thMap[gx] = arenaSurfaceGy(gx);
for (const f of flats || []) {
for (let dx = -2; dx <= 2; dx++) {
const gx = f.gx + dx;
if (thMap[gx] != null && Math.abs(thMap[gx] - f.gy) <= 4) thMap[gx] = f.gy;
}
}
for (let gx = F.ax0; gx <= F.ax0 + F.arenaW; gx++) {
const th = thMap[gx];
for (let gy = 0; gy < th; gy++) removeBlock(gx, gy);
const cap = th < SEA_GY - 10 ? 'stone' : 'grass';
if (!getBlock(gx, th)) setBlock(gx, th, cap);
for (let gy = th + 1; gy < BEDROCK_GY; gy++) {
if (!getBlock(gx, gy)) setBlock(gx, gy, gy <= th + 3 ? 'dirt' : 'stone');
}
if (th > SEA_GY) {
for (let gy = SEA_GY - 2; gy <= th; gy++) {
const b = getBlock(gx, gy);
if (b && BLOCKS[b.t].fluid) continue;
if (b) removeBlock(gx, gy);
setBlock(gx, gy, 'water');
}
}
}
}
function setupArena() {
state.worldSeed = F.seed;
state.placedBlocks = [];
state.removedBlocks = [];
grid.clear(); blocks.length = 0;
state.generated.clear();
F.ax0 = 5000 + (F.seed % 1000);
F.xMinPx = F.ax0 * TILE;
F.xMaxPx = (F.ax0 + F.arenaW) * TILE;
for (let gx = F.ax0 - 4; gx <= F.ax0 + F.arenaW + 4; gx++) genColumn(gx);
// --- арена «две горы + пропасть»: лепим рельеф поверх генерации ---
// точки спавна считаем ДО лепки, площадки выравниваем в sculpt
const gapC = F.ax0 + F.arenaW / 2;
const peakSpots = (gx0, gx1) => {
const out = [];
for (let gx = gx0; gx <= gx1 && out.length < 10; gx += 2) {
const sgy = arenaSurfaceGy(gx);
if (sgy < SEA_GY) out.push({ gx, x: gx * TILE + TILE / 2, y: sgy * TILE - 80 });
}
return out;
};
const left = peakSpots(gapC - 28, gapC - 12);
const right = peakSpots(gapC + 12, gapC + 28);
const flats = left.concat(right).map((s) => ({ gx: s.gx, gy: Math.round((s.y + 80) / TILE) }));
sculptArena(flats);
F.grid = {
isSolid: (gx, gy) => isSolid(gx, gy),
isFluid: (gx, gy) => { const b = getBlock(gx, gy); return !!(b && BLOCKS[b.t].fluid); },
unbreakable: (gx, gy) => { const b = getBlock(gx, gy); return !!(b && BLOCKS[b.t].unbreakable); },
remove: (gx, gy) => !!removeBlock(gx, gy),
groundBelowPx: (x, yTop) => {
const gx = Math.floor(x / TILE);
let gy = Math.max(0, Math.floor(yTop / TILE));
for (; gy <= BEDROCK_GY + 5; gy++) if (isSolid(gx, gy)) return gy * TILE;
return null;
},
waterYPx: SEA_GY * TILE,
xMinPx: F.xMinPx, xMaxPx: F.xMaxPx,
killYPx: (BEDROCK_GY + 8) * TILE,
};
F.sim = new Sim(F.grid, F.seed);
F.ammo = [newAmmo(), newAmmo()];
F.cool = [{}, {}]; // кулдауны: {airstrike: ходы}
F.digsLeft = 10; // копок лопатой за ход
// спавн: команды на вершинах гор (точки уже посчитаны выше)
const mk = (list, team, tag) => {
const offs = [-80, -40, 0, 40, 80]; // каждый боец — на своей позиции, без кучи
for (let i = 0; i < WORMS_PER_TEAM; i++) {
const s = list.length ? list[(i * 2) % list.length] : { x: (F.xMinPx + F.xMaxPx) / 2, y: 0 };
F.sim.addWorm(team, tag + (i + 1), s.x + offs[i % offs.length], s.y - (i * 30));
}
};
mk(left.length ? left : peakSpots(F.ax0 + 8, gapC - GAP_HALF - 2), 0, 'Гречка R');
mk(right.length ? right : peakSpots(gapC + GAP_HALF + 2, F.ax0 + F.arenaW - 8), 1, 'Гречка B');
// отстой физики + чистый старт: никакого урона от спавна
for (let i = 0; i < 300; i++) F.sim.step(1 / 60);
// вытолкнуть закопанных: вокруг каждого должно быть свободное место
for (const w of F.sim.worms) {
let n = 0;
while (n++ < 15 && F.sim.solidBox(w.x + 2, w.y + 2, w.w - 4, w.h - 4)) w.y -= 20;
}
for (let i = 0; i < 120; i++) F.sim.step(1 / 60);
for (const w of F.sim.worms) { w.hp = 100; w.fallStartY = w.y; w.vx = 0; w.vy = 0; }
F.sim.drainEvents();
// ночью — факелы рядом с персонажами
F.torches = [];
if (F.night) placeTorches();
F.lastTeam = 1; F.order = []; F.turnPos = 0;
F.over = false; F.winner = -1;
F.zoom = 0.55; // старт с общим планом: видны оба острова
F.camX = (F.xMinPx + F.xMaxPx) / 2; F.camY = SEA_GY * TILE - 150;
F.boomT = 0;
nextTurn();
console.log('[DBG] spawn hp=' + F.sim.worms.map((w) => w.name + ':' + Math.round(w.hp)).join(' '));
updateHud();
}
// Ночь: факел в 2–3 тайлах от каждого червяка, на поверхности.
function placeTorches() {
for (const w of F.sim.worms) {
const side = (w.id % 2 === 0) ? 1 : -1;
for (let dx = 2; dx <= 4; dx++) {
const gx = Math.floor((w.x + 17) / TILE) + side * dx;
if (gx * TILE < F.xMinPx || gx * TILE > F.xMaxPx) continue;
const gyTop = F.grid.groundBelowPx(gx * TILE + TILE / 2, 0);
if (gyTop == null) continue;
const gy = Math.floor(gyTop / TILE) - 1;
if (gy <= 0 || gyTop > SEA_GY * TILE + TILE) continue; // не в воду
if (getBlock(gx, gy)) continue;
if (setBlock(gx, gy, 'torch')) {
F.torches.push({ x: gx * TILE + TILE / 2, y: gy * TILE + TILE / 2 });
break;
}
}
}
}
function aliveWorms(team) { return F.sim.worms.filter((w) => w.alive && (team == null || w.team === team)); }
function nextTurn() {
const teams = F.sim.aliveTeams();
if (teams.length <= 1) return gameOver(teams[0]);
// следующий червяк другой команды (round-robin внутри команды)
const prevTeam = F.lastTeam;
const nt = teams.find((t) => t !== prevTeam) ?? teams[0];
const cands = aliveWorms(nt);
const prev = F.activeWorm;
let w = cands.find((c) => !prev || c.id !== prev.id) || cands[0];
if (cands.length > 1 && prev && prev.team === nt) {
const i = cands.findIndex((c) => c.id === prev.id);
w = cands[(i + 1) % cands.length];
}
F.activeWorm = w; F.teamTurn = nt; F.lastTeam = nt;
if (F.cool[nt].airstrike > 0) F.cool[nt].airstrike--;
F.digsLeft = 10;
F.phase = 'move'; F.timeLeft = TURN_TIME; F.shotsLeft = 0;
F.sim.wind = Math.round((Math.random() * 2 - 1) * MAX_WIND);
F.aim = -0.7 * 1; // вверх; направление по dir червяка при отрисовке
F.aimLocked = false;
F.power = 0; F.charging = false; F.firedThisTurn = false; F.zoomManual = false;
F.pendingAim = null;
if (F.ammo[nt][F.weapon] === 0) F.weapon = 'bazooka';
console.log('[DBG] turn team=' + nt + ' worm=' + w.name);
updateHud();
if (F.mode === 'ai' && nt === 1 && !F.over) aiTakeTurn();
}
// ---------- комп (синие) ----------
function aiTakeTurn() {
setTimeout(() => {
if (F.over || !F.activeWorm || F.activeWorm.team !== 1 || F.phase !== 'move') return;
aiAct();
}, 1400);
}
function aiAct() {
const w = F.activeWorm;
const foes = aliveWorms(0);
console.log('[DBG] ai acts worm=' + w.name);
if (!foes.length) return;
let tgt = foes[0], bd = 1e12;
for (const e of foes) {
const d = Math.hypot(e.x - w.x, e.y - w.y);
if (d < bd) { bd = d; tgt = e; }
}
const tx = tgt.x + 17, ty = tgt.y + 11;
const ammo = F.ammo[1];
const tryWeapon = (id, fn) => { if (ammo[id] !== 0) { F.weapon = id; fn(); return true; } return false; };
// вплотную — очередь
if (bd < 130) {
if (tryWeapon('uzi', () => { F.aim = Math.atan2(ty - (w.y + 11), tx - (w.x + 17)); F.power = 1; doFire(); })) return;
if (tryWeapon('shotgun', () => { F.aim = Math.atan2(ty - (w.y + 11), tx - (w.x + 17)); doFire(); })) return;
}
// средняя — граната с подбором таймера
if (bd < 320) {
if (tryWeapon('grenade', () => {
const a = solveShotLocal(w, tx, ty, true, F.sim.wind);
F.aim = a.ang; F.power = a.pow;
F.fuseIdx = bd < 150 ? 0 : bd < 230 ? 1 : 2;
doFire();
})) return;
}
// далеко — базука с пристрелкой
if (tryWeapon('bazooka', () => {
const a = solveShotLocal(w, tx, ty, false, F.sim.wind);
F.aim = a.ang + (Math.random() - 0.5) * 0.05;
F.power = Math.min(1, Math.max(0.25, a.pow));
doFire();
})) return;
// запасной вариант
tryWeapon('skip', () => doFire());
}
// Перебор углов/сил — общий код в sim.js (там же тесты).
function solveShotLocal(w, tx, ty, thrown, wind) {
return solveShot(F.grid, w.x + 17, w.y + 11, tx, ty, thrown, wind, w.dir);
}
function endTurn() {
F.sim.setMove(F.activeWorm.id, 0);
F.charging = false; F.power = 0;
nextTurn();
}
function settled() {
const s = F.sim;
if (s.proj.length || s.strikes.length || s.dyn.length) return false;
if (s.mines.some((m) => m.fuse > 0)) return false;
return s.worms.every((w) => !w.alive || w.grounded);
}
// выстрелы: настоящие сэмплы (CC0, audio/CC0.txt), запасной — синтез
const gunAudio = {};
function gunSample(kind) {
let a = gunAudio[kind];
if (!a) {
a = new Audio('audio/' + kind + '.mp3');
a.preload = 'auto'; gunAudio[kind] = a;
}
return a;
}
let gunCtx = null;
// овца бекует: «бе-е-е» — пила с вибрато, два слога
function playBaa() {
try {
gunCtx = gunCtx || new (window.AudioContext || window.webkitAudioContext)();
const ctx = gunCtx, t = ctx.currentTime;
for (let s = 0; s < 2; s++) {
const tt = t + s * 0.22;
const o = ctx.createOscillator(), g = ctx.createGain(), lfo = ctx.createOscillator(), lg = ctx.createGain();
o.type = 'sawtooth'; o.frequency.value = 520 + Math.random() * 60;
lfo.type = 'sine'; lfo.frequency.value = 28; lg.gain.value = 90;
lfo.connect(lg); lg.connect(o.frequency);
g.gain.setValueAtTime(0.0001, tt);
g.gain.exponentialRampToValueAtTime(0.35, tt + 0.03);
g.gain.exponentialRampToValueAtTime(0.01, tt + 0.2);
o.connect(g); g.connect(ctx.destination);
o.start(tt); o.stop(tt + 0.22); lfo.start(tt); lfo.stop(tt + 0.22);
}
} catch (e) {}
}
function playGun(kind) {
try {
const a = gunSample(kind);
a.currentTime = 0;
const p = a.play();
if (p && p.catch) p.catch(() => playGunSynth(kind));
} catch (e) { playGunSynth(kind); }
}
function playGunSynth(kind) {
try {
gunCtx = gunCtx || new (window.AudioContext || window.webkitAudioContext)();
const ctx = gunCtx, t = ctx.currentTime;
if (kind === 'shotgun') {
const len = Math.floor(ctx.sampleRate * 0.35);
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = ctx.createBufferSource(); src.buffer = buf;
const f = ctx.createBiquadFilter(); f.type = 'lowpass'; f.frequency.value = 480;
const g = ctx.createGain();
g.gain.setValueAtTime(0.9, t); g.gain.exponentialRampToValueAtTime(0.01, t + 0.35);
src.connect(f); f.connect(g); g.connect(ctx.destination); src.start(t);
const o = ctx.createOscillator(), og = ctx.createGain();
o.type = 'sine'; o.frequency.setValueAtTime(160, t);
o.frequency.exponentialRampToValueAtTime(45, t + 0.3);
og.gain.setValueAtTime(0.7, t); og.gain.exponentialRampToValueAtTime(0.01, t + 0.3);
o.connect(og); og.connect(ctx.destination); o.start(t); o.stop(t + 0.32);
} else {
for (let i = 0; i < 5; i++) {
const tt = t + i * 0.055;
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'square'; o.frequency.value = 900 + Math.random() * 300;
g.gain.setValueAtTime(0.22, tt); g.gain.exponentialRampToValueAtTime(0.01, tt + 0.04);
o.connect(g); g.connect(ctx.destination); o.start(tt); o.stop(tt + 0.05);
}
}
} catch (e) {}
}
function doFire(point) {
if (NET.on) return netFire(point);
const w = F.activeWorm;
if (!w || F.phase !== 'move' || F.over) return;
const def = WEAPONS[F.weapon];
if (F.ammo[F.teamTurn][F.weapon] === 0) return;
const ang = F.aim;
let ok = false;
if (def.kind === 'target' || def.kind === 'teleport') {
const tgt = point || F.pendingAim;
if (!tgt) return; // нет метки — нет удара
if (F.weapon === 'airstrike' && F.cool[F.teamTurn].airstrike > 0) return; // кулдаун
ok = F.sim.fire(w.id, F.weapon, { x: tgt.x, y: tgt.y });
F.pendingAim = null;
if (ok && F.weapon === 'airstrike') F.cool[F.teamTurn].airstrike = 2; // 2 своих хода ждать
} else if (def.kind === 'hitscan') {
if (F.shotsLeft <= 0) F.shotsLeft = def.shots;
ok = F.sim.fire(w.id, F.weapon, { angle: ang });
if (ok) {
F.shotsLeft--;
if (F.ammo[F.teamTurn][F.weapon] !== Infinity) F.ammo[F.teamTurn][F.weapon]--;
if (F.shotsLeft <= 0) { F.phase = 'retreat'; F.retreatT = RETREAT_TIME; }
updateHud();
return;
}
} else if (def.kind === 'dig') {
// лопата: копает туда, куда наведён прицел (как дробовик), ход не тратится (10 копок за ход)
if (F.digsLeft <= 0) return;
ok = F.sim.fire(w.id, F.weapon, { angle: ang });
if (ok) {
F.digsLeft--;
playSound('sand1');
const dpt = digPoint(w.x + 17, w.y + 17, ang);
burst(dpt ? dpt[0] : w.x + 17 + Math.cos(ang) * 52, dpt ? dpt[1] : w.y + 17 + Math.sin(ang) * 52, 14, ['#8d6e63', '#a1887f', '#6d4c41']);
updateHud();
}
return;
} else {
const params = { angle: ang };
if (def.power) params.power = F.power;
if (def.fuse) params.fuse = def.fuse[F.fuseIdx] || def.fuse[0];
ok = F.sim.fire(w.id, F.weapon, params);
}
if (!ok) return;
if (F.ammo[F.teamTurn][F.weapon] !== Infinity) F.ammo[F.teamTurn][F.weapon]--;
// звук выстрела — через событие fired (там же услышат и сетевые выстрелы)
console.log('[DBG] fired team=' + F.teamTurn + ' weapon=' + F.weapon);
F.firedThisTurn = true;
if (def.kind === 'skip' || def.kind === 'teleport') endTurn();
else { F.phase = 'retreat'; F.retreatT = RETREAT_TIME; }
updateHud();
}
function gameOver(team) {
F.over = true;
F.winner = team == null ? -1 : team;
const el = document.getElementById('banner');
el.style.display = 'flex';
el.querySelector('.b-text').textContent = team == null || team === undefined
? 'Ничья! Все утонули.'
: 'Победа: ' + TEAM_NAMES[team] + '!';
}
// ---------- события сима -> эффекты ----------
function pumpEvents() {
for (const e of F.sim.drainEvents()) {
if (e.t === 'explode') {
playSound('explode1');
boomFx(e.x, e.y, e.r || 2);
F.boomX = e.x; F.boomY = e.y; F.boomT = 2.0; // камера смотрит взрыв ещё 2 с
} else if (e.t === 'splash') playSound('splash');
else if (e.t === 'dig') burst(e.x, e.y, 10, ['#8d6e63', '#a1887f']);
else if (e.t === 'damage') {
if (e.dmg > 0) {
console.log('[DBG] damage worm=' + e.worm + ' dmg=' + e.dmg);
F.floaters.push({ x: 0, y: 0, worm: e.worm, text: '-' + e.dmg, t: 1.2 });
playSound('hit1');
}
} else if (e.t === 'die') {
const w = F.sim.wormById(e.worm);
if (w) burst(w.x + 17, w.y + 17, 14, ['#ffffff', '#00a1ff']);
} else if (e.t === 'fired') {
// выстрел услышали все: дробовик/узи — сэмплами, остальное — штатным звуком атаки (и сеть, и локалка)
if (e.weapon === 'shotgun') playGun('shotgun');
else if (e.weapon === 'uzi') playGun('uzi');
else if (e.weapon === 'sheep') { playBaa(); F.baaNext = performance.now() + 900; }
else playSound('attack');
} else if (e.t === 'mine_beep') playSound('click');
else if (e.t === 'teleport') playSound('click');
else if (e.t === 'strike_incoming') playSound('click');
}
// овца скачет — периодически бекует, пока жива (и сеть, и локалка). Вне цикла событий: иначе молчит между событиями
try {
if (F.sim && F.sim.proj.some((p) => p.k === 'sheep') && performance.now() > (F.baaNext || 0)) {
playBaa(); F.baaNext = performance.now() + 1000;
}
} catch (e) {}
}
function burst(x, y, n, colors, sz) {
for (let i = 0; i < n; i++) {
const a = Math.random() * Math.PI * 2, sp = 60 + Math.random() * 320;
F.particles.push({ x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 120, t: 0.5 + Math.random() * 0.5, c: colors[(Math.random() * colors.length) | 0], sz: sz || 6 });
}
}
// красивый взрыв: вспышка + огонь + дым + искры + комья + кольцо + тряска экрана. R — радиус взрыва с сервера
function boomFx(x, y, R) {
const P = (o) => F.particles.push(Object.assign({ x, y, vx: 0, vy: 0, t: 0.5, c: '#fff', sz: 6 }, o));
P({ c: '#fff7d6', sz: R * 44, t: 0.14 }); // вспышка
for (let i = 0; i < 14; i++) { const a = Math.random() * Math.PI * 2, sp = 40 + Math.random() * 200; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 160, t: 0.4 + Math.random() * 0.3, c: ['#f39c12', '#e67e22', '#e74c3c'][(Math.random() * 3) | 0], sz: 8 + Math.random() * 8 }); } // огонь
for (let i = 0; i < 10; i++) { const a = Math.random() * Math.PI * 2, sp = 20 + Math.random() * 80; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 90, t: 1.2 + Math.random() * 0.8, c: ['#7f8c8d', '#95a5a6', '#57606f'][(Math.random() * 3) | 0], sz: 10 + Math.random() * 10 }); } // дым
for (let i = 0; i < 18; i++) { const a = Math.random() * Math.PI * 2, sp = 400 + Math.random() * 500; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 100, t: 0.5 + Math.random() * 0.4, c: ['#ffe66d', '#ffffff', '#ffd76e'][(Math.random() * 3) | 0], sz: 4 }); } // искры
for (let i = 0; i < 12; i++) { const a = Math.random() * Math.PI * 2, sp = 120 + Math.random() * 300; P({ vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - 200, t: 0.8 + Math.random() * 0.5, c: ['#8d6e63', '#6d4c41', '#5d4037'][(Math.random() * 3) | 0], sz: 6 + Math.random() * 5 }); } // комья земли
F.particles.push({ x, y, vx: 0, vy: 0, t: 0.35, t0: 0.35, c: '#ffd76e', ring: 1, r1: R * 40 }); // кольцо
F.shakeT = 0.3; F.shakeR = Math.min(16, 5 + R * 3); // тряска экрана
}
// ---------- ввод ----------
function screenToWorld(sx, sy) {
const vw = canvas.clientWidth, vh = canvas.clientHeight;
return { x: (sx - vw / 2) / F.zoom + F.camX, y: (sy - vh / 2) / F.zoom + F.camY };
}
// hold-кнопки как в оригинале Гречки (bindHold)
function bindHold(id, down, up) {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener('pointerdown', (e) => { e.preventDefault(); down(); });
el.addEventListener('pointerup', (e) => { e.preventDefault(); if (up) up(); });
el.addEventListener('pointerleave', () => { if (up) up(); });
el.addEventListener('pointercancel', () => { if (up) up(); });
}
function showStick(sx, sy) {
const st = document.getElementById('stick');
st.style.display = 'block';
st.style.left = (sx - 60) + 'px';
st.style.top = (sy - 60) + 'px';
moveStickKnob(0, 0);
}
function hideStick() { document.getElementById('stick').style.display = 'none'; }
function moveStickKnob(dx, dy) {
const k = document.getElementById('stickKnob');
k.style.left = (34 + dx) + 'px';
k.style.top = (34 + dy) + 'px';
}
function bindInput() {
window.addEventListener('keydown', (e) => {
if (e.repeat) { F.keys[e.code] = true; return; }
F.keys[e.code] = true;
if (e.code === 'Tab') { e.preventDefault(); cycleWeapon(1); }
if (e.code === 'KeyC') cycleWorm();
if (e.code === 'KeyF') {
const def = WEAPONS[F.weapon];
if (def.fuse) { F.fuseIdx = (F.fuseIdx + 1) % def.fuse.length; updateHud(); }
}
if (e.code === 'ShiftLeft' || e.code === 'ShiftRight') jump();
if (e.code === 'Space') { e.preventDefault(); startCharge(); }
});
window.addEventListener('keyup', (e) => {
F.keys[e.code] = false;
if (e.code === 'Space') { e.preventDefault(); releaseFire(); }
});
// кнопки движения как в оригинале
bindHold('left', () => (F.touchMove = -1), () => { if (F.touchMove < 0) F.touchMove = 0; });
bindHold('right', () => (F.touchMove = 1), () => { if (F.touchMove > 0) F.touchMove = 0; });
bindHold('jumpBtn', () => jump());
// огонь: нажал — начинается заряд (для оружия с силой), отпустил — выстрел.
// держишь дольше — летит дальше; быстрое нажатие — короткий выстрел. Как пробел на компе.
bindHold('fireBtn', () => {
if (document.activeElement && document.activeElement.blur) document.activeElement.blur(); // пробел после клика — выстрел, а не повтор кнопки
if (F.phase !== 'move' || F.over) return;
if (F.mode === 'ai' && F.teamTurn === 1) return;
startCharge();
}, () => { releaseFire(); });
// таймер гранаты/кластера
bindHold('fuseBtn', () => {
const def = WEAPONS[F.weapon];
if (def.fuse) {
F.fuseIdx = (F.fuseIdx + 1) % def.fuse.length;
playSound('click');
updateHud();
}
});
// навёл курсор на кнопку — прицел замирает, вернулся на карту — снова следует за мышью
for (const bid of ['fireBtn', 'jumpBtn', 'wOpen', 'fuseBtn', 'zIn', 'zOut']) {
const bel = document.getElementById(bid);
if (!bel) continue;
bel.addEventListener('pointerenter', () => { F.uiHover = true; });
bel.addEventListener('pointerleave', () => { F.uiHover = false; });
}
// зум: колесо + кнопки + щипок
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
setZoom(F.zoom * (e.deltaY > 0 ? 0.88 : 1.14));
}, { passive: false });
canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // ПКМ — пан карты, не меню
bindHold('zIn', () => setZoom(F.zoom * 1.25));
bindHold('zOut', () => setZoom(F.zoom * 0.8));
const pts = new Map();
canvas.addEventListener('pointerdown', (e) => {
// правая кнопка мыши — тянуть карту (пан), как два пальца на телефоне
if (e.button === 2) { F.rpan = { x: e.clientX, y: e.clientY, id: e.pointerId }; return; }
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) {
const p = [...pts.values()];
F.pinchD = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
F.pinchMX = (p[0].x + p[1].x) / 2; F.pinchMY = (p[0].y + p[1].y) / 2;
if (F.stickId != null) { F.stickId = null; hideStick(); }
return;
}
const def = WEAPONS[F.weapon];
const r = canvas.getBoundingClientRect();
const sx = e.clientX - r.left, sy = e.clientY - r.top;
if (def.kind === 'target' || def.kind === 'teleport') {
// клик — только метка цели, удар — строго с кнопки ОГОНЬ
const wpt = screenToWorld(sx, sy);
F.pendingAim = wpt;
playSound('click');
updateHud();
return;
}
if (def.kind === 'aimed' || def.kind === 'thrown' || def.kind === 'hitscan' || def.kind === 'spray') {
// в сети чужим прицелом не водим
if (NET.on && !myTurn()) return;
// стик прицела появится под пальцем после сдвига; быстрый тап — смена бойца
F.stickId = e.pointerId; F.stickOX = sx; F.stickOY = sy;
F.stickDX = 0; F.stickDY = 0; F.stickShown = false; F.stickT = performance.now();
}
});
canvas.addEventListener('pointermove', (e) => {
if (pts.has(e.pointerId)) pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
// десктоп: прицел следует за мышью без зажатия (тач — только стиком)
// во время зарядки прицел заморожен — иначе уползёт за курсором вниз
if (e.pointerType === 'mouse' && e.buttons === 0 && F.stickId == null && !F.rpan && !F.aimLocked && !F.uiHover
&& F.activeWorm && F.phase === 'move' && !F.over && (!NET.on || myTurn())) {
const dk = WEAPONS[F.weapon].kind;
if (dk === 'aimed' || dk === 'thrown' || dk === 'hitscan' || dk === 'spray' || dk === 'dig') {
const r = canvas.getBoundingClientRect();
const wpt = screenToWorld(e.clientX - r.left, e.clientY - r.top);
F.aim = Math.atan2(wpt.y - (F.activeWorm.y + 11), wpt.x - (F.activeWorm.x + 17));
}
}
// пан правой кнопкой мыши
if (F.rpan && e.pointerId === F.rpan.id) {
F.camX -= (e.clientX - F.rpan.x) / F.zoom;
F.camY -= (e.clientY - F.rpan.y) / F.zoom;
F.rpan.x = e.clientX; F.rpan.y = e.clientY;
F.zoomManual = true; F.panT = 4;
return;
}
if (pts.size === 2) {
const p = [...pts.values()];
const d = Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
// два пальца: сдвиг середины — двигает карту, развод — зум
const mx = (p[0].x + p[1].x) / 2, my = (p[0].y + p[1].y) / 2;
if (F.pinchMX != null) {
F.camX -= (mx - F.pinchMX) / F.zoom;
F.camY -= (my - F.pinchMY) / F.zoom;
F.zoomManual = true;
F.panT = 4; // карту подержать: камера не убегает 4 с
}
F.pinchMX = mx; F.pinchMY = my;
if (F.pinchD > 0) setZoom(F.zoom * (d / F.pinchD));
F.pinchD = d;
return;
}
if (e.pointerId !== F.stickId) return;
const r = canvas.getBoundingClientRect();
let dx = (e.clientX - r.left) - F.stickOX, dy = (e.clientY - r.top) - F.stickOY;
const len = Math.hypot(dx, dy), max = 60;
if (!F.stickShown) {
if (len < 12) return; // ещё тап, не тянем
F.stickShown = true;
showStick(F.stickOX, F.stickOY);
}
if (len > max) { dx = (dx / len) * max; dy = (dy / len) * max; }
F.stickDX = dx; F.stickDY = dy;
moveStickKnob(dx, dy);
F.aimLocked = false; // явное перетаскивание стика — главный жест, разморозить
F.aim = Math.atan2(dy, dx);
F.power = Math.min(1, len / max);
const w = F.activeWorm;
if (w) w.dir = Math.cos(F.aim) >= 0 ? 1 : -1;
});
const endPt = (e) => {
pts.delete(e.pointerId);
if (F.rpan && e.pointerId === F.rpan.id) { F.rpan = null; return; }
if (pts.size < 2) { F.pinchD = 0; F.pinchMX = null; }
if (e.pointerId !== F.stickId) return;
const wasTap = !F.stickShown;
F.stickId = null; F.stickShown = false; hideStick();
if (wasTap) {
// тап по своему бойцу — смена активного (кроме оружия с меткой: там тап = цель)
const def2 = WEAPONS[F.weapon];
if (def2.kind !== 'target' && def2.kind !== 'teleport') {
const r = canvas.getBoundingClientRect();
const wpt = screenToWorld(e.clientX - r.left, e.clientY - r.top);
if (!selectWormAt(wpt.x, wpt.y)) {
// по бойцу не попали — тап наводит прицел в точку (как мышь на компе): удобно копать/целиться с телефона
const w = F.activeWorm;
if (w && F.phase === 'move' && !F.over && (!NET.on || myTurn())) {
F.aim = Math.atan2(wpt.y - (w.y + 17), wpt.x - (w.x + 17));
w.dir = Math.cos(F.aim) >= 0 ? 1 : -1;
}
}
}
return;
}
// отпустил стик — только конец наведения, огонь СТРОГО с кнопки ОГОНЬ
hideStick();
};
canvas.addEventListener('pointerup', endPt);
canvas.addEventListener('pointercancel', endPt);
}
function setZoom(z) {
if (!isFinite(z)) return; // планшетный развал: битый зум не пускаем
F.zoomManual = true; // ручной зум отменяет автозум до конца хода
F.zoom = Math.max(0.1, Math.min(1.6, z));
}
// Смена бойца: клик по своему живому червяку до выстрела.
function selectWormAt(wx, wy) {
if (NET.on) return netSwitch(wx, wy);
if (!F.activeWorm || F.phase !== 'move' || F.firedThisTurn || F.over) return false;
if (F.mode === 'ai' && F.teamTurn === 1) return false;
let best = null, bd = 40;
for (const w of F.sim.worms) {
if (!w.alive || w.team !== F.teamTurn || w.id === F.activeWorm.id) continue;
const d = Math.hypot(w.x + 17 - wx, w.y + 17 - wy);
if (d < bd) { bd = d; best = w; }
}
if (best) {
F.sim.setMove(F.activeWorm.id, 0);
F.activeWorm = best;
F.aim = -0.7; F.aimLocked = false; F.power = 0; F.charging = false;
playSound('click');
updateHud();
return true;
}
return false;
}
function jump() {
if (NET.on) { netJump(); return; }
if (F.mode === 'ai' && F.teamTurn === 1) return; // комп думает — его бойцом не прыгаем
if (F.activeWorm && F.phase !== 'over') { F.sim.jump(F.activeWorm.id); playSound('step'); }
}
function cycleWeapon(d) {
let i = WEAPON_ORDER.indexOf(F.weapon);
for (let k = 0; k < WEAPON_ORDER.length; k++) {
i = (i + d + WEAPON_ORDER.length) % WEAPON_ORDER.length;
if (F.ammo[F.teamTurn][WEAPON_ORDER[i]] !== 0) break;
}
F.weapon = WEAPON_ORDER[i];
F.pendingAim = null; // смена оружия снимает старую метку (иначе телепорт/удар уйдёт по ней)
updateHud();
}
// Смена бойца клавиатурой: следующий живой свой.
function cycleWorm() {
if (!F.activeWorm || F.phase !== 'move' || F.firedThisTurn || F.over) return;
if (F.mode === 'ai' && F.teamTurn === 1) return;
const mine = F.sim.worms.filter((w) => w.alive && w.team === F.teamTurn);
if (mine.length < 2) return;
const i = mine.findIndex((w) => w.id === F.activeWorm.id);
F.sim.setMove(F.activeWorm.id, 0);
F.activeWorm = mine[(i + 1) % mine.length];
F.aim = -0.7; F.aimLocked = false; F.power = 0; F.charging = false;
playSound('click');
updateHud();
}
function startCharge() {
const def = WEAPONS[F.weapon];
if (F.phase !== 'move' || F.over) return;
F.aimLocked = true; // прицел замер: навёл мышью — жми пробел, курсор больше не уведёт его
if (def.kind === 'hitscan' || def.kind === 'spray') { F.power = 1; doFire(); F.aimLocked = false; return; }
if (!def.power) { doFire(); F.aimLocked = false; return; }
F.charging = true; F.chargeT = 0;
}
function releaseFire() {
if (!F.charging) { F.aimLocked = false; return; }
F.charging = false; F.aimLocked = false;
// быстрое нажатие пробела — выстрел сразу с рабочей силой, держать — добрать силу вручную
const def = WEAPONS[F.weapon];
if (def.power && (F.chargeT || 0) < 0.25) F.power = 0.65;
doFire();
}
function pollKeys(dt) {
const w = F.activeWorm;
if (!w || F.over) return;
const k = F.keys;
let mv = 0;
if (k['ArrowLeft'] || k['KeyA']) mv -= 1;
if (k['ArrowRight'] || k['KeyD']) mv += 1;
if (F.touchMove) mv = F.touchMove;
// движение — только своим червяком в свой ход
if (F.mode === 'ai' && F.teamTurn === 1) mv = 0;
if (F.phase !== 'over') F.sim.setMove(w.id, mv);
if (k['ArrowUp'] || k['KeyW']) { F.aim -= 1.8 * dt; if (w) w.dir = Math.cos(F.aim) >= 0 ? 1 : -1; }
if (k['ArrowDown'] || k['KeyS']) { F.aim += 1.8 * dt; if (w) w.dir = Math.cos(F.aim) >= 0 ? 1 : -1; }
if (F.charging) {
F.chargeT += dt;
F.power = Math.min(1, F.chargeT / 1.5);
if (F.power >= 1) { F.charging = false; doFire(); }
}
}
// ---------- HUD: оружие справа сверху, пиксельные иконки ----------
// иконки рисуются кодом, пиксель-арт 12x12
function drawIcon(cv, id) {
const g = cv.getContext('2d');
const P = (x, y, w, h, c) => { g.fillStyle = c; g.fillRect(x, y, w, h); };
g.clearRect(0, 0, 48, 48);
g.save(); g.scale(4, 4); // 12x12 пикселей
const Q = (x, y, w, h, c) => { g.fillStyle = c; g.fillRect(x, y, w, h); };
switch (id) {
case 'bazooka': Q(1, 5, 9, 2, '#2d3436'); Q(8, 4, 3, 4, '#7f8c8d'); Q(0, 5, 1, 2, '#e74c3c'); break;
case 'grenade': Q(4, 3, 4, 5, '#27ae60'); Q(5, 1, 2, 2, '#7f8c8d'); Q(7, 0, 2, 1, '#95a5a6'); break;
case 'homing': Q(1, 5, 9, 2, '#c0392b'); Q(8, 4, 3, 4, '#7f8c8d'); Q(0, 5, 1, 2, '#ffe66d'); Q(2, 3, 2, 2, '#c0392b'); break;
case 'sheep': Q(2, 4, 8, 6, '#f5f6fa'); Q(0, 6, 2, 4, '#f5f6fa'); Q(8, 2, 4, 4, '#2d3436'); Q(9, 3, 1, 1, '#fff'); break;
case 'cluster': Q(3, 4, 4, 4, '#8e44ad'); Q(1, 6, 2, 2, '#8e44ad'); Q(8, 2, 2, 2, '#8e44ad'); Q(8, 8, 2, 2, '#8e44ad'); break;
case 'shotgun': Q(1, 4, 8, 1, '#6d4c41'); Q(1, 6, 8, 1, '#6d4c41'); Q(8, 3, 3, 5, '#a1887f'); Q(0, 4, 1, 3, '#e74c3c'); break;
case 'uzi': Q(2, 4, 6, 3, '#2d3436'); Q(8, 5, 3, 1, '#2d3436'); Q(4, 7, 1, 3, '#7f8c8d'); Q(0, 2, 2, 1, '#f1c40f'); Q(0, 0, 1, 1, '#f1c40f'); break;
case 'dynamite': Q(3, 3, 2, 7, '#c0392b'); Q(6, 3, 2, 7, '#c0392b'); Q(3, 5, 5, 1, '#7f8c8d'); Q(8, 1, 2, 1, '#f1c40f'); break;
case 'mine': Q(3, 6, 6, 4, '#2d3436'); Q(4, 4, 4, 2, '#7f8c8d'); Q(5, 2, 2, 2, '#e74c3c'); break;
case 'airstrike': Q(1, 2, 8, 2, '#95a5a6'); Q(4, 0, 3, 2, '#e74c3c'); Q(2, 6, 1, 2, '#e74c3c'); Q(5, 6, 1, 3, '#e74c3c'); Q(8, 6, 1, 2, '#e74c3c'); break;
case 'teleport': Q(4, 0, 4, 2, '#00a1ff'); Q(2, 3, 8, 2, '#00a1ff'); Q(4, 6, 4, 2, '#00a1ff'); Q(0, 4, 3, 4, '#00a1ff'); Q(9, 4, 3, 4, '#00a1ff'); Q(4, 9, 4, 2, '#00a1ff'); break;
case 'shovel': Q(5, 0, 2, 6, '#8d6e63'); Q(4, 0, 4, 1, '#5d4037'); Q(3, 6, 6, 5, '#b0bec5'); Q(4, 7, 4, 3, '#78909c'); Q(5, 11, 2, 1, '#546e7a'); break;
case 'skip': Q(1, 2, 4, 8, '#f1c40f'); Q(6, 2, 4, 8, '#f1c40f'); Q(1, 2, 9, 2, '#f1c40f'); Q(1, 8, 9, 2, '#f1c40f'); break;
}
g.restore();
P(0, 0, 0, 0, '#000'); // no-op, держим P используемой
}
function buildWeaponPopup() {
const pop = document.getElementById('wPop');
pop.innerHTML = '';
for (const id of WEAPON_ORDER) {
const row = document.createElement('div');
row.className = 'wrow'; row.dataset.w = id;
const cv = document.createElement('canvas');
cv.width = 48; cv.height = 48;
drawIcon(cv, id);
const nm = document.createElement('span');
nm.textContent = WEAPONS[id].name;
const am = document.createElement('span');
am.className = 'ammo';
row.appendChild(cv); row.appendChild(nm); row.appendChild(am);
row.onclick = () => {
if (F.ammo[F.teamTurn][id] === 0) return;
if (id === 'airstrike' && F.cool[F.teamTurn].airstrike > 0) return; // кулдаун
if (F.weapon !== id) F.pendingAim = null; // смена оружия снимает метку
F.weapon = id;
pop.classList.remove('on');
playSound('click');
updateHud();
};
pop.appendChild(row);
}
const btn = document.getElementById('wOpen');
btn.onclick = (e) => { e.stopPropagation(); pop.classList.toggle('on'); updateHud(); };
document.addEventListener('pointerdown', (e) => {
if (pop.classList.contains('on') && !pop.contains(e.target) && e.target !== btn && !btn.contains(e.target))
pop.classList.remove('on');
});
}
function updateHud() {
const s = F.sim;
if (!s) return;
const t0 = aliveWorms(0).reduce((sm, w) => sm + Math.max(0, w.hp), 0);
const t1 = aliveWorms(1).reduce((sm, w) => sm + Math.max(0, w.hp), 0);
setText('hudTeams', '🔴 ' + TEAM_NAMES[0] + ' ' + Math.round(t0) + ' vs 🔵 ' + TEAM_NAMES[1] + ' ' + Math.round(t1));
setText('hudTurn', F.activeWorm && !F.over
? (NET.simMode ? '⚡ Одновременно — ' + F.activeWorm.name
: 'Ходит: ' + F.activeWorm.name + (F.mode === 'ai' && F.teamTurn === 1 ? ' (комп думает…)' : '') + ' — ' + Math.ceil(F.timeLeft) + 'с')
: '');
const wd = s.wind, wa = Math.min(5, Math.abs(wd));
setText('hudWind', 'Ветер ' + (wd === 0 ? '· штиль' : (wd > 0 ? '▶' : '◀') + ' ' + '■'.repeat(wa) + '□'.repeat(5 - wa)));
// голос: бойцам — микрофон 🎤, зрителям — слушать 🔊; смена роли — голос гаснет, включить заново одним нажатием
const vRole = !NET.on ? '' : (NET.myTeam >= 0 ? 'p' : 's');
if (vRole !== lastVoiceRole) { lastVoiceRole = vRole; if (voiceOn) voiceOff(true); }
document.getElementById('micBtn').style.display = vRole === 'p' ? 'block' : 'none';
document.getElementById('earBtn').style.display = vRole === 's' ? 'block' : 'none';
// зрителям — только смотреть: кнопки действий прячем, зум и осмотр карты остаются
const showCtl = vRole !== 's' ? '' : 'none';
for (const id of ['controls', 'jumpBtn', 'fireBtn', 'wOpen', 'fuseBtn']) {
const el = document.getElementById(id);
if (el && (id !== 'fuseBtn' || showCtl === 'none')) el.style.display = showCtl;
}
// кнопка текущего оружия
const btn = document.getElementById('wOpen');
btn.innerHTML = '';
const cv = document.createElement('canvas');
cv.width = 48; cv.height = 48;
drawIcon(cv, F.weapon);
btn.appendChild(cv);
const def = WEAPONS[F.weapon];
btn.title = def.name + (def.fuse ? ' ⏱' + (def.fuse[F.fuseIdx] || def.fuse[0]) + 'с' : '');
// кнопка таймера — только для оружия с таймером (зрителям не показываем вовсе)
const fuseBtn = document.getElementById('fuseBtn');
if (fuseBtn) {
if (def.fuse && vRole !== 's') {
fuseBtn.style.display = 'block';
fuseBtn.textContent = '⏱' + (def.fuse[F.fuseIdx] || def.fuse[0]) + 'с';
} else fuseBtn.style.display = 'none';
}
// подсказки убраны — вверху только важные сообщения
setText('hint', '');
canvas.style.cursor = (def.kind === 'target' || def.kind === 'teleport') ? 'crosshair' : 'default';
// строки попапа
const pop = document.getElementById('wPop');
for (const row of pop.children) {
const id = row.dataset.w;
const left = F.ammo[F.teamTurn][id];
const cd = (id === 'airstrike' && F.cool[F.teamTurn].airstrike > 0) ? F.cool[F.teamTurn].airstrike : 0;
row.classList.toggle('sel', id === F.weapon);
row.classList.toggle('empty', left === 0 || cd > 0);
row.querySelector('.ammo').textContent = cd > 0 ? '⏳' + cd : (left === Infinity ? '∞' : '×' + left);
}
}
function setText(id, s) { const el = document.getElementById(id); if (el) el.textContent = s; }
// ---------- рендер ----------
// точка копки лопаты (клиентский рейкаст 1:1 с симом) — для кружка-прицела
function digPoint(cx, cy, ang) {
const dx = Math.cos(ang), dy = Math.sin(ang);
for (let d = 8; d <= 95; d += 8) {
const gx = Math.floor((cx + dx * d) / TILE), gy = Math.floor((cy + dy * d) / TILE);
if (F.grid.unbreakable(gx, gy) || F.grid.isFluid(gx, gy)) continue;
if (F.grid.isSolid(gx, gy)) return [gx * TILE + TILE / 2, gy * TILE + TILE / 2];
}
const px = cx + dx * 52, py = cy + dy * 52;
for (let dd = 0; dd <= 120; dd += 10) {
const gx = Math.floor(px / TILE), gy = Math.floor((py + dd) / TILE);
if (F.grid.unbreakable(gx, gy) || F.grid.isFluid(gx, gy)) continue;
if (F.grid.isSolid(gx, gy)) return [gx * TILE + TILE / 2, gy * TILE + TILE / 2];
}
return null;
}
// Точки превью траектории активного оружия (для отрисовки и автозума).
function aimPreview() {
const w = F.activeWorm;
if (!w || F.phase !== 'move' || F.over) return null;
const def = WEAPONS[F.weapon];
if (!def || (def.kind !== 'aimed' && def.kind !== 'thrown' && def.kind !== 'hitscan' && def.kind !== 'spray')) return null;
const cx = w.x + 17, cy = w.y + 11;
if (def.kind === 'hitscan' || def.kind === 'spray')
return previewStraight(F.grid, cx + Math.cos(F.aim) * 26, cy + Math.sin(F.aim) * 26, F.aim, 520);
return previewShot(F.grid, cx + Math.cos(F.aim) * 26, cy + Math.sin(F.aim) * 26,
F.aim, def.power ? F.power : 0.6,
{ thrown: def.kind === 'thrown', wind: F.sim.wind, windK: def.windK || 0, fuse: def.fuse ? def.fuse[F.fuseIdx] : 3, dir: w.dir });
}
// Автозум: держит в кадре червяка и конец прицела. Ручной зум отменяет до конца хода.
function autoZoom(vw, vh) {
if (F.zoomManual || F.panT > 0) return;
if (F.sim.proj.length || F.boomT > 0 || !F.activeWorm) return;
const pts = aimPreview();
if (!pts || !pts.length) return;
const w = F.activeWorm;
const end = pts[pts.length - 1];
// широкий кадр: не налезаем на стреляющего, держим контекст вокруг
const needW = Math.abs(end[0] - (w.x + 17)) + 520;
const needH = Math.abs(end[1] - (w.y + 17)) + 480;
const zt = Math.max(0.15, Math.min(0.7, vw / needW, vh / needH));
F.zoom += (zt - F.zoom) * 0.08;
}
function render() {
const vw = canvas.clientWidth, vh = canvas.clientHeight;
// отладка камеры/прицела для тестов (window.WDBG())
try {
window.WDBG = () => {
const s = F.sim, pts = (typeof aimPreview === 'function') ? aimPreview() : null;
return {
v: 51, weapon: F.weapon, phase: F.phase, over: !!F.over,
active: F.activeWorm ? { n: F.activeWorm.name, x: Math.round(F.activeWorm.x), y: Math.round(F.activeWorm.y) } : null,
aim: +((typeof F.aim === 'number') ? F.aim.toFixed(2) : -99), power: F.power,
cam: { x: Math.round(F.camX), y: Math.round(F.camY) }, zoom: +F.zoom.toFixed(2),
pts: pts ? pts.length : null,
end: pts && pts.length ? pts[pts.length - 1].map(Math.round) : null,
proj: s ? s.proj.length : -1, boomT: +(F.boomT || 0).toFixed(1), panT: +(F.panT || 0).toFixed(1),
};
};
} catch (e) {}
// автозум за прицелом: червяк + конец траектории в кадре
try { autoZoom(vw, vh); } catch (e) { try { window.WERR = 'autoZoom: ' + e.message; } catch (_) {} }
const z = F.zoom;
// камера за активным червяком / снарядом (мировые координаты центра экрана)
let tx = F.camX, ty = F.camY;
const s = F.sim;
if (!s) return;
// следим только за СВОИМ выстрелом: чужой снаряд камеру не дёргает.
// в локалке и у зрителя — за последним снарядом, как раньше.
// команда не опознана — лучше стоять на бойце, чем следить за чужим
let fp = null;
if (s.proj.length) {
if (!NET.on || NET.myTeam == null || NET.myTeam < 0) {
fp = s.proj[s.proj.length - 1];
} else {
for (let i = s.proj.length - 1; i >= 0; i--) {
const p0 = s.proj[i];
const pt = p0.t != null ? p0.t : (((s.wormById && s.wormById(p0.o)) || {}).team);
if (pt === NET.myTeam) { fp = p0; break; }
}
}
}
if (fp) {
// следим за полётом
tx = fp.x; ty = fp.y;
F.boomT = 0;
} else if (F.boomT > 0) {
// взрыв был только что — держим камеру на нём
tx = F.boomX; ty = F.boomY;
} else if (F.activeWorm && F.panT <= 0) {
// прицел: камера всегда на КОНЧИКЕ дуги — куда целишься, то и по центру экрана.
// без клампов и исключений: боец в кадре держится автозумом (отъезжает сам), а не сдвигом камеры
const canAim = F.phase === 'move' && (!NET.on || NET.myTeam == null || NET.myTeam < 0 || myTurn());
const pts = canAim ? aimPreview() : null;
if (pts && pts.length) {
const end = pts[pts.length - 1];
tx = end[0]; ty = end[1];
F.camAim = true; // жёсткое слежение за бегущим кончиком (пока заряжаешь — камера не отстаёт)
} else { tx = F.activeWorm.x + 17; ty = F.activeWorm.y + 17 - 60 / z; F.camAim = false; }
}
if (!(tx > 0)) { tx = (F.xMinPx + F.xMaxPx) / 2; ty = SEA_GY * TILE - 200; }
const hw = vw / 2 / z, hh = vh / 2 / z;
tx = Math.max(F.xMinPx - 100 + hw, Math.min(F.xMaxPx + 100 - hw, tx));
F.camX += (tx - F.camX) * (F.camAim ? 0.2 : 0.12);
F.camY += (ty - F.camY) * (F.camAim ? 0.2 : 0.12);
// тряска экрана после взрыва
let shx = 0, shy = 0;
if (F.shakeT > 0) {
F.shakeT -= 1 / 60;
const m = F.shakeR * F.shakeT * 3;
shx = (Math.random() - 0.5) * m; shy = (Math.random() - 0.5) * m;
}
ctx.fillStyle = F.night ? '#3a4670' : '#87CEEB';
ctx.fillRect(0, 0, vw, vh);
ctx.save();
ctx.translate(vw / 2, vh / 2);
ctx.scale(z, z);
ctx.translate(-F.camX + shx, -F.camY + shy);
const minGX = Math.floor((F.camX - hw) / TILE) - 2, maxGX = Math.floor((F.camX + hw) / TILE) + 2;
const minGY = Math.floor((F.camY - hh) / TILE) - 4, maxGY = Math.floor((F.camY + hh) / TILE) + 4;
const ui = Math.min(4, 1 / z); // экранный масштаб мелочей: при отдалении не мельчают
for (const b of blocks) {
if (b.dead) continue;
if (b.gx < minGX || b.gx > maxGX || b.gy < minGY || b.gy > maxGY) continue;
const def = BLOCKS[b.t];
if (def.alpha) { ctx.save(); ctx.globalAlpha = def.alpha; ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE); ctx.restore(); }
else ctx.drawImage(tex[b.t], b.gx * TILE, b.gy * TILE, TILE, TILE);
}
// (воду рисуют сами блоки water; общей подложки нет — иначе камень «тонет» визуально)
// мины / динамит
for (const m of s.mines) {
ctx.fillStyle = m.fuse > 0 ? (Math.sin(performance.now() / 60) > 0 ? '#ff0000' : '#7f8c8d') : (m.armed >= 1 ? '#2d3436' : '#95a5a6');
ctx.beginPath(); ctx.arc(m.x, m.y, 9, 0, 7); ctx.fill();
}
for (const d of s.dyn) {
ctx.fillStyle = '#c0392b'; ctx.fillRect(d.x - 10, d.y - 6, 20, 12);
ctx.fillStyle = '#f1c40f'; ctx.fillRect(d.x + 6, d.y - 10, 4, 4);
}
// снаряды
for (const p of s.proj) {
if (p.k === 'missile' || p.k === 'homing') {
ctx.fillStyle = p.k === 'homing' ? '#c0392b' : '#2d3436'; ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, 7); ctx.fill();
if (p.k === 'homing') { ctx.fillStyle = '#ffe66d'; ctx.beginPath(); ctx.arc(p.x, p.y, 2.5, 0, 7); ctx.fill(); } // жёлтый глазок самонавода
}
else if (p.k === 'grenade' || p.k === 'cluster') { ctx.fillStyle = p.k === 'cluster' ? '#8e44ad' : '#27ae60'; ctx.beginPath(); ctx.arc(p.x, p.y, 6, 0, 7); ctx.fill(); }
else if (p.k === 'striker') { ctx.fillStyle = '#e74c3c'; ctx.fillRect(p.x - 3, p.y - 8, 6, 16); }
else if (p.k === 'sheep') { // овца: большая, с тёмным контуром (видно на небе), ноги скачут
const sd = (p.vx || 0) >= 0 ? 1 : -1;
const gal = Math.floor(performance.now() / 140) % 2; // галоп: ноги по очереди
ctx.fillStyle = '#2d3436';
if (gal) { ctx.fillRect(p.x - 9, p.y + 3, 6, 7); ctx.fillRect(p.x + 3, p.y + 6, 6, 4); }
else { ctx.fillRect(p.x - 9, p.y + 6, 6, 4); ctx.fillRect(p.x + 3, p.y + 3, 6, 7); }
ctx.fillStyle = '#dfe6e9'; // шерсть
ctx.beginPath(); ctx.arc(p.x - 8, p.y - 3, 8, 0, 7); ctx.arc(p.x, p.y - 7, 9, 0, 7); ctx.arc(p.x + 8, p.y - 3, 8, 0, 7); ctx.fill();
ctx.lineWidth = 2.5; ctx.strokeStyle = '#2d3436'; ctx.stroke(); // контур — не сливается с небом
ctx.fillStyle = '#2d3436'; // морда по ходу
ctx.beginPath(); ctx.arc(p.x + sd * 12, p.y - 1, 6, 0, 7); ctx.fill();
ctx.fillStyle = '#dfe6e9'; // ухо
ctx.beginPath(); ctx.arc(p.x + sd * 6, p.y - 9, 3, 0, 7); ctx.fill();
ctx.fillStyle = '#ffffff'; // глаз
ctx.fillRect(p.x + sd * 11, p.y - 3, 2.5, 2.5);
}
else {
// дробь/пули: светящиеся с трассером — видно издалека
if ((p.vx || p.vy) && (Math.abs(p.vx) + Math.abs(p.vy) > 1)) {
ctx.strokeStyle = 'rgba(255,225,77,0.7)'; ctx.lineWidth = Math.max(2, 3 * ui);
ctx.beginPath(); ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.vx * 0.035, p.y - p.vy * 0.035); ctx.stroke();
}
ctx.save(); ctx.shadowColor = '#f1c40f'; ctx.shadowBlur = 14 * ui;
ctx.fillStyle = '#ffe14d';
const ps = 3 + 3 * ui;
ctx.fillRect(p.x - ps / 2, p.y - ps / 2, ps, ps); ctx.restore();
}
}
// самолёты авиаудара (сеть — позиции с сервера, локально — из сима)
for (const pl of (NET.on ? (F.planes || []) : s.strikes.filter((x) => x.started))) {
drawPlane(pl.px != null ? pl.px : pl.x, pl.py != null ? pl.py : pl.y, pl.dir || pl.d || 1);
}
// пиксельный самолётик авиаудара (носом по курсу)
function drawPlane(x, y, dir) {
ctx.save(); ctx.translate(x, y); ctx.scale(dir >= 0 ? 1 : -1, 1);
ctx.fillStyle = '#5f6f35'; ctx.fillRect(-22, -4, 44, 8); // фюзеляж
ctx.fillStyle = '#c0392b'; ctx.fillRect(18, -3, 6, 6); // нос
ctx.fillStyle = '#4a5627'; ctx.fillRect(-4, -3, 12, 22); // крыло
ctx.fillStyle = '#4a5627'; ctx.fillRect(-24, -14, 9, 10); // хвост
ctx.fillStyle = '#bfe3ef'; ctx.fillRect(2, -10, 10, 7); // кабина
ctx.fillStyle = '#ecf0f1'; ctx.fillRect(-14, -2, 6, 4); // звезда
ctx.restore();
}
// червяки-Гречки: с базукой — только активный с базукой/самонаводом, остальные — обычный;
// спрайты нарисованы мордой влево: dir>=0 (вправо) = зеркалить
const iHoldHeavy = (!NET.on || myTurn()) && (F.weapon === 'bazooka' || F.weapon === 'homing');
for (const w of s.worms) {
if (!w.alive) continue;
const img = F.heroImg;
const heavy = (w === F.activeWorm) && iHoldHeavy;
const picked = w.team === 0
? (heavy ? F.wormImgRed : (F.wormPlainRed && F.wormPlainRed.naturalWidth ? F.wormPlainRed : F.wormImgRed))
: (heavy ? F.wormImgBlue : (F.wormPlainBlue && F.wormPlainBlue.naturalWidth ? F.wormPlainBlue : F.wormImgBlue));
const showImg = (picked && picked.complete && picked.naturalWidth) ? picked
: (img.complete && img.naturalWidth) ? img : null;
if (showImg) {
ctx.save();
ctx.translate(w.x - 3 + TILE / 2, w.y - 3 + TILE / 2);
ctx.scale(w.dir >= 0 ? -1 : 1, 1);
const sz = TILE * 2;
ctx.drawImage(showImg, -sz / 2, -sz / 2 - TILE / 2, sz, sz);
ctx.restore();
}
// имя + хп — над головой спрайта (спрайт 2 блока, макушка на TILE выше)
if (w === F.activeWorm && !F.over) {
ctx.fillStyle = '#f1c40f';
const ay = w.y - TILE - 40 * ui;
ctx.beginPath();
ctx.moveTo(w.x + 17, ay); ctx.lineTo(w.x + 17 - 8 * ui, ay + 12 * ui); ctx.lineTo(w.x + 17 + 8 * ui, ay + 12 * ui);
ctx.fill();
}
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(w.x - 3, w.y - TILE - 18 * ui, TILE, 6 * ui);
ctx.fillStyle = w.hp > 50 ? '#2ecc71' : w.hp > 25 ? '#f39c12' : '#e74c3c';
ctx.fillRect(w.x - 3, w.y - TILE - 18 * ui, TILE * Math.max(0, w.hp) / 100, 6 * ui);
if (w.inWater) { // кислород под хп
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.fillRect(w.x - 3, w.y - TILE - 11 * ui, TILE, 4 * ui);
ctx.fillStyle = '#00a1ff';
ctx.fillRect(w.x - 3, w.y - TILE - 11 * ui, TILE * Math.max(0, w.o2) / 100, 4 * ui);
}
ctx.fillStyle = '#fff'; ctx.font = (11 * ui) + 'px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(w.name + ' ' + Math.max(0, Math.round(w.hp)), w.x + 17, w.y - TILE - 20 * ui);
// прицел активного
// прицел активного (в сети — только свой боец в свой ход)
if (w === F.activeWorm && !F.over && F.phase === 'move' && (!NET.on || myTurn())) {
const def = WEAPONS[F.weapon];
if (def.kind === 'aimed' || def.kind === 'thrown' || def.kind === 'hitscan' || def.kind === 'spray' || F.weapon === 'sheep') {
const cx = w.x + 17, cy = w.y + 11;
const straight = def.kind === 'hitscan' || def.kind === 'spray';
const aimLen = straight ? 130 : (F.weapon === 'sheep' ? 60 : 34); // овце — короткий указатель направления
ctx.strokeStyle = '#fff'; ctx.lineWidth = 2 * ui;
ctx.beginPath(); ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(F.aim) * aimLen, cy + Math.sin(F.aim) * aimLen); ctx.stroke();
// превью траектории (экранный размер точек)
const pts = aimPreview();
ctx.fillStyle = 'rgba(255,255,255,0.85)';
const dp = 2 * ui;
if (pts) for (const pt of pts) ctx.fillRect(pt[0] - dp / 2, pt[1] - dp / 2, dp, dp);
} else if (def.kind === 'dig') {
// лопата: линия прицела + кружок на земле, куда копнём
const dp0 = digPoint(w.x + 17, w.y + 17, F.aim);
ctx.strokeStyle = '#ffd54f'; ctx.lineWidth = 2 * ui;
ctx.beginPath(); ctx.moveTo(w.x + 17, w.y + 17);
ctx.lineTo(w.x + 17 + Math.cos(F.aim) * 95, w.y + 17 + Math.sin(F.aim) * 95); ctx.stroke();
if (dp0) {
ctx.setLineDash([6 * ui, 4 * ui]);
ctx.beginPath(); ctx.arc(dp0[0], dp0[1], 52 * F.zoom, 0, Math.PI * 2); ctx.stroke();
ctx.setLineDash([]);
}
}
}
}
// зрители на балкончиках (сеть): те же гречки, других цветов + имена
drawSpecs(ctx, ui);
// частицы
for (const pt of F.particles) {
if (pt.ring) { // расширяющееся кольцо взрыва
const k = 1 - Math.max(0, pt.t) / (pt.t0 || 0.35);
ctx.globalAlpha = Math.max(0, pt.t) * 2;
ctx.strokeStyle = pt.c; ctx.lineWidth = Math.max(1, 5 * (1 - k));
ctx.beginPath(); ctx.arc(pt.x, pt.y, 8 + (pt.r1 || 80) * k, 0, 7); ctx.stroke();
ctx.globalAlpha = 1;
} else {
ctx.fillStyle = pt.c; ctx.globalAlpha = Math.max(0, Math.min(1, pt.t * 1.5));
const s2 = (pt.sz || 6) / 2;
ctx.fillRect(pt.x - s2, pt.y - s2, pt.sz || 6, pt.sz || 6); ctx.globalAlpha = 1;
}
}
// метка цели (авиаудар/телепорт)
if (F.pendingAim && !F.over) {
const px = F.pendingAim.x, py = F.pendingAim.y, r = 14 * ui;
ctx.strokeStyle = '#e74c3c'; ctx.lineWidth = 3 * ui;
ctx.beginPath(); ctx.arc(px, py, r, 0, 7); ctx.stroke();
ctx.beginPath();
ctx.moveTo(px - r - 8 * ui, py); ctx.lineTo(px - r + 4 * ui, py);
ctx.moveTo(px + r - 4 * ui, py); ctx.lineTo(px + r + 8 * ui, py);
ctx.moveTo(px, py - r - 8 * ui); ctx.lineTo(px, py - r + 4 * ui);
ctx.moveTo(px, py + r - 4 * ui); ctx.lineTo(px, py + r + 8 * ui);
ctx.stroke();
}
// цифры урона
ctx.fillStyle = '#ff4757'; ctx.font = 'bold 16px sans-serif'; ctx.textAlign = 'center';
for (const fl of F.floaters) {
const w = F.sim.wormById(fl.worm);
if (w) ctx.fillText(fl.text, w.x + 17, w.y - 34 - (1.2 - fl.t) * 30);
}
ctx.restore();
// ночь: просто темнее фон и темнее земля + тёплое свечение у факелов
if (F.night) drawNightShade(vw, vh);
// полоса силы
if (F.charging) {
ctx.fillStyle = 'rgba(0,0,0,0.4)'; ctx.fillRect(vw / 2 - 110, vh - 120, 220, 18);
ctx.fillStyle = F.power > 0.85 ? '#e74c3c' : '#f1c40f';
ctx.fillRect(vw / 2 - 108, vh - 118, 216 * F.power, 14);
}
// таймер хода (левее кнопки оружия)
if (!F.over && F.activeWorm) {
ctx.fillStyle = F.timeLeft < 6 ? '#e74c3c' : '#fff';
ctx.font = 'bold 22px sans-serif'; ctx.textAlign = 'right';
ctx.fillText(Math.ceil(F.timeLeft) + 'с', vw - 76, 30);
}
}
// ---------- ночь: затемнение мира + свечение факелов ----------
function drawNightShade(vw, vh) {
ctx.save();
ctx.fillStyle = 'rgba(8,10,34,0.32)';
ctx.fillRect(0, 0, vw, vh);
// тёплые ореолы у уцелевших факелов
ctx.globalCompositeOperation = 'lighter';
const now = performance.now();
for (const t of F.torches) {
const b = getBlock(Math.floor(t.x / TILE), Math.floor(t.y / TILE));
if (!b || b.dead || b.t !== 'torch') continue;
const sx = (t.x - F.camX) * F.zoom + vw / 2;
const sy = (t.y - F.camY) * F.zoom + vh / 2;
const rr = 130 * F.zoom * (0.92 + Math.sin(now / 110 + t.x * 0.05) * 0.08);
if (sx < -rr || sy < -rr || sx > vw + rr || sy > vh + rr) continue;
const grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, rr);
grad.addColorStop(0, 'rgba(255,190,90,0.35)');
grad.addColorStop(1, 'rgba(255,190,90,0)');
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(sx, sy, rr, 0, 7); ctx.fill();
}
ctx.restore();
}
// ---------- главный цикл ----------
let lastT = 0, acc = 0;
function frame(now) {
requestAnimationFrame(frame);
if (!F.sim) return; // режим ещё не выбран
const dt = Math.min(0.05, (now - lastT) / 1000 || 0.016);
lastT = now;
// сеть: физикой владеет сервер — только ввод, эффекты, рендер
if (NET.on) {
if (!isFinite(F.camX) || !isFinite(F.camY) || !isFinite(F.zoom)) {
F.zoom = 0.55; F.camX = (F.xMinPx + F.xMaxPx) / 2; F.camY = SEA_GY * TILE - 150;
}
if (myTurn()) pollKeys(dt); // чужим бойцом управлять нельзя — ввод только в свой ход
netMoveTick();
// интерполяция снарядов между снапами: летят гладко, сервер поправляет
for (const p of F.sim.proj) {
if (p.vx || p.vy) { p.px = p.x; p.py = p.y; p.x += p.vx * dt; p.y += p.vy * dt; }
}
if (F.panT > 0) F.panT -= dt;
if (!F.sim.proj.length && F.boomT > 0) F.boomT -= dt;
pumpEvents();
for (const pt of F.particles) { pt.t -= dt; pt.x += pt.vx * dt; pt.y += pt.vy * dt; pt.vy += 900 * dt; }
F.particles = F.particles.filter((p) => p.t > 0);
for (const fl of F.floaters) fl.t -= dt;
F.floaters = F.floaters.filter((f) => f.t > 0);
render();
if ((now | 0) % 500 < 20) updateHud();
return;
}
if (F.over) { render(); return; }
pollKeys(dt);
if (F.panT > 0) F.panT -= dt;
// память о взрыве тает, когда ничего не летит
if (!F.sim.proj.length && F.boomT > 0) F.boomT -= dt;
// таймеры хода
if (F.phase === 'move') {
F.timeLeft -= dt;
if (F.timeLeft <= 0) { F.timeLeft = 0; endTurn(); }
} else if (F.phase === 'retreat') {
F.retreatT -= dt;
if (F.retreatT <= 0 && settled()) endTurn();
}
// физика фикс-степом
acc += dt;
const h = 1 / 60;
let n = 0;
while (acc >= h && n < 4) { F.sim.step(h); acc -= h; n++; }
pumpEvents();
// проверка победы
const teams = F.sim.aliveTeams();
if (teams.length <= 1 && !F.over) gameOver(teams[0]);
// частицы
for (const pt of F.particles) { pt.t -= dt; pt.x += pt.vx * dt; pt.y += pt.vy * dt; pt.vy += 900 * dt; }
F.particles = F.particles.filter((p) => p.t > 0);
for (const fl of F.floaters) fl.t -= dt;
F.floaters = F.floaters.filter((f) => f.t > 0);
render();
if ((now | 0) % 500 < 20) updateHud();
}
// ---------- сеть: лобби, комнаты по коду, зрители ----------
const NET = {
on: false, sock: null, code: null, myTeam: -2, myName: 'Игрок',
specs: [], // [{name,x,y,hopT,colorIdx}]
roster: null, inviteFrom: null, lastMv: 9,
};
const SPEC_COLORS = ['#ff8c1a', '#a64dff', '#33cc66', '#ffe14d', '#ff6fb5', '#f2f2f2'];
function netMsg(o) { if (NET.sock) NET.sock.emit('input', o); }
function netStatus(t, cls) {
const el = document.getElementById('netStatus');
if (!el) return;
el.textContent = t;
el.className = cls || '';
}
function netConnect() {
if (NET.sock) return NET.sock;
if (typeof io === 'undefined') { // сетевой модуль не загрузился (блокировка/кеш)
netStatus('○ Не загрузился сетевой модуль — обнови страницу', 'bad');
return { connected: false, emit() {}, connect() {}, on() {} };
}
NET.sock = io({ path: '/wapi/' });
const s = NET.sock;
s.on('joined', (m) => {
NET.code = m.code; NET.myTeam = m.team; NET.wasJoined = true;
s.emit('top', { name: NET.myName }); // подтянуть топ и свою статистику
setText('roomCode', 'Код: ' + m.code);
document.getElementById('lobbyWait').style.display = 'block';
document.getElementById('lobbyForm').style.display = 'none';
document.getElementById('roster').style.display = 'block';
// настройки и старт — только у создателя; гость просто ждёт автостарта
const host = !!m.host;
for (const id of ['modeRow', 'dnRow', 'bCopyLink']) {
const el = document.getElementById(id);
if (el) el.style.display = host ? '' : 'none';
}
document.getElementById('lobbyNote').style.display = host ? 'none' : 'block';
if (NET.myTeam === -1) setText('hint', 'Ты зритель 👀 Жми ⬆️ — твоя гречка прыгает');
});
s.on('room', (r) => { NET.roster = r; renderRoster(); });
s.on('match_start', (m) => netStart(m));
s.on('snap', (sn) => applySnap(sn));
s.on('diff', (m) => netApplyCells(m.cells || []));
s.on('sync', (m) => netApplyCells(m.cells || m.diff || []));
s.on('ev', (m) => { if (F.sim && m && m.e) { try { F.sim.ev(m.e); } catch (e) {} } });
s.on('invite', (m) => {
if (m.to === NET.myName) {
NET.inviteFrom = m.from;
setText('inviteText', m.from + ' зовёт играть!');
document.getElementById('invitePop').style.display = 'flex';
playSound('click');
}
});
s.on('invite_declined', (m) => setText('hint', m.name + ' отказался'));
s.on('game_over', (m) => netGameOver(m.winner, m));
// кто сейчас играет + войти зрителем
s.on('rooms', (m) => {
const el = document.getElementById('roomsTable');
if (!el) return;
const list = (m && m.list) || [];
if (!list.length) { el.innerHTML = '👀 Сейчас никто не играет — создай игру!'; return; }
el.innerHTML = '👀 Сейчас играют
' + list.map((r) => {
const who = r.players.map((n) => String(n).slice(0, 12).replace(/[<>&]/g, '')).join(' vs ') || '…';
const st = r.phase === 'play' ? 'бой' : 'лобби';
return '| ' + who + ' | ' + st + (r.specs ? ', зрителей: ' + r.specs : '') +
' | |
';
}).join('') + '
';
el.querySelectorAll('[data-watch]').forEach((b) => {
b.onclick = () => {
NET.myName = (document.getElementById('nick').value.trim() || 'Зритель').slice(0, 20);
try { localStorage.setItem('wormsName', NET.myName); } catch (e) {}
netConnect().emit('join', { code: b.dataset.watch, name: NET.myName, as: 'spectate' });
playSound('click');
};
});
});
s.on('top', (m) => {
const el = document.getElementById('topTable');
if (!el) return;
const list = (m && m.list) || [];
let html = '';
if (!list.length) html = '🏆 Топ-10: пока пусто — стань первым!';
else html = '🏆 Топ-10' + list.map((r, i) =>
'| ' + (i + 1) + '. | ' + String(r.name).slice(0, 20).replace(/[<>&]/g, '') +
' | 🏆' + r.wins + ' | ' + r.games + ' игр |
').join('') + '
';
if (m && m.me) {
const wr = m.me.games ? Math.round(m.me.wins / m.me.games * 100) : 0;
html += '⭐ Ты (' + String(m.me.name).slice(0, 20).replace(/[<>&]/g, '') +
'): 🏆' + m.me.wins + ' / ' + m.me.games + ' игр (' + wr + '%)
';
}
el.innerHTML = html;
});
s.on('error', (m) => {
setText('hint', m.text || 'Ошибка сети');
// ссылка протухла (комнаты уже нет) — вернуть ручной вход
if (NET.linkCode) {
NET.linkCode = null;
document.getElementById('codeIn').style.display = '';
document.getElementById('bCreate').style.display = '';
document.getElementById('bJoin').textContent = 'Присоединиться';
}
});
// обрыв сети: сокет сам переподключится — возвращаемся в комнату тем же именем,
// сервер узнает по имени и вернёт слот бойца (или зрителя)
s.on('connect', () => { NET.online = true; netStatus('● Соединение есть', 'ok'); });
s.on('disconnect', () => {
NET.online = false; netStatus('○ Соединение потеряно, переподключаю…', 'bad');
if (NET.code) setText('hint', 'Соединение потеряно, переподключаю…');
});
s.on('connect_error', () => { NET.online = false; netStatus('○ Нет соединения с сервером — проверь интернет', 'bad'); });
s.on('connect', () => {
if (NET.code && NET.myName && NET.wasJoined && !NET._rej) {
NET._rej = true;
s.emit('join', { code: NET.code, name: NET.myName });
setTimeout(() => { NET._rej = false; }, 3000);
}
});
return s;
}
// генератор сервера 1:1 (seed->terrain), чтобы мир совпал кадр в кадр
function nSeededRandom(seed, gx, gy) {
const n = Math.sin(gx * 12.9898 + gy * 78.233 + seed * 0.1) * 43758.5453;
return n - Math.floor(n);
}
function nSurfaceGyAt(seed, gx) {
const n1 = Math.sin(gx * 0.025 + seed * 0.001) * 8;
const n2 = Math.sin(gx * 0.012 + seed * 0.002) * 12;
const n3 = Math.sin(gx * 0.006 + seed * 0.003) * 6;
const n4 = Math.sin(gx * 0.045 + seed * 0.004) * 4;
const n5 = Math.cos(gx * 0.018 + seed * 0.005) * 5;
return Math.floor(SEA_GY - 8 + n1 + n2 + n3 + n4 + n5);
}
function nGenCell(seed, gx, gy) {
const sgy = nSurfaceGyAt(seed, gx);
if (gy < sgy) {
if (gy === sgy - 1) {
if (sgy <= SEA_GY && nSeededRandom(seed, gx, sgy - 2) < 0.12) return 'wood';
if (sgy <= SEA_GY && nSeededRandom(seed, gx, sgy - 1) < 0.10) return 'flower';
}
if (gy === sgy - 2 && sgy <= SEA_GY && nSeededRandom(seed, gx, sgy - 2) < 0.12) return 'wood';
if (gy === sgy - 3 && sgy <= SEA_GY && nSeededRandom(seed, gx, sgy - 2) < 0.12) return 'leaves';
return null;
}
if (gy === sgy) {
if (sgy > SEA_GY) return 'sand';
return sgy < SEA_GY - 10 ? 'stone' : 'grass';
}
if (gy > sgy && gy < SEA_GY && sgy > SEA_GY) return 'water';
if (gy === BEDROCK_GY) return 'bedrock';
if (gy > BEDROCK_GY) return null;
let t = 'stone';
if (gy <= sgy + 3) t = 'dirt';
if (sgy > SEA_GY && gy === sgy + 1 && nSeededRandom(seed, gx, gy) < 0.25) t = 'clay';
if (gy > sgy + 6 && nSeededRandom(seed, gx, gy) < 0.07) t = 'gravel';
const depth = gy - sgy, r = nSeededRandom(seed, gx, gy);
if (t === 'stone') {
if (r < 0.06) t = 'coal';
else if (r < 0.10) t = 'copper_ore';
else if (r < 0.13) t = 'iron_ore';
else if (depth > 40 && r < 0.145) t = 'gold_ore';
else if (depth > 70 && r < 0.152) t = 'diamond_ore';
}
return t;
}
function nArenaGy(ax0, arenaW, gx) {
const gapC = ax0 + arenaW / 2;
const d = Math.abs(gx - gapC);
if (d < 3) return SEA_GY + 3;
const t = Math.min(1, (d - 3) / 18);
return Math.round((SEA_GY + 3) + ((SEA_GY - 14) - (SEA_GY + 3)) * Math.sin(t * Math.PI / 2));
}
function nBase(seed, ax0, arenaW, thMap, gx, gy) {
const th = thMap[gx];
if (th == null) return nGenCell(seed, gx, gy);
if (gy < th) return null;
if (gy === th) {
const nat = nGenCell(seed, gx, gy);
if (nat && (BLOCKS[nat] || {}).solid) return nat;
return th < SEA_GY - 10 ? 'stone' : 'grass';
}
if (th > SEA_GY && gy >= SEA_GY - 2 && gy <= th) return 'water';
if (gy === BEDROCK_GY) return 'bedrock';
if (gy > BEDROCK_GY) return null;
const nat = nGenCell(seed, gx, gy);
if (nat && (BLOCKS[nat] || {}).solid) return nat;
if (nat && (BLOCKS[nat] || {}).fluid) return nat;
return gy <= th + 3 ? 'dirt' : 'stone';
}
// залить клиентскую землю миром сервера + накатить дифф
function netBuildWorld(m) {
F.seed = m.seed; F.ax0 = m.ax0; F.arenaW = m.arenaW || 100; F.night = !!m.night;
F.xMinPx = F.ax0 * TILE; F.xMaxPx = (F.ax0 + F.arenaW) * TILE;
const thMap = {};
for (let gx = F.ax0; gx <= F.ax0 + F.arenaW; gx++) thMap[gx] = nArenaGy(F.ax0, F.arenaW, gx);
// площадки как у сервера (peakSpots 1:1)
const spots = (gx0, gx1) => {
const out = [];
for (let gx = gx0; gx <= gx1 && out.length < 10; gx += 2) {
const sgy = nArenaGy(F.ax0, F.arenaW, gx);
if (sgy < SEA_GY) out.push({ gx, gy: Math.round((sgy * TILE - 80 + 80) / TILE) });
}
return out;
};
const gapC = F.ax0 + F.arenaW / 2;
for (const s of spots(gapC - 28, gapC - 12).concat(spots(gapC + 12, gapC + 28))) {
for (let dx = -2; dx <= 2; dx++) {
const gx = s.gx + dx;
if (thMap[gx] != null && Math.abs(thMap[gx] - s.gy) <= 4) thMap[gx] = s.gy;
}
}
// чистим полосу и заливаем
const x0 = F.ax0 - 14, x1 = F.ax0 + F.arenaW + 14;
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
if (b.gx >= x0 && b.gx <= x1) { grid.delete(k(b.gx, b.gy)); blocks.splice(i, 1); }
}
for (let gx = x0; gx <= x1; gx++)
for (let gy = 0; gy <= BEDROCK_GY + 5; gy++) {
const t = nBase(F.seed, F.ax0, F.arenaW, thMap, gx, gy);
if (t && BLOCKS[t]) { const b = { gx, gy, t, dead: false, active: false, fuse: 0 }; grid.set(k(gx, gy), b); blocks.push(b); }
}
netApplyCells(m.diff || []);
// точная карта с сервера (RLE) — поверх локальной сборки: никаких фантомных блоков
if (m.arena) applyArena(m.arena);
// факелы сервера 1:1
F.torches = [];
for (const tc of m.torches || []) {
const gx = Math.floor((tc.x - TILE / 2) / TILE), gy = Math.floor((tc.y - TILE / 2) / TILE);
if (!getBlock(gx, gy)) { const b = { gx, gy, t: 'torch', dead: false, active: false, fuse: 0 }; grid.set(k(gx, gy), b); blocks.push(b); }
F.torches.push({ x: tc.x, y: tc.y });
}
F.grid = {
isSolid: (gx, gy) => isSolid(gx, gy),
isFluid: (gx, gy) => { const b = getBlock(gx, gy); return !!(b && BLOCKS[b.t].fluid); },
unbreakable: (gx, gy) => { const b = getBlock(gx, gy); return !!(b && BLOCKS[b.t].unbreakable); },
remove: (gx, gy) => !!removeBlock(gx, gy),
groundBelowPx: (x, yTop) => {
const gx = Math.floor(x / TILE);
let gy = Math.max(0, Math.floor(yTop / TILE));
for (; gy <= BEDROCK_GY + 5; gy++) if (isSolid(gx, gy)) return gy * TILE;
return null;
},
waterYPx: SEA_GY * TILE, xMinPx: F.xMinPx, xMaxPx: F.xMaxPx,
killYPx: (BEDROCK_GY + 8) * TILE,
};
}
function netApplyCells(cells) {
for (const c of cells) {
const ex = getBlock(c.gx, c.gy);
if (ex) { grid.delete(k(c.gx, c.gy)); const i = blocks.indexOf(ex); if (i >= 0) blocks.splice(i, 1); }
if (c.t && BLOCKS[c.t]) { const b = { gx: c.gx, gy: c.gy, t: c.t, dead: false, active: false, fuse: 0 }; grid.set(k(c.gx, c.gy), b); blocks.push(b); }
}
}
// Точная арена с сервера: legend|rows, раны [count]code ('.' пусто, a/b/c… — legend[1/2/3…])
function applyArena(s) {
try {
const bar = s.indexOf('|');
const legend = s.slice(0, bar).split(',');
const rows = s.slice(bar + 1).split(';');
const x0 = F.ax0 - 8;
for (let ri = 0; ri < rows.length; ri++) {
const gy = ri - 8; // сервер шлёт с неба (gy -8): верхушки деревьев на пиках тоже синхроним
const row = rows[ri];
if (!row) continue;
let gx = x0, i = 0, num = '';
const put = (ch) => {
const t = ch === '.' ? null : legend[ch.charCodeAt(0) - 96];
const cur = getBlock(gx, gy);
if (t == null || t === '.') { if (cur) removeBlock(gx, gy); }
else if (BLOCKS[t]) {
if (!cur || cur.t !== t) {
if (cur) removeBlock(gx, gy);
const b = { gx, gy, t, dead: false, active: false, fuse: 0 };
grid.set(k(gx, gy), b); blocks.push(b);
}
}
gx++;
};
while (i < row.length) {
const ch = row[i++];
if (ch >= '0' && ch <= '9') { num += ch; continue; }
const n = num ? parseInt(num, 10) : 1; num = '';
for (let r = 0; r < n; r++) put(ch);
}
}
} catch (e) {}
}
// наблюдатель: slim-плашка с номером в очереди, сама гаснет через 8 секунд
let specBadgeTimer = null;
function showSpecBadge() {
const el = document.getElementById('specBadge');
if (!el) return;
const q = (NET.roster && NET.roster.spectators || []).findIndex((s) => s.name === NET.myName);
el.textContent = '👀 Ты наблюдатель' + (q >= 0 ? ' · в очереди №' + (q + 1) : '');
el.style.display = 'block';
if (specBadgeTimer) clearTimeout(specBadgeTimer);
specBadgeTimer = setTimeout(() => { el.style.display = 'none'; }, 8000);
}
function netStart(m) {
document.getElementById('lobby').style.display = 'none';
document.getElementById('modeOverlay').style.display = 'none';
document.getElementById('banner').style.display = 'none';
document.getElementById('pickPop').style.display = 'none';
document.getElementById('roster').style.display = 'block';
netBuildWorld(m);
F.sim = new Sim(F.grid, m.seed);
// команда могла смениться (чемпионат/приглашение): пересчитать по нику
const me = (m.teams || []).find((t) => t.name === NET.myName);
NET.myTeam = me ? me.team : -1;
if (NET.myTeam === -1) showSpecBadge();
F.ammo = [newAmmo(), newAmmo()];
F.cool = [{}, {}]; F.digsLeft = 10; F.pendingAim = null;
F.mode = 'net'; NET.on = true;
NET.simMode = m.mode === 'sim'; // одновременно: оба действуют сразу
if (NET.simMode) setText('hint', '⚡ Одновременно — оба стреляют сразу!');
if (m.champ) setText('hint', '🔄 Чемпион: ' + m.champ.stay + ' — дальше играет ' + m.champ.play);
F.over = false; F.winner = -1; F.activeWorm = null;
F.zoom = 0.55; F.camX = (F.xMinPx + F.xMaxPx) / 2; F.camY = SEA_GY * TILE - 150;
F.boomT = 0; F.particles = []; F.floaters = [];
if (NET.myTeam === -1) setText('hint', 'Ты зритель 👀 Жми ⬆️ — твоя гречка прыгает');
updateHud();
}
function applySnap(sn) {
if (!F.sim) return;
if (sn.phase === 'lobby') return;
const seen = new Set();
for (const sw of sn.worms || []) {
seen.add(sw.id);
let w = F.sim.wormById(sw.id);
if (!w) { w = F.sim.addWorm(sw.team, sw.name, sw.x, sw.y); }
w.x = sw.x; w.y = sw.y; w.dir = sw.dir; w.hp = sw.hp; w.alive = sw.alive;
w.inWater = !!sw.swim; w.o2 = sw.o2 != null ? sw.o2 : 100;
w.team = sw.team; w.name = sw.name;
}
// снаряды: старые тянем к серверной правде мягко (без рывков), новые создаём (команду и владельца не теряем — за ними следит камера)
const oldP = new Map(F.sim.proj.map((p) => [p.id, p]));
F.sim.proj = (sn.proj || []).map((p) => {
const o = oldP.get(p.id);
if (o) {
o.vx = p.vx || 0; o.vy = p.vy || 0;
o.x += (p.x - o.x) * 0.4; o.y += (p.y - o.y) * 0.4;
o.t = p.t; o.o = p.o;
return o;
}
return { id: p.id, kind: p.k, k: p.k, x: p.x, y: p.y, vx: p.vx || 0, vy: p.vy || 0, t: p.t, o: p.o };
});
F.sim.mines = (sn.mines || []).map((m) => ({ x: m.x, y: m.y, armed: m.armed ? 1 : 0, fuse: m.beep ? 1 : 0 }));
F.sim.dyn = (sn.dyn || []).map((d) => ({ x: d.x, y: d.y }));
F.sim.wind = sn.wind || 0;
F.planes = sn.planes || []; // самолётики авиаудара с сервера
if (sn.turn) {
// одновременно: активный — свой боец, очереди и таймера нет
if (sn.turn.sim) {
F.teamTurn = NET.myTeam; F.phase = 'move'; F.timeLeft = Infinity;
const w = F.sim.wormById((sn.turn.worms || {})[NET.myTeam]);
if (w && (!F.activeWorm || F.activeWorm.id !== w.id)) { F.activeWorm = w; NET.lastMv = 9; F.pendingAim = null; }
} else {
F.teamTurn = sn.turn.team; F.phase = sn.turn.phase; F.timeLeft = sn.turn.timeLeft;
const w = F.sim.wormById(sn.turn.wormId);
if (w) { if (!F.activeWorm || F.activeWorm.id !== w.id) F.pendingAim = null; F.activeWorm = w; NET.lastMv = 9; }
}
}
if (sn.ammo) F.ammo = sn.ammo;
if (sn.cool) F.cool = sn.cool;
if (sn.digs != null) F.digsLeft = sn.digs;
NET.specs = sn.specs || [];
if (sn.winner != null && sn.winner >= 0) netGameOver(sn.winner);
else if (sn.phase === 'play') { F.over = false; }
updateHud();
}
function myTurn() {
if (!NET.on || F.over || !F.activeWorm) return false;
// одновременно: всегда свой боец, без очереди
if (NET.simMode) return NET.myTeam >= 0 && F.activeWorm.team === NET.myTeam;
return F.phase === 'move' && F.teamTurn === NET.myTeam;
}
// ---------- Голос двоих бойцов (вкл/выкл кнопкой 🎤) ----------
// Зрители подключаются только слушать (кнопка 🔊), микрофон не трогаем.
const VOICE_URL = 'https://voicegrech.mkn8n.ru';
let vSock = null, vStream = null, vCtx = null, vProc = null, voiceOn = false, voiceMic = false, vPosTimer = null, vSpeakTimer = null, lastVoiceRole = '';
function voiceOff(silent) {
voiceOn = false; voiceMic = false;
if (vPosTimer) { clearInterval(vPosTimer); vPosTimer = null; }
if (vStream) { vStream.getTracks().forEach((t) => t.stop()); vStream = null; }
if (vProc) { vProc.disconnect(); vProc = null; }
if (vCtx) { vCtx.close(); vCtx = null; }
if (vSock) { vSock.disconnect(); vSock = null; }
for (const id of ['micBtn', 'earBtn']) {
const b = document.getElementById(id);
if (b) { b.classList.remove('on', 'err'); }
}
const m = document.getElementById('micBtn'); if (m) m.title = 'Голосовой чат (выкл)';
const e = document.getElementById('earBtn'); if (e) e.title = 'Слушать бойцов (выкл)';
if (!silent) playSound('click');
}
// mic=true: боец говорит+слышит; mic=false: зритель только слышит (микрофон не трогаем)
async function voiceStart(mic) {
if (voiceOn) { voiceOff(); return; }
voiceMic = mic;
const b = document.getElementById(mic ? 'micBtn' : 'earBtn');
try {
if (mic) {
vStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 24000 } });
}
vCtx = new AudioContext({ sampleRate: 24000 });
if (vCtx.state === 'suspended') await vCtx.resume();
if (mic) {
const src = vCtx.createMediaStreamSource(vStream);
vProc = vCtx.createScriptProcessor(4096, 1, 1);
vProc.onaudioprocess = (e) => {
if (!voiceOn || !vSock || !vSock.connected) return;
const pcm = e.inputBuffer.getChannelData(0);
const int16 = new Int16Array(pcm.length);
for (let i = 0; i < pcm.length; i++) {
const s = Math.max(-1, Math.min(1, pcm[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
vSock.emit('voice_data', { data: int16.buffer, codec: 'pcm' });
};
src.connect(vProc);
vProc.connect(vCtx.createGain()); // не в destination — себя не слышим
}
vSock = io(VOICE_URL, { transports: ['websocket', 'polling'] });
vSock.on('connect', () => {
vSock.emit('voice_join', { world_id: 'worms-' + NET.code, x: 0, y: 0, name: NET.myName || 'Игрок', mode: 'world', codec: 'pcm' });
});
vSock.on('voice_in', (p) => {
if (!vCtx || vCtx.state === 'closed') return;
try {
const int16 = new Int16Array(p.data);
const f32 = new Float32Array(int16.length);
for (let i = 0; i < int16.length; i++) f32[i] = int16[i] / (int16[i] < 0 ? 0x8000 : 0x7FFF);
const buf = vCtx.createBuffer(1, f32.length, 24000);
buf.getChannelData(0).set(f32);
const s2 = vCtx.createBufferSource(); s2.buffer = buf;
s2.connect(vCtx.destination); s2.start();
const ind = document.getElementById('speakInd');
if (ind && p.meta) {
ind.style.display = 'block';
ind.textContent = '🔊 ' + (p.meta.name || 'соперник');
if (vSpeakTimer) clearTimeout(vSpeakTimer);
vSpeakTimer = setTimeout(() => { ind.style.display = 'none'; }, 600);
}
} catch (e) {}
});
// позиция активного бойца (для порядка; в режиме world дальность не важна)
if (mic) vPosTimer = setInterval(() => {
if (vSock && vSock.connected && F.activeWorm) vSock.emit('voice_pos', { x: F.activeWorm.x, y: F.activeWorm.y });
}, 2000);
voiceOn = true;
if (b) {
b.classList.add('on');
b.title = mic ? 'Голосовой чат (вкл) — нажать чтобы выключить' : 'Слушаю бойцов — нажать чтобы выключить';
}
playSound('click');
} catch (e) {
voiceOff(true);
if (b) { b.classList.add('err'); b.title = 'Нет доступа к микрофону'; }
}
}
function netFire(point) {
const w = F.activeWorm;
if (!myTurn()) return;
const def = WEAPONS[F.weapon];
if (!def || F.ammo[F.teamTurn][F.weapon] === 0) return;
const f = { weapon: F.weapon, angle: F.aim };
if (def.power) f.power = (!F.power || F.power <= 0) ? 0.5 : F.power;
if (def.fuse) f.fuse = def.fuse[F.fuseIdx] || def.fuse[0];
if (def.kind === 'target' || def.kind === 'teleport') {
const tgt = point || F.pendingAim;
if (!tgt) return;
f.x = tgt.x; f.y = tgt.y;
F.pendingAim = null;
}
netMsg({ fire: f });
if (F.weapon === 'shotgun') playGun('shotgun');
else if (F.weapon === 'uzi') playGun('uzi');
else playSound('attack');
updateHud();
}
function netSwitch(wx, wy) {
if (!myTurn() || F.firedThisTurn) return false;
let best = null, bd = 60;
for (const w of F.sim.worms) {
if (!w.alive || w.team !== NET.myTeam || (F.activeWorm && w.id === F.activeWorm.id)) continue;
const d = Math.hypot(w.x + 17 - wx, w.y + 17 - wy);
if (d < bd) { bd = d; best = w; }
}
if (best) { netMsg({ switchTo: best.id }); playSound('click'); return true; }
return false;
}
function netJump() {
if (NET.myTeam === -1) { netMsg({ hop: true }); playSound('step'); return; }
if (myTurn()) netMsg({ jump: true });
}
function netMoveTick() {
if (!NET.on || !F.sim) return;
let mv = 0;
const kk = F.keys || {};
if (kk['ArrowLeft'] || kk['KeyA']) mv -= 1;
if (kk['ArrowRight'] || kk['KeyD']) mv += 1;
if (F.touchMove) mv = F.touchMove;
if (!myTurn()) mv = 0;
if (mv !== NET.lastMv) { NET.lastMv = mv; netMsg({ move: mv }); }
}
function netGameOver(winner, m) {
F.over = true; F.winner = winner;
m = m || {};
const iWon = winner === NET.myTeam;
const pp = document.getElementById('pickPop');
// окно конца боя — ОБОИМ бойцам: реванш и приглашения доступны каждому
if (NET.myTeam >= 0) {
pp.style.display = 'flex';
document.getElementById('bSame').style.display = m.same === false ? 'none' : 'block';
if (m.next) {
pp.querySelector('.b-text').textContent = '🔄 Дальше играет ' + m.next;
setText('hint', m.next + ' решает: принять или отказаться (откажется — позовём следующего)');
} else {
pp.querySelector('.b-text').textContent = iWon ? '🏆 Победа! С кем дальше?' : '😞 Поражение. Реванш?';
setText('hint', iWon ? 'Победа! Выбери следующего соперника 👇' : 'Жми «Ещё раз» или зови кого-нибудь из комнаты 👇');
}
const list = document.getElementById('pickList');
list.innerHTML = '';
for (const s of NET.rosterSpectators()) {
const b = document.createElement('button');
b.className = 'big'; b.textContent = '⚔️ ' + s.name;
b.onclick = () => { netMsg({ invite: s.name }); setText('hint', 'Приглашение отправлено: ' + s.name); };
list.appendChild(b);
}
} else {
pp.style.display = 'none';
setText('hint', 'Бой окончен');
}
updateHud();
}
NET.rosterSpectators = function () {
const r = NET.roster;
if (!r || !r.spectators) return [];
return r.spectators;
};
function renderRoster() {
const box = document.getElementById('rosterList');
if (!box) return;
box.innerHTML = '';
const r = NET.roster;
if (!r) return;
for (const p of r.players || []) {
const d = document.createElement('div');
d.textContent = (p.team === 0 ? '🔴 ' : '🔵 ') + p.name + (p.name === NET.myName ? ' (ты)' : '') + (p.online === false ? ' 💤' : '');
box.appendChild(d);
}
let qi = 0; // номера очереди чемпионата
for (const s of r.spectators || []) {
const d = document.createElement('div');
const dot = SPEC_COLORS[(s.colorIdx || 0) % SPEC_COLORS.length];
d.innerHTML = '● ' + s.name + (s.name === NET.myName ? ' (ты)' : '') + ' №' + (++qi) + ' в очереди';
if (F.over && NET.myTeam >= 0) {
const b = document.createElement('button');
b.textContent = '⚔️';
b.onclick = () => { netMsg({ invite: s.name }); setText('hint', 'Приглашение отправлено: ' + s.name); };
d.appendChild(b);
}
box.appendChild(d);
}
}
// зрители в кадре: спрайт гречки + цвет + имя + прыжок
function drawSpecs(ctx, ui) {
if (!NET.on) return;
for (const s of NET.specs) {
const hop = s.hopT > 0 ? Math.sin(Math.min(0.5, s.hopT) / 0.5 * Math.PI) * 34 : 0;
const x = s.x - 17, y = s.y - hop;
const img = F.heroImg;
if (img && img.complete && img.naturalWidth) ctx.drawImage(img, x - 3, y - 3, TILE, TILE);
else { ctx.fillStyle = SPEC_COLORS[(s.colorIdx || 0) % SPEC_COLORS.length]; ctx.fillRect(x, y, 34, 34); }
ctx.globalAlpha = 0.45;
ctx.fillStyle = SPEC_COLORS[(s.colorIdx || 0) % SPEC_COLORS.length];
ctx.fillRect(x - 3, y - 3, TILE, TILE);
ctx.globalAlpha = 1;
ctx.fillStyle = '#fff'; ctx.font = (11 * ui) + 'px sans-serif'; ctx.textAlign = 'center';
ctx.fillText(s.name, s.x, y - 8 * ui);
}
}
// ---------- старт ----------
function start() {
const q = new URLSearchParams(location.search);
if (q.has('seed')) F.seed = (+q.get('seed')) >>> 0;
resize();
window.addEventListener('resize', resize);
initTextures();
F.heroImg = new Image();
F.heroImg.src = 'https://supamg.mkn8n.ru/storage/v1/object/public/img//grechka.png';
F.wormImgRed = new Image();
F.wormImgRed.src = './worm-red-bazooka.png';
F.wormImgBlue = new Image();
F.wormImgBlue.src = './worm-blue-bazooka.png';
F.wormPlainRed = new Image();
F.wormPlainRed.src = './worm-plain-red.png';
F.wormPlainBlue = new Image();
F.wormPlainBlue.src = './worm-plain-blue.png';
bindInput();
buildWeaponPopup();
// день/ночь
const dDay = document.getElementById('dDay'), dNight = document.getElementById('dNight');
const markDN = () => {
if (dDay) dDay.style.outline = !F.night ? '3px solid #fff' : 'none';
if (dNight) dNight.style.outline = F.night ? '3px solid #fff' : 'none';
};
if (dDay) dDay.onclick = () => { F.night = false; markDN(); playSound('click'); };
if (dNight) dNight.onclick = () => { F.night = true; markDN(); playSound('click'); };
markDN();
const pickMode = (mode) => {
F.mode = mode;
document.getElementById('modeOverlay').style.display = 'none';
document.getElementById('banner').style.display = 'none';
F.seed = q.has('seed') ? (+q.get('seed')) >>> 0 : (Math.random() * 1000000) | 0;
setupArena();
};
document.getElementById('mAi').onclick = () => pickMode('ai');
document.getElementById('mHot').onclick = () => pickMode('hotseat');
// сеть: лобби
document.getElementById('mNet').onclick = () => {
document.getElementById('modeOverlay').style.display = 'none';
document.getElementById('lobby').style.display = 'flex';
netConnect().emit('top', { name: NET.myName }); // подтянуть топ-10
netConnect().emit('rooms'); // кто сейчас играет
playSound('click');
};
const netName = () => (document.getElementById('nick').value.trim() || 'Игрок').slice(0, 20);
document.getElementById('bCreate').onclick = () => {
NET.myName = netName();
try { localStorage.setItem('wormsName', NET.myName); } catch (e) {}
const sk = netConnect();
if (!sk.connected) { netStatus('○ Нет соединения с сервером — проверь интернет', 'bad'); try { sk.connect(); } catch (e) {} return; }
sk.emit('create', { name: NET.myName });
};
document.getElementById('bJoin').onclick = () => {
NET.myName = netName();
try { localStorage.setItem('wormsName', NET.myName); } catch (e) {}
const code = (NET.linkCode && /^\d{5}$/.test(NET.linkCode))
? NET.linkCode
: document.getElementById('codeIn').value.trim();
if (!/^\d{5}$/.test(code)) { setText('hint', 'Код — 5 цифр'); return; }
const sk = netConnect();
if (!sk.connected) { netStatus('○ Нет соединения с сервером — проверь интернет', 'bad'); try { sk.connect(); } catch (e) {} return; }
sk.emit('join', { code, name: NET.myName });
};
// бой начинается сам, когда заходит второй, — кнопки старта нет
// режим и время суток выбирает создатель — сразу уходят на сервер (для автостарта)
NET.mode = 'turns';
const paintMode = () => {
const set = (id, on, label) => {
const el = document.getElementById(id);
el.classList.toggle('on', on);
el.textContent = (on ? '✓ ' : '') + label;
};
set('bModeTurns', NET.mode !== 'sim', '🚶 По очереди');
set('bModeSim', NET.mode === 'sim', '⚡ Одновременно');
set('bDay', !F.night, '☀️ День');
set('bNight', !!F.night, '🌙 Ночь');
};
const sendPref = () => { try { netConnect().emit('pref', { mode: NET.mode, night: !!F.night }); } catch (e) {} };
document.getElementById('bModeTurns').onclick = () => { NET.mode = 'turns'; paintMode(); sendPref(); playSound('click'); };
document.getElementById('bModeSim').onclick = () => { NET.mode = 'sim'; paintMode(); sendPref(); playSound('click'); };
document.getElementById('bDay').onclick = () => { F.night = false; paintMode(); sendPref(); playSound('click'); };
document.getElementById('bNight').onclick = () => { F.night = true; paintMode(); sendPref(); playSound('click'); };
paintMode();
document.getElementById('bCopyLink').onclick = () => {
const link = location.origin + location.pathname + '?room=' + NET.code;
const done = () => setText('hint', 'Ссылка скопирована — кидай другу');
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(link).then(done, done);
else { window.prompt('Ссылка на бой:', link); done(); }
};
// вход по ссылке ?room=12345: код уже есть, нужны только имя и кнопка
try {
const rq = new URLSearchParams(location.search).get('room');
if (rq && /^\d{5}$/.test(rq)) {
NET.linkCode = rq;
document.getElementById('codeIn').style.display = 'none';
document.getElementById('bCreate').style.display = 'none';
document.getElementById('bJoin').textContent = '⚔️ В бой!';
}
} catch (e) {}
// автовход по ссылке: открыл — сразу в комнате (первый — боец, остальные — зрители).
// имя — сохранённое или ГостьN; сервер при повторе имени допишет (2)
if (NET.linkCode) {
let nm = '';
try { nm = (localStorage.getItem('wormsName') || '').trim(); } catch (e) {}
if (!nm) nm = 'Гость' + ((Math.random() * 9000 + 1000) | 0);
NET.myName = nm.slice(0, 20);
document.getElementById('nick').value = NET.myName;
document.getElementById('modeOverlay').style.display = 'none';
document.getElementById('lobby').style.display = 'flex';
netConnect().emit('join', { code: NET.linkCode, name: NET.myName });
netConnect().emit('top', { name: NET.myName });
}
document.getElementById('bAccept').onclick = () => {
document.getElementById('invitePop').style.display = 'none';
netMsg({ accept: true });
};
document.getElementById('bDecline').onclick = () => {
document.getElementById('invitePop').style.display = 'none';
netMsg({ accept: false });
};
document.getElementById('bSame').onclick = () => {
document.getElementById('pickPop').style.display = 'none';
netMsg({ rematch_same: true });
};
document.getElementById('micBtn').onclick = () => voiceStart(true);
document.getElementById('earBtn').onclick = () => voiceStart(false);
// автостарт для тестов (?seed= + ?auto=ai|hotseat + ?night=1)
if (q.has('auto')) {
if (q.has('night')) { F.night = true; markDN(); }
pickMode(q.get('auto') === 'hotseat' ? 'hotseat' : 'ai');
}
const again = document.getElementById('again');
if (again) again.onclick = () => {
document.getElementById('banner').style.display = 'none';
F.seed = (Math.random() * 1000000) | 0;
setupArena();
};
requestAnimationFrame(frame);
}
start();
})();