grechka-game/worms.js

3219 lines
145 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 = '👀 Сейчас играют<table>' + list.map((r) => {
const who = r.players.map((n) => String(n).slice(0, 12).replace(/[<>&]/g, '')).join(' vs ') || '…';
const st = r.phase === 'play' ? 'бой' : 'лобби';
return '<tr><td>' + who + '</td><td><small>' + st + (r.specs ? ', зрителей: ' + r.specs : '') +
'</small></td><td><button data-watch="' + r.code + '">Смотреть</button></td></tr>';
}).join('') + '</table>';
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<table>' + list.map((r, i) =>
'<tr><td>' + (i + 1) + '.</td><td>' + String(r.name).slice(0, 20).replace(/[<>&]/g, '') +
'</td><td>🏆' + r.wins + '</td><td><small>' + r.games + ' игр</small></td></tr>').join('') + '</table>';
if (m && m.me) {
const wr = m.me.games ? Math.round(m.me.wins / m.me.games * 100) : 0;
html += '<div style="margin-top:6px">⭐ Ты (' + String(m.me.name).slice(0, 20).replace(/[<>&]/g, '') +
'): 🏆' + m.me.wins + ' / ' + m.me.games + ' игр (' + wr + '%)</div>';
}
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 = '<span style="color:' + dot + '">●</span> ' + s.name + (s.name === NET.myName ? ' (ты)' : '') + ' <small>№' + (++qi) + ' в очереди</small>';
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();
})();